Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40fc741ad5 | ||
|
|
62728317cf | ||
|
|
47dbd7b514 |
@@ -40,20 +40,14 @@ jobs:
|
||||
image: debian:trixie
|
||||
prep: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs
|
||||
rmweb: apt-get purge -y punktfunk-web
|
||||
oncanary: grep -q ' canary main' /etc/apt/sources.list.d/punktfunk.list
|
||||
onstable: grep -q ' stable main' /etc/apt/sources.list.d/punktfunk.list
|
||||
- family: fedora-44
|
||||
image: fedora:44
|
||||
prep: dnf install -y -q curl git nodejs
|
||||
rmweb: dnf remove -y punktfunk-web
|
||||
oncanary: grep -q 'fedora-44-canary' /etc/yum.repos.d/punktfunk.repo
|
||||
onstable: grep -q 'rpm/fedora-44$' /etc/yum.repos.d/punktfunk.repo
|
||||
- family: arch
|
||||
image: archlinux:base
|
||||
prep: pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)
|
||||
rmweb: pacman -Rns --noconfirm punktfunk-web
|
||||
oncanary: grep -q '^\[punktfunk-canary\]' /etc/pacman.conf && ! grep -q '^\[punktfunk\]' /etc/pacman.conf
|
||||
onstable: grep -q '^\[punktfunk\]' /etc/pacman.conf && ! grep -q '^\[punktfunk-canary\]' /etc/pacman.conf
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
steps:
|
||||
@@ -87,39 +81,6 @@ jobs:
|
||||
! command -v punktfunk-web-server
|
||||
sh scripts/install.sh --yes --no-start
|
||||
command -v punktfunk-web-server
|
||||
# The channel switch, both ways, against the real registries — the half no textual gate can
|
||||
# reach. Two things are actually at risk here and nowhere else: that the canary repo resolves
|
||||
# at all, and that the walk back DOWN to stable happens instead of silently no-opping (canary
|
||||
# is always a minor ahead, so every package manager refuses that step without being told).
|
||||
# The version moving and then coming back is the proof; the repo-config greps localise a
|
||||
# failure to the rewrite half. A red here can also mean the family's canary channel is empty
|
||||
# — check https://git.unom.io/unom/-/packages before hunting in the script.
|
||||
- name: Switch to canary and back to stable
|
||||
run: |
|
||||
set -eu
|
||||
stable_v=$(punktfunk-host --version | head -1)
|
||||
sh scripts/install.sh --yes --no-start --channel canary
|
||||
${{ matrix.oncanary }}
|
||||
canary_v=$(punktfunk-host --version | head -1)
|
||||
test "$canary_v" != "$stable_v" \
|
||||
|| { echo "switch to canary left the version at $stable_v"; exit 1; }
|
||||
command -v punktfunk-web-server
|
||||
sh scripts/install.sh --yes --no-start --channel stable
|
||||
${{ matrix.onstable }}
|
||||
back_v=$(punktfunk-host --version | head -1)
|
||||
test "$back_v" = "$stable_v" \
|
||||
|| { echo "walk back to stable gave $back_v, not $stable_v (a downgrade that did not happen?)"; exit 1; }
|
||||
# A re-run with no --channel must follow the box, not the flag's stable default.
|
||||
- name: A bare re-run does not drag a canary box back to stable
|
||||
run: |
|
||||
set -eu
|
||||
sh scripts/install.sh --yes --no-start --channel canary
|
||||
canary_v=$(punktfunk-host --version | head -1)
|
||||
sh scripts/install.sh --yes --no-start
|
||||
${{ matrix.oncanary }}
|
||||
test "$(punktfunk-host --version | head -1)" = "$canary_v" \
|
||||
|| { echo "a bare re-run moved a canary box off $canary_v"; exit 1; }
|
||||
sh scripts/install.sh --yes --no-start --channel stable
|
||||
- name: --uninstall takes the packages and the repo off again
|
||||
run: |
|
||||
sh scripts/install.sh --yes --uninstall
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -178,12 +178,11 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
if !proto.is_empty() && proto != PROTO {
|
||||
return None; // some other DNS-SD service sharing the type — ignore
|
||||
}
|
||||
// Deterministic pick from the union of per-interface answers (the host OS's responder
|
||||
// contributes VPN/overlay addresses; `iter().next()` on the HashSet dialed an arbitrary
|
||||
// one) — same policy as the desktop client, shared in `punktfunk_core::discovery`.
|
||||
let candidates: Vec<std::net::Ipv4Addr> = info.get_addresses_v4().into_iter().collect();
|
||||
let addr = punktfunk_core::discovery::pick_host_addr(&candidates, val("addr").parse().ok())?
|
||||
.to_string();
|
||||
let addr = info
|
||||
.get_addresses_v4()
|
||||
.iter()
|
||||
.next()
|
||||
.map(|a| a.to_string())?;
|
||||
let id = val("id");
|
||||
let fullname = info.get_fullname();
|
||||
Some(Host {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -141,19 +141,9 @@ pub fn browse() -> (async_channel::Receiver<DiscoveryEvent>, Rescan) {
|
||||
// responder often answers AAAA for its hostname) would render a host card
|
||||
// that fails on every click. A v6-only advert is dropped — the honest
|
||||
// "not found" — until the stack actually speaks IPv6.
|
||||
//
|
||||
// Among the v4 addresses, pick deterministically: the set is a union of
|
||||
// per-interface answers from EVERY responder (a host on ZeroTier/…
|
||||
// contributes its overlay address via the OS responder), and taking
|
||||
// `iter().next()` of the HashSet dialed an arbitrary one — a field
|
||||
// client streamed over the host's VPN while both machines shared a LAN.
|
||||
let candidates: Vec<std::net::Ipv4Addr> =
|
||||
info.get_addresses_v4().into_iter().collect();
|
||||
let Some(addr) = punktfunk_core::discovery::pick_host_addr(
|
||||
&candidates,
|
||||
val("addr").parse().ok(),
|
||||
)
|
||||
.map(|a| a.to_string()) else {
|
||||
let Some(addr) =
|
||||
info.get_addresses_v4().iter().next().map(|a| a.to_string())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let id = val("id");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -306,117 +306,3 @@ pub trait VirtualDisplay: Send {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Stash a freshly-prepared topology restore into a backend instance's pending slot, keeping the
|
||||
/// **first** restore that instance ever captured.
|
||||
///
|
||||
/// One backend instance serves EVERY attempt of the host's pipeline retry loop (`native/stream.rs`
|
||||
/// opens the display once and lends it to `build_pipeline_with_retry` for up to 8 attempts), so
|
||||
/// `create` — and with it the backend's topology step — runs repeatedly against this one slot.
|
||||
/// Attempt 1 disables the operator's heads and prepares the restore; attempts 2..n then *correctly*
|
||||
/// find nothing left to disable and prepare `None`, because attempt 1 already darkened everything.
|
||||
///
|
||||
/// Assigning that `None` over the held restore is what left an `exclusive` Hyprland desk dark after
|
||||
/// a failed build: attempt 1's closure was dropped rather than run, so by the time the failure
|
||||
/// unwound and the backend dropped, its backstop had nothing to re-enable and only a hand-run
|
||||
/// `hyprctl reload` brought the heads back. Skipping the assignment is the whole fix.
|
||||
///
|
||||
/// First-wins keeps the *right* list too, not merely a surviving one: attempt 1 looked at the desk
|
||||
/// while it was still lit, so its set is every head that was on. Any later attempt can only see a
|
||||
/// subset of that.
|
||||
///
|
||||
/// Backends whose restore the registry drains after each `create` (KWin — pooled, so
|
||||
/// [`VirtualDisplay::take_topology_restore`] empties the slot) reach this with `None` held and are
|
||||
/// unaffected; it matters for the pass-through backends (Hyprland, wlroots/sway carry a portal fd,
|
||||
/// so the registry returns them before the take and the slot is never drained).
|
||||
pub(crate) fn stash_topology_restore(
|
||||
slot: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||
prepared: Option<Box<dyn FnOnce() + Send>>,
|
||||
) {
|
||||
if slot.is_none() {
|
||||
*slot = prepared;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod topology_restore_tests {
|
||||
use super::stash_topology_restore;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn counting(hits: &Arc<AtomicUsize>) -> Option<Box<dyn FnOnce() + Send>> {
|
||||
let hits = Arc::clone(hits);
|
||||
Some(Box::new(move || {
|
||||
hits.fetch_add(1, Ordering::SeqCst);
|
||||
}))
|
||||
}
|
||||
|
||||
/// The failure shape this exists for: the retry loop runs `create` eight times against ONE
|
||||
/// backend instance, only the first of which has heads to disable — and the build then fails,
|
||||
/// so the backstop `Drop` is the only thing that will ever run the restore. It must still be
|
||||
/// holding one.
|
||||
#[test]
|
||||
fn eight_failed_attempts_do_not_strand_the_restore() {
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let mut slot: Option<Box<dyn FnOnce() + Send>> = None;
|
||||
|
||||
// Attempt 1: the desk was lit, two heads went dark, restore prepared.
|
||||
stash_topology_restore(&mut slot, counting(&hits));
|
||||
// Attempts 2..8: `disable_other_heads` correctly finds nothing enabled but the managed
|
||||
// output, so each prepares `None`. None of them may take attempt 1's restore away.
|
||||
for _ in 0..7 {
|
||||
stash_topology_restore(&mut slot, None);
|
||||
}
|
||||
|
||||
let restore = slot.expect("the retry loop stranded the restore — the desk stays dark");
|
||||
restore();
|
||||
assert_eq!(
|
||||
hits.load(Ordering::SeqCst),
|
||||
1,
|
||||
"the restore must run exactly once, on the failure unwind"
|
||||
);
|
||||
}
|
||||
|
||||
/// A second prepared restore never displaces the first: attempt 1 saw the full set of lit
|
||||
/// heads, a later one can only have seen a subset.
|
||||
#[test]
|
||||
fn a_later_restore_never_displaces_the_first() {
|
||||
let first = Arc::new(AtomicUsize::new(0));
|
||||
let second = Arc::new(AtomicUsize::new(0));
|
||||
let mut slot: Option<Box<dyn FnOnce() + Send>> = None;
|
||||
|
||||
stash_topology_restore(&mut slot, counting(&first));
|
||||
stash_topology_restore(&mut slot, counting(&second));
|
||||
|
||||
slot.expect("a restore should be held")();
|
||||
assert_eq!(first.load(Ordering::SeqCst), 1, "the first must be kept");
|
||||
assert_eq!(
|
||||
second.load(Ordering::SeqCst),
|
||||
0,
|
||||
"the second must be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty slot still accepts one — including after the registry drained it (the KWin path),
|
||||
/// so a pooled backend's later create can hand off a fresh restore as before.
|
||||
#[test]
|
||||
fn an_empty_slot_still_accepts_a_restore() {
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let mut slot: Option<Box<dyn FnOnce() + Send>> = None;
|
||||
|
||||
stash_topology_restore(&mut slot, counting(&hits));
|
||||
let _drained = slot.take(); // the registry lifted it into the group
|
||||
assert!(slot.is_none());
|
||||
stash_topology_restore(&mut slot, counting(&hits));
|
||||
assert!(slot.is_some(), "a drained slot must be refillable");
|
||||
}
|
||||
|
||||
/// Nothing to disable and nothing held stays nothing held — an `extend`-shaped session must not
|
||||
/// grow a restore out of thin air.
|
||||
#[test]
|
||||
fn nothing_prepared_leaves_the_slot_empty() {
|
||||
let mut slot: Option<Box<dyn FnOnce() + Send>> = None;
|
||||
stash_topology_restore(&mut slot, None);
|
||||
assert!(slot.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,40 +65,12 @@ fn selection_file() -> String {
|
||||
|
||||
/// The installed custom-picker shim: a tiny script that cats [`selection_file`]. xdph runs
|
||||
/// `custom_picker_binary` and reads one selection line from its stdout; an empty read (no session
|
||||
/// has written the file) leaves xdph to its own fallback.
|
||||
/// has written the file) leaves xdph to its interactive picker — the graceful fallback.
|
||||
fn picker_shim_path() -> String {
|
||||
let dir = crate::session::runtime_dir();
|
||||
format!("{dir}/punktfunk-xdph-picker.sh")
|
||||
}
|
||||
|
||||
/// The xdph config we manage one key in, and the key.
|
||||
fn xdph_config_path() -> Result<std::path::PathBuf> {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.ok_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?;
|
||||
Ok(base.join("hypr").join("xdph.conf"))
|
||||
}
|
||||
const XDPH_BLOCK: crate::portal_config::Block<'static> =
|
||||
crate::portal_config::Block::Hyprlang("screencopy");
|
||||
const XDPH_PICKER_KEY: &str = "custom_picker_binary";
|
||||
|
||||
/// Is a picker command safe to paste into the shim's `exec` line?
|
||||
///
|
||||
/// The value comes from the user's own config, so this is not a privilege boundary — they already
|
||||
/// own both files and the shell that runs them. It is a *robustness* boundary: a newline would
|
||||
/// truncate the script into something that silently does the wrong thing, and command substitution
|
||||
/// in a file we generate is the kind of thing a reader has to stop and reason about. A picker is a
|
||||
/// command name with maybe some flags; anything else, we simply omit the fallback and behave
|
||||
/// exactly as before.
|
||||
fn picker_is_plain(cmd: &str) -> bool {
|
||||
!cmd.is_empty()
|
||||
&& cmd.len() <= 512
|
||||
&& cmd
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || " ._/@:+=-".contains(c))
|
||||
}
|
||||
|
||||
/// The picker line for output `name` — `[SELECTION]/screen:<name>`, whose every byte is load-bearing.
|
||||
/// Lives in [`crate::portal_picker`] with a transcription of xdph's parser, because it is a wire
|
||||
/// format with no error report and this file only compiles on Linux.
|
||||
@@ -171,31 +143,19 @@ pub struct HyprlandDisplay {
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
|
||||
/// overlay is never coming instead of inferring it from an absence.
|
||||
last_cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
/// The topology-restore action the FIRST `create` on this instance prepared (re-enable the heads
|
||||
/// an `exclusive` topology disabled). Written only through
|
||||
/// [`stash_topology_restore`](crate::backend::stash_topology_restore) — first-wins, because one
|
||||
/// instance serves every attempt of the host's pipeline retry loop and only attempt 1 finds
|
||||
/// heads to disable.
|
||||
///
|
||||
/// ⚠️ Unlike KWin's field of the same name, this one is NOT picked up by the registry, and
|
||||
/// [`Drop`] is therefore the ONLY thing that ever runs it — not a backstop. A Hyprland display
|
||||
/// carries a portal fd, so `registry::acquire` returns it as pass-through *before* it reaches
|
||||
/// `take_topology_restore()`; nothing lifts this into a display group. The comment that used to
|
||||
/// sit here claimed the opposite, which is how a stranded restore read as a registry bug.
|
||||
///
|
||||
/// The live consequence of that (unfixed, separate from the strand): the restore is effectively
|
||||
/// per-SESSION here, so two concurrent `exclusive` sessions sharing this desk will have the
|
||||
/// first one to end re-enable the heads under the second. Closing it means giving the
|
||||
/// pass-through path group bookkeeping it does not have today — #284's territory.
|
||||
/// The topology-restore action the last `create` prepared (re-enable the heads an `exclusive`
|
||||
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
|
||||
/// operator's screens come back when the display GROUP's last member drops (design §6.1), not
|
||||
/// when this one session ends. A backstop [`Drop`] runs it if the registry never took it, so a
|
||||
/// physical head is never left dark. Mirrors `kwin.rs`'s field of the same name.
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
impl Drop for HyprlandDisplay {
|
||||
fn drop(&mut self) {
|
||||
// The ONLY path that runs it (see the field docs — the registry never takes a pass-through
|
||||
// display's restore). This is what re-lights the desk when a pipeline build fails: the
|
||||
// failure unwinds past `PreparedDisplay`, dropping the backend instance that still holds
|
||||
// attempt 1's restore.
|
||||
// Backstop only: the registry takes the restore right after `create` (moving it into the
|
||||
// group), so this is normally `None`. If some path skipped the take, re-enable here rather
|
||||
// than strand the operator's heads dark.
|
||||
if let Some(restore) = self.pending_restore.take() {
|
||||
restore();
|
||||
}
|
||||
@@ -212,8 +172,7 @@ impl HyprlandDisplay {
|
||||
}
|
||||
|
||||
/// Apply the effective [`crate::policy::Topology`] for the just-created output `ours`, and stash
|
||||
/// the restore this instance runs on drop (see [`Self::pending_restore`] — the registry does not
|
||||
/// take a pass-through display's restore).
|
||||
/// the restore for the registry (see [`Self::pending_restore`]).
|
||||
///
|
||||
/// Called at the very END of [`create`](VirtualDisplay::create), on purpose: nothing can fail
|
||||
/// after it, so there is no path that disables the operator's heads and then unwinds past the
|
||||
@@ -228,13 +187,9 @@ impl HyprlandDisplay {
|
||||
Topology::Primary => warn_primary_is_not_expressible(),
|
||||
Topology::Exclusive => {
|
||||
let disabled = disable_other_heads(ours);
|
||||
let prepared = (!disabled.is_empty()).then(|| {
|
||||
self.pending_restore = (!disabled.is_empty()).then(|| {
|
||||
Box::new(move || restore_heads(&disabled)) as Box<dyn FnOnce() + Send>
|
||||
});
|
||||
// Keep the FIRST restore, never the latest: the retry loop calls `create` up to
|
||||
// eight times on this one instance, and only attempt 1 has heads to disable — so a
|
||||
// plain assignment overwrote it with attempt 2's `None` and stranded the desk dark.
|
||||
crate::backend::stash_topology_restore(&mut self.pending_restore, prepared);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,11 +390,6 @@ const CAST_CLOSE_BUDGET: Duration = Duration::from_secs(3);
|
||||
/// `xdp_dbus_impl_session_call_close_sync`), so by the time `close()` returns, xdph has already run
|
||||
/// `destroyStream` and logged `Session destroyed`. The output we remove next is one nobody is
|
||||
/// capturing.
|
||||
/// How many casts of ours are live right now. The picker config is borrowed for exactly as long as
|
||||
/// this is non-zero (design D6(b): restore "the moment no punktfunk session needs the shim"), and a
|
||||
/// host that streams two outputs at once must not hand the picker back when the first one ends.
|
||||
static LIVE_CASTS: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
struct StopGuard {
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Signalled by the portal thread once it has closed the ScreenCast session.
|
||||
@@ -454,25 +404,8 @@ impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
let Some(closed) = self.closed.take() else {
|
||||
// No cast was ever established, so this guard never counted toward [`LIVE_CASTS`] —
|
||||
// the increment happens in the same arm that arms `closed`.
|
||||
return;
|
||||
};
|
||||
LIVE_CASTS.fetch_sub(1, Ordering::SeqCst);
|
||||
// 🛑 **Do NOT hand the picker back here.** Restoring per-cast means the NEXT session finds
|
||||
// the config changed, rewrites it, and restarts xdph — and a ScreenCast bound across an
|
||||
// xdph restart never delivers a buffer. The portal runtime caches its D-Bus connection
|
||||
// process-globally (see `portal_thread`), so the restart orphans the cached connection and
|
||||
// the handshake then succeeds against a session nothing is alive to serve. Measured on
|
||||
// Omarchy 4.0.1: every session after the first died on
|
||||
// `no PipeWire frame within 10s … format negotiated but no buffers arrived`, which is a
|
||||
// black screen on the client, with xdph's own log showing it starting up mid-cast.
|
||||
//
|
||||
// Leaving the shim installed is safe precisely because it DELEGATES: with no selection
|
||||
// pending it execs the picker that was there before us, so an ordinary browser share
|
||||
// behaves exactly as it did. That is what D6 actually asks for — the user's screen sharing
|
||||
// keeps working — and it is why the takeover can be idempotent instead of churning.
|
||||
// The config is put back by `punktfunk-omarchy remove`, from the marker we wrote.
|
||||
match closed.recv_timeout(CAST_CLOSE_BUDGET) {
|
||||
// Closed — xdph has torn the capture down, the output is safe to remove.
|
||||
Ok(()) => {}
|
||||
@@ -546,48 +479,23 @@ fn reclaim_leftovers_once() {
|
||||
/// Best-effort by construction: a failure costs window placement, not the session, and a box with no
|
||||
/// physical head was already placing windows correctly.
|
||||
///
|
||||
/// Two eras, same [`dpms_one`] shape and for the same reason. The classic
|
||||
/// `hyprctl dispatch focusmonitor <name>` is what a hyprlang box wants; under the **Lua** config
|
||||
/// manager `dispatch` is shorthand for `hl.dispatch(...)`, so those bare words parse as a Lua
|
||||
/// expression and die with `')' expected near '<name>'`.
|
||||
///
|
||||
/// ⭐ The Lua spelling is **`hl.dsp.focus({ monitor = "<name>" })`** — measured on Omarchy 4.0.1
|
||||
/// (Hyprland 0.56.2) 2026-08-28. The older note here said the fix could not reach a Lua box
|
||||
/// because "`hl.dsp.focusmonitor` does not exist"; that is true, and it was the wrong name. The
|
||||
/// compositor says so itself when asked with any other key:
|
||||
/// *"hl.focus: unrecognized arguments. Expected one of: direction, monitor, window,
|
||||
/// urgent_or_last, last"*.
|
||||
///
|
||||
/// This is not only about window placement on Omarchy. A headless output nothing has focused
|
||||
/// stays empty, an empty output produces no damage, and no damage means **no PipeWire frames** —
|
||||
/// the capture then fails its first-frame deadline and the client sees a black screen. So try
|
||||
/// classic, then Lua, and report both if neither lands.
|
||||
/// ⚠️ **This is a no-op under the Lua config manager.** Measured on .138 (0.55.4, Lua) 2026-08-18:
|
||||
/// `hyprctl dispatch focusmonitor <name>` is parsed as Lua (`hl.dispatch(focusmonitor <name>)`) and
|
||||
/// rejected, and `hl.dsp.focusmonitor` does not exist either — so the #283 window-placement fix
|
||||
/// does not reach a Lua-configured box. Both rejections carry "error", so [`hyprctl_dispatch`]
|
||||
/// reports them and this warns rather than failing silently; the gap itself is unfixed and belongs
|
||||
/// to the #283 follow-up, not to the topology work here.
|
||||
pub(crate) fn focus_output(name: &str) {
|
||||
let classic = match hyprctl_dispatch(&focus_argv(name)) {
|
||||
Ok(()) => None,
|
||||
Err(e) => match hyprctl_dispatch(&["dispatch", &lua_focus_expr(name)]) {
|
||||
Ok(()) => None,
|
||||
Err(lua_err) => Some(format!("hyprlang: {e:#}; lua: {lua_err:#}")),
|
||||
},
|
||||
};
|
||||
match classic {
|
||||
None => tracing::info!(output = %name, "focused the streamed headless output"),
|
||||
Some(why) => tracing::warn!(
|
||||
output = %name, error = %why,
|
||||
match hyprctl_dispatch(&focus_argv(name)) {
|
||||
Ok(()) => tracing::info!(output = %name, "focused the streamed headless output"),
|
||||
Err(e) => tracing::warn!(
|
||||
output = %name, error = %format!("{e:#}"),
|
||||
"could not focus the streamed headless output — apps this session launches may open on \
|
||||
a physical monitor instead of on the stream, and an unfocused headless output can \
|
||||
produce no frames at all"
|
||||
a physical monitor instead of on the stream"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Lua-config-manager spelling of "focus this monitor". Pure, so a test pins the shape: the
|
||||
/// quoting and the `monitor =` key are the whole trick, and an unquoted argument is exactly what
|
||||
/// the classic form gets wrong on that manager.
|
||||
fn lua_focus_expr(name: &str) -> String {
|
||||
format!("hl.dsp.focus({{ monitor = \"{name}\" }})")
|
||||
}
|
||||
|
||||
/// The `hyprctl` argv that focuses `name`, split out so a test pins its SHAPE.
|
||||
///
|
||||
/// `focusmonitor` is a **dispatcher**, so it lives behind the `dispatch` subcommand. Getting that
|
||||
@@ -1084,9 +992,6 @@ fn select_and_cast(
|
||||
// A cast exists now, so teardown has something to close and must wait for it. Only this
|
||||
// arm arms the wait: see the field note on `StopGuard::closed`.
|
||||
guard.closed = Some(closed_rx);
|
||||
// …and only this arm counts toward the borrowed picker, for the same reason: a
|
||||
// handshake that never produced a cast has nothing to hand back.
|
||||
LIVE_CASTS.fetch_add(1, Ordering::SeqCst);
|
||||
Ok((fd, node_id, cursor_mode, guard))
|
||||
}
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
|
||||
@@ -1403,45 +1308,11 @@ fn warn_if_permissions_enforced() {
|
||||
/// Make sure xdph uses our custom picker: install the shim (once) and write the managed config,
|
||||
/// restarting xdph if the config changed (it reads config only at startup). Mirrors the wlroots
|
||||
/// `ensure_xdpw_config` pattern.
|
||||
///
|
||||
/// **The picker is the user's, borrowed — not taken** (design D6). `custom_picker_binary` is one
|
||||
/// key with one holder, and on a distro that ships its own (Omarchy sets
|
||||
/// `hyprland-preview-share-picker`, the picker every Chromium share on the box goes through)
|
||||
/// pointing it at us is pointing *every* share at us. Two things keep that honest:
|
||||
///
|
||||
/// * the shim **delegates**: with no selection pending it `exec`s whatever was configured before
|
||||
/// us, so an ordinary browser share behaves exactly as it did — during our session, after it,
|
||||
/// after a crash, and after a reboot that emptied `$XDG_RUNTIME_DIR`. This is the part that does
|
||||
/// not depend on us getting a teardown right;
|
||||
/// * the config edit records what it replaced, so [`restore_xdph_config`] can put their value
|
||||
/// back verbatim when the last cast ends.
|
||||
fn ensure_xdph_config() -> Result<()> {
|
||||
let path = xdph_config_path()?;
|
||||
// What the user had here before us — from our own marker if we have taken this over already
|
||||
// (a second session, a moved `$XDG_RUNTIME_DIR`), else whatever is in the file now. Reading
|
||||
// the marker FIRST is what stops the second takeover from recording our own shim as "theirs".
|
||||
let (current, prior) = crate::portal_config::peek(&path, XDPH_BLOCK, XDPH_PICKER_KEY);
|
||||
let fallback = match prior {
|
||||
Some(p) => p,
|
||||
None => current,
|
||||
}
|
||||
.filter(|c| picker_is_plain(c));
|
||||
|
||||
// 1. Install the picker shim (idempotent — content is fixed for a given fallback).
|
||||
// 1. Install the picker shim (idempotent — content is fixed).
|
||||
let shim = picker_shim_path();
|
||||
let sel = selection_file();
|
||||
// `-s` not `-f`: an empty selection file means "no selection", which is the fallback's case.
|
||||
// Unquoted expansion on the `exec` line is deliberate — a picker may carry flags, and
|
||||
// `picker_is_plain` is what makes word-splitting the only thing that can happen here.
|
||||
let shim_body = match &fallback {
|
||||
Some(cmd) => format!(
|
||||
"#!/bin/sh\n# Managed by punktfunk. Hands xdph the output this host is streaming; with\n# no selection pending, defers to the picker configured before us.\n[ -s \"{sel}\" ] && exec cat \"{sel}\"\nexec {cmd} \"$@\"\n"
|
||||
),
|
||||
// Nothing to defer to: an empty read leaves xdph to its own fallback, as before.
|
||||
None => format!(
|
||||
"#!/bin/sh\n# Managed by punktfunk.\n[ -s \"{sel}\" ] && exec cat \"{sel}\"\nexit 0\n"
|
||||
),
|
||||
};
|
||||
let shim_body = format!("#!/bin/sh\nexec cat \"{sel}\" 2>/dev/null\n");
|
||||
if std::fs::read_to_string(&shim).is_ok_and(|c| c == shim_body) {
|
||||
// already installed
|
||||
} else {
|
||||
@@ -1462,58 +1333,26 @@ fn ensure_xdph_config() -> Result<()> {
|
||||
}
|
||||
|
||||
// 2. Write the managed xdph config and restart xdph on change.
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.ok_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?;
|
||||
let path = base.join("hypr").join("xdph.conf");
|
||||
// ONE key, in place. This used to `fs::write` a complete file over whatever the user had,
|
||||
// destroying every other xdph setting they owned on first connect.
|
||||
let changed = crate::portal_config::ensure_key(&path, XDPH_BLOCK, XDPH_PICKER_KEY, &shim)?;
|
||||
let changed = crate::portal_config::ensure_key(
|
||||
&path,
|
||||
crate::portal_config::Block::Hyprlang("screencopy"),
|
||||
"custom_picker_binary",
|
||||
&shim,
|
||||
)?;
|
||||
if !changed {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
defers_to = fallback.as_deref().unwrap_or("(xdph's own fallback)"),
|
||||
"pointed xdg-desktop-portal-hyprland at the managed picker shim"
|
||||
);
|
||||
restart_xdph();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hand `custom_picker_binary` back to whoever had it before us, and restart xdph so the change
|
||||
/// takes (it reads config only at startup).
|
||||
///
|
||||
/// Called from the host's **shutdown** path ([`crate::restore_takeover_now`]), never per cast —
|
||||
/// see [`StopGuard::drop`] for why per-cast churn is a black screen. Safe to call on a box whose
|
||||
/// config we never touched, where it does nothing at all.
|
||||
///
|
||||
/// The restart is the acknowledged cost (design D6(d)): xdph has no way to tell us whether another
|
||||
/// application's cast is live, so a share started *during* our session can be cut here. That is the
|
||||
/// same restart the takeover above already performs, at the other end of the session, and it is the
|
||||
/// lesser of the two evils — the alternative is leaving a running xdph pointed at a shim whose
|
||||
/// selection file is gone, which breaks the box's screen sharing until the next login.
|
||||
pub(crate) fn restore_picker_on_shutdown() {
|
||||
restore_xdph_config();
|
||||
}
|
||||
|
||||
fn restore_xdph_config() {
|
||||
let Ok(path) = xdph_config_path() else { return };
|
||||
match crate::portal_config::restore_key(&path, XDPH_BLOCK, XDPH_PICKER_KEY) {
|
||||
Ok(false) => return, // not ours; nothing to undo
|
||||
Ok(true) => tracing::info!(
|
||||
path = %path.display(),
|
||||
"restored the screen-share picker xdg-desktop-portal-hyprland had before this host"
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %format!("{e:#}"),
|
||||
"could not restore the previous screen-share picker");
|
||||
return;
|
||||
}
|
||||
}
|
||||
restart_xdph();
|
||||
}
|
||||
|
||||
/// Bounded: `systemctl --user` blocks on the user manager's job queue, and this runs on the
|
||||
/// session's stream thread. The result is ignored — a timeout just means xdph picks the new config
|
||||
/// up whenever it next starts.
|
||||
fn restart_xdph() {
|
||||
tracing::info!(path = %path.display(), "pointed xdg-desktop-portal-hyprland at the managed picker shim");
|
||||
// Bounded: `systemctl --user` blocks on the user manager's job queue, and this runs on the
|
||||
// session's stream thread. Its result was already ignored — a timeout just means xdph picks the
|
||||
// new config up whenever it next starts.
|
||||
let _ = crate::proc::status_within(
|
||||
Command::new("systemctl").args([
|
||||
"--user",
|
||||
@@ -1522,6 +1361,7 @@ fn restart_xdph() {
|
||||
]),
|
||||
PORTAL_RESTART_BUDGET,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The ScreenCast portal handshake — the xdg ScreenCast portal is backend-neutral (served here by
|
||||
@@ -1724,22 +1564,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The Lua-era spelling, which is the half that was missing. Measured against Hyprland 0.56.2
|
||||
/// on Omarchy 4.0.1: the key is `monitor` (the compositor lists the alternatives when it is
|
||||
/// anything else) and the NAME MUST BE QUOTED — unquoted is precisely the classic form's
|
||||
/// failure, `')' expected near 'PF'`, which is what left a headless output unfocused, empty,
|
||||
/// and producing no frames at all.
|
||||
#[test]
|
||||
fn the_lua_focus_expression_quotes_the_monitor_name() {
|
||||
assert_eq!(
|
||||
lua_focus_expr("PF-1234-1"),
|
||||
"hl.dsp.focus({ monitor = \"PF-1234-1\" })"
|
||||
);
|
||||
// The two eras must not converge on one string: each is rejected by the other's parser,
|
||||
// and that is what makes "try one, then the other" safe to run blind.
|
||||
assert_ne!(lua_focus_expr("PF-1"), focus_argv("PF-1").join(" "));
|
||||
}
|
||||
|
||||
/// `HYPRLAND_INSTANCE_SIGNATURE` reaches `hyprctl` as a per-CHILD override, never as a `set_var`
|
||||
/// on the host's own environment — that write was a `getenv` data race with every other thread
|
||||
/// of a live session (security-review 2026-08-25). Pinning both arms: a discovered signature is
|
||||
|
||||
@@ -571,7 +571,7 @@ impl VirtualDisplay for KwinDisplay {
|
||||
// sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts
|
||||
// into the display group and runs once, when the group's LAST member is torn down (ordered before
|
||||
// that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore.
|
||||
let prepared = (!disabled.is_empty()).then(|| {
|
||||
self.pending_restore = (!disabled.is_empty()).then(|| {
|
||||
let disabled = disabled.clone();
|
||||
// In-process first; fall back to kscreen-doctor if the compositor doesn't answer in
|
||||
// budget. **Both halves now return honest verdicts** — `reenable_outputs` reports
|
||||
@@ -604,11 +604,6 @@ impl VirtualDisplay for KwinDisplay {
|
||||
.ok();
|
||||
}) as Box<dyn FnOnce() + Send>
|
||||
});
|
||||
// Keep the FIRST restore. KWin is registry-POOLED, so the registry drains this slot right
|
||||
// after every `create` and it is normally empty here — but guard it the way the
|
||||
// pass-through backends must, so a retry-loop create can never overwrite a held restore.
|
||||
// See [`stash_topology_restore`].
|
||||
crate::backend::stash_topology_restore(&mut self.pending_restore, prepared);
|
||||
// Layout position (§6.2) is applied by the registry via `apply_position` right after create
|
||||
// (it owns the display group, so it computes auto-row / manual placement over the whole group).
|
||||
let mut out = VirtualOutput::owned(
|
||||
|
||||
@@ -22,19 +22,6 @@ pub(crate) enum Block<'a> {
|
||||
Hyprlang(&'a str),
|
||||
}
|
||||
|
||||
/// The comment we leave beside a key we took over, recording what the user had there:
|
||||
/// `# punktfunk: previous <key> = <value>` (or `= (none)` when the key did not exist).
|
||||
///
|
||||
/// Why in the file rather than in our own state directory: this has to survive a SIGKILLed host,
|
||||
/// a reboot (which empties `$XDG_RUNTIME_DIR`) and an uninstall that leaves the config behind, and
|
||||
/// it has to be written atomically together with the change it describes. A sidecar state file
|
||||
/// satisfies none of those; a comment in the same atomic write satisfies all three. It is also
|
||||
/// legible: an operator reading their own `xdph.conf` can see exactly what we replaced and put it
|
||||
/// back by hand.
|
||||
const PRIOR: &str = "# punktfunk: previous";
|
||||
/// What the marker records when the key was absent before we set it.
|
||||
const PRIOR_NONE: &str = "(none)";
|
||||
|
||||
/// Is `line` the header that opens `block`?
|
||||
fn opens(line: &str, block: Block<'_>) -> bool {
|
||||
let t = line.trim();
|
||||
@@ -53,10 +40,33 @@ fn assigns(line: &str, key: &str) -> bool {
|
||||
line.split('=').next().is_some_and(|lhs| lhs.trim() == key)
|
||||
}
|
||||
|
||||
/// The span of `block` in `lines`: `(index of its header, index one past its last line)`.
|
||||
/// `None` when the block is absent.
|
||||
fn block_span(lines: &[&str], block: Block<'_>) -> Option<(usize, usize)> {
|
||||
let open_at = lines.iter().position(|l| opens(l, block))?;
|
||||
/// Set `key` to `value` inside `block`, preserving every other line.
|
||||
///
|
||||
/// Three cases, all of which the tests pin: the block is absent (append it), the block has the key
|
||||
/// (replace that one line, keeping its indentation), and the block lacks the key (insert before the
|
||||
/// block ends).
|
||||
pub(crate) fn upsert(existing: &str, block: Block<'_>, key: &str, value: &str) -> String {
|
||||
let sep = match block {
|
||||
Block::Ini(_) => "=",
|
||||
Block::Hyprlang(_) => " = ",
|
||||
};
|
||||
let assignment = |indent: &str| format!("{indent}{key}{sep}{value}");
|
||||
|
||||
let lines: Vec<&str> = existing.lines().collect();
|
||||
let Some(open_at) = lines.iter().position(|l| opens(l, block)) else {
|
||||
// Absent: append the whole block, keeping the user's file intact above it.
|
||||
let mut out = existing.trim_end().to_string();
|
||||
if !out.is_empty() {
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
out.push_str(&match block {
|
||||
Block::Ini(name) => format!("[{name}]\n{}\n", assignment("")),
|
||||
Block::Hyprlang(name) => format!("{name} {{\n{}\n}}\n", assignment(" ")),
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
// Where the block ends: the next `[`-header for INI, the closing brace for hyprlang, else EOF.
|
||||
let end_at = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -67,142 +77,17 @@ fn block_span(lines: &[&str], block: Block<'_>) -> Option<(usize, usize)> {
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(lines.len());
|
||||
Some((open_at, end_at))
|
||||
}
|
||||
|
||||
/// The value `key` currently holds in `block`, if it holds one.
|
||||
pub(crate) fn current_value(existing: &str, block: Block<'_>, key: &str) -> Option<String> {
|
||||
let lines: Vec<&str> = existing.lines().collect();
|
||||
let (open_at, end_at) = block_span(&lines, block)?;
|
||||
(open_at + 1..end_at)
|
||||
.find(|&i| assigns(lines[i], key))
|
||||
.map(|i| {
|
||||
lines[i]
|
||||
.split_once('=')
|
||||
.map_or("", |(_, v)| v)
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// What the user had at `key` before we took it over, read back from our marker comment:
|
||||
/// `Some(Some(v))` = they had `v`, `Some(None)` = the key was absent, `None` = we never took it
|
||||
/// over (so there is nothing of ours to undo, and the value there is genuinely theirs).
|
||||
pub(crate) fn prior_value(existing: &str, block: Block<'_>, key: &str) -> Option<Option<String>> {
|
||||
let lines: Vec<&str> = existing.lines().collect();
|
||||
let (open_at, end_at) = block_span(&lines, block)?;
|
||||
let want = format!("{PRIOR} {key} =");
|
||||
let raw = (open_at + 1..end_at)
|
||||
.map(|i| lines[i].trim())
|
||||
.find_map(|l| l.strip_prefix(&want))?
|
||||
.trim()
|
||||
.to_string();
|
||||
Some((raw != PRIOR_NONE).then_some(raw))
|
||||
}
|
||||
|
||||
/// Undo our takeover of `key`: put the recorded prior value back (or delete the key when there was
|
||||
/// none) and drop the marker. `None` when no marker is present — the file is not ours to touch.
|
||||
///
|
||||
/// Deliberately NOT "delete our line": D6 asks for the *prior value*, because on Omarchy that value
|
||||
/// is `hyprland-preview-share-picker`, i.e. every browser share on the box. Deleting the key would
|
||||
/// fall back to whatever xdph defaults to, which is not the same thing as what the user had.
|
||||
pub(crate) fn restore(existing: &str, block: Block<'_>, key: &str) -> Option<String> {
|
||||
let prior = prior_value(existing, block, key)?;
|
||||
let lines: Vec<&str> = existing.lines().collect();
|
||||
let (open_at, end_at) = block_span(&lines, block)?;
|
||||
let marker = format!("{PRIOR} {key} =");
|
||||
let mut out: Vec<String> = Vec::with_capacity(lines.len());
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let inside = i > open_at && i < end_at;
|
||||
if inside && line.trim().starts_with(&marker) {
|
||||
continue; // the marker itself goes away with the takeover it records
|
||||
}
|
||||
if inside && assigns(line, key) {
|
||||
// `Some` → put their line back with their indentation and the grammar's separator,
|
||||
// exactly as `upsert` wrote ours. `None` → there was no such key before us, so there
|
||||
// is none after us either: drop the line rather than blank it.
|
||||
if let Some(v) = &prior {
|
||||
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
|
||||
let sep = match block {
|
||||
Block::Ini(_) => "=",
|
||||
Block::Hyprlang(_) => " = ",
|
||||
};
|
||||
out.push(format!("{indent}{key}{sep}{v}"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push((*line).to_string());
|
||||
}
|
||||
let mut joined = out.join("\n");
|
||||
if existing.ends_with('\n') || !joined.ends_with('\n') {
|
||||
joined.push('\n');
|
||||
}
|
||||
Some(joined)
|
||||
}
|
||||
|
||||
/// Set `key` to `value` inside `block`, preserving every other line.
|
||||
///
|
||||
/// Three cases, all of which the tests pin: the block is absent (append it), the block has the key
|
||||
/// (replace that one line, keeping its indentation), and the block lacks the key (insert before the
|
||||
/// block ends).
|
||||
///
|
||||
/// The first time we replace a key we also leave a [`PRIOR`] marker recording what was there, so
|
||||
/// [`restore`] can put it back after a crash, a reboot or an uninstall. Written once: a later edit
|
||||
/// (the shim path moves with `$XDG_RUNTIME_DIR`) must not record OUR previous value as the user's.
|
||||
pub(crate) fn upsert(existing: &str, block: Block<'_>, key: &str, value: &str) -> String {
|
||||
let sep = match block {
|
||||
Block::Ini(_) => "=",
|
||||
Block::Hyprlang(_) => " = ",
|
||||
};
|
||||
let assignment = |indent: &str| format!("{indent}{key}{sep}{value}");
|
||||
|
||||
let lines: Vec<&str> = existing.lines().collect();
|
||||
let Some((open_at, end_at)) = block_span(&lines, block) else {
|
||||
// Absent: append the whole block, keeping the user's file intact above it. There was no
|
||||
// key here, so the marker records that — an uninstall must remove our line, not leave a
|
||||
// key the user never had.
|
||||
let mut out = existing.trim_end().to_string();
|
||||
if !out.is_empty() {
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
let marker = |indent: &str| format!("{indent}{PRIOR} {key} = {PRIOR_NONE}");
|
||||
out.push_str(&match block {
|
||||
Block::Ini(name) => format!("[{name}]\n{}\n{}\n", marker(""), assignment("")),
|
||||
Block::Hyprlang(name) => format!(
|
||||
"{name} {{\n{}\n{}\n}}\n",
|
||||
marker(" "),
|
||||
assignment(" ")
|
||||
),
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
// Already marked? Then we have taken this key over before and the marker holds the USER's
|
||||
// value; re-recording here would overwrite it with our own previous shim path.
|
||||
let marked = prior_value(existing, block, key).is_some();
|
||||
let mut out: Vec<String> = lines.iter().map(|l| (*l).to_string()).collect();
|
||||
if let Some(i) = (open_at + 1..end_at).find(|&i| assigns(lines[i], key)) {
|
||||
let indent: String = lines[i].chars().take_while(|c| c.is_whitespace()).collect();
|
||||
let had = lines[i]
|
||||
.split_once('=')
|
||||
.map_or("", |(_, v)| v)
|
||||
.trim()
|
||||
.to_string();
|
||||
out[i] = assignment(&indent);
|
||||
if !marked {
|
||||
out.insert(i, format!("{indent}{PRIOR} {key} = {had}"));
|
||||
}
|
||||
} else {
|
||||
let indent = match block {
|
||||
Block::Ini(_) => "",
|
||||
Block::Hyprlang(_) => " ",
|
||||
};
|
||||
if !marked {
|
||||
out.insert(end_at, format!("{indent}{PRIOR} {key} = {PRIOR_NONE}"));
|
||||
out.insert(end_at + 1, assignment(indent));
|
||||
} else {
|
||||
out.insert(end_at, assignment(indent));
|
||||
}
|
||||
out.insert(end_at, assignment(indent));
|
||||
}
|
||||
let mut joined = out.join("\n");
|
||||
if existing.ends_with('\n') || !joined.ends_with('\n') {
|
||||
@@ -286,58 +171,6 @@ pub(crate) fn ensure_key(path: &Path, block: Block<'_>, key: &str, value: &str)
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Read `path` and hand back its text, or `None` when it does not exist. Any other read failure —
|
||||
/// including non-UTF-8 — is an error for the same reason [`ensure_key`] spells out: a config we
|
||||
/// cannot read is a config we refuse to rewrite.
|
||||
fn read_config(path: &Path) -> Result<Option<String>> {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => Ok(Some(
|
||||
std::str::from_utf8(&bytes)
|
||||
.with_context(|| {
|
||||
format!("{} is not UTF-8 — refusing to rewrite it", path.display())
|
||||
})?
|
||||
.to_string(),
|
||||
)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e).with_context(|| format!("read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The value `key` holds in `path` today, and what the user had there before we took it over.
|
||||
/// Both `None` on a file we have never touched (or that does not exist).
|
||||
pub(crate) fn peek(
|
||||
path: &Path,
|
||||
block: Block<'_>,
|
||||
key: &str,
|
||||
) -> (Option<String>, Option<Option<String>>) {
|
||||
let Ok(Some(text)) = read_config(path) else {
|
||||
return (None, None);
|
||||
};
|
||||
(
|
||||
current_value(&text, block, key),
|
||||
prior_value(&text, block, key),
|
||||
)
|
||||
}
|
||||
|
||||
/// Undo our takeover of `key` in `path` — see [`restore`]. Returns `true` when the file changed.
|
||||
///
|
||||
/// A file with no marker of ours is left byte-for-byte alone and reports `false`: this must be safe
|
||||
/// to call unconditionally (at teardown, from an uninstall script, after a crash) on a box where we
|
||||
/// never touched the config at all.
|
||||
pub(crate) fn restore_key(path: &Path, block: Block<'_>, key: &str) -> Result<bool> {
|
||||
let Some(existing) = read_config(path)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(updated) = restore(&existing, block, key) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if updated == existing {
|
||||
return Ok(false);
|
||||
}
|
||||
write_atomic(path, updated.as_bytes())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Replace `path`'s contents with `bytes` **atomically**: fill a temp file beside it, then rename
|
||||
/// over it. `fs::write` truncates first and fills afterwards, so a crash, a full disk or a killed
|
||||
/// host between the two leaves the user's config truncated — the same loss this module exists to
|
||||
@@ -432,9 +265,7 @@ mod tests {
|
||||
out.contains("[somethingelse]\nkeep=me"),
|
||||
"user content kept"
|
||||
);
|
||||
assert!(out.contains(
|
||||
"[screencast]\n# punktfunk: previous chooser_cmd = (none)\nchooser_cmd=cat x"
|
||||
));
|
||||
assert!(out.contains("[screencast]\nchooser_cmd=cat x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -442,9 +273,7 @@ mod tests {
|
||||
let user = "[screencast]\nchooser_type=simple\nchooser_cmd=OLD\noutput_name=DP-1\n";
|
||||
let out = upsert(user, Block::Ini("screencast"), "chooser_cmd", "NEW");
|
||||
assert!(out.contains("chooser_cmd=NEW"));
|
||||
// OLD survives ONLY as the restore marker — no live assignment may still name it.
|
||||
assert!(!out.lines().any(|l| l.trim() == "chooser_cmd=OLD"));
|
||||
assert!(out.contains("# punktfunk: previous chooser_cmd = OLD"));
|
||||
assert!(!out.contains("OLD"));
|
||||
assert!(out.contains("chooser_type=simple"), "sibling key kept");
|
||||
assert!(out.contains("output_name=DP-1"), "sibling key kept");
|
||||
}
|
||||
@@ -472,10 +301,7 @@ mod tests {
|
||||
"/run/user/1000/shim.sh",
|
||||
);
|
||||
assert!(out.contains("custom_picker_binary = /run/user/1000/shim.sh"));
|
||||
assert!(!out
|
||||
.lines()
|
||||
.any(|l| l.trim() == "custom_picker_binary = OLD"));
|
||||
assert!(out.contains("# punktfunk: previous custom_picker_binary = OLD"));
|
||||
assert!(!out.contains("OLD"));
|
||||
assert!(
|
||||
out.contains("allow_token_by_default = true"),
|
||||
"sibling kept"
|
||||
@@ -523,125 +349,7 @@ mod tests {
|
||||
fn an_empty_file_yields_just_the_block() {
|
||||
assert_eq!(
|
||||
upsert("", Block::Ini("screencast"), "k", "v"),
|
||||
"[screencast]\n# punktfunk: previous k = (none)\nk=v\n"
|
||||
);
|
||||
}
|
||||
|
||||
// ── the takeover is reversible (design D6) ─────────────────────────────────────────────────
|
||||
//
|
||||
// Omarchy ships its OWN `~/.config/hypr/xdph.conf` naming
|
||||
// `custom_picker_binary = hyprland-preview-share-picker` — the picker every Chromium share on
|
||||
// the box goes through. Taking that key over without a way back is not a cosmetic leftover: it
|
||||
// is "screen sharing stopped working on this machine" for as long as the config survives, i.e.
|
||||
// past a reboot and past an uninstall.
|
||||
|
||||
/// The round trip that matters: their picker → ours → theirs, byte-identical.
|
||||
#[test]
|
||||
fn an_omarchy_picker_survives_the_round_trip() {
|
||||
let user = "screencopy {\n allow_token_by_default = true\n custom_picker_binary = hyprland-preview-share-picker\n}\n";
|
||||
let ours = upsert(
|
||||
user,
|
||||
Block::Hyprlang("screencopy"),
|
||||
"custom_picker_binary",
|
||||
"/run/user/1000/pf-picker.sh",
|
||||
);
|
||||
assert!(ours.contains("custom_picker_binary = /run/user/1000/pf-picker.sh"));
|
||||
assert_eq!(
|
||||
prior_value(&ours, Block::Hyprlang("screencopy"), "custom_picker_binary"),
|
||||
Some(Some("hyprland-preview-share-picker".to_string()))
|
||||
);
|
||||
let back = restore(&ours, Block::Hyprlang("screencopy"), "custom_picker_binary")
|
||||
.expect("a file we took over is restorable");
|
||||
assert_eq!(
|
||||
back, user,
|
||||
"the user's file must come back exactly as it was"
|
||||
);
|
||||
}
|
||||
|
||||
/// A second takeover (the shim path moves with `$XDG_RUNTIME_DIR`) must not record OUR path as
|
||||
/// theirs — that is how a restore puts back a dead runtime path instead of their picker.
|
||||
#[test]
|
||||
fn a_second_takeover_keeps_the_first_prior_value() {
|
||||
let user = "screencopy {\n custom_picker_binary = theirs\n}\n";
|
||||
let once = upsert(
|
||||
user,
|
||||
Block::Hyprlang("screencopy"),
|
||||
"custom_picker_binary",
|
||||
"/run/a",
|
||||
);
|
||||
let twice = upsert(
|
||||
&once,
|
||||
Block::Hyprlang("screencopy"),
|
||||
"custom_picker_binary",
|
||||
"/run/b",
|
||||
);
|
||||
assert!(twice.contains("custom_picker_binary = /run/b"));
|
||||
assert_eq!(
|
||||
restore(
|
||||
&twice,
|
||||
Block::Hyprlang("screencopy"),
|
||||
"custom_picker_binary"
|
||||
)
|
||||
.as_deref(),
|
||||
Some(user)
|
||||
);
|
||||
}
|
||||
|
||||
/// When the key did not exist before us, restoring REMOVES it — putting an empty or defaulted
|
||||
/// value there would be a setting the user never had.
|
||||
#[test]
|
||||
fn a_key_we_invented_is_removed_on_restore_not_blanked() {
|
||||
let user = "screencopy {\n allow_token_by_default = true\n}\n";
|
||||
let ours = upsert(
|
||||
user,
|
||||
Block::Hyprlang("screencopy"),
|
||||
"custom_picker_binary",
|
||||
"/run/a",
|
||||
);
|
||||
assert_eq!(
|
||||
prior_value(&ours, Block::Hyprlang("screencopy"), "custom_picker_binary"),
|
||||
Some(None)
|
||||
);
|
||||
assert_eq!(
|
||||
restore(&ours, Block::Hyprlang("screencopy"), "custom_picker_binary").as_deref(),
|
||||
Some(user)
|
||||
);
|
||||
}
|
||||
|
||||
/// Restoring a file we never touched must be a no-op, not a deletion — this runs at teardown
|
||||
/// on every Hyprland box, including ones whose config is entirely the user's.
|
||||
#[test]
|
||||
fn a_file_without_our_marker_is_not_ours_to_restore() {
|
||||
let user = "screencopy {\n custom_picker_binary = theirs\n}\n";
|
||||
assert_eq!(
|
||||
restore(user, Block::Hyprlang("screencopy"), "custom_picker_binary"),
|
||||
None
|
||||
);
|
||||
assert_eq!(restore("", Block::Ini("screencast"), "chooser_cmd"), None);
|
||||
}
|
||||
|
||||
/// The INI half (xdpw) reverses identically — the two backends share this module precisely so
|
||||
/// a fix on one is not a fix on one.
|
||||
#[test]
|
||||
fn the_ini_grammar_reverses_too() {
|
||||
let user = "[screencast]\nchooser_type=simple\nchooser_cmd=slurp\noutput_name=DP-1\n";
|
||||
let ours = upsert(user, Block::Ini("screencast"), "chooser_cmd", "/run/a");
|
||||
assert_eq!(
|
||||
restore(&ours, Block::Ini("screencast"), "chooser_cmd").as_deref(),
|
||||
Some(user)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_value_reads_what_is_there_now() {
|
||||
let user = "screencopy {\n custom_picker_binary = theirs\n}\n";
|
||||
assert_eq!(
|
||||
current_value(user, Block::Hyprlang("screencopy"), "custom_picker_binary").as_deref(),
|
||||
Some("theirs")
|
||||
);
|
||||
assert_eq!(
|
||||
current_value(user, Block::Hyprlang("screencopy"), "nope"),
|
||||
None
|
||||
"[screencast]\nk=v\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -721,53 +429,11 @@ mod io_tests {
|
||||
assert!(ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat x").expect("write"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&p).expect("created"),
|
||||
"[screencast]\n# punktfunk: previous chooser_cmd = (none)\nchooser_cmd=cat x\n"
|
||||
"[screencast]\nchooser_cmd=cat x\n"
|
||||
);
|
||||
assert!(!backup_of(&p).exists());
|
||||
}
|
||||
|
||||
/// The on-disk half of the D6 round trip, including the case that made it a hard requirement:
|
||||
/// an Omarchy box whose `xdph.conf` names their own share picker. After `restore_key` the file
|
||||
/// must be byte-identical to what they shipped.
|
||||
#[test]
|
||||
fn restore_key_puts_the_users_picker_back_byte_for_byte() {
|
||||
let s = Scratch::new("restore");
|
||||
let p = s.path("xdph.conf");
|
||||
let user = "screencopy {\n allow_token_by_default = true\n custom_picker_binary = hyprland-preview-share-picker\n}\n";
|
||||
std::fs::write(&p, user).expect("seed");
|
||||
let block = Block::Hyprlang("screencopy");
|
||||
assert!(
|
||||
ensure_key(&p, block, "custom_picker_binary", "/run/user/1000/pf.sh").expect("take")
|
||||
);
|
||||
assert_eq!(
|
||||
peek(&p, block, "custom_picker_binary"),
|
||||
(
|
||||
Some("/run/user/1000/pf.sh".to_string()),
|
||||
Some(Some("hyprland-preview-share-picker".to_string()))
|
||||
)
|
||||
);
|
||||
assert!(restore_key(&p, block, "custom_picker_binary").expect("restore"));
|
||||
assert_eq!(std::fs::read_to_string(&p).expect("restored"), user);
|
||||
// Idempotent, and safe to call on a file that is no longer ours.
|
||||
assert!(!restore_key(&p, block, "custom_picker_binary").expect("second restore"));
|
||||
assert_eq!(std::fs::read_to_string(&p).expect("unchanged"), user);
|
||||
}
|
||||
|
||||
/// Teardown calls this on every Hyprland box. A config that was never ours — and a config that
|
||||
/// does not exist — must come through untouched.
|
||||
#[test]
|
||||
fn restore_key_is_a_no_op_on_a_config_we_never_took_over() {
|
||||
let s = Scratch::new("restore-noop");
|
||||
let p = s.path("xdph.conf");
|
||||
let block = Block::Hyprlang("screencopy");
|
||||
assert!(!restore_key(&p, block, "custom_picker_binary").expect("absent file"));
|
||||
assert!(!p.exists(), "restoring must not CREATE a config");
|
||||
let user = "screencopy {\n custom_picker_binary = theirs\n}\n";
|
||||
std::fs::write(&p, user).expect("seed");
|
||||
assert!(!restore_key(&p, block, "custom_picker_binary").expect("not ours"));
|
||||
assert_eq!(std::fs::read_to_string(&p).expect("intact"), user);
|
||||
}
|
||||
|
||||
/// `create_new` is what makes the backup once-only, and this is the invariant it buys: after a
|
||||
/// second edit (a new `$XDG_RUNTIME_DIR`, so a new value) the backup must still hold the user's
|
||||
/// PRISTINE file — not our own previous output.
|
||||
@@ -920,7 +586,7 @@ mod io_tests {
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&real).expect("target created"),
|
||||
"[screencast]\n# punktfunk: previous chooser_cmd = (none)\nchooser_cmd=cat x\n"
|
||||
"[screencast]\nchooser_cmd=cat x\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,22 +75,18 @@ pub struct WlrootsDisplay {
|
||||
/// overlay is never coming instead of inferring it from an absence.
|
||||
last_cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
/// The topology-restore action the last `create` prepared (re-enable the heads an `exclusive`
|
||||
/// topology disabled). Written only through
|
||||
/// [`stash_topology_restore`](crate::backend::stash_topology_restore) — first-wins, because one
|
||||
/// instance serves every attempt of the host's pipeline retry loop and only attempt 1 finds
|
||||
/// heads to disable.
|
||||
///
|
||||
/// ⚠️ As on the Hyprland twin (and unlike KWin), the registry never picks this up: a sway display
|
||||
/// carries a portal fd, so `registry::acquire` returns it as pass-through before reaching
|
||||
/// `take_topology_restore()`. [`Drop`] is the ONLY thing that runs it, with the same per-session
|
||||
/// caveat for concurrent `exclusive` sessions noted there.
|
||||
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
|
||||
/// operator's screens come back when the display GROUP's last member drops (design §6.1), not
|
||||
/// when this one session ends. A backstop [`Drop`] runs it if the registry never took it, so a
|
||||
/// physical head is never left dark. Mirrors `kwin.rs` and the Hyprland twin.
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
impl Drop for WlrootsDisplay {
|
||||
fn drop(&mut self) {
|
||||
// The ONLY path that runs it (see the field docs — the registry never takes a pass-through
|
||||
// display's restore); it is what re-lights the desk when a pipeline build fails.
|
||||
// Backstop only: the registry takes the restore right after `create` (moving it into the
|
||||
// group), so this is normally `None`. If some path skipped the take, re-enable here rather
|
||||
// than strand the operator's heads dark.
|
||||
if let Some(restore) = self.pending_restore.take() {
|
||||
restore();
|
||||
}
|
||||
@@ -107,8 +103,7 @@ impl WlrootsDisplay {
|
||||
}
|
||||
|
||||
/// Apply the effective [`crate::policy::Topology`] for the just-created output `ours`, and stash
|
||||
/// the restore this instance runs on drop (see [`Self::pending_restore`] — the registry does not
|
||||
/// take a pass-through display's restore).
|
||||
/// the restore for the registry (see [`Self::pending_restore`]).
|
||||
///
|
||||
/// Called at the very END of [`create`](VirtualDisplay::create), on purpose: nothing can fail
|
||||
/// after it, so there is no path that disables the operator's heads and then unwinds past the
|
||||
@@ -123,13 +118,9 @@ impl WlrootsDisplay {
|
||||
Topology::Primary => warn_primary_is_not_expressible(),
|
||||
Topology::Exclusive => {
|
||||
let disabled = disable_other_heads(ours);
|
||||
let prepared = (!disabled.is_empty()).then(|| {
|
||||
self.pending_restore = (!disabled.is_empty()).then(|| {
|
||||
Box::new(move || restore_heads(&disabled)) as Box<dyn FnOnce() + Send>
|
||||
});
|
||||
// Keep the FIRST restore, never the latest — the same retry-loop trap as the
|
||||
// Hyprland twin, and sway is pass-through (portal fd) too, so this slot is likewise
|
||||
// never drained by the registry. See [`stash_topology_restore`].
|
||||
crate::backend::stash_topology_restore(&mut self.pending_restore, prepared);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,13 +501,6 @@ pub fn takeover_privilege_verdict() -> TakeoverVerdict {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn restore_takeover_now() {
|
||||
gamescope::restore_takeover_now();
|
||||
// The xdph screen-share picker is the other thing a host can outlive holding. It is NOT
|
||||
// restored per cast on purpose — doing that rewrites the config on every session, which
|
||||
// restarts xdph, which orphans the portal runtime's cached D-Bus connection and produces a
|
||||
// stream that never delivers a buffer (see `hyprland::StopGuard::drop`). Shutdown is the right
|
||||
// moment: no cast is live, so the restart it triggers costs nothing, and the operator's own
|
||||
// picker is back the instant the host is gone. No-op on a box we never took it over on.
|
||||
hyprland::restore_picker_on_shutdown();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
//! Shared discovery-address selection: which A record to dial when an mDNS advert resolves to
|
||||
//! several.
|
||||
//!
|
||||
//! The resolved set is a UNION of answers from every responder on every interface. The host's
|
||||
//! own advert registers exactly one address (its routed primary — see the host crate's
|
||||
//! `discovery.rs`), but the host OS's built-in mDNS responder also answers A queries for the
|
||||
//! same `<host>.local.` label per interface, with that interface's address — so a host running
|
||||
//! an overlay network (ZeroTier, Tailscale, …) whose multicast reaches this client contributes
|
||||
//! its overlay address to the set. Field case: a client dialed the host's ZeroTier address
|
||||
//! while both machines shared a LAN, because the pick was `HashSet::iter().next()` — arbitrary,
|
||||
//! and re-rolled on every re-announce.
|
||||
//!
|
||||
//! [`rank_host_addr`] is the pure policy (testable); [`pick_host_addr`] applies it with this
|
||||
//! machine's live context.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, UdpSocket};
|
||||
|
||||
/// Common leading bits of two addresses — the "how on-link is this" proxy the ranking runs on.
|
||||
/// No netmasks: a longer shared prefix with one of our own addresses is monotonically "more
|
||||
/// likely on this segment", which is all a RANKING needs.
|
||||
fn prefix_bits(a: Ipv4Addr, b: Ipv4Addr) -> u32 {
|
||||
(u32::from(a) ^ u32::from(b)).leading_zeros()
|
||||
}
|
||||
|
||||
/// The address to dial, chosen deterministically. Score, best wins, in order:
|
||||
///
|
||||
/// 1. longest common prefix with ANY of this machine's unicast addresses — an address on one of
|
||||
/// our own subnets beats one we would have to route. This alone settles the overlay case in
|
||||
/// both directions: on a shared LAN the host's LAN address out-prefixes its overlay address,
|
||||
/// and a client that can ONLY reach the host through the overlay has no LAN interface for
|
||||
/// the host's LAN address to match, so the overlay address wins instead;
|
||||
/// 2. the address the host itself declared (mDNS TXT `addr`, its routed primary) — settles a
|
||||
/// multi-NIC host's tie without ever overriding reachability, because a declared address we
|
||||
/// cannot see on-link already lost rung 1;
|
||||
/// 3. longest common prefix with OUR routed (default-route) source address — a host that
|
||||
/// predates the `addr` TXT still resolves the common ties here;
|
||||
/// 4. the numerically lowest address — pure determinism, so a re-announce cannot flap the pick.
|
||||
pub fn rank_host_addr(
|
||||
candidates: &[Ipv4Addr],
|
||||
host_declared: Option<Ipv4Addr>,
|
||||
local_ips: &[Ipv4Addr],
|
||||
routed_local: Option<Ipv4Addr>,
|
||||
) -> Option<Ipv4Addr> {
|
||||
candidates.iter().copied().max_by_key(|&c| {
|
||||
(
|
||||
local_ips
|
||||
.iter()
|
||||
.map(|&l| prefix_bits(c, l))
|
||||
.max()
|
||||
.unwrap_or(0),
|
||||
host_declared == Some(c),
|
||||
routed_local.map_or(0, |r| prefix_bits(c, r)),
|
||||
std::cmp::Reverse(u32::from(c)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// [`rank_host_addr`] with this machine's live context: every non-loopback unicast IPv4, plus
|
||||
/// the source address the OS routes toward the internet. Gathered per call — discovery events
|
||||
/// are rare, and interfaces change (VPN up/down) between them.
|
||||
pub fn pick_host_addr(
|
||||
candidates: &[Ipv4Addr],
|
||||
host_declared: Option<Ipv4Addr>,
|
||||
) -> Option<Ipv4Addr> {
|
||||
rank_host_addr(
|
||||
candidates,
|
||||
host_declared,
|
||||
&local_ipv4s(),
|
||||
routed_local_ipv4(),
|
||||
)
|
||||
}
|
||||
|
||||
fn local_ipv4s() -> Vec<Ipv4Addr> {
|
||||
if_addrs::get_if_addrs()
|
||||
.map(|ifs| {
|
||||
ifs.into_iter()
|
||||
.filter_map(|i| match i.ip() {
|
||||
IpAddr::V4(v) if !v.is_loopback() => Some(v),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Same trick as the host's `primary_local_ip`: a UDP `connect()` performs the route lookup
|
||||
/// without sending a packet, and `local_addr` is the source address the OS chose.
|
||||
fn routed_local_ipv4() -> Option<Ipv4Addr> {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||
sock.connect("8.8.8.8:80").ok()?;
|
||||
match sock.local_addr().ok()?.ip() {
|
||||
IpAddr::V4(v) if !v.is_loopback() => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::rank_host_addr;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn ip(s: &str) -> Ipv4Addr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
// The 2026-08-28 field case: host advertises from its LAN address, the OS responder adds
|
||||
// the ZeroTier address over the overlay's multicast, and both machines are on both
|
||||
// networks. The LAN address must win — with or without the host's TXT declaration.
|
||||
#[test]
|
||||
fn shared_lan_beats_shared_overlay() {
|
||||
let candidates = [ip("192.168.196.206"), ip("192.168.1.170")];
|
||||
let locals = [ip("192.168.1.150"), ip("192.168.196.57")];
|
||||
for declared in [None, Some(ip("192.168.1.170"))] {
|
||||
assert_eq!(
|
||||
rank_host_addr(&candidates, declared, &locals, Some(ip("192.168.1.150"))),
|
||||
Some(ip("192.168.1.170"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A client that can ONLY reach the host through the overlay (different site): the host's
|
||||
// declared LAN address is not on any of our subnets, so it must NOT win — the overlay
|
||||
// address is the reachable one.
|
||||
#[test]
|
||||
fn overlay_only_client_ignores_the_declared_lan_address() {
|
||||
let candidates = [ip("192.168.1.170"), ip("192.168.196.206")];
|
||||
let locals = [ip("10.1.2.3"), ip("192.168.196.57")];
|
||||
assert_eq!(
|
||||
rank_host_addr(
|
||||
&candidates,
|
||||
Some(ip("192.168.1.170")),
|
||||
&locals,
|
||||
Some(ip("10.1.2.3"))
|
||||
),
|
||||
Some(ip("192.168.196.206"))
|
||||
);
|
||||
}
|
||||
|
||||
// A multi-NIC host (Ethernet + Wi-Fi on the same LAN) ties on every reachability rung;
|
||||
// its own declaration settles which of ITS addresses we dial. Without the declaration
|
||||
// (older host) the pick is still deterministic.
|
||||
#[test]
|
||||
fn declared_addr_settles_a_multi_nic_tie() {
|
||||
let candidates = [ip("192.168.1.170"), ip("192.168.1.171")];
|
||||
let locals = [ip("192.168.1.150")];
|
||||
assert_eq!(
|
||||
rank_host_addr(
|
||||
&candidates,
|
||||
Some(ip("192.168.1.171")),
|
||||
&locals,
|
||||
Some(ip("192.168.1.150"))
|
||||
),
|
||||
Some(ip("192.168.1.171"))
|
||||
);
|
||||
assert_eq!(
|
||||
rank_host_addr(&candidates, None, &locals, Some(ip("192.168.1.150"))),
|
||||
Some(ip("192.168.1.170")),
|
||||
"no declaration: lowest address, never a hash-order roll"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_context_is_still_deterministic() {
|
||||
let candidates = [ip("10.0.0.9"), ip("10.0.0.5")];
|
||||
assert_eq!(
|
||||
rank_host_addr(&candidates, None, &[], None),
|
||||
Some(ip("10.0.0.5"))
|
||||
);
|
||||
assert_eq!(rank_host_addr(&[], None, &[], None), None);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ pub mod client;
|
||||
pub mod clipboard;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod discovery;
|
||||
pub mod error;
|
||||
pub mod fec;
|
||||
pub mod input;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,7 @@ authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
# `ureq-tls` is for `ctl`: ureq's own `TlsConfig` has no hook for a custom certificate verifier,
|
||||
# so the pinned operator client hands rustls a `ClientConfig` through core's shared glue — the same
|
||||
# path the tray and the desktop client already take. `quic` brings `tls` (and `PinVerify`) with it.
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic", "ureq-tls"] }
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
# Signed update-manifest fetch/verify + install-kind detection, shared with the Linux client.
|
||||
pf-update-check = { path = "../pf-update-check" }
|
||||
# Config-dir + owner-private file helpers (moved out of the gamestream junk drawer, plan §W6).
|
||||
@@ -310,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
|
||||
|
||||
@@ -1,653 +0,0 @@
|
||||
//! `punktfunk-host ctl` — the operator surface as a **subcommand**, not a second binary.
|
||||
//!
|
||||
//! Everything the web console's daily 95 % does — approve a pending device, type a Moonlight PIN,
|
||||
//! rename or unpair, change an access preset, stop a session, watch events — reachable from a
|
||||
//! terminal, a shell script, or a Quickshell `Process`. Its first consumer is the Omarchy shell
|
||||
//! plugin (design D10/D13), but nothing here is Omarchy-specific: the surface is a loopback client
|
||||
//! of the mgmt API, and `watch`'s line-JSON is as usable from a waybar module or `while read`.
|
||||
//!
|
||||
//! **Why a subcommand.** A new binary touches every Linux artifact we ship (the Arch PKGBUILD, the
|
||||
//! deb/rpm builders, the sysext, the Nix module, signing and manifests) to buy nothing: `main.rs`
|
||||
//! already dispatches a dozen verbs, and in-crate means the client deserialises what the server
|
||||
//! serialises with no second declaration of the types to drift. The one cost is startup — the host
|
||||
//! binary links the world — and it is paid once per *action*, not per poll, because the
|
||||
//! interactive consumer holds one long-lived `watch`. Measured threshold, recorded so the decision
|
||||
//! is falsifiable: if `ctl status --json` p50 exceeds ~150 ms on the target box, lift `ctl/` behind
|
||||
//! a thin bin; the module boundary makes that mechanical.
|
||||
//!
|
||||
//! **Security** is [`client`]'s module docs: pin before token (I2), no credential on argv or in
|
||||
//! the environment (I1), no server-side change at all (I4 — this crate's `mgmt/auth.rs` is
|
||||
//! untouched by the whole surface). ctl consumes the token the host persists; it never mints one.
|
||||
//!
|
||||
//! **Approval UX (I6)** is enforced here rather than left to each front-end: `approve`/`deny` take
|
||||
//! an **id**, never "the newest", and every listing prints the claimed name next to the
|
||||
//! fingerprint tail so an operator approving a device is looking at what the device claims *and*
|
||||
//! at something it cannot forge.
|
||||
//!
|
||||
//! Exit codes: 0 success, 1 the host refused, 2 usage, 3 no host reachable, 4 certificate pin
|
||||
//! mismatch. 4 is separate on purpose — it is the security signal, and a script that treats it as
|
||||
//! "host down" would retry straight into a squatter.
|
||||
|
||||
pub mod client;
|
||||
mod watch;
|
||||
|
||||
use client::{Client, Failure, Result, SCHEMA_VERSION};
|
||||
use punktfunk_core::quic::{
|
||||
GRANT_PRESET_CONTROLLER_ONLY, GRANT_PRESET_FULL, GRANT_PRESET_VIEW_ONLY,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub fn main(args: &[String]) -> anyhow::Result<()> {
|
||||
// `--json` is positionless: it is a mode, not an argument to any one verb.
|
||||
let json = args.iter().any(|a| a == "--json");
|
||||
let rest: Vec<&str> = args
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|a| *a != "--json")
|
||||
.collect();
|
||||
match run(&rest, json) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(f) => {
|
||||
if json {
|
||||
// Machine consumers get the failure on stdout in the same envelope as a success,
|
||||
// so a QML `Process` parses one shape and reads `error` or `data`.
|
||||
println!(
|
||||
"{}",
|
||||
json!({"v": SCHEMA_VERSION, "error": {"code": f.code, "message": f.message}})
|
||||
);
|
||||
} else {
|
||||
eprintln!("punktfunk-host ctl: {}", f.message);
|
||||
}
|
||||
std::process::exit(f.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: &[&str], json: bool) -> Result<()> {
|
||||
let Some(verb) = args.first().copied() else {
|
||||
print_usage();
|
||||
return Err(Failure::usage("no verb given"));
|
||||
};
|
||||
let rest = &args[1..];
|
||||
match verb {
|
||||
"-h" | "--help" | "help" => {
|
||||
print_usage();
|
||||
Ok(())
|
||||
}
|
||||
"status" => {
|
||||
let v = Client::connect(None)?.get("/api/v1/status")?;
|
||||
out(json, &v, render_status);
|
||||
Ok(())
|
||||
}
|
||||
"sessions" => {
|
||||
let v = Client::connect(None)?.get("/api/v1/status")?;
|
||||
let slice = json!({
|
||||
"active_sessions": v.get("active_sessions").cloned().unwrap_or(Value::Null),
|
||||
"video_streaming": v.get("video_streaming").cloned().unwrap_or(Value::Null),
|
||||
"audio_streaming": v.get("audio_streaming").cloned().unwrap_or(Value::Null),
|
||||
"session": v.get("session").cloned().unwrap_or(Value::Null),
|
||||
"stream": v.get("stream").cloned().unwrap_or(Value::Null),
|
||||
"games": v.get("games").cloned().unwrap_or(Value::Null),
|
||||
});
|
||||
out(json, &slice, render_sessions);
|
||||
Ok(())
|
||||
}
|
||||
"stop-session" => {
|
||||
let v = Client::connect(None)?.delete("/api/v1/session")?;
|
||||
out(json, &v, |_| println!("session stopped"));
|
||||
Ok(())
|
||||
}
|
||||
"end-game" => {
|
||||
let v = Client::connect(None)?.post("/api/v1/game/end", &json!({}))?;
|
||||
out(json, &v, |_| println!("game ended"));
|
||||
Ok(())
|
||||
}
|
||||
"pair" => pair(rest, json),
|
||||
"pending" => {
|
||||
let v = Client::connect(None)?.get("/api/v1/native/pending")?;
|
||||
out(json, &v, render_pending);
|
||||
Ok(())
|
||||
}
|
||||
"approve" => approve(rest, json),
|
||||
"deny" => {
|
||||
let id = one_id(rest, "deny")?;
|
||||
let v = Client::connect(None)?
|
||||
.post(&format!("/api/v1/native/pending/{id}/deny"), &json!({}))?;
|
||||
out(json, &v, move |_| println!("denied device {id}"));
|
||||
Ok(())
|
||||
}
|
||||
"pin" => {
|
||||
let pin = rest
|
||||
.first()
|
||||
.copied()
|
||||
.ok_or_else(|| Failure::usage("pin: give the PIN the client is showing"))?;
|
||||
let v = Client::connect(None)?.post("/api/v1/pair/pin", &json!({ "pin": pin }))?;
|
||||
out(json, &v, |_| println!("PIN submitted"));
|
||||
Ok(())
|
||||
}
|
||||
// Not an API call at all: a ticket the console can verify with the token it already holds.
|
||||
// See `console_url` — the point is that reading the 0600 token IS the proof.
|
||||
"console-url" => {
|
||||
let url = console_url()?;
|
||||
out(json, &json!({ "url": url }), move |_| println!("{url}"));
|
||||
Ok(())
|
||||
}
|
||||
"clients" => {
|
||||
let c = Client::connect(None)?;
|
||||
// Both planes, labelled — a device list that silently covered only one of them is how
|
||||
// "I unpaired it and it still connects" happens.
|
||||
let both = json!({
|
||||
"native": c.get("/api/v1/native/clients")?,
|
||||
"gamestream": c.get("/api/v1/clients")?,
|
||||
});
|
||||
out(json, &both, render_clients);
|
||||
Ok(())
|
||||
}
|
||||
"rename" => rename(rest, json),
|
||||
"unpair" => unpair(rest, json),
|
||||
"access" => access(rest, json),
|
||||
"watch" => {
|
||||
let kinds = flag_value(rest, "--kinds");
|
||||
let since = flag_value(rest, "--since")
|
||||
.map(|s| {
|
||||
s.parse::<u64>().map_err(|_| {
|
||||
Failure::usage("watch: --since takes an event sequence number")
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
watch::run(kinds.as_deref(), since)
|
||||
}
|
||||
other => {
|
||||
print_usage();
|
||||
Err(Failure::usage(format!("unknown ctl verb '{other}'")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── verbs with enough argument shape to deserve their own function ─────────────────────────────
|
||||
|
||||
fn pair(args: &[&str], json: bool) -> Result<()> {
|
||||
let c = Client::connect(None)?;
|
||||
match args.first().copied() {
|
||||
Some("arm") => {
|
||||
let mut body = json!({});
|
||||
if let Some(ttl) = num_flag(args, "--ttl")? {
|
||||
body["ttl_secs"] = json!(ttl);
|
||||
}
|
||||
if let Some(exp) = num_flag(args, "--expires-in")? {
|
||||
body["expires_in_secs"] = json!(exp);
|
||||
}
|
||||
if let Some(g) = preset_flag(args)? {
|
||||
body["grants"] = json!(g);
|
||||
}
|
||||
// Binding the window to one fingerprint is the difference between "a device may pair"
|
||||
// and "any LAN peer may burn my pairing window" (security review #9) — so it is a
|
||||
// first-class flag here, not console-only.
|
||||
if let Some(fp) = flag_value(args, "--fingerprint") {
|
||||
body["fingerprint"] = json!(fp);
|
||||
}
|
||||
let v = c.post("/api/v1/native/pair/arm", &body)?;
|
||||
out(json, &v, render_pair);
|
||||
Ok(())
|
||||
}
|
||||
Some("disarm") => {
|
||||
let v = c.delete("/api/v1/native/pair")?;
|
||||
out(json, &v, |_| println!("pairing window closed"));
|
||||
Ok(())
|
||||
}
|
||||
Some("status") | None => {
|
||||
let v = c.get("/api/v1/native/pair")?;
|
||||
// The GameStream PIN flow lives on a different route; fold it in so one command
|
||||
// answers "is anything waiting for me?" for both planes.
|
||||
let mut v = v;
|
||||
if let Ok(gs) = c.get("/api/v1/pair") {
|
||||
v["pin_pending"] = gs.get("pin_pending").cloned().unwrap_or(json!(false));
|
||||
}
|
||||
out(json, &v, render_pair);
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(Failure::usage(format!(
|
||||
"pair: expected arm | disarm | status, got '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn approve(args: &[&str], json: bool) -> Result<()> {
|
||||
let id = one_id(args, "approve")?;
|
||||
let mut body = json!({});
|
||||
if let Some(name) = flag_value(args, "--name") {
|
||||
body["name"] = json!(name);
|
||||
}
|
||||
if let Some(g) = preset_flag(args)? {
|
||||
body["grants"] = json!(g);
|
||||
}
|
||||
if let Some(exp) = num_flag(args, "--expires-in")? {
|
||||
body["expires_in_secs"] = json!(exp);
|
||||
}
|
||||
let v = Client::connect(None)?.post(&format!("/api/v1/native/pending/{id}/approve"), &body)?;
|
||||
out(json, &v, move |_| println!("approved device {id}"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rename(args: &[&str], json: bool) -> Result<()> {
|
||||
let (fp, name) = match (args.first(), args.get(1)) {
|
||||
(Some(fp), Some(name)) => (*fp, *name),
|
||||
_ => return Err(Failure::usage("rename: <fingerprint> <name>")),
|
||||
};
|
||||
let c = Client::connect(None)?;
|
||||
// The two planes keep separate stores and separate routes, and a fingerprint belongs to
|
||||
// exactly one of them. Try native first (the default plane), fall back to GameStream, so the
|
||||
// operator does not have to know which store a device they can see in `ctl clients` lives in.
|
||||
let native = c.patch(
|
||||
&format!("/api/v1/native/clients/{fp}"),
|
||||
&json!({ "name": name }),
|
||||
);
|
||||
let v = match native {
|
||||
Ok(v) => v,
|
||||
Err(e) if e.code == client::EXIT_API => {
|
||||
c.patch(&format!("/api/v1/clients/{fp}"), &json!({ "label": name }))?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
out(json, &v, move |_| println!("renamed {fp} to {name}"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unpair(args: &[&str], json: bool) -> Result<()> {
|
||||
let c = Client::connect(None)?;
|
||||
if args.contains(&"--all") {
|
||||
// Unpairing everything is the one destructive verb here — every device on the box has to
|
||||
// be re-paired by hand afterwards. Human mode asks; machine mode demands `--yes`, because
|
||||
// a plugin cannot answer a prompt and must not be able to do this by accident.
|
||||
if !args.contains(&"--yes") {
|
||||
if json {
|
||||
return Err(Failure::usage(
|
||||
"unpair --all needs --yes in --json mode (it cannot prompt)",
|
||||
));
|
||||
}
|
||||
if !confirm("Unpair EVERY device on both planes? [y/N] ") {
|
||||
return Err(Failure::usage("cancelled"));
|
||||
}
|
||||
}
|
||||
let both = json!({
|
||||
"native": c.delete("/api/v1/native/clients")?,
|
||||
"gamestream": c.delete("/api/v1/clients")?,
|
||||
});
|
||||
out(json, &both, |_| println!("all devices unpaired"));
|
||||
return Ok(());
|
||||
}
|
||||
let fp = args
|
||||
.first()
|
||||
.copied()
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.ok_or_else(|| Failure::usage("unpair: <fingerprint>, or --all"))?;
|
||||
let native = c.delete(&format!("/api/v1/native/clients/{fp}"));
|
||||
let v = match native {
|
||||
Ok(v) => v,
|
||||
Err(e) if e.code == client::EXIT_API => c.delete(&format!("/api/v1/clients/{fp}"))?,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
out(json, &v, move |_| println!("unpaired {fp}"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn access(args: &[&str], json: bool) -> Result<()> {
|
||||
let (fp, preset) = match (args.first(), args.get(1)) {
|
||||
(Some(fp), Some(p)) => (*fp, *p),
|
||||
_ => {
|
||||
return Err(Failure::usage(
|
||||
"access: <fingerprint> <full|controller|view>",
|
||||
))
|
||||
}
|
||||
};
|
||||
// Presets only. The full grant matrix is the console's job (per-client-access design): a
|
||||
// bitmask on a command line is exactly the kind of thing that gets a digit wrong and silently
|
||||
// hands a device the keyboard.
|
||||
let grants = grants_for(preset)?;
|
||||
let v = Client::connect(None)?.patch(
|
||||
&format!("/api/v1/native/clients/{fp}"),
|
||||
&json!({ "grants": grants }),
|
||||
)?;
|
||||
out(json, &v, move |_| println!("{fp}: access set to {preset}"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A one-shot console URL carrying a ticket that logs the operator straight in.
|
||||
///
|
||||
/// **What is being trusted, and what is not.** The console binds all interfaces so it can be
|
||||
/// reached from a phone on the LAN, and its admin surface is pairing, unpair and session control —
|
||||
/// so the network is not evidence of anything and the password stays. What IS evidence is the
|
||||
/// **mgmt token**: a 0600 file inside the 0700 config dir, readable only by the uid the host runs
|
||||
/// as. Whoever can read it can already drive the whole admin API directly (it is the credential
|
||||
/// the console's own proxy presents), so letting them skip a password they could simply read
|
||||
/// widens nothing. A visitor without a ticket still meets the login page.
|
||||
///
|
||||
/// The ticket is `<unix-seconds>.<nonce>.<HMAC-SHA256>` over `pf-console-handoff:v1:ts:nonce`,
|
||||
/// keyed by the token. The console recomputes it with its own copy — no new host route, no shared
|
||||
/// state, nothing to expire on this side. TTL and single-use are enforced by the console
|
||||
/// (`web/server/routes/_auth/handoff.get.ts`); the nonce is what keeps two launches in the same
|
||||
/// second from colliding in its replay set.
|
||||
fn console_url() -> Result<String> {
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
let dir = pf_paths::config_dir();
|
||||
let token = crate::mgmt_token::read_persisted(&dir).ok_or_else(|| {
|
||||
Failure::unreachable(format!(
|
||||
"no management token in {} — the console shares this file, so without it there is \
|
||||
nothing for a handoff to prove. Start the host once and retry.",
|
||||
dir.join("mgmt-token").display()
|
||||
))
|
||||
})?;
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let mut raw = [0u8; 16];
|
||||
rand::RngCore::fill_bytes(&mut rand::rng(), &mut raw);
|
||||
let nonce = hex::encode(raw);
|
||||
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(token.as_bytes())
|
||||
.map_err(|e| Failure::api(format!("could not key the handoff HMAC: {e}")))?;
|
||||
mac.update(format!("pf-console-handoff:v1:{ts}:{nonce}").as_bytes());
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
// The console's own port, not the mgmt one. It is not published anywhere the way
|
||||
// `mgmt-endpoint` is, so the documented default stands until somebody moves it.
|
||||
Ok(format!(
|
||||
"https://localhost:47992/_auth/handoff?t={ts}.{nonce}.{sig}"
|
||||
))
|
||||
}
|
||||
|
||||
fn grants_for(preset: &str) -> Result<u32> {
|
||||
match preset {
|
||||
"full" => Ok(GRANT_PRESET_FULL),
|
||||
"controller" => Ok(GRANT_PRESET_CONTROLLER_ONLY),
|
||||
"view" => Ok(GRANT_PRESET_VIEW_ONLY),
|
||||
other => Err(Failure::usage(format!(
|
||||
"unknown access preset '{other}' (want full | controller | view)"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// ── argument helpers ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn flag_value(args: &[&str], flag: &str) -> Option<String> {
|
||||
let i = args.iter().position(|a| *a == flag)?;
|
||||
args.get(i + 1).map(|s| (*s).to_string())
|
||||
}
|
||||
|
||||
fn num_flag(args: &[&str], flag: &str) -> Result<Option<u64>> {
|
||||
match flag_value(args, flag) {
|
||||
None => Ok(None),
|
||||
Some(v) => v
|
||||
.parse()
|
||||
.map(Some)
|
||||
.map_err(|_| Failure::usage(format!("{flag} takes a number of seconds, got '{v}'"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn preset_flag(args: &[&str]) -> Result<Option<u32>> {
|
||||
flag_value(args, "--preset")
|
||||
.map(|p| grants_for(&p))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// The pending-device id, which is always the **first** argument. Deliberately not "the first
|
||||
/// thing that parses as a number anywhere in the line": that would let `--expires-in 3600` be read
|
||||
/// as the device to approve, which is the one mistake I6 exists to make impossible.
|
||||
fn one_id(args: &[&str], verb: &str) -> Result<u32> {
|
||||
let raw = args
|
||||
.first()
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.ok_or_else(|| Failure::usage(format!("{verb}: <id> first, from `ctl pending`")))?;
|
||||
raw.parse()
|
||||
.map_err(|_| Failure::usage(format!("{verb}: '{raw}' is not a pending-device id")))
|
||||
}
|
||||
|
||||
fn confirm(prompt: &str) -> bool {
|
||||
use std::io::Write as _;
|
||||
print!("{prompt}");
|
||||
let _ = std::io::stdout().flush();
|
||||
let mut line = String::new();
|
||||
std::io::stdin().read_line(&mut line).is_ok() && matches!(line.trim(), "y" | "Y" | "yes")
|
||||
}
|
||||
|
||||
// ── output ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One shape for every verb: the versioned envelope in `--json` mode, the terse table otherwise.
|
||||
/// Nothing we ship parses the human half (I8), which is what lets it stay readable.
|
||||
fn out(json: bool, v: &Value, human: impl FnOnce(&Value)) {
|
||||
if json {
|
||||
println!("{}", json!({ "v": SCHEMA_VERSION, "data": v }));
|
||||
} else {
|
||||
human(v);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_status(v: &Value) {
|
||||
let streaming = v["video_streaming"].as_bool().unwrap_or(false);
|
||||
println!("host {}", if streaming { "streaming" } else { "idle" });
|
||||
println!("sessions {}", v["active_sessions"].as_i64().unwrap_or(0));
|
||||
println!(
|
||||
"paired {} native, {} gamestream",
|
||||
v["native_paired_clients"].as_i64().unwrap_or(0),
|
||||
v["paired_clients"].as_i64().unwrap_or(0)
|
||||
);
|
||||
if v["pin_pending"].as_bool().unwrap_or(false) {
|
||||
println!("pin PENDING — run `ctl pin <PIN>` with the code the client shows");
|
||||
}
|
||||
render_games(v);
|
||||
}
|
||||
|
||||
fn render_sessions(v: &Value) {
|
||||
println!("sessions {}", v["active_sessions"].as_i64().unwrap_or(0));
|
||||
if let Some(s) = v["session"].as_object() {
|
||||
println!(
|
||||
"mode {}x{} @ {}",
|
||||
s.get("width").and_then(Value::as_i64).unwrap_or(0),
|
||||
s.get("height").and_then(Value::as_i64).unwrap_or(0),
|
||||
s.get("fps").and_then(Value::as_i64).unwrap_or(0)
|
||||
);
|
||||
}
|
||||
render_games(v);
|
||||
}
|
||||
|
||||
fn render_games(v: &Value) {
|
||||
let Some(games) = v["games"].as_array().filter(|g| !g.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
println!("\nGAME CLIENT PLANE STATE");
|
||||
for g in games {
|
||||
println!(
|
||||
"{:<30} {:<19} {:<11} {}",
|
||||
trunc(g["title"].as_str().unwrap_or("(desktop)"), 30),
|
||||
trunc(g["client"].as_str().unwrap_or("—"), 19),
|
||||
g["plane"].as_str().unwrap_or("—"),
|
||||
g["state"].as_str().unwrap_or("—"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pending(v: &Value) {
|
||||
let Some(rows) = v.as_array().filter(|r| !r.is_empty()) else {
|
||||
println!("no devices waiting for approval");
|
||||
return;
|
||||
};
|
||||
// I6: the claimed name AND the fingerprint tail, always — the name is what the device says it
|
||||
// is, the tail is what it can't lie about.
|
||||
println!("ID NAME FINGERPRINT AGE ACCESS");
|
||||
for r in rows {
|
||||
println!(
|
||||
"{:<5} {:<25} {:<12} {:<8} {}",
|
||||
r["id"].as_i64().unwrap_or(-1),
|
||||
trunc(r["name"].as_str().unwrap_or("(unnamed)"), 25),
|
||||
tail(r["fingerprint"].as_str().unwrap_or("")),
|
||||
format!("{}s", r["age_secs"].as_i64().unwrap_or(0)),
|
||||
r["access_level"].as_str().unwrap_or("—"),
|
||||
);
|
||||
}
|
||||
println!("\napprove with `ctl approve <ID>`, refuse with `ctl deny <ID>`");
|
||||
}
|
||||
|
||||
fn render_clients(v: &Value) {
|
||||
println!("PLANE NAME FINGERPRINT ACCESS EXPIRES");
|
||||
for r in v["native"].as_array().into_iter().flatten() {
|
||||
println!(
|
||||
"{:<11} {:<25} {:<12} {:<11} {}",
|
||||
"native",
|
||||
trunc(r["name"].as_str().unwrap_or("(unnamed)"), 25),
|
||||
tail(r["fingerprint"].as_str().unwrap_or("")),
|
||||
r["access_level"].as_str().unwrap_or("—"),
|
||||
match r["expires_unix"].as_i64() {
|
||||
None => "permanent".to_string(),
|
||||
Some(t) => format!("unix {t}"),
|
||||
}
|
||||
);
|
||||
}
|
||||
for r in v["gamestream"].as_array().into_iter().flatten() {
|
||||
// The GameStream store has no grants and no expiry — its devices are pinned certificates,
|
||||
// full stop. Two dashes rather than borrowed native semantics.
|
||||
println!(
|
||||
"{:<11} {:<25} {:<12} {:<11} —",
|
||||
"gamestream",
|
||||
trunc(r["label"].as_str().unwrap_or("(unnamed)"), 25),
|
||||
tail(r["fingerprint"].as_str().unwrap_or("")),
|
||||
"—",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pair(v: &Value) {
|
||||
println!(
|
||||
"native {}",
|
||||
match (
|
||||
v["enabled"].as_bool().unwrap_or(false),
|
||||
v["armed"].as_bool().unwrap_or(false)
|
||||
) {
|
||||
(false, _) => "the native plane is not running".to_string(),
|
||||
(true, false) => "disarmed".to_string(),
|
||||
(true, true) => match v["expires_in_secs"].as_i64() {
|
||||
Some(s) => format!("ARMED, {s}s left"),
|
||||
None => "ARMED".to_string(),
|
||||
},
|
||||
}
|
||||
);
|
||||
if let Some(pin) = v["pin"].as_str() {
|
||||
println!("pin {pin} — enter this on the device");
|
||||
}
|
||||
if v["pin_pending"].as_bool().unwrap_or(false) {
|
||||
println!("moonlight a client is waiting on its PIN — `ctl pin <PIN>`");
|
||||
}
|
||||
println!("paired {}", v["paired_clients"].as_i64().unwrap_or(0));
|
||||
}
|
||||
|
||||
/// Last 10 hex characters, the shape an operator compares against what the device shows. Never the
|
||||
/// whole 64 — a wall of hex is exactly what makes people stop reading it.
|
||||
fn tail(fp: &str) -> String {
|
||||
match fp.len() {
|
||||
0 => "—".to_string(),
|
||||
n if n <= 10 => fp.to_string(),
|
||||
n => format!("…{}", &fp[n - 10..]),
|
||||
}
|
||||
}
|
||||
|
||||
fn trunc(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
// A plain `&str`, printed through `{USAGE}` rather than as a format string: the `watch`
|
||||
// example below contains JSON braces, which a literal format string would try to interpolate.
|
||||
const USAGE: &str = r#"punktfunk-host ctl — operator control over the local management API
|
||||
|
||||
USAGE:
|
||||
punktfunk-host ctl <VERB> [ARGS] [--json]
|
||||
|
||||
STATE
|
||||
status host state, session count, paired counts
|
||||
sessions the active session(s) and any launched game
|
||||
watch [--kinds K,..] [--since N]
|
||||
the host event stream as line-JSON on stdout, one object per
|
||||
line; reconnects by itself and emits {"kind":"ctl.resync"}
|
||||
when it fell off the catch-up ring
|
||||
|
||||
PAIRING
|
||||
pair status is a pairing window open, and is a PIN waiting
|
||||
pair arm [--ttl S] [--expires-in S] [--preset P] [--fingerprint FP]
|
||||
open a native pairing window (--fingerprint binds it to ONE device)
|
||||
pair disarm close it
|
||||
pending devices knocking, awaiting approval
|
||||
approve <ID> [--name N] [--preset P] [--expires-in S]
|
||||
deny <ID>
|
||||
pin <PIN> submit the PIN a Moonlight/GameStream client is showing
|
||||
|
||||
CONSOLE
|
||||
console-url print a one-shot URL that opens the web console already logged in.
|
||||
The ticket is signed with the management token, so being able to
|
||||
read that 0600 file IS the proof — a visitor without one still
|
||||
meets the login page.
|
||||
|
||||
DEVICES
|
||||
clients paired devices on both planes
|
||||
rename <FP> <NAME>
|
||||
access <FP> <full|controller|view>
|
||||
unpair <FP> | unpair --all [--yes]
|
||||
|
||||
OPTIONS
|
||||
--json versioned JSON on stdout (the contract; the tables are for humans)
|
||||
|
||||
EXIT CODES
|
||||
0 ok 1 the host refused 2 usage 3 no host reachable 4 certificate pin mismatch
|
||||
|
||||
The token and the certificate pin are read from the host's config directory; neither is ever
|
||||
accepted on the command line or from the environment."#;
|
||||
eprintln!("{USAGE}");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn presets_map_to_the_hosts_own_masks() {
|
||||
assert_eq!(grants_for("full").unwrap(), GRANT_PRESET_FULL);
|
||||
assert_eq!(
|
||||
grants_for("controller").unwrap(),
|
||||
GRANT_PRESET_CONTROLLER_ONLY
|
||||
);
|
||||
assert_eq!(grants_for("view").unwrap(), GRANT_PRESET_VIEW_ONLY);
|
||||
// A typo must be usage (2), never a silently-wrong mask.
|
||||
assert_eq!(grants_for("fulll").unwrap_err().code, client::EXIT_USAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_tails_stay_short_and_never_panic_on_multibyte() {
|
||||
assert_eq!(tail(""), "—");
|
||||
assert_eq!(tail("abc"), "abc");
|
||||
assert_eq!(tail(&"a".repeat(64)), format!("…{}", "a".repeat(10)));
|
||||
assert_eq!(trunc("ünïcödé title", 5), "ünïc…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_are_read_positionlessly() {
|
||||
let args = ["arm", "--ttl", "120", "--preset", "view"];
|
||||
assert_eq!(num_flag(&args, "--ttl").unwrap(), Some(120));
|
||||
assert_eq!(preset_flag(&args).unwrap(), Some(GRANT_PRESET_VIEW_ONLY));
|
||||
assert_eq!(num_flag(&args, "--expires-in").unwrap(), None);
|
||||
// A non-numeric TTL is usage, not a silently-dropped flag.
|
||||
assert_eq!(
|
||||
num_flag(&["--ttl", "soon"], "--ttl").unwrap_err().code,
|
||||
client::EXIT_USAGE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve_takes_an_id_and_never_guesses() {
|
||||
assert_eq!(one_id(&["7", "--name", "tv"], "approve").unwrap(), 7);
|
||||
// A flag's VALUE must never be read as the id — `approve --expires-in 3600` naming
|
||||
// device 3600 is precisely the accident I6 forbids.
|
||||
assert!(one_id(&["--expires-in", "3600"], "approve").is_err());
|
||||
assert!(one_id(&["newest"], "approve").is_err());
|
||||
assert!(one_id(&[], "approve").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
//! The ctl surface's loopback client — discovery, the **pinned** TLS transport, and the one place
|
||||
//! a credential is read (implementation plan §§1–2, invariants I1/I2).
|
||||
//!
|
||||
//! Three files on disk are the whole configuration; nothing comes from argv or the environment:
|
||||
//!
|
||||
//! | file | what | absent ⇒ |
|
||||
//! |------|------|----------|
|
||||
//! | `mgmt-endpoint` | the port the host actually bound (`pf_paths::published_mgmt_port`) | fall back to 47990, exactly like the tray and the console |
|
||||
//! | `native-cert.pem` (else `cert.pem`) | the leaf the mgmt listener presents — **the pin** | [`EXIT_UNREACHABLE`]: no pin, so no token, so no call |
|
||||
//! | `mgmt-token` | the operator bearer the console already uses | [`EXIT_UNREACHABLE`] ("is the host installed?") |
|
||||
//!
|
||||
//! **I2, pin before token.** The agent's rustls verifier is the workspace's canonical
|
||||
//! [`PinVerify`](punktfunk_core::tls::PinVerify) with the host's own leaf fingerprint. rustls
|
||||
//! validates the server certificate *during the handshake*, and ureq writes the request line and
|
||||
//! headers only after the handshake completes — so on a mismatch the `Authorization` header is
|
||||
//! never serialised, let alone sent. That is a property of the ordering, not of a check we
|
||||
//! remember to run, which is why the port-squat vector (another local uid binding the mgmt port
|
||||
//! while the host is down) closes with no server-side change at all (I4).
|
||||
//!
|
||||
//! Telling a pin mismatch apart from "nothing is listening" is what [`PinVerify::with_observed`]
|
||||
//! is for: it records the leaf it saw *before* comparing, so after a failed connect a slot holding
|
||||
//! a fingerprint that isn't ours means squat/rotation ([`EXIT_PIN`]), and an empty slot means we
|
||||
//! never got a certificate at all ([`EXIT_UNREACHABLE`]).
|
||||
//!
|
||||
//! **I1, no credential on argv/env/logs.** There is deliberately no `--token` flag and no
|
||||
//! `PUNKTFUNK_MGMT_TOKEN` read here: an operator who had to put the token in ctl's environment
|
||||
//! would publish it in `/proc/<pid>/environ`, which is exactly the cross-uid leak the config dir's
|
||||
//! 0700 mode exists to prevent. The cost is stated in the docs: a host handed its token by
|
||||
//! `--mgmt-token`/env and *never* persisting one is not reachable by ctl. Every packaged host
|
||||
//! persists (`mgmt_token::load_or_generate`), so this is a dev-box footnote, not a gap.
|
||||
//!
|
||||
//! **ctl never mints.** A missing `mgmt-token` is a hard error, never a "let me generate one" —
|
||||
//! the inverse of the `web-password` silent-adoption finding (security sweep 2026-08-15). The
|
||||
//! host is the only minter; ctl is a consumer.
|
||||
//!
|
||||
//! Responses come back as [`serde_json::Value`], not the in-crate mgmt structs. That is not
|
||||
//! laziness about drift, it is the *stronger* answer for the contract this ships: `--json` echoes
|
||||
//! the server's own JSON verbatim, so a field added to a response reaches the plugin with no ctl
|
||||
//! diff at all, and the OpenAPI drift gate remains the single place the shapes are pinned. Only
|
||||
//! the human tables name fields, and a table that misses a new one is cosmetic.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Exit codes (implementation plan §4). Distinct because scripts branch on them — most of all
|
||||
/// [`EXIT_PIN`], which is a security signal and not an ordinary failure.
|
||||
pub const EXIT_API: i32 = 1;
|
||||
pub const EXIT_USAGE: i32 = 2;
|
||||
pub const EXIT_UNREACHABLE: i32 = 3;
|
||||
pub const EXIT_PIN: i32 = 4;
|
||||
|
||||
/// The JSON envelope version (I8). Additive: fields may be added, never removed or retyped, and
|
||||
/// this bumps only if that promise has to break.
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// A failed verb: the exit code the process takes, and the line a human reads.
|
||||
#[derive(Debug)]
|
||||
pub struct Failure {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
pub fn new(code: i32, message: impl Into<String>) -> Self {
|
||||
Failure {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub fn usage(message: impl Into<String>) -> Self {
|
||||
Self::new(EXIT_USAGE, message)
|
||||
}
|
||||
pub fn unreachable(message: impl Into<String>) -> Self {
|
||||
Self::new(EXIT_UNREACHABLE, message)
|
||||
}
|
||||
pub fn api(message: impl Into<String>) -> Self {
|
||||
Self::new(EXIT_API, message)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Failure>;
|
||||
|
||||
/// Connect timeout — loopback, so a slow one means "nothing is there", not "the network is far".
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Whole-call timeout for the one-shot verbs. `watch` passes `None` (it is long-lived by design).
|
||||
const CALL_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
pub struct Client {
|
||||
agent: ureq::Agent,
|
||||
base: String,
|
||||
/// `Bearer <token>`, built once from the 0600 file. The ONLY place in this crate where the
|
||||
/// operator credential exists outside `mgmt_token` (I1) — grep for `bearer` to audit it.
|
||||
bearer: String,
|
||||
/// The leaf the last handshake actually presented — the [`EXIT_PIN`] discriminator.
|
||||
observed: Arc<Mutex<Option<[u8; 32]>>>,
|
||||
pin: [u8; 32],
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Discover, pin and authenticate. `global_timeout` is `None` for `watch`.
|
||||
pub fn connect(global_timeout: Option<Duration>) -> Result<Client> {
|
||||
Self::connect_in(&pf_paths::config_dir(), global_timeout)
|
||||
}
|
||||
|
||||
/// The IO half, taking the config directory — so the pin-mismatch negative can be exercised
|
||||
/// against a real TLS listener without mutating `PUNKTFUNK_CONFIG_DIR` (which needs `unsafe`
|
||||
/// since edition 2024, and which this module refuses to need). Same split, same reason, as
|
||||
/// `pf_paths::published_mgmt_port_in`.
|
||||
pub fn connect_in(dir: &Path, global_timeout: Option<Duration>) -> Result<Client> {
|
||||
let pin = load_pin(dir)?;
|
||||
let token = load_token(dir)?;
|
||||
let port = pf_paths::published_mgmt_port_in(dir).unwrap_or(crate::mgmt::DEFAULT_PORT);
|
||||
let observed = Arc::new(Mutex::new(None));
|
||||
Ok(Client {
|
||||
agent: agent(pin, observed.clone(), global_timeout),
|
||||
// Always loopback: the admin surface is honoured from LOOPBACK peers only
|
||||
// (`mgmt::auth`), so any other address would be refused by the host anyway.
|
||||
base: format!("https://127.0.0.1:{port}"),
|
||||
bearer: format!("Bearer {token}"),
|
||||
observed,
|
||||
pin,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self, path: &str) -> Result<serde_json::Value> {
|
||||
let sent = self
|
||||
.agent
|
||||
.get(self.url(path))
|
||||
.header("Authorization", &self.bearer)
|
||||
.call();
|
||||
self.finish(sent, path)
|
||||
}
|
||||
|
||||
pub fn delete(&self, path: &str) -> Result<serde_json::Value> {
|
||||
let sent = self
|
||||
.agent
|
||||
.delete(self.url(path))
|
||||
.header("Authorization", &self.bearer)
|
||||
.call();
|
||||
self.finish(sent, path)
|
||||
}
|
||||
|
||||
pub fn post(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
|
||||
let sent = self
|
||||
.agent
|
||||
.post(self.url(path))
|
||||
.header("Authorization", &self.bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.send(body.to_string());
|
||||
self.finish(sent, path)
|
||||
}
|
||||
|
||||
pub fn patch(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
|
||||
let sent = self
|
||||
.agent
|
||||
.patch(self.url(path))
|
||||
.header("Authorization", &self.bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.send(body.to_string());
|
||||
self.finish(sent, path)
|
||||
}
|
||||
|
||||
/// The streaming half, for `watch`: the raw response body of a GET, left unread so the caller
|
||||
/// can consume SSE frames as they arrive.
|
||||
pub fn stream(&self, path: &str) -> Result<Box<dyn std::io::Read + Send>> {
|
||||
let resp = self
|
||||
.agent
|
||||
.get(self.url(path))
|
||||
.header("Authorization", &self.bearer)
|
||||
.call()
|
||||
.map_err(|e| self.transport_failure(e, path))?;
|
||||
let status = resp.status().as_u16();
|
||||
if !(200..300).contains(&status) {
|
||||
let mut resp = resp;
|
||||
let body = resp.body_mut().read_to_string().unwrap_or_default();
|
||||
return Err(Failure::api(http_error(status, &body, path)));
|
||||
}
|
||||
Ok(Box::new(resp.into_body().into_reader()))
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{path}", self.base)
|
||||
}
|
||||
|
||||
fn finish(
|
||||
&self,
|
||||
sent: std::result::Result<ureq::http::Response<ureq::Body>, ureq::Error>,
|
||||
path: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut resp = sent.map_err(|e| self.transport_failure(e, path))?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.body_mut().read_to_string().unwrap_or_default();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(Failure::api(http_error(status, &body, path)));
|
||||
}
|
||||
if body.trim().is_empty() {
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
serde_json::from_str(&body)
|
||||
.map_err(|e| Failure::api(format!("{path}: the host sent JSON we can't parse ({e})")))
|
||||
}
|
||||
|
||||
/// Classify a transport failure. The observed-fingerprint slot is what separates "somebody
|
||||
/// else is on that port" (a security answer, [`EXIT_PIN`]) from "nobody is" ([`EXIT_UNREACHABLE`]).
|
||||
fn transport_failure(&self, e: ureq::Error, path: &str) -> Failure {
|
||||
if let Some(seen) = *self.observed.lock().unwrap() {
|
||||
if seen != self.pin {
|
||||
return Failure::new(
|
||||
EXIT_PIN,
|
||||
format!(
|
||||
"certificate pin mismatch on {base} — the process answering there presented \
|
||||
{seen}, but this host's identity is {ours}. No token was sent. Either the \
|
||||
host regenerated its certificate (delete the stale pairing state and \
|
||||
re-pair) or another local process is squatting the management port.",
|
||||
base = self.base,
|
||||
seen = hex::encode(seen),
|
||||
ours = hex::encode(self.pin),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Failure::unreachable(format!(
|
||||
"cannot reach the management API at {}{path}: {e}. Is the host running \
|
||||
(`systemctl --user status punktfunk-host`)?",
|
||||
self.base
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a non-2xx into the line a human gets, unwrapping the `ApiError` envelope when there is one.
|
||||
fn http_error(status: u16, body: &str, path: &str) -> String {
|
||||
let detail = serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error")?.as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| body.trim().chars().take(200).collect());
|
||||
match status {
|
||||
401 | 403 => format!(
|
||||
"{path}: the host rejected our token ({status}). The persisted `mgmt-token` and the \
|
||||
running host disagree — restart the host, or delete the file and let it re-mint."
|
||||
),
|
||||
404 => format!("{path}: no such thing here ({status} {detail})"),
|
||||
503 => format!("{path}: {detail} ({status})"),
|
||||
_ if detail.is_empty() => format!("{path}: the host answered {status}"),
|
||||
_ => format!("{path}: {detail} ({status})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The mgmt listener presents the **native** identity when one exists and the legacy GameStream
|
||||
/// identity otherwise (`mgmt::run` takes a `NativeIdentity`; `identity::load_or_adopt` mints
|
||||
/// `native-cert.pem` or adopts `cert.pem`). Same order the tray and the plugin runner already use —
|
||||
/// pinning the wrong one of the pair is a guaranteed [`EXIT_PIN`] on a perfectly healthy host.
|
||||
fn load_pin(dir: &Path) -> Result<[u8; 32]> {
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
let pem = std::fs::read(dir.join("native-cert.pem"))
|
||||
.or_else(|_| std::fs::read(dir.join("cert.pem")))
|
||||
.map_err(|_| {
|
||||
Failure::unreachable(format!(
|
||||
"no host certificate in {} (looked for native-cert.pem, then cert.pem). \
|
||||
Without it there is nothing to pin, and ctl will not send a token unpinned. \
|
||||
Has the host ever run on this machine?",
|
||||
dir.display()
|
||||
))
|
||||
})?;
|
||||
let der = rustls::pki_types::CertificateDer::from_pem_slice(&pem).map_err(|e| {
|
||||
Failure::unreachable(format!(
|
||||
"the host certificate in {} is not readable as PEM ({e})",
|
||||
dir.display()
|
||||
))
|
||||
})?;
|
||||
Ok(punktfunk_core::tls::cert_fingerprint(der.as_ref()))
|
||||
}
|
||||
|
||||
/// Read the persisted operator token. **Never generates one** — see the module docs.
|
||||
fn load_token(dir: &Path) -> Result<String> {
|
||||
crate::mgmt_token::read_persisted(dir).ok_or_else(|| {
|
||||
Failure::unreachable(format!(
|
||||
"no management token in {} — ctl reads the one the host persists and never mints its \
|
||||
own. Start the host once (`systemctl --user start punktfunk-host`) and retry.",
|
||||
dir.join("mgmt-token").display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The pinned agent — the tray's shape (`punktfunk-tray/src/status.rs`), with the pin made
|
||||
/// mandatory and the observed slot wired up so a mismatch is reportable.
|
||||
fn agent(
|
||||
pin: [u8; 32],
|
||||
observed: Arc<Mutex<Option<[u8; 32]>>>,
|
||||
global_timeout: Option<Duration>,
|
||||
) -> ureq::Agent {
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
let tls = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.expect("rustls default protocol versions")
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(punktfunk_core::tls::PinVerify::with_observed(
|
||||
Some(pin),
|
||||
observed,
|
||||
)))
|
||||
.with_no_client_auth();
|
||||
// ureq's own `TlsConfig` has roots, a client cert and an off-switch but no hook for a custom
|
||||
// verifier, so the agent takes the `ClientConfig` directly through the shared glue.
|
||||
punktfunk_core::tls::ureq_agent::agent(
|
||||
Arc::new(tls),
|
||||
ureq::Agent::config_builder()
|
||||
.timeout_connect(Some(CONNECT_TIMEOUT))
|
||||
.timeout_global(global_timeout)
|
||||
// Let 4xx/5xx come back as responses so the `ApiError` body reaches the operator
|
||||
// instead of being flattened into "status 400".
|
||||
.http_status_as_error(false)
|
||||
.max_redirects(0)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read as _;
|
||||
|
||||
/// **The I2 negative — the reason the pin exists.** A process that is not the host answers on
|
||||
/// the management port (the port-squat vector: another local uid binds it while the host is
|
||||
/// down). It presents a perfectly valid, perfectly well-formed self-signed certificate that
|
||||
/// simply isn't ours.
|
||||
///
|
||||
/// Two things must be true, and only the second one is about cryptography:
|
||||
/// 1. the verb fails with [`EXIT_PIN`] — a *distinct* code, so a script does not retry into
|
||||
/// the squatter the way it would for "host down";
|
||||
/// 2. the squatter receives **zero application bytes**. rustls rejects the certificate during
|
||||
/// the handshake, so ureq never gets to serialise a request line — the `Authorization`
|
||||
/// header is not "sent and ignored", it is never constructed. That ordering is the whole
|
||||
/// security property, and this test is what stops a future refactor (an agent added per
|
||||
/// agent, a retry that disables verification "just to see") from quietly inverting it.
|
||||
#[test]
|
||||
fn a_squatter_gets_exit_4_and_not_one_byte_of_the_token() {
|
||||
punktfunk_core::tls::install_default_provider();
|
||||
let dir = std::env::temp_dir().join(format!("pf-ctl-pin-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// Two identities that have nothing to do with each other: what the config dir says the
|
||||
// host is, and what actually answers on the port.
|
||||
let ours = crate::identity::ephemeral().unwrap();
|
||||
let squatter = crate::identity::ephemeral().unwrap();
|
||||
let server = crate::gamestream::tls::server_config_optional_client(
|
||||
&squatter.cert_pem,
|
||||
&squatter.key_pem,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::fs::write(dir.join("native-cert.pem"), &ours.cert_pem).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("mgmt-token"),
|
||||
"PUNKTFUNK_MGMT_TOKEN=s3kr1t-token\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("mgmt-endpoint"),
|
||||
format!("PUNKTFUNK_MGMT_URL=https://127.0.0.1:{port}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let seen = Arc::new(Mutex::new(Vec::<u8>::new()));
|
||||
let recorder = seen.clone();
|
||||
let squat = std::thread::spawn(move || {
|
||||
let (mut sock, _) = listener.accept().expect("squatter accept");
|
||||
let mut conn = rustls::ServerConnection::new(server).expect("squatter tls");
|
||||
// The client will send a fatal alert instead of finishing — that is the pass condition,
|
||||
// so an error here is expected and deliberately ignored.
|
||||
let _ = conn.complete_io(&mut sock);
|
||||
let mut plaintext = Vec::new();
|
||||
let _ = conn.reader().read_to_end(&mut plaintext);
|
||||
*recorder.lock().unwrap() = plaintext;
|
||||
});
|
||||
|
||||
let err = Client::connect_in(&dir, Some(Duration::from_secs(10)))
|
||||
.and_then(|c| c.get("/api/v1/status"))
|
||||
.expect_err("a mismatched certificate must not produce a successful call");
|
||||
assert_eq!(err.code, EXIT_PIN, "wrong exit code: {}", err.message);
|
||||
assert!(
|
||||
err.message.contains("pin mismatch"),
|
||||
"the operator must be told WHICH failure this is: {}",
|
||||
err.message
|
||||
);
|
||||
|
||||
squat.join().unwrap();
|
||||
let bytes = seen.lock().unwrap().clone();
|
||||
assert!(
|
||||
bytes.is_empty(),
|
||||
"the squatter read {} application bytes; the token must never leave the process \
|
||||
before the pin matches",
|
||||
bytes.len()
|
||||
);
|
||||
assert!(!String::from_utf8_lossy(&bytes).contains("s3kr1t-token"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The other half of I2: a config dir with no certificate at all is [`EXIT_UNREACHABLE`], not
|
||||
/// a quiet fallback to an unverified connection. "No pin available" must never mean "connect
|
||||
/// anyway" — that is the shape the tray can afford (it holds no token) and ctl cannot.
|
||||
#[test]
|
||||
fn no_certificate_means_no_call_at_all() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-ctl-nocert-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("mgmt-token"), "PUNKTFUNK_MGMT_TOKEN=deadbeef\n").unwrap();
|
||||
// `.err()` rather than `expect_err`: `Client` deliberately has no `Debug`, because the
|
||||
// derived one would print the bearer into any panic message or `{:?}` a future edit adds.
|
||||
let err = Client::connect_in(&dir, None)
|
||||
.err()
|
||||
.expect("no cert, no connection");
|
||||
assert_eq!(err.code, EXIT_UNREACHABLE);
|
||||
assert!(err.message.contains("native-cert.pem"), "{}", err.message);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// And ctl never mints: a config dir with a certificate but no token fails, and leaves the
|
||||
/// directory exactly as it found it (the inverted `web-password` lesson).
|
||||
#[test]
|
||||
fn a_missing_token_is_an_error_never_a_freshly_minted_one() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-ctl-notoken-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let ours = crate::identity::ephemeral().unwrap();
|
||||
std::fs::write(dir.join("native-cert.pem"), &ours.cert_pem).unwrap();
|
||||
let err = Client::connect_in(&dir, None)
|
||||
.err()
|
||||
.expect("no token, no connection");
|
||||
assert_eq!(err.code, EXIT_UNREACHABLE);
|
||||
assert!(!dir.join("mgmt-token").exists(), "ctl minted a token");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_error_unwraps_the_api_envelope() {
|
||||
let msg = http_error(
|
||||
400,
|
||||
r#"{"error":"grants has reserved bits set"}"#,
|
||||
"/api/v1/x",
|
||||
);
|
||||
assert!(msg.contains("grants has reserved bits set"), "{msg}");
|
||||
assert!(msg.contains("400"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_error_names_the_token_when_auth_fails() {
|
||||
let msg = http_error(401, "", "/api/v1/status");
|
||||
assert!(msg.contains("mgmt-token"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_codes_are_distinct() {
|
||||
// Scripts branch on these; a collision would silently merge "no host" with "squatter".
|
||||
let all = [EXIT_API, EXIT_USAGE, EXIT_UNREACHABLE, EXIT_PIN];
|
||||
let mut seen: Vec<i32> = all.to_vec();
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
assert_eq!(seen.len(), all.len());
|
||||
// And none of them is 0 — a failure that exits 0 is worse than a wrong code.
|
||||
assert!(all.iter().all(|c| *c != 0));
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
//! `punktfunk-host ctl watch` — the host's SSE event stream bridged to **line-JSON on stdout**,
|
||||
//! one object per line, flushed per line. That shape is the whole point: a Quickshell `Process`, a
|
||||
//! waybar `custom/` module and `while read -r line` all consume it without an SSE parser, and none
|
||||
//! of them ever sees a token (I3 — the plugin holds no credential because it holds no HTTP client).
|
||||
//!
|
||||
//! Reconnection is the interesting part. `mgmt/events.rs` keeps a ~1024-event ring and resumes
|
||||
//! from `Last-Event-ID`; a consumer whose cursor has fallen off the ring gets a synthetic
|
||||
//! `event: dropped` frame first and is expected to re-snapshot. We surface both facts to the
|
||||
//! consumer as ordinary lines:
|
||||
//!
|
||||
//! - `{"v":1,"kind":"ctl.resync"}` — emitted **once** after a `dropped` frame, and also after any
|
||||
//! reconnect that could not resume exactly (no cursor yet). A widget that sees it re-runs
|
||||
//! `ctl status` / `ctl pending` rather than trusting its incremental state.
|
||||
//! - `{"v":1,"kind":"ctl.disconnected","data":{"error":"…"}}` — the stream dropped and we are
|
||||
//! backing off. Purely informational; the reconnect is automatic.
|
||||
//! - `{"v":1,"kind":"ctl.heartbeat"}` — the host's SSE keep-alive, roughly every 15 s. A consumer
|
||||
//! can use it as a liveness signal, and it is also what lets *us* notice that our own consumer
|
||||
//! has gone away (see [`emit`]).
|
||||
//!
|
||||
//! The cursor advances on every frame with an `id:`, so a host restart mid-watch resumes from the
|
||||
//! last event actually delivered. Backoff is capped and jittered only by the cap: an operator's
|
||||
//! plugin reconnecting in a tight loop against a host that is down would otherwise be the thing
|
||||
//! that keeps hitting the SSE connection cap.
|
||||
//!
|
||||
//! The connection cap (`MAX_EVENT_STREAMS` = 32) is shared with the console; the plugin is
|
||||
//! specified to hold exactly one stream. Exhausting it is a 503, which arrives here as an ordinary
|
||||
//! API failure with the host's own message.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::client::{Client, Failure, Result, SCHEMA_VERSION};
|
||||
|
||||
/// Reconnect backoff: quick enough that a host restart is invisible to a widget, slow enough that
|
||||
/// a host that is genuinely down doesn't get hammered.
|
||||
const BACKOFF_MIN: Duration = Duration::from_secs(1);
|
||||
const BACKOFF_MAX: Duration = Duration::from_secs(30);
|
||||
|
||||
pub fn run(kinds: Option<&str>, since: Option<u64>) -> Result<()> {
|
||||
let mut cursor = since;
|
||||
let mut backoff = BACKOFF_MIN;
|
||||
// First connect is the only one allowed to fail the process: a bad pin, a missing token or a
|
||||
// host that has never run are all conditions a retry cannot fix, and a `watch` that silently
|
||||
// spins forever on them is worse than an exit code the caller can see.
|
||||
let mut client = Client::connect(None)?;
|
||||
loop {
|
||||
match pump(&client, kinds, &mut cursor) {
|
||||
// The stream ended cleanly (host shutdown) — reconnect like any other drop.
|
||||
Ok(()) => emit_control("ctl.disconnected", Some("stream closed by the host")),
|
||||
Err(e) if e.code == super::client::EXIT_PIN => return Err(e),
|
||||
Err(e) => emit_control("ctl.disconnected", Some(&e.message)),
|
||||
}
|
||||
std::thread::sleep(backoff);
|
||||
backoff = (backoff * 2).min(BACKOFF_MAX);
|
||||
// Rebuild the client on every reconnect rather than reusing it: that re-reads
|
||||
// `native-cert.pem`, so a host that regenerated its identity while we were disconnected
|
||||
// is picked up instead of pinning us out forever (risk register #1). A pin that is now
|
||||
// genuinely wrong still exits 4 on the next attempt, which is the intended signal.
|
||||
match Client::connect(None) {
|
||||
Ok(c) => {
|
||||
client = c;
|
||||
backoff = BACKOFF_MIN;
|
||||
}
|
||||
Err(e) if e.code == super::client::EXIT_PIN => return Err(e),
|
||||
Err(e) => emit_control("ctl.disconnected", Some(&e.message)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One connection's worth of frames. Returns `Ok(())` when the server closed the stream.
|
||||
fn pump(client: &Client, kinds: Option<&str>, cursor: &mut Option<u64>) -> Result<()> {
|
||||
let mut path = String::from("/api/v1/events");
|
||||
let mut sep = '?';
|
||||
if let Some(k) = kinds {
|
||||
path.push_str(&format!("{sep}kinds={}", urlencode(k)));
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(c) = *cursor {
|
||||
path.push_str(&format!("{sep}since={c}"));
|
||||
}
|
||||
let reader = BufReader::new(client.stream(&path)?);
|
||||
|
||||
// One SSE frame = `id:`/`event:`/`data:` lines terminated by a blank line. Keep-alive comments
|
||||
// (`:` prefix) are skipped; they exist to detect a dead peer, not to be forwarded.
|
||||
let mut id: Option<u64> = None;
|
||||
let mut kind: Option<String> = None;
|
||||
let mut data = String::new();
|
||||
for line in reader.lines() {
|
||||
let line =
|
||||
line.map_err(|e| Failure::unreachable(format!("event stream read failed: {e}")))?;
|
||||
if line.is_empty() {
|
||||
if let Some(k) = kind.take() {
|
||||
dispatch(&k, &data, id, cursor);
|
||||
}
|
||||
id = None;
|
||||
data.clear();
|
||||
continue;
|
||||
}
|
||||
let Some((field, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.strip_prefix(' ').unwrap_or(value);
|
||||
match field {
|
||||
// A comment (`: keep-alive`) splits with an empty field name. The host sends one every
|
||||
// 15 s, and we turn it into the one line that proves BOTH directions are alive.
|
||||
//
|
||||
// The write is the point. Our consumer is a shell widget, and when it dies its end of
|
||||
// our stdout pipe closes — but a stream that is only ever READ never notices, so an
|
||||
// idle host leaves `ctl watch` running forever against the server's connection cap.
|
||||
// Measured on an Omarchy box: six orphaned watchers after three shell restarts, all on
|
||||
// a host with no events at all. Writing here turns the next keep-alive into an EPIPE,
|
||||
// and [`emit`] exits on it.
|
||||
"" => emit(serde_json::json!({ "v": SCHEMA_VERSION, "kind": "ctl.heartbeat" })),
|
||||
"id" => id = value.parse().ok(),
|
||||
"event" => kind = Some(value.to_string()),
|
||||
"data" => {
|
||||
if !data.is_empty() {
|
||||
data.push('\n');
|
||||
}
|
||||
data.push_str(value);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Turn one decoded frame into a line of stdout, advancing the resume cursor.
|
||||
fn dispatch(kind: &str, data: &str, id: Option<u64>, cursor: &mut Option<u64>) {
|
||||
if let Some(seq) = id {
|
||||
*cursor = Some(seq);
|
||||
}
|
||||
if kind == "dropped" {
|
||||
// We fell off the catch-up ring: whatever the consumer believes about pending devices or
|
||||
// live sessions may be stale, and no amount of further events will repair it.
|
||||
emit_control("ctl.resync", None);
|
||||
return;
|
||||
}
|
||||
let payload = serde_json::from_str::<serde_json::Value>(data)
|
||||
.unwrap_or_else(|_| serde_json::Value::String(data.to_string()));
|
||||
emit(serde_json::json!({
|
||||
"v": SCHEMA_VERSION,
|
||||
"kind": kind,
|
||||
"seq": id,
|
||||
"data": payload,
|
||||
}));
|
||||
}
|
||||
|
||||
fn emit_control(kind: &str, error: Option<&str>) {
|
||||
let mut line = serde_json::json!({ "v": SCHEMA_VERSION, "kind": kind });
|
||||
if let Some(e) = error {
|
||||
line["data"] = serde_json::json!({ "error": e });
|
||||
}
|
||||
emit(line);
|
||||
}
|
||||
|
||||
/// One line, flushed. A widget reading incrementally must not wait on an 8 KiB stdio buffer to
|
||||
/// fill before it learns a device is knocking.
|
||||
///
|
||||
/// **A failed write ends the process**, rather than being ignored as it was: the only reason a
|
||||
/// write to our own stdout fails is that the consumer is gone, and carrying on would hold an SSE
|
||||
/// stream open against the host's connection cap for as long as the box stays up. Exit 0 — the
|
||||
/// consumer going away is a normal end to a `watch`, not an error anyone needs to see.
|
||||
fn emit(line: serde_json::Value) {
|
||||
let mut out = std::io::stdout().lock();
|
||||
if writeln!(out, "{line}").is_err() || out.flush().is_err() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal percent-encoding for the `kinds` query value. The grammar the host accepts is
|
||||
/// `[a-z0-9_.*,-]`, so this only ever has to escape what a typo could introduce.
|
||||
fn urlencode(s: &str) -> String {
|
||||
s.bytes()
|
||||
.map(|b| match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'*' | b',' => {
|
||||
(b as char).to_string()
|
||||
}
|
||||
_ => format!("%{b:02X}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kinds_survive_encoding_and_typos_are_escaped() {
|
||||
assert_eq!(
|
||||
urlencode("stream.*,pairing.pending"),
|
||||
"stream.*,pairing.pending"
|
||||
);
|
||||
assert_eq!(urlencode("a b"), "a%20b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_frame_advances_the_cursor_and_asks_for_a_resync() {
|
||||
// The cursor must advance even for `dropped`: resuming from before it would replay the
|
||||
// same fell-off-the-ring condition on every reconnect.
|
||||
let mut cursor = None;
|
||||
dispatch("dropped", r#"{"dropped":true}"#, Some(7), &mut cursor);
|
||||
assert_eq!(cursor, Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_frame_advances_the_cursor() {
|
||||
let mut cursor = Some(3);
|
||||
dispatch(
|
||||
"pairing.pending",
|
||||
r#"{"kind":"pairing.pending"}"#,
|
||||
Some(9),
|
||||
&mut cursor,
|
||||
);
|
||||
assert_eq!(cursor, Some(9));
|
||||
// A frame with no id (the synthetic ones) must not rewind it.
|
||||
dispatch("stream.started", "{}", None, &mut cursor);
|
||||
assert_eq!(cursor, Some(9));
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,6 @@ pub mod ids {
|
||||
pub const VIRTUAL_DECK_VHCI: &str = "virtual_deck_vhci";
|
||||
pub const UINPUT_ACCESS: &str = "uinput_access";
|
||||
pub const SERVER_CONFLICT: &str = "server_conflict";
|
||||
pub const HYPRLAND_PERMISSIONS: &str = "hyprland_permissions";
|
||||
pub const OMARCHY_UPDATES: &str = "omarchy_updates";
|
||||
}
|
||||
|
||||
/// What a probe found. `Inapplicable` is deliberately distinct from `Ok`: "this box will never do
|
||||
@@ -528,8 +526,6 @@ mod tests {
|
||||
ids::VIRTUAL_DECK_VHCI,
|
||||
ids::UINPUT_ACCESS,
|
||||
ids::SERVER_CONFLICT,
|
||||
ids::HYPRLAND_PERMISSIONS,
|
||||
ids::OMARCHY_UPDATES,
|
||||
] {
|
||||
assert!(
|
||||
ids.iter().any(|i| i == expected),
|
||||
|
||||
@@ -31,110 +31,6 @@ pub(crate) fn register_all(reg: &Diagnostics) {
|
||||
reg.register(virtual_deck_vhci);
|
||||
reg.register(uinput_access);
|
||||
reg.register(server_conflict);
|
||||
reg.register(hyprland_permissions);
|
||||
reg.register(omarchy_updates);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// hyprland_permissions
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Hyprland 0.49+ has `ecosystem.enforce_permissions`. It is off by default, but when it is on and
|
||||
/// the host has not been granted, screencopy and virtual input are **denied silently** — black
|
||||
/// frames and dropped input, with no error anywhere. That is the entire reason this check exists:
|
||||
/// every other failure on this path reports itself, and this one reports nothing at all.
|
||||
///
|
||||
/// Not Omarchy-specific — it applies to every Hyprland box — but Omarchy is exactly the kind of
|
||||
/// distro that might turn it on in a future release, which is what made it worth a row.
|
||||
fn hyprland_permissions() -> HostCheck {
|
||||
let id = ids::HYPRLAND_PERMISSIONS;
|
||||
if !cfg!(target_os = "linux") {
|
||||
return HostCheck::inapplicable(id, "Hyprland's permission system is a Linux feature.");
|
||||
}
|
||||
// `hyprctl` reachable at all is the "is this a Hyprland session?" test — the same one the
|
||||
// backend uses. An absent binary or a compositor that is not Hyprland is not a problem here.
|
||||
let Some(out) = command_output(
|
||||
"hyprctl",
|
||||
&["-j", "getoption", "ecosystem:enforce_permissions"],
|
||||
) else {
|
||||
return HostCheck::inapplicable(
|
||||
id,
|
||||
"This machine is not running a Hyprland session, so Hyprland's permission system \
|
||||
does not apply.",
|
||||
);
|
||||
};
|
||||
let enforced = serde_json::from_str::<serde_json::Value>(&out)
|
||||
.ok()
|
||||
.and_then(|j| j.get("int").and_then(|v| v.as_i64()))
|
||||
.is_some_and(|v| v != 0);
|
||||
if !enforced {
|
||||
return HostCheck::ok(
|
||||
id,
|
||||
"Hyprland is not enforcing per-application permissions, so nothing needs granting.",
|
||||
);
|
||||
}
|
||||
HostCheck::problem(
|
||||
id,
|
||||
CheckStatus::Warn,
|
||||
// Warning, not Critical: enforcement being ON does not mean we are DENIED — a box where
|
||||
// the host is already granted streams perfectly, and this probe cannot tell the two apart
|
||||
// from outside the compositor. Claiming Critical here would cry wolf on a healthy box.
|
||||
Severity::Warning,
|
||||
"Hyprland is enforcing permissions and this host may not be granted".to_string(),
|
||||
"Hyprland denies screencopy and virtual input SILENTLY — the client sees black frames and \
|
||||
input that does nothing, and neither the host nor the compositor logs an error. If \
|
||||
streaming already works, the host is already granted and there is nothing to do."
|
||||
.to_string(),
|
||||
)
|
||||
.with_remedy(Remedy {
|
||||
text: "Grant this host screencopy and virtual input in your Hyprland config, then reload \
|
||||
it. On a Lua-era config (Hyprland 4.x / Omarchy) the lines go in hyprland.lua or a \
|
||||
module it includes; on hyprlang they are `permission = …` lines."
|
||||
.to_string(),
|
||||
command: Some(
|
||||
"o.permission(\"/usr/bin/punktfunk-host\", \"screencopy\", \"allow\")\n\
|
||||
o.permission(\"/usr/bin/punktfunk-host\", \"plugin\", \"allow\")"
|
||||
.to_string(),
|
||||
),
|
||||
relogin_required: false,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// omarchy_updates
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// On Omarchy, updates go through `omarchy update` and the console's apply button is deliberately
|
||||
/// absent (design D5). Without a row saying so, "the update button is missing on my box" is an
|
||||
/// unanswerable support question — the check exists to answer it in the place the operator is
|
||||
/// already looking.
|
||||
fn omarchy_updates() -> HostCheck {
|
||||
let id = ids::OMARCHY_UPDATES;
|
||||
if !crate::osinfo::is_omarchy() {
|
||||
return HostCheck::inapplicable(id, "This machine is not running Omarchy.");
|
||||
}
|
||||
let version = command_output("omarchy-version", &[]).unwrap_or_default();
|
||||
let pretty = &crate::osinfo::detect().pretty;
|
||||
let summary = if version.is_empty() {
|
||||
format!("{pretty}: update with `omarchy update`")
|
||||
} else {
|
||||
format!("{version}: update with `omarchy update`")
|
||||
};
|
||||
HostCheck::ok(id, summary)
|
||||
.with_param("update_command", "omarchy update")
|
||||
.with_param("version", version)
|
||||
}
|
||||
|
||||
/// Run a command and return its trimmed stdout, or `None` if it is absent or failed. Shared by the
|
||||
/// two checks above; deliberately not a general helper — the catalog's other probes ask the owning
|
||||
/// crate rather than shelling out, and these two have no owning crate to ask.
|
||||
fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new(program).args(args).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -21,11 +21,6 @@
|
||||
//! - `os` — the host's OS identity chain (`windows` | `macos` | `linux[/<family>][/<id>]`, e.g.
|
||||
//! `linux/fedora/bazzite` — see [`crate::osinfo`]), so a client can show an OS icon on the host
|
||||
//! card. Advisory/unauthenticated like `mac`: a wrong value only draws a wrong icon.
|
||||
//! - `addr` — the IPv4 this advert was registered for (the host's routed primary). The A-record
|
||||
//! set a client resolves is a union polluted by OTHER responders answering per-interface (the
|
||||
//! host OS's own mDNS stack answers for `<host>.local.` on a VPN/overlay interface with that
|
||||
//! interface's address); the client's picker (`punktfunk_core::discovery`) uses this declared
|
||||
//! value to break ties among addresses it resolved anyway. Advisory like the rest.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||
@@ -225,9 +220,6 @@ pub fn advertise_native(
|
||||
if !macs.is_empty() {
|
||||
props.insert("mac".into(), macs.join(","));
|
||||
}
|
||||
// `addr` — which of the host's addresses this advert is FOR (see module doc): the
|
||||
// client-side picker's tie-breaker against per-interface answers from other responders.
|
||||
props.insert("addr".into(), ip.to_string());
|
||||
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason
|
||||
// WoL silently fails. Re-checked on an address change because the routed NIC may be a
|
||||
// different one now.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -94,10 +94,6 @@ mod log_capture;
|
||||
mod mgmt;
|
||||
#[forbid(unsafe_code)]
|
||||
mod mgmt_token;
|
||||
// `ctl` is a CLIENT of everything above — it holds the operator token and the certificate pin, so
|
||||
// it gets the same `forbid` as the surfaces it talks to.
|
||||
#[forbid(unsafe_code)]
|
||||
mod ctl;
|
||||
#[cfg_attr(not(test), forbid(unsafe_code))]
|
||||
mod native;
|
||||
#[forbid(unsafe_code)]
|
||||
@@ -105,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.
|
||||
@@ -340,9 +337,6 @@ fn is_management_cli(args: &[String]) -> bool {
|
||||
| Some("driver")
|
||||
| Some("web")
|
||||
| Some("tray")
|
||||
// A loopback API client. None of the host-startup work applies, and `watch` is a
|
||||
// long-lived process — the GPU clock profile and the DXGI hook must not follow it.
|
||||
| Some("ctl")
|
||||
| Some("openapi")
|
||||
| Some("library")
|
||||
| Some("detect-conflicts")
|
||||
@@ -475,10 +469,6 @@ fn real_main() -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// The operator control surface: `ctl status`, `ctl approve 3`, `ctl watch`, … A loopback
|
||||
// client of this same binary's management API, so a shell plugin or a script can drive
|
||||
// pairing and sessions without a browser (design/omarchy-integration.md D13). See ctl.rs.
|
||||
Some("ctl") => ctl::main(&args[1..]),
|
||||
// Install and run host plugins: `plugins add playnite`, `plugins enable`, … Package ops are
|
||||
// forwarded to the bun runner; enable/disable/status drive the systemd unit (Linux) or the
|
||||
// PunktfunkScripting scheduled task (Windows). See plugins.rs.
|
||||
@@ -1063,9 +1053,6 @@ fn print_usage() {
|
||||
USAGE:
|
||||
punktfunk-host serve [OPTIONS] native punktfunk/1 host + management REST API
|
||||
(secure default; add --gamestream for Moonlight compat)
|
||||
punktfunk-host ctl <VERB> operator control over the local management API —
|
||||
pairing, devices, sessions, `watch` (line-JSON for a
|
||||
shell widget); `ctl --help` for the verb list
|
||||
punktfunk-host plugins <CMD> install/run host plugins (add, remove, list, enable,
|
||||
disable, status) — `plugins --help` for details
|
||||
punktfunk-host tray <CMD> status-tray lifecycle (start, stop, status) — Windows;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,18 +38,6 @@ pub fn load_or_generate_plugin() -> Result<String> {
|
||||
load_or_generate_impl(PLUGIN_ENV_VAR, PLUGIN_FILE)
|
||||
}
|
||||
|
||||
/// Read the persisted operator token from `dir`, or `None` when there isn't one. **Never mints.**
|
||||
///
|
||||
/// This is what `ctl` uses: a client that generated its own `mgmt-token` would be planting the
|
||||
/// credential the host then adopts — the `web-password` silent-adoption finding (security sweep
|
||||
/// 2026-08-15) with the roles reversed. The host is the only minter; every other reader either
|
||||
/// finds a token or fails loudly. It also deliberately ignores `PUNKTFUNK_MGMT_TOKEN`: a consumer
|
||||
/// that took the token from its environment would publish it in `/proc/<pid>/environ`.
|
||||
pub(crate) fn read_persisted(dir: &Path) -> Option<String> {
|
||||
let contents = fs::read_to_string(dir.join(FILE)).ok()?;
|
||||
parse_token(&contents, ENV_VAR)
|
||||
}
|
||||
|
||||
fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
|
||||
if let Ok(v) = std::env::var(env_var) {
|
||||
let v = v.trim();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -113,25 +113,6 @@ fn parse_os_release(contents: &str) -> OsInfo {
|
||||
OsInfo { chain, pretty }
|
||||
}
|
||||
|
||||
/// Is this an **Omarchy** box? (`ID=omarchy`, which the chain carries verbatim as its leaf.)
|
||||
///
|
||||
/// A *flavour*, never a family: `ID_LIKE=arch` already routes everything family-shaped — the
|
||||
/// install ladder, `InstallKind::Pacman`, the docs — correctly, and nothing here should change
|
||||
/// that. What the flavour decides is narrower and listed in one place so it stays auditable:
|
||||
///
|
||||
/// * the update tier (`crate::update`): Omarchy's own `omarchy update` owns the pacman
|
||||
/// transaction, and a pacman guard blocks the direct `pacman -Syu` our root helper would run,
|
||||
/// so the console reports **notify-only** and names their command;
|
||||
/// * diagnostics rows (`crate::diagnostics`), which is where an operator finds out what an
|
||||
/// Omarchy-specific check saw.
|
||||
///
|
||||
/// Detected from the same `os-release` parse as [`detect`], which Omarchy rewrites on every
|
||||
/// `omarchy-settings` upgrade — so it survives updates, which a marker file in our own package
|
||||
/// would not. Misdetection degrades to plain-Arch behaviour, which is exactly the old behaviour.
|
||||
pub fn is_omarchy() -> bool {
|
||||
detect().chain.ends_with("/omarchy")
|
||||
}
|
||||
|
||||
/// Strip one matching pair of surrounding `"` or `'` quotes.
|
||||
fn unquote(v: &str) -> String {
|
||||
let v = v.trim();
|
||||
@@ -190,23 +171,6 @@ mod tests {
|
||||
assert_eq!(pretty, "Bazzite 42 (Kinoite)");
|
||||
}
|
||||
|
||||
/// Omarchy 4.x writes `ID=omarchy` / `ID_LIKE=arch` / `VERSION_ID=<pkgver>` and *rewrites*
|
||||
/// os-release on every `omarchy-settings` upgrade, so this is the detection that survives an
|
||||
/// `omarchy update`. It must land in the arch family (the install ladder and `InstallKind`
|
||||
/// depend on it) AND keep `omarchy` as the leaf (the flavour predicate reads it).
|
||||
#[test]
|
||||
fn omarchy_is_arch_family_and_keeps_its_leaf() {
|
||||
let (chain, pretty) = parsed(
|
||||
"NAME=\"Omarchy\"\nPRETTY_NAME=\"Omarchy\"\nID=omarchy\nID_LIKE=arch\nVERSION_ID=4.0.1\n",
|
||||
);
|
||||
assert_eq!(chain, "linux/arch/omarchy");
|
||||
assert_eq!(pretty, "Omarchy");
|
||||
assert!(chain.ends_with("/omarchy"), "is_omarchy() reads this");
|
||||
// …and a plain Arch box must NOT trip the flavour.
|
||||
let (arch, _) = parsed("ID=arch\nPRETTY_NAME=\"Arch Linux\"\n");
|
||||
assert!(!arch.ends_with("/omarchy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steamos_is_arch_family() {
|
||||
let (chain, _) = parsed("ID=steamos\nID_LIKE=arch\nPRETTY_NAME=\"SteamOS\"\n");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -73,15 +73,6 @@ pub(crate) fn apply_support() -> &'static str {
|
||||
if apply_disabled() {
|
||||
return "notify";
|
||||
}
|
||||
// Omarchy owns the pacman transaction (design D5). `omarchy update` snapshots with snapper,
|
||||
// runs the full sysupgrade, then migrations and hooks — and a pacman guard blocks the bare
|
||||
// `pacman -Syu` our root helper would run, so a one-click apply here would either be refused
|
||||
// or bypass the snapshot the user's rollback depends on. Our packages ride their transaction
|
||||
// for free once the repo is configured, so notify-only loses nothing.
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::osinfo::is_omarchy() {
|
||||
return "notify";
|
||||
}
|
||||
let (kind, _) = detect::detect();
|
||||
match kind {
|
||||
detect::InstallKind::WindowsInstaller => "full",
|
||||
@@ -113,11 +104,6 @@ pub(crate) fn apply_support() -> &'static str {
|
||||
pub(crate) fn opt_in_hint() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Never invite an Omarchy operator to join `punktfunk-update`: apply is notify-only there
|
||||
// regardless (D5), so the opt-in would buy them a group membership and no button.
|
||||
if crate::osinfo::is_omarchy() {
|
||||
return None;
|
||||
}
|
||||
let (kind, _) = detect::detect();
|
||||
let capable = matches!(
|
||||
kind,
|
||||
@@ -411,14 +397,6 @@ pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), Apply
|
||||
if !windows_leg && !linux_leg {
|
||||
return Err(ApplyError::Unsupported);
|
||||
}
|
||||
// The same D5 refusal as `apply_support`, enforced here rather than only reported there: a
|
||||
// direct POST to the apply route on an Omarchy box that HAS the helper, the group and the
|
||||
// full-sysupgrade opt-in would otherwise run `pacman -Syu` straight into their guard — or
|
||||
// past it, skipping the snapper snapshot their rollback story is built on.
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::osinfo::is_omarchy() {
|
||||
return Err(ApplyError::Unsupported);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if linux_leg && kind != detect::InstallKind::SteamosSource {
|
||||
// The Deck source rebuild is user-owned and needs no root helper; every other Linux
|
||||
|
||||
@@ -37,15 +37,6 @@ fn classify(p: &pf_update_check::detect::Probe) -> (InstallKind, Channel) {
|
||||
/// The per-kind "how to update" command the console shows while (or instead of) an apply
|
||||
/// path existing (design §5). One line, copy-pastable, no placeholders.
|
||||
pub(crate) fn channel_hint(kind: InstallKind) -> String {
|
||||
// Omarchy flavour (design D5): same pacman DELIVERY, different command. `omarchy update` is
|
||||
// the only supported way to run a transaction there — it snapshots first, then migrates, then
|
||||
// runs their hooks — and our packages ride it automatically once the repo is configured. The
|
||||
// flavour lives here rather than in `pf-update-check` deliberately: that crate is shared with
|
||||
// the Linux client, and client-on-Omarchy is explicitly out of scope.
|
||||
#[cfg(target_os = "linux")]
|
||||
if kind == InstallKind::Pacman && crate::osinfo::is_omarchy() {
|
||||
return "omarchy update (snapshots first; punktfunk rides the same transaction)".into();
|
||||
}
|
||||
pf_update_check::detect::update_command(kind, Product::Host)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -98,23 +98,6 @@
|
||||
"sudo pacman -Syu punktfunk-host punktfunk-web punktfunk-scripting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"$comment": "Omarchy IS Arch for delivery — same signed repo, same packages — but NOT the same install command. Omarchy ships a libalpm PreTransaction hook (00-omarchy-update-guard) that ABORTS any transaction whose pacman invocation carries both -S and -u, so Arch's `pacman -Syu <pkgs>` dies with 'Woah partner...' and installs nothing (measured on 4.0.1, 2026-08-28). `-Sy` refreshes without a sysupgrade and is not blocked, and `-S` then installs. Everything Omarchy-specific is the setup line after them.",
|
||||
"id": "omarchy",
|
||||
"name": "Omarchy",
|
||||
"installs": "host",
|
||||
"packageManager": "pacman",
|
||||
"docs": "/docs/omarchy",
|
||||
"repo": "https://git.unom.io/api/packages/unom/arch",
|
||||
"install": [
|
||||
"curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key | sudo pacman-key --add -",
|
||||
"sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69",
|
||||
"grep -q '^\\[punktfunk\\]' /etc/pacman.conf || printf '\\n[punktfunk]\\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\\n' | sudo tee -a /etc/pacman.conf >/dev/null",
|
||||
"sudo pacman -Sy",
|
||||
"sudo pacman -S punktfunk-host punktfunk-web punktfunk-scripting",
|
||||
"punktfunk-omarchy setup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fedora",
|
||||
"name": "Fedora 43+",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,14 +7,6 @@ For **Arch Linux** and Arch-based distros (CachyOS, EndeavourOS, …). The host
|
||||
binary repo and updates with `pacman -Syu` like everything else. SteamOS is different — it has
|
||||
[its own page](/docs/steamos-host).
|
||||
|
||||
**One command instead?** The [guided installer](/docs/install#guided-install-preview) (preview) does
|
||||
everything on this page — it detects the distro, installs the host, joins the groups, opens the
|
||||
firewall and starts it:
|
||||
|
||||
<Installer />
|
||||
|
||||
The steps below are the same thing by hand, and stay the documented path.
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
- **NVIDIA:** `sudo pacman -S --needed nvidia-utils` (NVENC and the zero-copy path; Arch's `ffmpeg`
|
||||
|
||||
@@ -251,20 +251,8 @@ Ending the session when a game exits needs no script: it is the default, on the
|
||||
|
||||
## The event stream (`GET /api/v1/events`)
|
||||
|
||||
For a shell script or a status widget, the easy way is
|
||||
[`punktfunk-host ctl watch`](/docs/host-cli#ctl) — it does the SSE, the `Last-Event-ID` resume and
|
||||
the reconnect for you, and prints **one JSON object per line**, so the credentials never leave the
|
||||
host binary:
|
||||
|
||||
```sh
|
||||
punktfunk-host ctl watch --kinds pairing.pending,stream.'*'
|
||||
```
|
||||
|
||||
It also emits a synthetic `{"kind":"ctl.resync"}` line when the stream fell off the host's catch-up
|
||||
ring, which is the signal to re-snapshot rather than trust what you have.
|
||||
|
||||
For code that wants the raw stream, subscribe to SSE on the management API directly (loopback +
|
||||
bearer token — the same credentials as the rest of the admin surface):
|
||||
For code, subscribe to the SSE stream on the management API (loopback + bearer token — the
|
||||
same credentials as the rest of the admin surface):
|
||||
|
||||
```sh
|
||||
. ~/.config/punktfunk/mgmt-token # sets PUNKTFUNK_MGMT_TOKEN
|
||||
|
||||
@@ -8,14 +8,6 @@ Plasma — so this is the most appliance-like setup. One host streams **both** o
|
||||
Steam **Gaming Mode** and the **KDE desktop**, and follows the box when you switch, even mid-stream.
|
||||
Nothing in the config picks a mode.
|
||||
|
||||
**One command instead?** The [guided installer](/docs/install#guided-install-preview) (preview) does
|
||||
everything on this page — it detects the distro, installs the host, joins the groups, opens the
|
||||
firewall and starts it:
|
||||
|
||||
<Installer />
|
||||
|
||||
The steps below are the same thing by hand, and stay the documented path.
|
||||
|
||||
## 1. Install the host
|
||||
|
||||
The host installs as a **systemd system extension** — it overlays `/usr` from
|
||||
|
||||
@@ -141,22 +141,10 @@ trap by construction, which is why a single `vX.Y.Z` tag can safely release the
|
||||
once (the old `host-v*` / `win-v*` / `host-win-v*` tag namespaces are retired — `v*` is the only
|
||||
release tag now).
|
||||
|
||||
## Switch an installed box between channels
|
||||
## Migrating an existing box to canary
|
||||
|
||||
On a Linux host the guided installer does it, in either direction — it rewrites the repo, and
|
||||
re-resolves the packages in a direction the package manager would otherwise refuse (canary is always
|
||||
a minor ahead of stable, so **canary → stable is a downgrade**):
|
||||
|
||||
```sh
|
||||
curl -fsSLO https://punktfunk.unom.io/install.sh
|
||||
sh install.sh --channel canary # or: --channel stable
|
||||
```
|
||||
|
||||
It asks before moving, names both channels, and leaves `~/.config/punktfunk` alone — config,
|
||||
pairings and the console password carry across both ways. Run it with **no** `--channel` and it
|
||||
follows whatever the box is already on, so re-running it for anything else never moves you.
|
||||
|
||||
By hand, or on a platform the script does not cover — the repo is one path segment either way:
|
||||
Boxes added before this split point at the current stable channels, which now only move on releases.
|
||||
Point your dev fleet at **canary**:
|
||||
|
||||
```sh
|
||||
# apt
|
||||
@@ -167,18 +155,6 @@ sudo apt update && sudo apt upgrade
|
||||
sudo sed -i 's#/rpm/bazzite#/rpm/bazzite-canary#' /etc/yum.repos.d/punktfunk.repo # or fedora-44 → fedora-44-canary
|
||||
rpm-ostree upgrade
|
||||
|
||||
# pacman (Arch): rename the section, then -S (NOT -Syu — it will not step down to a lower version)
|
||||
sudo sed -i 's/^\[punktfunk\]$/[punktfunk-canary]/' /etc/pacman.conf
|
||||
sudo pacman -Sy && sudo pacman -S punktfunk-host punktfunk-web punktfunk-scripting
|
||||
|
||||
# Bazzite sysext
|
||||
sudo punktfunk-sysext install --channel canary
|
||||
|
||||
# Flatpak (Steam Deck client)
|
||||
flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref
|
||||
```
|
||||
|
||||
Coming **back** to stable is the same edit reversed, plus the flag that permits a step down —
|
||||
`sudo apt install --allow-downgrades punktfunk-host=<version>` (get it from `apt-cache madison`),
|
||||
`sudo dnf distro-sync punktfunk punktfunk-web punktfunk-scripting`, `sudo pacman -S …` as above, or
|
||||
`sudo punktfunk-sysext install --channel stable`.
|
||||
|
||||
@@ -10,14 +10,6 @@ package needs 2.39) — [build from source](/docs/build-from-source) there, or u
|
||||
> KDE (KWin 6.3) is below the floor, and a Cinnamon desktop (Linux Mint, LMDE) can only host through
|
||||
> gamescope. [What each desktop can do, and what Linux Mint 22 can't](/docs/requirements#cinnamon-linux-mint-and-lmde).
|
||||
|
||||
**One command instead?** The [guided installer](/docs/install#guided-install-preview) (preview) does
|
||||
everything on this page — it detects the distro, installs the host, joins the groups, opens the
|
||||
firewall and starts it:
|
||||
|
||||
<Installer />
|
||||
|
||||
The steps below are the same thing by hand, and stay the documented path.
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
- **NVIDIA:** the driver lives in `non-free`, which a default install doesn't enable. Enable it,
|
||||
|
||||
@@ -6,14 +6,6 @@ description: Install the Punktfunk host on Fedora 43 or newer from the RPM repo
|
||||
For **Fedora 43 or newer** (Workstation or KDE). Bazzite and other Fedora Atomic spins have
|
||||
[their own page](/docs/bazzite).
|
||||
|
||||
**One command instead?** The [guided installer](/docs/install#guided-install-preview) (preview) does
|
||||
everything on this page — it detects the distro, installs the host, joins the groups, opens the
|
||||
firewall and starts it:
|
||||
|
||||
<Installer />
|
||||
|
||||
The steps below are the same thing by hand, and stay the documented path.
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
- **NVIDIA:** the driver and an NVENC-capable FFmpeg both come from **RPM Fusion** — Fedora's own
|
||||
|
||||
@@ -10,7 +10,6 @@ command — [`punktfunk`](#punktfunk-on-the-client-machine), which ships with th
|
||||
| Command | What it does | Platform |
|
||||
|---|---|---|
|
||||
| [`serve`](#serve) | Run the host. | all |
|
||||
| [`ctl`](#ctl) | Drive a running host: pairing, devices, sessions, events. | all |
|
||||
| [`punktfunk1-host`](#punktfunk1-host) | Standalone native-only test host. | all |
|
||||
| [`service`](#service-windows) | Register, start, stop and remove the Windows service. | Windows |
|
||||
| [`tray`](#tray-windows) | Start, stop or query the status-tray icon. | Windows |
|
||||
@@ -77,97 +76,6 @@ turn off the mandatory-pairing default and serve any device on the network (trus
|
||||
only). `punktfunk1-host` (below) requires pairing by default too; its `--allow-tofu` flag is the
|
||||
test-host equivalent of `--open`.
|
||||
|
||||
## `ctl`
|
||||
|
||||
Drive a **running** host from a terminal: approve a device, type a Moonlight PIN, rename or unpair,
|
||||
stop a session, watch events. Everything the [web console](/docs/web-console) does day to day,
|
||||
without a browser — and everything it does is the same management API the console talks to, over
|
||||
loopback.
|
||||
|
||||
```sh
|
||||
punktfunk-host ctl status
|
||||
punktfunk-host ctl pending
|
||||
punktfunk-host ctl approve 3
|
||||
```
|
||||
|
||||
| Verb | What it does |
|
||||
|---|---|
|
||||
| `status` | Host state, live session count, paired-device counts. |
|
||||
| `sessions` | The active session(s) and any launched game. |
|
||||
| `pair status` | Is a pairing window open, and is a PIN waiting? |
|
||||
| `pair arm` | Open a native pairing window and print the PIN. `--ttl <s>` how long the window stays open, `--expires-in <s>` how long the device's access lasts, `--preset <full\|controller\|view>`, `--fingerprint <fp>` to bind the window to **one** device. |
|
||||
| `pair disarm` | Close it. |
|
||||
| `pending` | Devices knocking, with their claimed name and fingerprint tail. |
|
||||
| `approve <ID>` | Admit one, by id. `--name`, `--preset`, `--expires-in` as above. |
|
||||
| `deny <ID>` | Refuse one. |
|
||||
| `pin <PIN>` | Submit the PIN a Moonlight/GameStream client is showing. |
|
||||
| `clients` | Paired devices on both planes, labelled. |
|
||||
| `rename <FP> <NAME>` | Name a device. |
|
||||
| `access <FP> <PRESET>` | `full`, `controller` or `view` — see [Access levels](/docs/access-levels). The full grant matrix stays in the console. |
|
||||
| `unpair <FP>` | Remove one device. `unpair --all` removes every device on both planes (asks first; needs `--yes` with `--json`). |
|
||||
| `stop-session` | Stop the active session. |
|
||||
| `end-game` | End the launched game. |
|
||||
| `watch` | Stream host events as line-JSON on stdout, one object per line. `--kinds stream.*,pairing.pending` filters; `--since <seq>` resumes. |
|
||||
|
||||
Add `--json` to any verb for machine-readable output: `{"v":1,"data":…}` on success,
|
||||
`{"v":1,"error":{"code":…,"message":…}}` on failure, both on stdout. That envelope is the contract —
|
||||
the tables above are for humans and are not stable.
|
||||
|
||||
### Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | Success. |
|
||||
| `1` | The host refused the request (the message carries its reason). |
|
||||
| `2` | Usage error. |
|
||||
| `3` | No host reachable — not running, or never run on this machine. |
|
||||
| `4` | **Certificate pin mismatch.** Kept distinct on purpose: a script that treats it as "host down" and retries would be retrying into whatever is answering on that port. |
|
||||
|
||||
### Watching events
|
||||
|
||||
`watch` holds one long-lived connection and reconnects by itself, which makes it the right shape for
|
||||
a status widget or a script:
|
||||
|
||||
```sh
|
||||
punktfunk-host ctl watch --kinds pairing.pending,stream.'*' | while read -r line; do
|
||||
echo "$line"
|
||||
done
|
||||
```
|
||||
|
||||
Two synthetic lines are ours rather than the host's:
|
||||
|
||||
- `{"v":1,"kind":"ctl.resync"}` — the stream fell behind the host's catch-up ring, so anything you
|
||||
believe about pending devices or live sessions may be stale. Re-run `ctl status` / `ctl pending`
|
||||
instead of trusting your incremental state.
|
||||
- `{"v":1,"kind":"ctl.disconnected","data":{"error":…}}` — the connection dropped; a reconnect is
|
||||
already in progress.
|
||||
|
||||
The host caps concurrent event streams (the console holds one); past the cap you get a `503` with
|
||||
the host's own message and exit 1.
|
||||
|
||||
### How it authenticates
|
||||
|
||||
`ctl` reads two files from the host's config directory (`~/.config/punktfunk`, mode 0700) and
|
||||
nothing else:
|
||||
|
||||
- `mgmt-token` — the operator token the host mints for itself on first start, the same one the web
|
||||
console uses. `ctl` **consumes** it and never creates one: a missing token is an error, not a
|
||||
prompt.
|
||||
- `native-cert.pem` (or `cert.pem` on older hosts) — the host's own certificate, which `ctl` pins
|
||||
**before** sending the token. If the process answering on the management port presents anything
|
||||
else, the connection fails during the TLS handshake and no credential is ever transmitted —
|
||||
that is exit code 4.
|
||||
|
||||
There is deliberately **no `--token` flag and no token environment variable**. A credential on a
|
||||
command line or in an environment is readable by other processes on the box through
|
||||
`/proc/<pid>/cmdline` and `/proc/<pid>/environ`, which is exactly what the 0700 config directory
|
||||
exists to prevent. The consequence worth knowing: a host started with `--mgmt-token` (or
|
||||
`PUNKTFUNK_MGMT_TOKEN`) and no persisted token file cannot be reached by `ctl`. Every packaged
|
||||
install persists one, so this only affects hand-run dev hosts.
|
||||
|
||||
Everything runs over loopback, because the management API honours the admin surface from loopback
|
||||
peers only — `ctl` adds no listener and no new way in.
|
||||
|
||||
## `punktfunk1-host`
|
||||
|
||||
A standalone native-only host, mainly for testing the `punktfunk/1` path without the GameStream server
|
||||
|
||||
@@ -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.
|
||||
@@ -13,7 +13,6 @@ repositories on Linux and from a signed installer on Windows — pick your syste
|
||||
<Card title="Debian 13+ (and LMDE)" href="/docs/debian" description="The same apt repo as Ubuntu." />
|
||||
<Card title="Fedora 43+" href="/docs/fedora" description="dnf — add the RPM repo, then one dnf install for host, console and plugin runner." />
|
||||
<Card title="Arch / CachyOS" href="/docs/arch" description="pacman — a signed binary repo, then one full -Syu for all three packages." />
|
||||
<Card title="Omarchy" href="/docs/omarchy" description="The Arch repo, plus one setup command for ufw, autostart and the app menu." />
|
||||
<Card title="Bazzite / Fedora Atomic" href="/docs/bazzite" description="A systemd sysext — no layering, no reboot." />
|
||||
<Card title="SteamOS (Steam Deck as host)" href="/docs/steamos-host" description="One script, built on-device." />
|
||||
<Card title="NixOS" href="/docs/nixos" description="A flake input and a NixOS module, with a binary cache." />
|
||||
@@ -37,7 +36,7 @@ Prefer to read what runs first (it's plain `sh`, ~450 lines):
|
||||
|
||||
It asks before anything optional (Moonlight compat, the shared clipboard, the `punktfunk` group,
|
||||
starting at boot) and every answer has a default, so `sh install.sh --yes` — or piping it with no
|
||||
terminal — runs unattended; `--channel stable|canary`, `--mgmt-port`, `--no-start`, `--uninstall` and the environment
|
||||
terminal — runs unattended; `--channel canary`, `--mgmt-port`, `--no-start`, `--uninstall` and the environment
|
||||
twins (`PUNKTFUNK_INSTALL_YES`, `PUNKTFUNK_INSTALL_CHANNEL`, `PUNKTFUNK_INSTALL_GAMESTREAM`,
|
||||
`PUNKTFUNK_INSTALL_CLIPBOARD`, `PUNKTFUNK_INSTALL_PUNKTFUNK_GROUP`, `PUNKTFUNK_INSTALL_LINGER`,
|
||||
`PUNKTFUNK_INSTALL_MGMT_PORT`) are listed by `--help`. It covers Ubuntu/Debian, Fedora, Arch-family
|
||||
@@ -45,11 +44,6 @@ and Bazzite/Fedora Atomic; NixOS, SteamOS and Windows it points at their pages.
|
||||
per-system pages above remain the documented path, and the script runs exactly the install commands
|
||||
they show (CI fails if the two drift apart). Re-running is safe.
|
||||
|
||||
`--channel` also **switches** a host that is already installed — see
|
||||
[Release Channels](/docs/channels#switch-an-installed-box-between-channels). Without the flag the
|
||||
script follows whichever channel the box is already on, so re-running it to fix a group or a
|
||||
firewall rule never moves you off canary.
|
||||
|
||||
## Good to know
|
||||
|
||||
- **Read [Security & Safe Use](/docs/security) once.** A streaming host is remote control of the
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"debian",
|
||||
"fedora",
|
||||
"arch",
|
||||
"omarchy",
|
||||
"bazzite",
|
||||
"steamos-host",
|
||||
"nixos",
|
||||
@@ -32,6 +31,7 @@
|
||||
"client-settings",
|
||||
"profiles-and-links",
|
||||
"wake-on-lan",
|
||||
"host-power",
|
||||
"access-levels",
|
||||
"automation",
|
||||
"running-as-a-service",
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
---
|
||||
title: Omarchy
|
||||
description: Install the Punktfunk host on Omarchy 4.x — one setup command wires up the firewall, autostart, the app menu and toasts the Omarchy way.
|
||||
---
|
||||
|
||||
**Omarchy 4.x ("Quattro")** is Arch underneath, so the packages, the repo and `pacman` all work
|
||||
exactly as on the [Arch page](/docs/arch). What is different is everything *around* the install:
|
||||
ufw is on by default, autostart is a user unit tied to the uwsm session, apps belong in the
|
||||
Omarchy menu, and updates go through `omarchy update`. One command handles all of it.
|
||||
|
||||
<Callout type="info">
|
||||
Omarchy already ships Sunshine as an installable service and preinstalls moonlight-qt. Punktfunk
|
||||
**coexists** with Sunshine on its own ports — see [Sunshine on the same
|
||||
box](#sunshine-on-the-same-box) before you turn anything on.
|
||||
</Callout>
|
||||
|
||||
**One command instead?** The [guided installer](/docs/install#guided-install-preview) (preview) does
|
||||
everything on this page — it detects the distro, installs the host, joins the groups, opens the
|
||||
firewall and starts it:
|
||||
|
||||
<Installer />
|
||||
|
||||
The steps below are the same thing by hand, and stay the documented path.
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
Same as Arch:
|
||||
|
||||
- **NVIDIA:** `sudo pacman -S --needed nvidia-utils`
|
||||
- **AMD / Intel:** the Mesa stack you already have (`vulkan-radeon` / `vulkan-intel`,
|
||||
`libva-mesa-driver` / `intel-media-driver`).
|
||||
|
||||
## 2. Install the host
|
||||
|
||||
<Install platform="omarchy" />
|
||||
|
||||
`punktfunk-web` is the browser console and is optional but recommended — it is where the deep
|
||||
settings live. (`punktfunk-scripting` adds the plugin runner that fills your game library.)
|
||||
|
||||
<Callout type="warn">
|
||||
**Not `pacman -Syu <package>` — that is the Arch line and Omarchy refuses it.** Omarchy installs a
|
||||
pacman hook that aborts any transaction carrying both `-S` and `-u`, so it can funnel system
|
||||
upgrades through `omarchy update`; the Arch one-liner dies with *"Woah partner…"* and installs
|
||||
nothing. `-Sy` refreshes the databases without a system upgrade and is not blocked, and `-S` then
|
||||
installs. Everything else about the repo is identical to Arch.
|
||||
</Callout>
|
||||
|
||||
<Callout type="warn">
|
||||
Omarchy pins Arch packages to a **frozen snapshot mirror**, so its `ffmpeg` may sit behind rolling
|
||||
Arch. If `pacman` reports an unsatisfiable `libav*.so` dependency, the package is newer than your
|
||||
snapshot — wait for the next `omarchy update`, or [build from
|
||||
source](/docs/build-from-source).
|
||||
</Callout>
|
||||
|
||||
## 3. Wire it into Omarchy
|
||||
|
||||
```sh
|
||||
punktfunk-omarchy setup
|
||||
```
|
||||
|
||||
That one command, each step idempotent and each one reversible:
|
||||
|
||||
| Step | What it does |
|
||||
| --- | --- |
|
||||
| **Groups** | offers to add you to `input` (virtual gamepads) and `punktfunk` (the virtual Steam Deck pad). Both apply at your **next login**. |
|
||||
| **Autostart** | enables `punktfunk-host` as a user service so it comes up at login. It also installs the `graphical-session.target` binding — see [Autostart and your session](#autostart-and-your-session) for when that does more than nothing. |
|
||||
| **Firewall** | adds ufw rules scoped to your local networks (and `tailscale0` if you have it), each tagged `punktfunk-omarchy` so `remove` finds them again. |
|
||||
| **App menu** | installs "Punktfunk Console" as an Omarchy webapp — it appears in Apps (`Super`+`Space`). |
|
||||
| **Toasts** *(optional)* | pairing requests and stream start/stop as Omarchy notifications, with **Approve** / **Deny** buttons on the pairing one. |
|
||||
| **Idle** *(optional)* | keeps the screen awake for the length of a stream and restores your own setting afterwards. |
|
||||
| **Theme** *(optional)* | a `~/.config/omarchy/themed/` template so the console follows `omarchy-theme-set`. |
|
||||
|
||||
Check it any time with `punktfunk-omarchy status`, and undo all of it with `punktfunk-omarchy
|
||||
remove` (your pairings and `~/.config/punktfunk` are left alone).
|
||||
|
||||
## 4. Pair
|
||||
|
||||
Open **Punktfunk Console** from Apps and pair your first device — or stay in the terminal:
|
||||
|
||||
```sh
|
||||
punktfunk-host ctl pair arm # opens a pairing window and prints the PIN
|
||||
punktfunk-host ctl pending # devices knocking, with names and fingerprint tails
|
||||
punktfunk-host ctl approve 1
|
||||
```
|
||||
|
||||
See [the ctl reference](/docs/host-cli#ctl) for the full verb list.
|
||||
|
||||
## Autostart and your session
|
||||
|
||||
Omarchy offers **two** Hyprland entries at the login screen, and they differ in a way that matters
|
||||
here:
|
||||
|
||||
| Session | What it runs | `graphical-session.target` |
|
||||
| --- | --- | --- |
|
||||
| **Hyprland** | `start-hyprland` | never starts |
|
||||
| **Hyprland (uwsm-managed)** | `uwsm start … Hyprland` | starts with the session |
|
||||
|
||||
`punktfunk-omarchy setup` enables the host as a user service, so **it comes up at login either
|
||||
way**. What the second session additionally buys is that the host *restarts* when the session does,
|
||||
instead of surviving a logout holding a Wayland socket that died with the old compositor.
|
||||
|
||||
On the plain **Hyprland** session that target never starts — which is also why Omarchy's own
|
||||
`omarchy-crash-watch` and `omarchy-sleep-lock` units sit enabled-but-dead there. `punktfunk-omarchy
|
||||
status` tells you which situation you are in.
|
||||
|
||||
It is rarely worth switching sessions just for this: on Hyprland the host re-derives the live
|
||||
compositor on every connect, so the stale-socket failure the binding guards against on KDE and
|
||||
GNOME does not really arise. If you want it anyway, pick *Hyprland (uwsm-managed)* at the login
|
||||
screen — nothing about Punktfunk needs changing.
|
||||
|
||||
## Firewall and the video data plane
|
||||
|
||||
`punktfunk-omarchy setup` opens the control ports the Omarchy way — scoped to RFC1918 ranges rather
|
||||
than to the whole world:
|
||||
|
||||
```
|
||||
ufw allow from 192.168.0.0/16 to any app punktfunk-native comment "punktfunk-omarchy"
|
||||
```
|
||||
|
||||
**The video data plane has no rule, and that is expected.** It binds an ephemeral UDP port chosen
|
||||
per session, so there is nothing fixed to open. Under ufw's default deny-incoming the client's
|
||||
first packet is dropped and the session falls back to a blind send: the host's own outbound packet
|
||||
creates the conntrack entry, and the return path rides it. On a LAN — which is the whole point of
|
||||
hosting here — this works, and it is why `punched=false` in the logs is normal rather than a fault.
|
||||
|
||||
If you want strict control instead, pin the port and open exactly it:
|
||||
|
||||
```sh
|
||||
echo 'PUNKTFUNK_DATA_PORT=9778' >> ~/.config/punktfunk/host.env
|
||||
sudo ufw allow from 192.168.0.0/16 to any port 9778 proto udp comment "punktfunk-omarchy"
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
The cost is one concurrent session on that port; extra sessions fall back to ephemeral ports again.
|
||||
|
||||
## Updating
|
||||
|
||||
Punktfunk updates with everything else:
|
||||
|
||||
```sh
|
||||
omarchy update
|
||||
```
|
||||
|
||||
Our repo rides that transaction once it is configured, and it snapshots with snapper first. The web
|
||||
console therefore shows updates as **notify-only** on Omarchy and points at this command rather than
|
||||
offering a one-click apply: a bare `pacman -Syu` is blocked by Omarchy's own guard, and going around
|
||||
it would skip the snapshot your rollback depends on.
|
||||
|
||||
A snapper rollback reverts `/usr` — the binaries — while `~/.config/punktfunk` and your pairings
|
||||
live in `/home` and persist. Nothing needs re-pairing after a rollback.
|
||||
|
||||
## Sunshine on the same box
|
||||
|
||||
`omarchy install service sunshine` and Punktfunk can run together, with one caveat:
|
||||
|
||||
- **The native plane coexists.** Ports 9777 (QUIC), 47990 (management API) and 47992 (console) are
|
||||
ours alone, so the Punktfunk apps and the console work with Sunshine running and untouched.
|
||||
- **The GameStream plane collides.** Moonlight compatibility uses 47984/47989/48010 and
|
||||
47998–48010 — exactly Sunshine's ports. Ours are **off by default**; leave them off unless you
|
||||
are migrating, and only one host can bind them.
|
||||
|
||||
To migrate: `omarchy remove service sunshine`, then re-run `punktfunk-omarchy setup` and answer yes
|
||||
to GameStream if you still want stock Moonlight clients. More in [Switching from
|
||||
Sunshine](/docs/switching-from-sunshine).
|
||||
|
||||
## Screen sharing keeps working
|
||||
|
||||
Omarchy ships its own screen-share picker (`hyprland-preview-share-picker`), the one every browser
|
||||
share on the box goes through. Punktfunk needs that same setting to select a headless output
|
||||
without a dialog, so while a stream runs it borrows the setting — and **defers to your picker**
|
||||
whenever no Punktfunk selection is pending. Your browser shares behave exactly as before, during a
|
||||
session and after it, and the original value is written back when the last stream ends. If a host
|
||||
is killed mid-session, `punktfunk-omarchy remove` puts it back too.
|
||||
|
||||
## Keyboard shortcut (optional)
|
||||
|
||||
Punktfunk claims no chord — Omarchy occupies most of `Super`+*. Add your own to
|
||||
`~/.config/hypr/bindings.lua`:
|
||||
|
||||
```lua
|
||||
o.bind("SUPER + SHIFT + P", "Punktfunk", { webapp = "https://localhost:47992" })
|
||||
```
|
||||
|
||||
## Wake-on-LAN and disk encryption
|
||||
|
||||
Omarchy enables **full-disk encryption by default**. [Wake-on-LAN](/docs/wake-on-lan) still wakes
|
||||
the machine, but a cold boot stops at the LUKS passphrase prompt — nothing can stream until someone
|
||||
types it. Wake from **suspend** is unaffected, so suspend rather than shut down a box you want to
|
||||
wake into.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Run `punktfunk-omarchy status` first — it checks the units, the ufw rules, your groups, the portal
|
||||
and Hyprland's permission mode in one pass.
|
||||
|
||||
**Black frames, or input that does nothing.** Check whether Hyprland's permission enforcement is on:
|
||||
|
||||
```sh
|
||||
hyprctl -j getoption ecosystem:enforce_permissions
|
||||
```
|
||||
|
||||
If it is, the compositor is *silently* denying screencopy and virtual input — there is no error, only
|
||||
black frames and dropped input. Grant the host in your Hyprland config:
|
||||
|
||||
```lua
|
||||
o.permission("/usr/bin/punktfunk-host", "screencopy", "allow")
|
||||
o.permission("/usr/bin/punktfunk-host", "plugin", "allow")
|
||||
```
|
||||
|
||||
**No picture at all.** `xdg-desktop-portal-hyprland` must be running — it is what capture goes
|
||||
through. `systemctl --user status xdg-desktop-portal-hyprland`.
|
||||
|
||||
**The host is not running after a reboot.** `punktfunk-omarchy status` will say whether it is bound
|
||||
to the desktop session; if not, re-run `punktfunk-omarchy setup`.
|
||||
|
||||
More in [Troubleshooting](/docs/troubleshooting) and on the [Hyprland page](/docs/hyprland), which
|
||||
covers the compositor-level details this page assumes.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```sh
|
||||
punktfunk-omarchy remove # the Omarchy wiring
|
||||
sudo pacman -R punktfunk-host punktfunk-web # the packages
|
||||
```
|
||||
|
||||
`remove` reverses every step of `setup` — units, drop-in, ufw rules, the webapp, the hooks, the
|
||||
theme template — and restores the screen-share picker. Your config and pairings survive both; see
|
||||
[Uninstall](/docs/uninstall) to clear those too.
|
||||
@@ -14,7 +14,7 @@ install command, the group to join, and nothing else:
|
||||
|
||||
| Linux | Windows |
|
||||
|---|---|
|
||||
| [Ubuntu](/docs/ubuntu) · [Debian](/docs/debian) · [Fedora](/docs/fedora) · [Arch / CachyOS](/docs/arch) · [Omarchy](/docs/omarchy) · [Bazzite](/docs/bazzite) · [SteamOS](/docs/steamos-host) · [NixOS](/docs/nixos) | [Windows 11](/docs/windows-host) |
|
||||
| [Ubuntu](/docs/ubuntu) · [Debian](/docs/debian) · [Fedora](/docs/fedora) · [Arch / CachyOS](/docs/arch) · [Bazzite](/docs/bazzite) · [SteamOS](/docs/steamos-host) · [NixOS](/docs/nixos) | [Windows 11](/docs/windows-host) |
|
||||
|
||||
Not sure your machine qualifies? [Requirements](/docs/requirements) is the checklist.
|
||||
|
||||
|
||||
@@ -156,16 +156,6 @@ Minimum compositor versions (newer is fine):
|
||||
|
||||
- **KWin ≥ 6.5.6** ([KDE Plasma](/docs/kde)) — headless virtual outputs.
|
||||
- **GNOME ≥ 48** ([Mutter](/docs/gnome)) — virtual-monitor screen-cast.
|
||||
- **Hyprland — no version floor** ([Hyprland](/docs/hyprland)): the `hyprctl` path is
|
||||
version-independent, and both config eras (hyprlang and the newer Lua one) are handled. Contracts
|
||||
are verified against **0.55.4** and **0.56.2**. What Hyprland *does* need is
|
||||
**`xdg-desktop-portal-hyprland`** — capture goes through it, and Hyprland does not pull it in.
|
||||
On **0.49+**, if you have turned `ecosystem.enforce_permissions` on (off by default), grant the
|
||||
host screencopy and virtual input: a denial is *silent black frames and dropped input*, never an
|
||||
error.
|
||||
- **Omarchy ≥ 4.0** ([Omarchy](/docs/omarchy)) — not a compositor floor but an integration one: 4.0
|
||||
replaced the shell, the menu format and the Hyprland config language at once, so every point
|
||||
`punktfunk-omarchy` touches is different below it.
|
||||
- **gamescope ≥ 3.16.22** ([Bazzite/Steam](/docs/gamescope)) — below this, headless capture
|
||||
deadlocks against PipeWire ≥ 1.6.
|
||||
- **gamescope ≥ 3.16.23** for the Steam overlay (Shift+Tab / Quick Access Menu) to reach the stream
|
||||
|
||||
@@ -628,12 +628,6 @@ whose caveat *is* "nobody has run this on real hardware" — a wrong ✅ is wors
|
||||
well-trodden path, and there is no probe that would catch it failing. One spawn-and-capture run on
|
||||
an NVIDIA box settles it.
|
||||
- **Touch input from a Windows client.** Same shared code as Linux, no on-glass run.
|
||||
- **The whole Omarchy integration on an Omarchy box.** `punktfunk-omarchy setup`, the LAN-scoped
|
||||
ufw rules, the webapp menu entry, the notification and idle hooks, and the screen-share picker
|
||||
hand-back are all written against Omarchy 4.0.1's documented seams and unit-tested where they
|
||||
parse or generate a file — but none of it has been run on Omarchy. Nothing about it is enabled
|
||||
until an operator runs that command, so a plain Arch box is unaffected either way. One install →
|
||||
setup → pair → stream run on a 4.x box settles it.
|
||||
- **The `pf-webos` LG TV client.** A community project in another repository. Its codecs, HDR
|
||||
behaviour and feature set cannot be established from here.
|
||||
- **Everything client-side about Moonlight.** Wake-on-LAN, overlays, updates and which extensions
|
||||
|
||||
@@ -9,14 +9,6 @@ For **Ubuntu 26.04 or newer**, Desktop or Server, GNOME or KDE.
|
||||
> virtual display, and there is no gamescope for it. Use 26.04.
|
||||
> [Why, in detail](/docs/requirements#the-floor-for-a-working-host).
|
||||
|
||||
**One command instead?** The [guided installer](/docs/install#guided-install-preview) (preview) does
|
||||
everything on this page — it detects the distro, installs the host, joins the groups, opens the
|
||||
firewall and starts it:
|
||||
|
||||
<Installer />
|
||||
|
||||
The steps below are the same thing by hand, and stay the documented path.
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
- **NVIDIA:** install the recommended driver and its GL/EGL userspace (Wayland needs it), then
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -98,23 +98,6 @@
|
||||
"sudo pacman -Syu punktfunk-host punktfunk-web punktfunk-scripting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"$comment": "Omarchy IS Arch for delivery — same signed repo, same packages — but NOT the same install command. Omarchy ships a libalpm PreTransaction hook (00-omarchy-update-guard) that ABORTS any transaction whose pacman invocation carries both -S and -u, so Arch's `pacman -Syu <pkgs>` dies with 'Woah partner...' and installs nothing (measured on 4.0.1, 2026-08-28). `-Sy` refreshes without a sysupgrade and is not blocked, and `-S` then installs. Everything Omarchy-specific is the setup line after them.",
|
||||
"id": "omarchy",
|
||||
"name": "Omarchy",
|
||||
"installs": "host",
|
||||
"packageManager": "pacman",
|
||||
"docs": "/docs/omarchy",
|
||||
"repo": "https://git.unom.io/api/packages/unom/arch",
|
||||
"install": [
|
||||
"curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key | sudo pacman-key --add -",
|
||||
"sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69",
|
||||
"grep -q '^\\[punktfunk\\]' /etc/pacman.conf || printf '\\n[punktfunk]\\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\\n' | sudo tee -a /etc/pacman.conf >/dev/null",
|
||||
"sudo pacman -Sy",
|
||||
"sudo pacman -S punktfunk-host punktfunk-web punktfunk-scripting",
|
||||
"punktfunk-omarchy setup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fedora",
|
||||
"name": "Fedora 43+",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -197,11 +197,6 @@ package_punktfunk-host() {
|
||||
'kwin: stream a KDE Plasma desktop (kwin VirtualDisplay backend)'
|
||||
'mutter: stream a GNOME desktop (Mutter RecordVirtual backend)'
|
||||
'sway: stream a wlroots desktop (Sway VirtualDisplay backend)'
|
||||
# Owed independently of Omarchy: the Hyprland backend has been first-class for
|
||||
# releases (headless outputs, xdph capture, exclusive topology) with no dependency
|
||||
# naming it, so a Hyprland box could install the host and find capture unavailable.
|
||||
'hyprland: stream a Hyprland desktop (headless-output backend) — Omarchy ships this'
|
||||
'xdg-desktop-portal-hyprland: ScreenCast portal for the Hyprland backend (REQUIRED to capture on Hyprland)'
|
||||
'xdg-desktop-portal-kde: portal for the headless KDE session helper'
|
||||
'xdg-desktop-portal-wlr: portal for the headless Sway session helper'
|
||||
'punktfunk-web: browser management console (device pairing + status)'
|
||||
@@ -316,28 +311,6 @@ package_punktfunk-host() {
|
||||
install -Dm0644 "$R/packaging/bazzite/gamescope-headless-session" \
|
||||
"$pkgdir/etc/gamescope-session-plus/sessions.d/steam"
|
||||
install -Dm0644 "$R/api/openapi.json" "$pkgdir/usr/share/punktfunk/openapi.json"
|
||||
# The session drop-in as a TEMPLATE the Omarchy setup script installs into the user's unit dir.
|
||||
# It is already shipped to /usr/lib/systemd/user/... as a documented no-op elsewhere; here it is
|
||||
# a file `punktfunk-omarchy setup` can copy, because on Omarchy graphical-session.target really
|
||||
# starts and binding to it is what makes the host restart with the session.
|
||||
install -Dm0644 "$R/scripts/punktfunk-host-desktop-session.conf" \
|
||||
"$pkgdir/usr/share/punktfunk/punktfunk-host-desktop-session.conf"
|
||||
# Omarchy integration (design/omarchy-integration.md). The script is INSTALLED, never run: a
|
||||
# package that opened firewall ports or edited a user's config on install would be doing both
|
||||
# behind the operator's back. `punktfunk-omarchy setup` is the consent step.
|
||||
install -Dm0755 "$R/packaging/linux/omarchy/punktfunk-omarchy" \
|
||||
"$pkgdir/usr/bin/punktfunk-omarchy"
|
||||
# The app mark, so a launcher entry has something to draw. Only the CLIENT package shipped it,
|
||||
# which is why the Omarchy webapp entry came out with a blank `Icon=` on a host-only box — the
|
||||
# name resolved to nothing. Scalable, so every launcher size is covered by one file.
|
||||
install -Dm0644 "$R/packaging/linux/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg" \
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg"
|
||||
for h in pairing-pending stream-started stream-stopped idle-guard; do
|
||||
install -Dm0755 "$R/packaging/linux/omarchy/hooks/$h" \
|
||||
"$pkgdir/usr/share/punktfunk/omarchy/hooks/$h"
|
||||
done
|
||||
install -Dm0644 "$R/packaging/linux/omarchy/themed/punktfunk.json.tpl" \
|
||||
"$pkgdir/usr/share/punktfunk/omarchy/themed/punktfunk.json.tpl"
|
||||
# Firewall openers — NOT auto-enabled (an Arch package never touches the admin's running firewall).
|
||||
# Stock Arch ships no firewall; CachyOS ships ufw; some spins (EndeavourOS) enable firewalld — so we
|
||||
# install BOTH a ufw application profile and firewalld service definitions, and the one for whatever
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# punktfunk hook: hold Omarchy's idle timer off for the length of a stream — and put the user's
|
||||
# own setting back afterwards (design D9).
|
||||
#
|
||||
# idle-guard on # a stream started: remember the current state, then stay awake
|
||||
# idle-guard off # the last stream ended: restore what was remembered
|
||||
#
|
||||
# Why this is needed at all: remote input normally resets the compositor's idle timer, so an
|
||||
# ordinary game session never blanks. The acute case is an INPUT-QUIET stream — a film, a long
|
||||
# cutscene, a lobby — where nothing moves the pointer for half an hour and the box locks in the
|
||||
# middle of it.
|
||||
#
|
||||
# 🛑 The rule that makes this safe to install: **restore, never assume.** An operator who set
|
||||
# `stay-awake` themselves must still have it after a session ends, so `on` snapshots the state and
|
||||
# `off` puts that snapshot back rather than blindly running `allow-idle`. The snapshot lives in
|
||||
# $XDG_RUNTIME_DIR, so a reboot cannot leave a stale one behind.
|
||||
#
|
||||
# ⚠ `omarchy-toggle-idle status` prints **JSON**, not a keyword:
|
||||
# {"enabled":true,"class":"enabled","tooltip":"Allow Idle Lock & Screensaver"} # stay-awake ON
|
||||
# {"enabled":false,"class":"disabled","tooltip":"Stay Awake"} # stay-awake OFF
|
||||
# Note the trap in that payload: the tooltip names the action the button WOULD take, so the string
|
||||
# "Stay Awake" appears exactly when stay-awake is OFF. Matching on the tooltip — or on the words
|
||||
# `stay-awake`/`allow-idle`, which never appear at all — gets it backwards or silently never
|
||||
# matches. `"enabled":true` is the only field that means what it says. (Measured on Omarchy 4.0.1.)
|
||||
#
|
||||
# What this does NOT do: suppress an explicit lock. `omarchy.lock` invoked by the user (or by
|
||||
# suspend) still locks — only the idle countdown is held off, and only while streaming.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
STATE="${XDG_RUNTIME_DIR:-/tmp}/punktfunk-omarchy-idle-prior"
|
||||
|
||||
command -v omarchy-toggle-idle >/dev/null || exit 0
|
||||
|
||||
case "${1:-}" in
|
||||
on)
|
||||
# Refcount-free by design: `stream.started` firing twice (two clients) just re-reads a state
|
||||
# that is already `stay-awake`, and the snapshot is written only if there is not one already —
|
||||
# so the SECOND stream cannot overwrite the user's real setting with our own.
|
||||
if [[ ! -f "$STATE" ]]; then
|
||||
case "$(omarchy-toggle-idle status 2>/dev/null)" in
|
||||
*'"enabled":true'*) echo stay-awake > "$STATE" ;;
|
||||
*'"enabled":false'*) echo allow-idle > "$STATE" ;;
|
||||
# An output shape we do not recognise (a future Omarchy). Record that we do not know,
|
||||
# and `off` will leave the setting alone rather than guess at it.
|
||||
*) echo unknown > "$STATE" ;;
|
||||
esac
|
||||
fi
|
||||
omarchy-toggle-idle stay-awake >/dev/null 2>&1 || true
|
||||
;;
|
||||
off)
|
||||
prior="$(cat "$STATE" 2>/dev/null || echo unknown)"
|
||||
rm -f "$STATE"
|
||||
case "$prior" in
|
||||
# They already wanted the box awake — leave it that way.
|
||||
stay-awake) : ;;
|
||||
allow-idle) omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true ;;
|
||||
# No snapshot (a host that crashed and restarted), or a status we could not read: do NOT
|
||||
# guess. Leaving the box awake is recoverable in one click; forcing allow-idle on someone
|
||||
# who had deliberately set stay-awake is not.
|
||||
*) : ;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo "usage: idle-guard on|off" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user