Compare commits

..
Author SHA1 Message Date
enricobuehler 40fc741ad5 chore(ci): the three new ABI constants join the undocumented-env baseline
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m4s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m35s
ci / bun-nix (pull_request) Successful in 49s
ci / docs-site (pull_request) Successful in 1m2s
ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 2m12s
ci / docs-drift (pull_request) Successful in 1m4s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m26s
android / android (pull_request) Successful in 8m37s
ci / rust (pull_request) Successful in 16m32s
The docs-drift ratchet matches PUNKTFUNK_* by token spelling, not by env
read, so the cbindgen #defines this branch adds — GRANT_POWER,
GRANT_ALL_PRE_POWER and HOST_POWER_CLOSE_CODE — read as new undocumented
knobs. They are ABI constants, not knobs: every sibling grant bit and
close code is already in this file for the same reason.
2026-08-28 23:50:44 +02:00
enricobuehler 62728317cf The couch gets the other half of the round trip: host power on every client
ci / bun-nix (pull_request) Successful in 28s
ci / docs-site (pull_request) Successful in 1m3s
ci / docs-drift (pull_request) Failing after 1m9s
ci / web (pull_request) Successful in 1m33s
apple / swift (pull_request) Successful in 2m9s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m0s
android / android (pull_request) Successful in 7m19s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 6m48s
ci / rust (pull_request) Failing after 8m11s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 2m15s
design/host-actions.md P2. The host has offered sleep/restart/shutdown since
the previous commit; this is where a person can reach them. Every client's host
menu grows the rows right where Wake host appears when the machine is asleep —
finish on the TV, sleep the host from the same menu that woke it.

One shared core: pf_client_core::host_actions holds the discovery read, the
id-only invoke, and a process-wide TTL cache the console, the GTK page and the
Windows tile all read, so three shells cannot end up disagreeing about what a
host offers. Every shell warms it on a refresh tick it already had.

Discovery is PRE-fetched, never fetched when a menu opens: the console screen
holds a cloned row, and rows that appear under a cursor already moving are a
hazard when two of them shut a machine down. For the same reason the console's
armed state became WHICH row is armed rather than a bare flag — a flag let an
arming press on Forget fire Shut down host. Restart and shut down confirm;
sleep is reversible from the same menu, so it goes on one press. An action the
host says it cannot run right now stays listed, disabled, and says why.

Surfaces: console-UI rows + ConsoleCmd::HostAction with session and Android
dispatch; GTK card menu (plus a card-level Toast) with an AlertDialog confirm;
Windows tile menu, its label built and matched through one function so a menu
whose rows outlived their handlers cannot run a different verb; Android touch
home and Skia console; Apple host card and gamepad options. Unknown ids render
from the host's own title on all of them — a later host action needs no client
release to appear.

Typed close: the Swift hostPower case and the Kotlin host-power token. Without
them, sleeping your own host from the couch reads as a crash on those two
clients.
2026-08-28 23:26:26 +02:00
enricobuehler 47dbd7b514 The host learns to end its own day: sleep, restart and shutdown as gated actions
design/host-actions.md P0 (host core) + P1 (web console). A reusable action
registry — GET /api/v1/actions (per-caller permission + honest availability) and
an id-only POST /api/v1/actions/{id} on the mgmt cert lane — with three power
built-ins, so the Wake-on-LAN round trip finally closes from the couch.

Core: GRANT_POWER (1<<6) joins the mask; GRANT_ALL 0x3F -> 0x7F; the legacy-full
read rule (an explicitly stored pre-power 0x3F reads as the current GRANT_ALL,
so old Full records neither render Custom nor silently lack Power); typed close
RejectReason::HostPower (0x6B) so ended sessions say why.

Host: power executors (logind via zbus behind the already-shipped polkit rule,
deliberately without -ignore-inhibit; Windows SeShutdownPrivilege +
InitiateSystemShutdownExW / SetSuspendState; macOS answers 501); ordering
reply-202 -> typed close -> 1 s grace -> act; single-flight; another device's
live session blocks a cert-lane invoke (409); action.invoked on the event bus;
denials logged once per (fingerprint, action) per boot. The plugin token gets
neither route.

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