Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35f549f04d | ||
|
|
a6efc467ce | ||
|
|
95a2683aae | ||
|
|
8fcd9354b5 | ||
|
|
81c103c54a | ||
|
|
ad6b5184dc | ||
|
|
e12f3a62de | ||
|
|
60d3e89c07 | ||
|
|
55b1dc848d | ||
|
|
51f64382ec | ||
|
|
14ec3d1b5d | ||
|
|
6ca7e4a7ec | ||
|
|
9620c71b1f | ||
|
|
4e392a7666 | ||
|
|
37902ebac8 | ||
|
|
2e4561d572 | ||
|
|
aa8c5d0e0e | ||
|
|
78723f381e | ||
|
|
46e617f7e6 | ||
|
|
9c64cc6099 | ||
|
|
33914867d1 | ||
|
|
38d2b0e454 | ||
|
|
30c31ed31b | ||
|
|
739d63fc04 | ||
|
|
c7d270b466 | ||
|
|
b8720e5e4a | ||
|
|
32a89fd8ec | ||
|
|
d9cb3ce701 |
@@ -1,4 +1,8 @@
|
||||
<!-- What and why — the diff says how. -->
|
||||
<!-- TITLE: type(scope): summary — imperative, ≤72 chars, no trailing period.
|
||||
Gitea turns this title into the merge subject, so it has to read as a commit.
|
||||
Types: feat fix docs refactor perf test chore ci security. See docs/writing.md. -->
|
||||
|
||||
<!-- What and why — the diff says how. The investigation belongs here, not in the commit. -->
|
||||
|
||||
**User-facing fact changed?** (an install step, a knob, a port, what a feature does, a limit)
|
||||
→ the docs-site page that owns it is updated in this PR, or this is n/a. Install/repo/port facts
|
||||
|
||||
@@ -40,14 +40,20 @@ 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:
|
||||
@@ -81,6 +87,39 @@ 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
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
Guidance for coding agents working in this repository.
|
||||
|
||||
## Writing standards
|
||||
|
||||
Read `docs/writing.md` before you write a commit message, a `CHANGELOG.md` entry, or a comment.
|
||||
It is the house style for all three, and §4 is a per-PR checklist. The short version:
|
||||
|
||||
- Commit subject is `type(scope): summary` — imperative, **72-character hard cap**, no trailing
|
||||
period, one logical change. No `Co-Authored-By` trailer.
|
||||
- The commit body is *why*, wrapped at 72. The investigation, the measurements and the rejected
|
||||
paths go on the pull request, never in the message.
|
||||
- Write the Gitea PR title as a conventional commit; Gitea makes it the merge subject.
|
||||
- New `CHANGELOG.md` sections use Keep a Changelog categories. Leave the older sections alone.
|
||||
- A comment states an invariant or a trap. A comment never enforces a trust boundary — a type,
|
||||
a test or an assertion does.
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
+231
@@ -10,6 +10,237 @@ long enough to bury the user-facing half it was appended to. See `docs/releases/
|
||||
If you embed `punktfunk-core`, package Punktfunk, or write a plugin, this file is for you. Start
|
||||
with the version table of the release you are moving to, then read **Breaking changes**.
|
||||
|
||||
New sections use [Keep a Changelog](https://keepachangelog.com/) categories — `Breaking`,
|
||||
`Added`, `Changed`, `Fixed`, `Security` — plus the version table, and stay to one screen for a
|
||||
patch and two for a minor. Sections written before 28 August 2026 predate that rule and are left
|
||||
as they are. See `docs/writing.md` §2.
|
||||
|
||||
---
|
||||
|
||||
## v0.33.0
|
||||
|
||||
45 commits since v0.32.0 (29 non-merge), counted at the tip this was cut from.
|
||||
|
||||
**Nothing versioned moves.** Wire protocol stays 2, C ABI stays 26, driver protocol 6, gamepad
|
||||
channel 3, plugin index schema 1, host event schema 1, gamescope `pfhdr8`. The management API is
|
||||
the one surface that grows: `api/openapi.json` gains `/api/v1/actions` and `/api/v1/actions/{id}`.
|
||||
First section written to `docs/writing.md` §2.
|
||||
|
||||
**Two things an embedder must read.** `PUNKTFUNK_GRANT_ALL` widens from `0x3F` to `0x7F` — a value
|
||||
change to an existing macro, not an addition — and `CLIENT_CAP_KEEP_HOST_AUDIO` is renamed to
|
||||
`PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO`. Both are under **Breaking**.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.32.0 | v0.33.0 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged. Two additions, each ignorable by an older peer: close code `107` (`HOST_POWER_CLOSE_CODE`, reject reason `HostPower`) and the advisory mDNS TXT key `addr` |
|
||||
| C ABI | 26 | **26** | unchanged. Additive: `PUNKTFUNK_GRANT_POWER` (`1 << 6`), `PUNKTFUNK_GRANT_ALL_PRE_POWER`, `PUNKTFUNK_HOST_POWER_CLOSE_CODE`, `PUNKTFUNK_STATUS_REJECTED_HOST_POWER` (`-32`). **But `PUNKTFUNK_GRANT_ALL` changes value** — see Breaking |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.32.0 tag |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged; nothing under the Windows gamepad backends moved |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| Host event schema | 1 | **1** | unchanged. Additive: the `action.invoked` event kind, carrying `id`, an optional `device` and `outcome` |
|
||||
| `api/openapi.json` | 0.32.0 | **0.33.0** | **MOVES.** Two added paths, `GET /api/v1/actions` and `POST /api/v1/actions/{id}`. `api/` and `docs-site/public/` are byte-identical to each other |
|
||||
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; `packaging/gamescope/PKGBUILD` shows no diff |
|
||||
| `@punktfunk/host` (SDK) | 0.1.6 | **0.1.6** | unchanged **on purpose**. `sdk/src/gen/punktfunk.ts` is regenerated with the actions types, but the plugin token is refused both routes, so no plugin can call them. Cut `sdk-v0.1.7` when a plugin-reachable route moves |
|
||||
| `@punktfunk/plugin-kit` | 0.4.4 | **0.4.4** | unchanged; nothing under `plugin-kit/` moved |
|
||||
|
||||
### Breaking
|
||||
|
||||
- **`PUNKTFUNK_GRANT_ALL` widens `0x3F` → `0x7F`.** `GRANT_POWER` (`1 << 6`) joins the mask. Code
|
||||
comparing a stored mask against `GRANT_ALL` for equality now reads a pre-power record as Custom.
|
||||
The host applies a legacy-full rule — an explicitly stored `0x3F` reads as the current
|
||||
`GRANT_ALL` — so existing Full-control pairings keep Full control and gain Power with it. An
|
||||
embedder holding its own copy of a mask must apply the same rule, or compare against the new
|
||||
`PUNKTFUNK_GRANT_ALL_PRE_POWER`. *A pairing saved as Full control before this release can now
|
||||
sleep, restart and shut down the host.*
|
||||
- **`CLIENT_CAP_KEEP_HOST_AUDIO` is renamed `PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO`.** cbindgen had
|
||||
no rename entry, so v0.32.0 emitted the bare name into every embedder's namespace. The value is
|
||||
still `32`. Anything compiled against the unprefixed spelling fails to compile; add the prefix.
|
||||
- **The `custom_picker_binary` shim is restored on host exit, not after each cast** (Hyprland,
|
||||
Omarchy). Restoring per cast rewrote `xdph.conf` and restarted
|
||||
`xdg-desktop-portal-hyprland`, and a ScreenCast bound across that restart never delivers a
|
||||
buffer — so every session after the first produced no frames. The shim now stays installed
|
||||
between sessions and delegates to the previously configured picker, so ordinary browser shares
|
||||
behave as before. `punktfunk-omarchy remove` restores it from its marker.
|
||||
|
||||
### Added
|
||||
|
||||
- **Host actions: a reusable registry with three power built-ins.** `GET /api/v1/actions` returns
|
||||
the caller's permitted actions with honest availability; `POST /api/v1/actions/{id}` invokes one
|
||||
by id on the mgmt cert lane. `power.sleep`, `power.reboot` and `power.shutdown` ship. Executors:
|
||||
logind via zbus behind the shipped polkit rule, deliberately without `-ignore-inhibit`; Windows
|
||||
`SeShutdownPrivilege` + `InitiateSystemShutdownExW` / `SetSuspendState`; macOS answers 501.
|
||||
Ordering is reply-202 → typed close → 1 s grace → act, single-flight, and another device's live
|
||||
session blocks a cert-lane invoke with 409. Denials log once per `(fingerprint, action)` per
|
||||
boot. **The plugin token gets neither route.** See `design/host-actions.md`.
|
||||
- **`PUNKTFUNK_GRANT_POWER` (`1 << 6`)** — route-gated like `CLIPBOARD` / `MIC` / `LAUNCH`; no
|
||||
datagram carries it, so `classify` is untouched. Machine power only: future plugin or custom
|
||||
actions get their own class, never this bit.
|
||||
- **`punktfunk-host ctl`** — 15 verbs over the existing admin lane. Pairing arm/pending/approve/
|
||||
deny, the Moonlight PIN, both planes' device lists with rename/unpair/access presets, and
|
||||
session stop/end-game. `watch` bridges the server-sent-event stream to line-JSON on stdout, with
|
||||
`Last-Event-ID` resume and a synthetic `ctl.resync` line after a `dropped` frame. `--json`
|
||||
returns a versioned envelope. **Pin before token:** the agent pins the host's own leaf through
|
||||
punktfunk-core's `PinVerify`, so rustls rejects a squatter *during the handshake* and ureq never
|
||||
serialises an `Authorization` header. There is deliberately no `--token` flag and no token env
|
||||
read — a credential in either is readable cross-uid through `/proc`. The pin is
|
||||
`native-cert.pem` then `cert.pem`, because the mgmt listener serves the native identity.
|
||||
- **`punktfunk-omarchy setup | remove | status`** — installed by the host package, never run by it.
|
||||
It sets up LAN-scoped comment-tagged ufw rules, the uwsm session drop-in, and the console as a
|
||||
webapp. It also merges an app-menu submenu between markers into the user's
|
||||
`omarchy-menu.jsonc`, writes sample hooks through `hooks.json`, adds an idle guard that
|
||||
snapshots and restores the user's own stay-awake setting, and installs the theme template.
|
||||
`osinfo::is_omarchy()` makes the console update tier notify-only, enforced at the apply route
|
||||
rather than merely reported.
|
||||
- **Console handoff ticket.** `punktfunk-host ctl console-url` mints
|
||||
`<unix-seconds>.<nonce>.<HMAC-SHA256>` over `pf-console-handoff:v1:ts:nonce`, signed with the
|
||||
management token both sides already hold. 60 s TTL, single use, symmetric window, one 401 for
|
||||
every rejection. No new host route and no shared state. The test suite carries a vector minted by
|
||||
the real Rust host and cross-checked against python's `hmac`.
|
||||
- **mDNS advisory TXT key `addr`.** The host declares which address its advert is for. It never
|
||||
overrides reachability — it settles a multi-NIC host's tie, and old hosts without it still
|
||||
resolve deterministically.
|
||||
- **`action.invoked` on the host event bus.** Emitted on ACCEPT, and again if the executor later
|
||||
fails. A succeeded power action ends the process, so "accepted with no failure after it" is the
|
||||
success signal a hook can act on.
|
||||
- **Two diagnostics catalog rows.** `hyprland_permissions` — Hyprland 0.49+ can deny screencopy and
|
||||
virtual input **silently**, and no probe outside the compositor can tell granted from denied, so
|
||||
it is a Warning rather than Critical. `omarchy_updates` — the console's apply button is
|
||||
deliberately absent on Omarchy, and without a row naming where updates come from, "my update
|
||||
button is missing" is unanswerable.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`docs/writing.md` is the writing rulebook.** Conventional Commits with a 72-character subject
|
||||
cap, Keep a Changelog categories for new CHANGELOG sections, and comments that state an invariant
|
||||
rather than recap a diff. CONTRIBUTING, AGENTS.md and the PR template point at it. Nothing
|
||||
existing is rewritten.
|
||||
- **`scripts/install.sh --channel` switches an installed host, in both directions.** It previously
|
||||
applied only to a box with nothing installed. canary→stable is a downgrade, which every package
|
||||
manager refuses unless told. So apt gets explicit `madison` version pins, pacman `-Sy` then `-S`
|
||||
(never `-Syu`, which looks at the lower stable version and does nothing), and dnf a
|
||||
`distro-sync` behind the `install`. A switch moves every punktfunk package on the box, not the
|
||||
three the script installs. With no explicit `--channel` the script follows whatever the box is
|
||||
on, so a bare re-run cannot drag a canary box back a channel.
|
||||
- **Omarchy installs with `pacman -Sy` then `pacman -S`, never `-Syu`.** Omarchy ships a libalpm
|
||||
PreTransaction hook that aborts any transaction whose pacman invocation carries both `-S` and
|
||||
`-u`. Plain Arch keeps its full `-Syu`.
|
||||
- **`punktfunk-web.service` waits on `punktfunk-web-init.service`** (`Type=oneshot`) for the
|
||||
management token and a non-empty identity cert/key pair. `After=punktfunk-host.service` was never
|
||||
a readiness gate: the host is `Type=simple`, so systemd calls it started the instant it is
|
||||
spawned. The two files are written far apart — the token early in `serve`, the cert last inside
|
||||
`identity::load_or_adopt` — so waiting on the token alone would only move the failure. Timing out
|
||||
is not fatal: web-init exits 0, the `Restart` backstop takes over, and the log names the
|
||||
precondition. Also drops `ConditionPathExists=!%h/.config/punktfunk/web-password`, which skipped
|
||||
the unit from the second boot onward. Mirrored into the NixOS module.
|
||||
- **Tray's service-restart row is now "Restart Punktfunk"**, before the clients ship a
|
||||
machine-level "Restart host".
|
||||
- **Web and docs-site dependency majors.** Vite 7 → 8, `@vitejs/plugin-react` 5 → 6,
|
||||
`vite-tsconfig-paths` 5 → 6, `@types/node` 22 → 26, TypeScript 5.9 → 7, biome 2.5, `@unom/ui`
|
||||
0.10.0, `motion` 13.1, `@unom/app-ui` 0.3.0. The stale-chunk recovery now comes from `@unom/ui`'s
|
||||
`reloadOnStaleChunk` instead of a fourth local copy.
|
||||
- **`check-docs-drift.sh`'s undocumented-env baseline gains the four new ABI constants.** The
|
||||
ratchet matches `PUNKTFUNK_*` by token spelling and cannot tell an environment variable from a
|
||||
cbindgen export.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Automatic bitrate ratcheted to the floor and never climbed back** (0.32.0 regression). Phase 4
|
||||
hands the encoder `enc_kbps(budget)` and reads back through `budget_kbps()`. That roundtrip
|
||||
deflates 1–2 kbps by design, so the `applied < requested` compare recorded a phantom
|
||||
`encoder_ceiling_kbps` on **every** successful apply. The control task clamps each later
|
||||
`SetBitrate` to it, the client learned it as a host cap, and every descent lowered the ceiling
|
||||
further — a one-way ratchet. 0.31.4 was immune, its read-back staying in encoder units.
|
||||
`EncDerive::applied_budget_kbps` now reads back in the request's own truncated terms, so only an
|
||||
apply short of what the request itself derives to records a ceiling. Regression test proven
|
||||
against the backed-out fix.
|
||||
- **The startup link-capacity probe fired before video existed.** It ran on a flat 2 s timer, but
|
||||
host bring-up takes 6–8 s (indirect display driver acquisition), so the burst landed on the
|
||||
first IDR frame: 5–11 s of black video, the decoder refusing access units, 2101 audio
|
||||
underruns. It then missed its own 6 s timeout anyway (8.6 s measured), so the ceiling stayed
|
||||
negotiated — all disturbance, no measurement. The probe now arms only once a frame has
|
||||
completed, with a 15 s timeout.
|
||||
- **A Hyprland `topology: exclusive` session left the desk dark after a failed build.** The host
|
||||
lends one backend instance to `build_pipeline_with_retry` for up to eight attempts, and
|
||||
`pending_restore` was assigned unconditionally — so attempt 2, correctly finding nothing left to
|
||||
disable, dropped attempt 1's restore closure on the floor. `stash_topology_restore` makes the
|
||||
slot first-wins, which is also the right list: attempt 1 looked at the desk while it was still
|
||||
lit. KWin gets the same guard. Two doc corrections, since they are what made this read as a
|
||||
registry bug: the Hyprland and sway `pending_restore` comments claimed the registry lifts the
|
||||
restore and `Drop` is a backstop. Both carry a portal fd, so `registry::acquire` returns them as
|
||||
pass-through at the `remote_fd.is_some()` check — several statements *above* its
|
||||
`take_topology_restore()` call — and `Drop` is the only thing that runs them.
|
||||
- **A theme switch reset the streamed head's resolution.** `omarchy-theme-set` ends in
|
||||
`omarchy-restart-hyprctl`, which is `hyprctl reload`, and a reload drops every runtime `hyprctl
|
||||
keyword` — our monitor rule included. The Hyprland backend now subscribes to the compositor event
|
||||
socket and re-applies the rule on `configreloaded`, so any reload gets it. A socket read, not a
|
||||
poll. The mode only: re-disabling `exclusive`'s heads from this watcher risks a permanently dark
|
||||
desk, because teardown's own `restore_heads` runs a reload and nothing orders the two.
|
||||
- **The streamed head could not be focused on a Lua-configured Hyprland.** The spelling is
|
||||
`hl.dsp.focus({ monitor = "<name>" })`, measured on Hyprland 0.56.2 — the compositor volunteers
|
||||
it when asked with any other key. An unfocused headless output stays empty, an empty output
|
||||
produces no damage, no damage means no PipeWire frames, and capture then misses its first-frame
|
||||
deadline: a black screen on every Omarchy box.
|
||||
- **Clients rolled dice on a multi-address host.** The host registers exactly one address, but the
|
||||
OS mDNS responder also answers A queries per interface. So the resolved set a client folds is a
|
||||
union polluted by every overlay network whose multicast reaches it — and both clients picked
|
||||
with `HashSet::iter().next()`, re-rolled on every re-announce. `punktfunk_core::discovery` now
|
||||
ranks, best wins: longest common prefix with any local unicast address, then the host's declared
|
||||
`addr`, then prefix against the default-route source, then numerically lowest. Shared by the
|
||||
desktop and Android clients.
|
||||
- **The client advertised HDR its video processor could not tone-map.** `VIDEO_CAP_HDR` invites a
|
||||
PQ stream, and on a Windows box with no HDR10 swapchain the D3D11VA hand-off relies on the video
|
||||
processor's PQ→sRGB tonemap — a driver capability nothing validated. Field 2026-08-26: an Arc
|
||||
A370M went green on every HDR session while AV1 8-bit SDR at the same mode streamed clean.
|
||||
`video_d3d11::pq_tonemap_supported()` asks `CheckVideoProcessorFormatConversion` for the exact
|
||||
pair the SDR ring sets (P010 G2084 studio in, BGRA8 sRGB out). Only a definitive driver "no"
|
||||
answers false; API failure keeps today's behaviour. `ten_bit_sdr` stays unprobed on purpose —
|
||||
10-bit SDR is no tonemap.
|
||||
- **`keep_host_audio` reached only three surfaces.** Shipped wired end to end in 0.32.0, but Apple
|
||||
and Android carried no reference to it — there is no shared settings schema, and each client
|
||||
hand-mirrors `trust::Settings`. Both now have the defaults key, the profile overlay, the settings
|
||||
row and the capability bit in their connect. `pf-console-ui`'s `RowId::KeepHostAudio` leaves
|
||||
`desktop_only`.
|
||||
- **`punktfunk-omarchy status` lied twice.** It reported "no punktfunk rules" seconds after adding
|
||||
six, because a failed `sudo -n` read was being treated as proof of absence. And it reported xdph
|
||||
missing on a box that was capturing through it: `pgrep -x` can never match
|
||||
`xdg-desktop-portal-hyprland`, since Linux truncates comm to 15 characters.
|
||||
- **The idle guard never restored anything.** `omarchy-toggle-idle status` prints JSON, not a
|
||||
keyword, and its tooltip names the action the button *would* take — so the string "Stay Awake"
|
||||
appears precisely when stay-awake is off. Every arm fell through to "do nothing" and the box
|
||||
stayed awake after every session. Now reads `"enabled":true`.
|
||||
- **`ctl watch` leaked a process per dead consumer.** A watcher only ever reads, so when the widget
|
||||
died its end of our stdout pipe closed unnoticed. The SSE keep-alive now surfaces as a
|
||||
`ctl.heartbeat` line, which gives us a write to fail on, and a failed write ends the process.
|
||||
- **`install.sh --uninstall` left everything the packages did not own** — tagged ufw rules, the
|
||||
user-unit drop-in, the app-menu entry, `hooks.json`, and the `custom_picker_binary` takeover.
|
||||
The last of those kept pointing a live compositor at a picker shim that was no longer installed.
|
||||
It now runs `punktfunk-omarchy remove` before pacman takes the package away.
|
||||
- **A deploy under an open tab killed the next navigation** (web, docs-site). Routes are
|
||||
code-split, so a tab holding pre-deploy HTML asks for chunks the new server has never heard of;
|
||||
`defaultPreload: "intent"` made a hover enough to trip it. Both routers now handle
|
||||
`vite:preloadError` and navigate to `latestLocation` rather than reloading in place, with a
|
||||
sessionStorage stamp against a reload loop. Deliberately not `preventDefault()`, which suppresses
|
||||
Vite's rethrow and hands the router an `undefined` module.
|
||||
- **The launcher entry drew no icon.** The host package shipped no app icon at all, and
|
||||
`omarchy-webapp-install` derives an icon name that resolves to nothing. The host package now
|
||||
ships the scalable mark, and setup writes `Icon=` and `Exec=` itself.
|
||||
- **The console ignored the desktop theme, then wore only half of it.** `useUiConfig` was
|
||||
`staleTime: Infinity`, so the console sat in the old palette until someone reloaded it — now
|
||||
polled at 2 s, and refetched on focus. Separately, only `accent` and `mode` were carried, while
|
||||
`background` and `foreground` were dropped on the floor. All three now reach the page, expanded
|
||||
into every surface through `color-mix(in oklab, …)`. `--success` / `--warning` / `--destructive`
|
||||
deliberately do not follow: they encode meaning, and a theme whose accent is red must not leave
|
||||
"delete" and "save" the same colour. `web/tools/check-omarchy-palette.mjs` reads the ratios back
|
||||
out of the stylesheet and asserts WCAG contrast across six shipped themes in `postbuild`.
|
||||
- **Web: three findings biome surfaced.** `noUncheckedIndexedAccess` type errors in the ticket
|
||||
parser, an unread `entries` state in Library written on every grid load, and an O(n²) `flatten`
|
||||
in SourceSettings.
|
||||
|
||||
---
|
||||
|
||||
## v0.32.0
|
||||
|
||||
+16
-2
@@ -118,10 +118,24 @@ must still exist in the tree, the counts of undocumented `PUNKTFUNK_*` variables
|
||||
`punktfunk-host` subcommands may never grow (document the new knob, or consciously raise the
|
||||
baseline in the script), and internal docs links must resolve.
|
||||
|
||||
Match the surrounding code's comment density and naming. Commit messages end with the
|
||||
`Co-Authored-By` trailer (see `git log`).
|
||||
Match the surrounding code's comment density and naming.
|
||||
|
||||
See the [README's Build & test section](README.md#build--test-from-source) for the extra dev
|
||||
commands (the FEC loss harness, the standalone C-ABI proof) and
|
||||
[Design invariants](README.md#design-invariants) for the rules a change is expected to hold to, and
|
||||
the [docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
|
||||
|
||||
## How to write it (commits, changelog, comments)
|
||||
|
||||
House style lives in **[docs/writing.md](docs/writing.md)**, which also carries a per-PR checklist.
|
||||
The three rules you need before your first commit:
|
||||
|
||||
- **Commit subjects follow [Conventional Commits](https://www.conventionalcommits.org/) —
|
||||
`type(scope): summary`, 72-character cap, imperative, no trailing period.** The *why* goes in
|
||||
the body, wrapped at 72. The investigation goes on the pull request, not in the message. A Gitea
|
||||
PR title becomes the merge subject, so write the PR title as a conventional commit too.
|
||||
- **New `CHANGELOG.md` sections use [Keep a Changelog](https://keepachangelog.com/) categories**
|
||||
— `Breaking` / `Added` / `Changed` / `Fixed` / `Security` — plus the version table. Keep the
|
||||
existing sections as they are. What a *user* can do goes in `docs/releases/vX.Y.Z.md` instead.
|
||||
- **A comment states an invariant or a trap, not a recap of the diff.** If a trust boundary
|
||||
matters, a type, test or assertion has to enforce it — a comment alone never does.
|
||||
|
||||
Generated
+39
-39
@@ -747,9 +747,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -2343,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3035,7 +3035,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3053,7 +3053,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3076,7 +3076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3086,7 +3086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3112,7 +3112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3125,7 +3125,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3139,11 +3139,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3172,14 +3172,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3194,7 +3194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3202,7 +3202,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3214,7 +3214,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3223,7 +3223,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3256,7 +3256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3267,7 +3267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3278,7 +3278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3490,7 +3490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3500,7 +3500,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"anyhow",
|
||||
@@ -3524,7 +3524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3541,7 +3541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
@@ -3555,7 +3555,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3573,7 +3573,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3606,7 +3606,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3615,7 +3615,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3685,7 +3685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3699,7 +3699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3723,7 +3723,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -4822,7 +4822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.32.0"
|
||||
version = "0.33.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.32.0"
|
||||
"version": "0.33.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/actions": {
|
||||
|
||||
@@ -178,11 +178,12 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
if !proto.is_empty() && proto != PROTO {
|
||||
return None; // some other DNS-SD service sharing the type — ignore
|
||||
}
|
||||
let addr = info
|
||||
.get_addresses_v4()
|
||||
.iter()
|
||||
.next()
|
||||
.map(|a| a.to_string())?;
|
||||
// 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 id = val("id");
|
||||
let fullname = info.get_fullname();
|
||||
Some(Host {
|
||||
|
||||
@@ -141,9 +141,19 @@ 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.
|
||||
let Some(addr) =
|
||||
info.get_addresses_v4().iter().next().map(|a| a.to_string())
|
||||
else {
|
||||
//
|
||||
// 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 {
|
||||
continue;
|
||||
};
|
||||
let id = val("id");
|
||||
|
||||
@@ -306,3 +306,117 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
|
||||
use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::io::BufRead;
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
@@ -65,12 +67,40 @@ 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 interactive picker — the graceful fallback.
|
||||
/// has written the file) leaves xdph to its own 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.
|
||||
@@ -143,19 +173,31 @@ 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 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.
|
||||
/// 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.
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
impl Drop for HyprlandDisplay {
|
||||
fn drop(&mut self) {
|
||||
// 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.
|
||||
// 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.
|
||||
if let Some(restore) = self.pending_restore.take() {
|
||||
restore();
|
||||
}
|
||||
@@ -172,7 +214,8 @@ impl HyprlandDisplay {
|
||||
}
|
||||
|
||||
/// Apply the effective [`crate::policy::Topology`] for the just-created output `ours`, and stash
|
||||
/// the restore for the registry (see [`Self::pending_restore`]).
|
||||
/// the restore this instance runs on drop (see [`Self::pending_restore`] — the registry does not
|
||||
/// take a pass-through display's 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
|
||||
@@ -187,9 +230,13 @@ impl HyprlandDisplay {
|
||||
Topology::Primary => warn_primary_is_not_expressible(),
|
||||
Topology::Exclusive => {
|
||||
let disabled = disable_other_heads(ours);
|
||||
self.pending_restore = (!disabled.is_empty()).then(|| {
|
||||
let prepared = (!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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,6 +364,7 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
remote_fd: Some(fd),
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(Keepalive {
|
||||
_reload: watch_config_reloads(name.clone(), mode),
|
||||
_stop: stop,
|
||||
_output: output,
|
||||
}),
|
||||
@@ -344,10 +392,111 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
/// output xdph was still actively capturing, every single teardown. See [`StopGuard`] for what that
|
||||
/// did to xdph.
|
||||
struct Keepalive {
|
||||
/// First, so the watcher is gone before the cast stops and the output is removed — it must
|
||||
/// never re-apply a monitor rule onto a head this same teardown is about to delete.
|
||||
_reload: Option<ReloadWatcher>,
|
||||
_stop: StopGuard,
|
||||
_output: OutputGuard,
|
||||
}
|
||||
|
||||
/// Puts the streamed head's monitor rule back after a `hyprctl reload`.
|
||||
///
|
||||
/// 🛑 **A reload drops EVERY runtime `hyprctl keyword`** (see [`restore_heads`], which relies on
|
||||
/// exactly that) — and [`set_monitor_rule`]'s mode is one of them. So a reload silently returns the
|
||||
/// streamed head to its default size mid-stream, and the client sees a resolution change nobody
|
||||
/// asked for. Nothing in Hyprland re-applies it.
|
||||
///
|
||||
/// On Omarchy that is not an edge case, it is routine: `omarchy-theme-set` ends in
|
||||
/// `omarchy-restart-hyprctl`, which is literally `hyprctl reload`, so **every theme switch reset the
|
||||
/// stream's resolution** (field report 2026-08-28). Resizing the client window appeared to fix it
|
||||
/// only because a resize on Linux re-creates the output, which runs [`set_monitor_rule`] again.
|
||||
///
|
||||
/// This subscribes to the compositor's own event socket rather than polling, so the rule is back
|
||||
/// within a round trip and an idle session costs nothing. Any reload gets it — the operator's own
|
||||
/// `hyprctl reload`, `omarchy-refresh-config`, a theme switch — not just the one that was reported.
|
||||
///
|
||||
/// ponytail: the MODE only. A reload also undoes `topology: exclusive`'s head disables, but
|
||||
/// re-disabling them from here risks a permanently dark desk: teardown's own [`restore_heads`] runs
|
||||
/// a `hyprctl reload` to re-light them, and this watcher lives on a different object than that
|
||||
/// restore does, so nothing orders the two. Re-apply the disables here once they share a lifetime.
|
||||
fn watch_config_reloads(name: String, mode: Mode) -> Option<ReloadWatcher> {
|
||||
let path = event_socket_path()?;
|
||||
let sock = match UnixStream::connect(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
path = %path.display(), error = %e,
|
||||
"hyprland: no event socket — a `hyprctl reload` (every theme switch, on Omarchy) \
|
||||
will reset this stream's resolution until the client resizes"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// The guard's copy: shutting THIS down is what unparks the blocking read below.
|
||||
let stopper = sock.try_clone().ok()?;
|
||||
thread::spawn(move || {
|
||||
for line in std::io::BufReader::new(sock).lines() {
|
||||
// A read error — the guard's shutdown, or the compositor going away — ends the watch.
|
||||
// There is nothing left to re-apply a rule to in either case.
|
||||
let Ok(line) = line else { return };
|
||||
if !is_config_reload(&line) {
|
||||
continue;
|
||||
}
|
||||
tracing::info!(
|
||||
output = %name, w = mode.width, h = mode.height,
|
||||
"hyprland: config reloaded — re-applying the streamed head's monitor rule"
|
||||
);
|
||||
if let Err(e) = set_monitor_rule(&name, mode) {
|
||||
// `set_monitor_rule` only errors when the head has no framebuffer at all, which
|
||||
// after a reload means it is gone (teardown, or the compositor restarted). Stop.
|
||||
tracing::warn!(
|
||||
output = %name, error = %format!("{e:#}"),
|
||||
"hyprland: could not re-apply the monitor rule after a config reload — the \
|
||||
client keeps the head's default resolution until it resizes"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
Some(ReloadWatcher(stopper))
|
||||
}
|
||||
|
||||
/// Ends [`watch_config_reloads`]'s thread by shutting its socket down underneath it.
|
||||
///
|
||||
/// The thread is parked in a blocking read, so a plain stop flag would leave it alive until the
|
||||
/// compositor happened to emit an event — one stranded thread per session, and sessions are minted
|
||||
/// on every mid-stream resize. `shutdown` makes that read return immediately.
|
||||
struct ReloadWatcher(UnixStream);
|
||||
|
||||
impl Drop for ReloadWatcher {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hyprland's event socket for the instance we are driving, or `None` when there is none to find.
|
||||
/// Same signature [`hyprctl_command`] threads onto every child, so the watch and the commands can
|
||||
/// never end up aimed at different compositors.
|
||||
fn event_socket_path() -> Option<std::path::PathBuf> {
|
||||
let sig = crate::session::hypr_signature()?;
|
||||
let runtime = crate::with_env_lock(|| std::env::var_os("XDG_RUNTIME_DIR"))
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(format!("/run/user/{}", crate::proc::current_uid()))
|
||||
});
|
||||
Some(runtime.join("hypr").join(sig).join(".socket2.sock"))
|
||||
}
|
||||
|
||||
/// Is this event line the config reload?
|
||||
///
|
||||
/// Hyprland's `.socket2.sock` speaks `<name>>><data>`, so the match is on the NAME. A `contains`
|
||||
/// would also fire on any event whose DATA happens to hold the word — a window titled
|
||||
/// `configreloaded`, a workspace named after it — and every false hit is a `hyprctl` round trip and
|
||||
/// a mode re-apply on a live stream.
|
||||
fn is_config_reload(line: &str) -> bool {
|
||||
line.split(">>").next() == Some("configreloaded")
|
||||
}
|
||||
|
||||
/// How long teardown waits for the portal to confirm the ScreenCast session is closed before giving
|
||||
/// up and removing the output anyway. One D-Bus round trip through xdg-desktop-portal to xdph; three
|
||||
/// seconds is generous. Bounded on purpose: a portal that has already wedged must not be able to
|
||||
@@ -390,6 +539,11 @@ 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.
|
||||
@@ -404,8 +558,25 @@ 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(()) => {}
|
||||
@@ -479,23 +650,48 @@ 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.
|
||||
///
|
||||
/// ⚠️ **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.
|
||||
/// 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.
|
||||
pub(crate) fn focus_output(name: &str) {
|
||||
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:#}"),
|
||||
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,
|
||||
"could not focus the streamed headless output — apps this session launches may open on \
|
||||
a physical monitor instead of on the stream"
|
||||
a physical monitor instead of on the stream, and an unfocused headless output can \
|
||||
produce no frames at all"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -992,6 +1188,9 @@ 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}"),
|
||||
@@ -1308,11 +1507,45 @@ 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<()> {
|
||||
// 1. Install the picker shim (idempotent — content is fixed).
|
||||
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).
|
||||
let shim = picker_shim_path();
|
||||
let sel = selection_file();
|
||||
let shim_body = format!("#!/bin/sh\nexec cat \"{sel}\" 2>/dev/null\n");
|
||||
// `-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"
|
||||
),
|
||||
};
|
||||
if std::fs::read_to_string(&shim).is_ok_and(|c| c == shim_body) {
|
||||
// already installed
|
||||
} else {
|
||||
@@ -1333,26 +1566,58 @@ 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,
|
||||
crate::portal_config::Block::Hyprlang("screencopy"),
|
||||
"custom_picker_binary",
|
||||
&shim,
|
||||
)?;
|
||||
let changed = crate::portal_config::ensure_key(&path, XDPH_BLOCK, XDPH_PICKER_KEY, &shim)?;
|
||||
if !changed {
|
||||
return Ok(());
|
||||
}
|
||||
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.
|
||||
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() {
|
||||
let _ = crate::proc::status_within(
|
||||
Command::new("systemctl").args([
|
||||
"--user",
|
||||
@@ -1361,7 +1626,6 @@ fn ensure_xdph_config() -> Result<()> {
|
||||
]),
|
||||
PORTAL_RESTART_BUDGET,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The ScreenCast portal handshake — the xdg ScreenCast portal is backend-neutral (served here by
|
||||
@@ -1525,6 +1789,22 @@ fn portal_thread(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The whole re-apply hangs off this one line match, and both ways of getting it wrong are
|
||||
/// expensive: too strict and a theme switch still resets the stream's resolution; too loose
|
||||
/// (a `contains`) and any window whose TITLE holds the word triggers a `hyprctl` round trip
|
||||
/// plus a mode re-apply, on every keystroke that retitles it.
|
||||
#[test]
|
||||
fn only_the_config_reload_event_re_applies_the_monitor_rule() {
|
||||
assert!(is_config_reload("configreloaded>>"));
|
||||
// Real lines from `.socket2.sock`, none of which is a reload.
|
||||
assert!(!is_config_reload("monitoradded>>PF-1234-1"));
|
||||
assert!(!is_config_reload("monitorremovedv2>>3,PF-1234-1,PF-1234-1"));
|
||||
assert!(!is_config_reload("activewindow>>kitty,~/src"));
|
||||
// The `contains` trap: the word is in the DATA, not the event name.
|
||||
assert!(!is_config_reload("activewindowv2>>title: configreloaded"));
|
||||
assert!(!is_config_reload("workspace>>configreloaded"));
|
||||
}
|
||||
|
||||
/// The Lua config manager parses a `dispatch` argument as a Lua expression, so the monitor
|
||||
/// name and the state must both be QUOTED — an unquoted `dpms off HDMI-A-1` is what dies with
|
||||
/// `')' expected near 'off'` on 0.55.4. Pinning the shape here because the quoting is the
|
||||
@@ -1564,6 +1844,22 @@ 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.
|
||||
self.pending_restore = (!disabled.is_empty()).then(|| {
|
||||
let prepared = (!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,6 +604,11 @@ 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,6 +22,19 @@ 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();
|
||||
@@ -40,33 +53,10 @@ fn assigns(line: &str, key: &str) -> bool {
|
||||
line.split('=').next().is_some_and(|lhs| lhs.trim() == key)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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))?;
|
||||
let end_at = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -77,17 +67,142 @@ pub(crate) fn upsert(existing: &str, block: Block<'_>, key: &str, value: &str) -
|
||||
})
|
||||
.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(_) => " ",
|
||||
};
|
||||
out.insert(end_at, assignment(indent));
|
||||
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));
|
||||
}
|
||||
}
|
||||
let mut joined = out.join("\n");
|
||||
if existing.ends_with('\n') || !joined.ends_with('\n') {
|
||||
@@ -171,6 +286,58 @@ 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
|
||||
@@ -265,7 +432,9 @@ mod tests {
|
||||
out.contains("[somethingelse]\nkeep=me"),
|
||||
"user content kept"
|
||||
);
|
||||
assert!(out.contains("[screencast]\nchooser_cmd=cat x"));
|
||||
assert!(out.contains(
|
||||
"[screencast]\n# punktfunk: previous chooser_cmd = (none)\nchooser_cmd=cat x"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -273,7 +442,9 @@ 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"));
|
||||
assert!(!out.contains("OLD"));
|
||||
// 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("chooser_type=simple"), "sibling key kept");
|
||||
assert!(out.contains("output_name=DP-1"), "sibling key kept");
|
||||
}
|
||||
@@ -301,7 +472,10 @@ mod tests {
|
||||
"/run/user/1000/shim.sh",
|
||||
);
|
||||
assert!(out.contains("custom_picker_binary = /run/user/1000/shim.sh"));
|
||||
assert!(!out.contains("OLD"));
|
||||
assert!(!out
|
||||
.lines()
|
||||
.any(|l| l.trim() == "custom_picker_binary = OLD"));
|
||||
assert!(out.contains("# punktfunk: previous custom_picker_binary = OLD"));
|
||||
assert!(
|
||||
out.contains("allow_token_by_default = true"),
|
||||
"sibling kept"
|
||||
@@ -349,7 +523,125 @@ mod tests {
|
||||
fn an_empty_file_yields_just_the_block() {
|
||||
assert_eq!(
|
||||
upsert("", Block::Ini("screencast"), "k", "v"),
|
||||
"[screencast]\nk=v\n"
|
||||
"[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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -429,11 +721,53 @@ 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]\nchooser_cmd=cat x\n"
|
||||
"[screencast]\n# punktfunk: previous chooser_cmd = (none)\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.
|
||||
@@ -586,7 +920,7 @@ mod io_tests {
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&real).expect("target created"),
|
||||
"[screencast]\nchooser_cmd=cat x\n"
|
||||
"[screencast]\n# punktfunk: previous chooser_cmd = (none)\nchooser_cmd=cat x\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,18 +75,22 @@ 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), 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.
|
||||
/// 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.
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
impl Drop for WlrootsDisplay {
|
||||
fn drop(&mut self) {
|
||||
// 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.
|
||||
// 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.
|
||||
if let Some(restore) = self.pending_restore.take() {
|
||||
restore();
|
||||
}
|
||||
@@ -103,7 +107,8 @@ impl WlrootsDisplay {
|
||||
}
|
||||
|
||||
/// Apply the effective [`crate::policy::Topology`] for the just-created output `ours`, and stash
|
||||
/// the restore for the registry (see [`Self::pending_restore`]).
|
||||
/// the restore this instance runs on drop (see [`Self::pending_restore`] — the registry does not
|
||||
/// take a pass-through display's 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
|
||||
@@ -118,9 +123,13 @@ impl WlrootsDisplay {
|
||||
Topology::Primary => warn_primary_is_not_expressible(),
|
||||
Topology::Exclusive => {
|
||||
let disabled = disable_other_heads(ours);
|
||||
self.pending_restore = (!disabled.is_empty()).then(|| {
|
||||
let prepared = (!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,6 +501,13 @@ 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"))]
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ pub mod client;
|
||||
pub mod clipboard;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod discovery;
|
||||
pub mod error;
|
||||
pub mod fec;
|
||||
pub mod input;
|
||||
|
||||
@@ -9,7 +9,10 @@ authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
# `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"] }
|
||||
# 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).
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
//! `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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! The ctl surface's loopback client — discovery, the **pinned** TLS transport, and the one place
|
||||
//! a credential is read (implementation plan §§1–2, invariants I1/I2).
|
||||
//!
|
||||
//! Three files on disk are the whole configuration; nothing comes from argv or the environment:
|
||||
//!
|
||||
//! | file | what | absent ⇒ |
|
||||
//! |------|------|----------|
|
||||
//! | `mgmt-endpoint` | the port the host actually bound (`pf_paths::published_mgmt_port`) | fall back to 47990, exactly like the tray and the console |
|
||||
//! | `native-cert.pem` (else `cert.pem`) | the leaf the mgmt listener presents — **the pin** | [`EXIT_UNREACHABLE`]: no pin, so no token, so no call |
|
||||
//! | `mgmt-token` | the operator bearer the console already uses | [`EXIT_UNREACHABLE`] ("is the host installed?") |
|
||||
//!
|
||||
//! **I2, pin before token.** The agent's rustls verifier is the workspace's canonical
|
||||
//! [`PinVerify`](punktfunk_core::tls::PinVerify) with the host's own leaf fingerprint. rustls
|
||||
//! validates the server certificate *during the handshake*, and ureq writes the request line and
|
||||
//! headers only after the handshake completes — so on a mismatch the `Authorization` header is
|
||||
//! never serialised, let alone sent. That is a property of the ordering, not of a check we
|
||||
//! remember to run, which is why the port-squat vector (another local uid binding the mgmt port
|
||||
//! while the host is down) closes with no server-side change at all (I4).
|
||||
//!
|
||||
//! Telling a pin mismatch apart from "nothing is listening" is what [`PinVerify::with_observed`]
|
||||
//! is for: it records the leaf it saw *before* comparing, so after a failed connect a slot holding
|
||||
//! a fingerprint that isn't ours means squat/rotation ([`EXIT_PIN`]), and an empty slot means we
|
||||
//! never got a certificate at all ([`EXIT_UNREACHABLE`]).
|
||||
//!
|
||||
//! **I1, no credential on argv/env/logs.** There is deliberately no `--token` flag and no
|
||||
//! `PUNKTFUNK_MGMT_TOKEN` read here: an operator who had to put the token in ctl's environment
|
||||
//! would publish it in `/proc/<pid>/environ`, which is exactly the cross-uid leak the config dir's
|
||||
//! 0700 mode exists to prevent. The cost is stated in the docs: a host handed its token by
|
||||
//! `--mgmt-token`/env and *never* persisting one is not reachable by ctl. Every packaged host
|
||||
//! persists (`mgmt_token::load_or_generate`), so this is a dev-box footnote, not a gap.
|
||||
//!
|
||||
//! **ctl never mints.** A missing `mgmt-token` is a hard error, never a "let me generate one" —
|
||||
//! the inverse of the `web-password` silent-adoption finding (security sweep 2026-08-15). The
|
||||
//! host is the only minter; ctl is a consumer.
|
||||
//!
|
||||
//! Responses come back as [`serde_json::Value`], not the in-crate mgmt structs. That is not
|
||||
//! laziness about drift, it is the *stronger* answer for the contract this ships: `--json` echoes
|
||||
//! the server's own JSON verbatim, so a field added to a response reaches the plugin with no ctl
|
||||
//! diff at all, and the OpenAPI drift gate remains the single place the shapes are pinned. Only
|
||||
//! the human tables name fields, and a table that misses a new one is cosmetic.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Exit codes (implementation plan §4). Distinct because scripts branch on them — most of all
|
||||
/// [`EXIT_PIN`], which is a security signal and not an ordinary failure.
|
||||
pub const EXIT_API: i32 = 1;
|
||||
pub const EXIT_USAGE: i32 = 2;
|
||||
pub const EXIT_UNREACHABLE: i32 = 3;
|
||||
pub const EXIT_PIN: i32 = 4;
|
||||
|
||||
/// The JSON envelope version (I8). Additive: fields may be added, never removed or retyped, and
|
||||
/// this bumps only if that promise has to break.
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// A failed verb: the exit code the process takes, and the line a human reads.
|
||||
#[derive(Debug)]
|
||||
pub struct Failure {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
pub fn new(code: i32, message: impl Into<String>) -> Self {
|
||||
Failure {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub fn usage(message: impl Into<String>) -> Self {
|
||||
Self::new(EXIT_USAGE, message)
|
||||
}
|
||||
pub fn unreachable(message: impl Into<String>) -> Self {
|
||||
Self::new(EXIT_UNREACHABLE, message)
|
||||
}
|
||||
pub fn api(message: impl Into<String>) -> Self {
|
||||
Self::new(EXIT_API, message)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Failure>;
|
||||
|
||||
/// Connect timeout — loopback, so a slow one means "nothing is there", not "the network is far".
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Whole-call timeout for the one-shot verbs. `watch` passes `None` (it is long-lived by design).
|
||||
const CALL_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
pub struct Client {
|
||||
agent: ureq::Agent,
|
||||
base: String,
|
||||
/// `Bearer <token>`, built once from the 0600 file. The ONLY place in this crate where the
|
||||
/// operator credential exists outside `mgmt_token` (I1) — grep for `bearer` to audit it.
|
||||
bearer: String,
|
||||
/// The leaf the last handshake actually presented — the [`EXIT_PIN`] discriminator.
|
||||
observed: Arc<Mutex<Option<[u8; 32]>>>,
|
||||
pin: [u8; 32],
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Discover, pin and authenticate. `global_timeout` is `None` for `watch`.
|
||||
pub fn connect(global_timeout: Option<Duration>) -> Result<Client> {
|
||||
Self::connect_in(&pf_paths::config_dir(), global_timeout)
|
||||
}
|
||||
|
||||
/// The IO half, taking the config directory — so the pin-mismatch negative can be exercised
|
||||
/// against a real TLS listener without mutating `PUNKTFUNK_CONFIG_DIR` (which needs `unsafe`
|
||||
/// since edition 2024, and which this module refuses to need). Same split, same reason, as
|
||||
/// `pf_paths::published_mgmt_port_in`.
|
||||
pub fn connect_in(dir: &Path, global_timeout: Option<Duration>) -> Result<Client> {
|
||||
let pin = load_pin(dir)?;
|
||||
let token = load_token(dir)?;
|
||||
let port = pf_paths::published_mgmt_port_in(dir).unwrap_or(crate::mgmt::DEFAULT_PORT);
|
||||
let observed = Arc::new(Mutex::new(None));
|
||||
Ok(Client {
|
||||
agent: agent(pin, observed.clone(), global_timeout),
|
||||
// Always loopback: the admin surface is honoured from LOOPBACK peers only
|
||||
// (`mgmt::auth`), so any other address would be refused by the host anyway.
|
||||
base: format!("https://127.0.0.1:{port}"),
|
||||
bearer: format!("Bearer {token}"),
|
||||
observed,
|
||||
pin,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self, path: &str) -> Result<serde_json::Value> {
|
||||
let sent = self
|
||||
.agent
|
||||
.get(self.url(path))
|
||||
.header("Authorization", &self.bearer)
|
||||
.call();
|
||||
self.finish(sent, path)
|
||||
}
|
||||
|
||||
pub fn delete(&self, path: &str) -> Result<serde_json::Value> {
|
||||
let sent = self
|
||||
.agent
|
||||
.delete(self.url(path))
|
||||
.header("Authorization", &self.bearer)
|
||||
.call();
|
||||
self.finish(sent, path)
|
||||
}
|
||||
|
||||
pub fn post(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
|
||||
let sent = self
|
||||
.agent
|
||||
.post(self.url(path))
|
||||
.header("Authorization", &self.bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.send(body.to_string());
|
||||
self.finish(sent, path)
|
||||
}
|
||||
|
||||
pub fn patch(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
|
||||
let sent = self
|
||||
.agent
|
||||
.patch(self.url(path))
|
||||
.header("Authorization", &self.bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.send(body.to_string());
|
||||
self.finish(sent, path)
|
||||
}
|
||||
|
||||
/// The streaming half, for `watch`: the raw response body of a GET, left unread so the caller
|
||||
/// can consume SSE frames as they arrive.
|
||||
pub fn stream(&self, path: &str) -> Result<Box<dyn std::io::Read + Send>> {
|
||||
let resp = self
|
||||
.agent
|
||||
.get(self.url(path))
|
||||
.header("Authorization", &self.bearer)
|
||||
.call()
|
||||
.map_err(|e| self.transport_failure(e, path))?;
|
||||
let status = resp.status().as_u16();
|
||||
if !(200..300).contains(&status) {
|
||||
let mut resp = resp;
|
||||
let body = resp.body_mut().read_to_string().unwrap_or_default();
|
||||
return Err(Failure::api(http_error(status, &body, path)));
|
||||
}
|
||||
Ok(Box::new(resp.into_body().into_reader()))
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{path}", self.base)
|
||||
}
|
||||
|
||||
fn finish(
|
||||
&self,
|
||||
sent: std::result::Result<ureq::http::Response<ureq::Body>, ureq::Error>,
|
||||
path: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut resp = sent.map_err(|e| self.transport_failure(e, path))?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.body_mut().read_to_string().unwrap_or_default();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(Failure::api(http_error(status, &body, path)));
|
||||
}
|
||||
if body.trim().is_empty() {
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
serde_json::from_str(&body)
|
||||
.map_err(|e| Failure::api(format!("{path}: the host sent JSON we can't parse ({e})")))
|
||||
}
|
||||
|
||||
/// Classify a transport failure. The observed-fingerprint slot is what separates "somebody
|
||||
/// else is on that port" (a security answer, [`EXIT_PIN`]) from "nobody is" ([`EXIT_UNREACHABLE`]).
|
||||
fn transport_failure(&self, e: ureq::Error, path: &str) -> Failure {
|
||||
if let Some(seen) = *self.observed.lock().unwrap() {
|
||||
if seen != self.pin {
|
||||
return Failure::new(
|
||||
EXIT_PIN,
|
||||
format!(
|
||||
"certificate pin mismatch on {base} — the process answering there presented \
|
||||
{seen}, but this host's identity is {ours}. No token was sent. Either the \
|
||||
host regenerated its certificate (delete the stale pairing state and \
|
||||
re-pair) or another local process is squatting the management port.",
|
||||
base = self.base,
|
||||
seen = hex::encode(seen),
|
||||
ours = hex::encode(self.pin),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Failure::unreachable(format!(
|
||||
"cannot reach the management API at {}{path}: {e}. Is the host running \
|
||||
(`systemctl --user status punktfunk-host`)?",
|
||||
self.base
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a non-2xx into the line a human gets, unwrapping the `ApiError` envelope when there is one.
|
||||
fn http_error(status: u16, body: &str, path: &str) -> String {
|
||||
let detail = serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error")?.as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| body.trim().chars().take(200).collect());
|
||||
match status {
|
||||
401 | 403 => format!(
|
||||
"{path}: the host rejected our token ({status}). The persisted `mgmt-token` and the \
|
||||
running host disagree — restart the host, or delete the file and let it re-mint."
|
||||
),
|
||||
404 => format!("{path}: no such thing here ({status} {detail})"),
|
||||
503 => format!("{path}: {detail} ({status})"),
|
||||
_ if detail.is_empty() => format!("{path}: the host answered {status}"),
|
||||
_ => format!("{path}: {detail} ({status})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The mgmt listener presents the **native** identity when one exists and the legacy GameStream
|
||||
/// identity otherwise (`mgmt::run` takes a `NativeIdentity`; `identity::load_or_adopt` mints
|
||||
/// `native-cert.pem` or adopts `cert.pem`). Same order the tray and the plugin runner already use —
|
||||
/// pinning the wrong one of the pair is a guaranteed [`EXIT_PIN`] on a perfectly healthy host.
|
||||
fn load_pin(dir: &Path) -> Result<[u8; 32]> {
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
let pem = std::fs::read(dir.join("native-cert.pem"))
|
||||
.or_else(|_| std::fs::read(dir.join("cert.pem")))
|
||||
.map_err(|_| {
|
||||
Failure::unreachable(format!(
|
||||
"no host certificate in {} (looked for native-cert.pem, then cert.pem). \
|
||||
Without it there is nothing to pin, and ctl will not send a token unpinned. \
|
||||
Has the host ever run on this machine?",
|
||||
dir.display()
|
||||
))
|
||||
})?;
|
||||
let der = rustls::pki_types::CertificateDer::from_pem_slice(&pem).map_err(|e| {
|
||||
Failure::unreachable(format!(
|
||||
"the host certificate in {} is not readable as PEM ({e})",
|
||||
dir.display()
|
||||
))
|
||||
})?;
|
||||
Ok(punktfunk_core::tls::cert_fingerprint(der.as_ref()))
|
||||
}
|
||||
|
||||
/// Read the persisted operator token. **Never generates one** — see the module docs.
|
||||
fn load_token(dir: &Path) -> Result<String> {
|
||||
crate::mgmt_token::read_persisted(dir).ok_or_else(|| {
|
||||
Failure::unreachable(format!(
|
||||
"no management token in {} — ctl reads the one the host persists and never mints its \
|
||||
own. Start the host once (`systemctl --user start punktfunk-host`) and retry.",
|
||||
dir.join("mgmt-token").display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The pinned agent — the tray's shape (`punktfunk-tray/src/status.rs`), with the pin made
|
||||
/// mandatory and the observed slot wired up so a mismatch is reportable.
|
||||
fn agent(
|
||||
pin: [u8; 32],
|
||||
observed: Arc<Mutex<Option<[u8; 32]>>>,
|
||||
global_timeout: Option<Duration>,
|
||||
) -> ureq::Agent {
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
let tls = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.expect("rustls default protocol versions")
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(punktfunk_core::tls::PinVerify::with_observed(
|
||||
Some(pin),
|
||||
observed,
|
||||
)))
|
||||
.with_no_client_auth();
|
||||
// ureq's own `TlsConfig` has roots, a client cert and an off-switch but no hook for a custom
|
||||
// verifier, so the agent takes the `ClientConfig` directly through the shared glue.
|
||||
punktfunk_core::tls::ureq_agent::agent(
|
||||
Arc::new(tls),
|
||||
ureq::Agent::config_builder()
|
||||
.timeout_connect(Some(CONNECT_TIMEOUT))
|
||||
.timeout_global(global_timeout)
|
||||
// Let 4xx/5xx come back as responses so the `ApiError` body reaches the operator
|
||||
// instead of being flattened into "status 400".
|
||||
.http_status_as_error(false)
|
||||
.max_redirects(0)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read as _;
|
||||
|
||||
/// **The I2 negative — the reason the pin exists.** A process that is not the host answers on
|
||||
/// the management port (the port-squat vector: another local uid binds it while the host is
|
||||
/// down). It presents a perfectly valid, perfectly well-formed self-signed certificate that
|
||||
/// simply isn't ours.
|
||||
///
|
||||
/// Two things must be true, and only the second one is about cryptography:
|
||||
/// 1. the verb fails with [`EXIT_PIN`] — a *distinct* code, so a script does not retry into
|
||||
/// the squatter the way it would for "host down";
|
||||
/// 2. the squatter receives **zero application bytes**. rustls rejects the certificate during
|
||||
/// the handshake, so ureq never gets to serialise a request line — the `Authorization`
|
||||
/// header is not "sent and ignored", it is never constructed. That ordering is the whole
|
||||
/// security property, and this test is what stops a future refactor (an agent added per
|
||||
/// agent, a retry that disables verification "just to see") from quietly inverting it.
|
||||
#[test]
|
||||
fn a_squatter_gets_exit_4_and_not_one_byte_of_the_token() {
|
||||
punktfunk_core::tls::install_default_provider();
|
||||
let dir = std::env::temp_dir().join(format!("pf-ctl-pin-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// Two identities that have nothing to do with each other: what the config dir says the
|
||||
// host is, and what actually answers on the port.
|
||||
let ours = crate::identity::ephemeral().unwrap();
|
||||
let squatter = crate::identity::ephemeral().unwrap();
|
||||
let server = crate::gamestream::tls::server_config_optional_client(
|
||||
&squatter.cert_pem,
|
||||
&squatter.key_pem,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::fs::write(dir.join("native-cert.pem"), &ours.cert_pem).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("mgmt-token"),
|
||||
"PUNKTFUNK_MGMT_TOKEN=s3kr1t-token\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("mgmt-endpoint"),
|
||||
format!("PUNKTFUNK_MGMT_URL=https://127.0.0.1:{port}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let seen = Arc::new(Mutex::new(Vec::<u8>::new()));
|
||||
let recorder = seen.clone();
|
||||
let squat = std::thread::spawn(move || {
|
||||
let (mut sock, _) = listener.accept().expect("squatter accept");
|
||||
let mut conn = rustls::ServerConnection::new(server).expect("squatter tls");
|
||||
// The client will send a fatal alert instead of finishing — that is the pass condition,
|
||||
// so an error here is expected and deliberately ignored.
|
||||
let _ = conn.complete_io(&mut sock);
|
||||
let mut plaintext = Vec::new();
|
||||
let _ = conn.reader().read_to_end(&mut plaintext);
|
||||
*recorder.lock().unwrap() = plaintext;
|
||||
});
|
||||
|
||||
let err = Client::connect_in(&dir, Some(Duration::from_secs(10)))
|
||||
.and_then(|c| c.get("/api/v1/status"))
|
||||
.expect_err("a mismatched certificate must not produce a successful call");
|
||||
assert_eq!(err.code, EXIT_PIN, "wrong exit code: {}", err.message);
|
||||
assert!(
|
||||
err.message.contains("pin mismatch"),
|
||||
"the operator must be told WHICH failure this is: {}",
|
||||
err.message
|
||||
);
|
||||
|
||||
squat.join().unwrap();
|
||||
let bytes = seen.lock().unwrap().clone();
|
||||
assert!(
|
||||
bytes.is_empty(),
|
||||
"the squatter read {} application bytes; the token must never leave the process \
|
||||
before the pin matches",
|
||||
bytes.len()
|
||||
);
|
||||
assert!(!String::from_utf8_lossy(&bytes).contains("s3kr1t-token"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The other half of I2: a config dir with no certificate at all is [`EXIT_UNREACHABLE`], not
|
||||
/// a quiet fallback to an unverified connection. "No pin available" must never mean "connect
|
||||
/// anyway" — that is the shape the tray can afford (it holds no token) and ctl cannot.
|
||||
#[test]
|
||||
fn no_certificate_means_no_call_at_all() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-ctl-nocert-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("mgmt-token"), "PUNKTFUNK_MGMT_TOKEN=deadbeef\n").unwrap();
|
||||
// `.err()` rather than `expect_err`: `Client` deliberately has no `Debug`, because the
|
||||
// derived one would print the bearer into any panic message or `{:?}` a future edit adds.
|
||||
let err = Client::connect_in(&dir, None)
|
||||
.err()
|
||||
.expect("no cert, no connection");
|
||||
assert_eq!(err.code, EXIT_UNREACHABLE);
|
||||
assert!(err.message.contains("native-cert.pem"), "{}", err.message);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// And ctl never mints: a config dir with a certificate but no token fails, and leaves the
|
||||
/// directory exactly as it found it (the inverted `web-password` lesson).
|
||||
#[test]
|
||||
fn a_missing_token_is_an_error_never_a_freshly_minted_one() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-ctl-notoken-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let ours = crate::identity::ephemeral().unwrap();
|
||||
std::fs::write(dir.join("native-cert.pem"), &ours.cert_pem).unwrap();
|
||||
let err = Client::connect_in(&dir, None)
|
||||
.err()
|
||||
.expect("no token, no connection");
|
||||
assert_eq!(err.code, EXIT_UNREACHABLE);
|
||||
assert!(!dir.join("mgmt-token").exists(), "ctl minted a token");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_error_unwraps_the_api_envelope() {
|
||||
let msg = http_error(
|
||||
400,
|
||||
r#"{"error":"grants has reserved bits set"}"#,
|
||||
"/api/v1/x",
|
||||
);
|
||||
assert!(msg.contains("grants has reserved bits set"), "{msg}");
|
||||
assert!(msg.contains("400"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_error_names_the_token_when_auth_fails() {
|
||||
let msg = http_error(401, "", "/api/v1/status");
|
||||
assert!(msg.contains("mgmt-token"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_codes_are_distinct() {
|
||||
// Scripts branch on these; a collision would silently merge "no host" with "squatter".
|
||||
let all = [EXIT_API, EXIT_USAGE, EXIT_UNREACHABLE, EXIT_PIN];
|
||||
let mut seen: Vec<i32> = all.to_vec();
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
assert_eq!(seen.len(), all.len());
|
||||
// And none of them is 0 — a failure that exits 0 is worse than a wrong code.
|
||||
assert!(all.iter().all(|c| *c != 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! `punktfunk-host ctl watch` — the host's SSE event stream bridged to **line-JSON on stdout**,
|
||||
//! one object per line, flushed per line. That shape is the whole point: a Quickshell `Process`, a
|
||||
//! waybar `custom/` module and `while read -r line` all consume it without an SSE parser, and none
|
||||
//! of them ever sees a token (I3 — the plugin holds no credential because it holds no HTTP client).
|
||||
//!
|
||||
//! Reconnection is the interesting part. `mgmt/events.rs` keeps a ~1024-event ring and resumes
|
||||
//! from `Last-Event-ID`; a consumer whose cursor has fallen off the ring gets a synthetic
|
||||
//! `event: dropped` frame first and is expected to re-snapshot. We surface both facts to the
|
||||
//! consumer as ordinary lines:
|
||||
//!
|
||||
//! - `{"v":1,"kind":"ctl.resync"}` — emitted **once** after a `dropped` frame, and also after any
|
||||
//! reconnect that could not resume exactly (no cursor yet). A widget that sees it re-runs
|
||||
//! `ctl status` / `ctl pending` rather than trusting its incremental state.
|
||||
//! - `{"v":1,"kind":"ctl.disconnected","data":{"error":"…"}}` — the stream dropped and we are
|
||||
//! backing off. Purely informational; the reconnect is automatic.
|
||||
//! - `{"v":1,"kind":"ctl.heartbeat"}` — the host's SSE keep-alive, roughly every 15 s. A consumer
|
||||
//! can use it as a liveness signal, and it is also what lets *us* notice that our own consumer
|
||||
//! has gone away (see [`emit`]).
|
||||
//!
|
||||
//! The cursor advances on every frame with an `id:`, so a host restart mid-watch resumes from the
|
||||
//! last event actually delivered. Backoff is capped and jittered only by the cap: an operator's
|
||||
//! plugin reconnecting in a tight loop against a host that is down would otherwise be the thing
|
||||
//! that keeps hitting the SSE connection cap.
|
||||
//!
|
||||
//! The connection cap (`MAX_EVENT_STREAMS` = 32) is shared with the console; the plugin is
|
||||
//! specified to hold exactly one stream. Exhausting it is a 503, which arrives here as an ordinary
|
||||
//! API failure with the host's own message.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::client::{Client, Failure, Result, SCHEMA_VERSION};
|
||||
|
||||
/// Reconnect backoff: quick enough that a host restart is invisible to a widget, slow enough that
|
||||
/// a host that is genuinely down doesn't get hammered.
|
||||
const BACKOFF_MIN: Duration = Duration::from_secs(1);
|
||||
const BACKOFF_MAX: Duration = Duration::from_secs(30);
|
||||
|
||||
pub fn run(kinds: Option<&str>, since: Option<u64>) -> Result<()> {
|
||||
let mut cursor = since;
|
||||
let mut backoff = BACKOFF_MIN;
|
||||
// First connect is the only one allowed to fail the process: a bad pin, a missing token or a
|
||||
// host that has never run are all conditions a retry cannot fix, and a `watch` that silently
|
||||
// spins forever on them is worse than an exit code the caller can see.
|
||||
let mut client = Client::connect(None)?;
|
||||
loop {
|
||||
match pump(&client, kinds, &mut cursor) {
|
||||
// The stream ended cleanly (host shutdown) — reconnect like any other drop.
|
||||
Ok(()) => emit_control("ctl.disconnected", Some("stream closed by the host")),
|
||||
Err(e) if e.code == super::client::EXIT_PIN => return Err(e),
|
||||
Err(e) => emit_control("ctl.disconnected", Some(&e.message)),
|
||||
}
|
||||
std::thread::sleep(backoff);
|
||||
backoff = (backoff * 2).min(BACKOFF_MAX);
|
||||
// Rebuild the client on every reconnect rather than reusing it: that re-reads
|
||||
// `native-cert.pem`, so a host that regenerated its identity while we were disconnected
|
||||
// is picked up instead of pinning us out forever (risk register #1). A pin that is now
|
||||
// genuinely wrong still exits 4 on the next attempt, which is the intended signal.
|
||||
match Client::connect(None) {
|
||||
Ok(c) => {
|
||||
client = c;
|
||||
backoff = BACKOFF_MIN;
|
||||
}
|
||||
Err(e) if e.code == super::client::EXIT_PIN => return Err(e),
|
||||
Err(e) => emit_control("ctl.disconnected", Some(&e.message)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One connection's worth of frames. Returns `Ok(())` when the server closed the stream.
|
||||
fn pump(client: &Client, kinds: Option<&str>, cursor: &mut Option<u64>) -> Result<()> {
|
||||
let mut path = String::from("/api/v1/events");
|
||||
let mut sep = '?';
|
||||
if let Some(k) = kinds {
|
||||
path.push_str(&format!("{sep}kinds={}", urlencode(k)));
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(c) = *cursor {
|
||||
path.push_str(&format!("{sep}since={c}"));
|
||||
}
|
||||
let reader = BufReader::new(client.stream(&path)?);
|
||||
|
||||
// One SSE frame = `id:`/`event:`/`data:` lines terminated by a blank line. Keep-alive comments
|
||||
// (`:` prefix) are skipped; they exist to detect a dead peer, not to be forwarded.
|
||||
let mut id: Option<u64> = None;
|
||||
let mut kind: Option<String> = None;
|
||||
let mut data = String::new();
|
||||
for line in reader.lines() {
|
||||
let line =
|
||||
line.map_err(|e| Failure::unreachable(format!("event stream read failed: {e}")))?;
|
||||
if line.is_empty() {
|
||||
if let Some(k) = kind.take() {
|
||||
dispatch(&k, &data, id, cursor);
|
||||
}
|
||||
id = None;
|
||||
data.clear();
|
||||
continue;
|
||||
}
|
||||
let Some((field, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.strip_prefix(' ').unwrap_or(value);
|
||||
match field {
|
||||
// A comment (`: keep-alive`) splits with an empty field name. The host sends one every
|
||||
// 15 s, and we turn it into the one line that proves BOTH directions are alive.
|
||||
//
|
||||
// The write is the point. Our consumer is a shell widget, and when it dies its end of
|
||||
// our stdout pipe closes — but a stream that is only ever READ never notices, so an
|
||||
// idle host leaves `ctl watch` running forever against the server's connection cap.
|
||||
// Measured on an Omarchy box: six orphaned watchers after three shell restarts, all on
|
||||
// a host with no events at all. Writing here turns the next keep-alive into an EPIPE,
|
||||
// and [`emit`] exits on it.
|
||||
"" => emit(serde_json::json!({ "v": SCHEMA_VERSION, "kind": "ctl.heartbeat" })),
|
||||
"id" => id = value.parse().ok(),
|
||||
"event" => kind = Some(value.to_string()),
|
||||
"data" => {
|
||||
if !data.is_empty() {
|
||||
data.push('\n');
|
||||
}
|
||||
data.push_str(value);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Turn one decoded frame into a line of stdout, advancing the resume cursor.
|
||||
fn dispatch(kind: &str, data: &str, id: Option<u64>, cursor: &mut Option<u64>) {
|
||||
if let Some(seq) = id {
|
||||
*cursor = Some(seq);
|
||||
}
|
||||
if kind == "dropped" {
|
||||
// We fell off the catch-up ring: whatever the consumer believes about pending devices or
|
||||
// live sessions may be stale, and no amount of further events will repair it.
|
||||
emit_control("ctl.resync", None);
|
||||
return;
|
||||
}
|
||||
let payload = serde_json::from_str::<serde_json::Value>(data)
|
||||
.unwrap_or_else(|_| serde_json::Value::String(data.to_string()));
|
||||
emit(serde_json::json!({
|
||||
"v": SCHEMA_VERSION,
|
||||
"kind": kind,
|
||||
"seq": id,
|
||||
"data": payload,
|
||||
}));
|
||||
}
|
||||
|
||||
fn emit_control(kind: &str, error: Option<&str>) {
|
||||
let mut line = serde_json::json!({ "v": SCHEMA_VERSION, "kind": kind });
|
||||
if let Some(e) = error {
|
||||
line["data"] = serde_json::json!({ "error": e });
|
||||
}
|
||||
emit(line);
|
||||
}
|
||||
|
||||
/// One line, flushed. A widget reading incrementally must not wait on an 8 KiB stdio buffer to
|
||||
/// fill before it learns a device is knocking.
|
||||
///
|
||||
/// **A failed write ends the process**, rather than being ignored as it was: the only reason a
|
||||
/// write to our own stdout fails is that the consumer is gone, and carrying on would hold an SSE
|
||||
/// stream open against the host's connection cap for as long as the box stays up. Exit 0 — the
|
||||
/// consumer going away is a normal end to a `watch`, not an error anyone needs to see.
|
||||
fn emit(line: serde_json::Value) {
|
||||
let mut out = std::io::stdout().lock();
|
||||
if writeln!(out, "{line}").is_err() || out.flush().is_err() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal percent-encoding for the `kinds` query value. The grammar the host accepts is
|
||||
/// `[a-z0-9_.*,-]`, so this only ever has to escape what a typo could introduce.
|
||||
fn urlencode(s: &str) -> String {
|
||||
s.bytes()
|
||||
.map(|b| match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'*' | b',' => {
|
||||
(b as char).to_string()
|
||||
}
|
||||
_ => format!("%{b:02X}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kinds_survive_encoding_and_typos_are_escaped() {
|
||||
assert_eq!(
|
||||
urlencode("stream.*,pairing.pending"),
|
||||
"stream.*,pairing.pending"
|
||||
);
|
||||
assert_eq!(urlencode("a b"), "a%20b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_frame_advances_the_cursor_and_asks_for_a_resync() {
|
||||
// The cursor must advance even for `dropped`: resuming from before it would replay the
|
||||
// same fell-off-the-ring condition on every reconnect.
|
||||
let mut cursor = None;
|
||||
dispatch("dropped", r#"{"dropped":true}"#, Some(7), &mut cursor);
|
||||
assert_eq!(cursor, Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_frame_advances_the_cursor() {
|
||||
let mut cursor = Some(3);
|
||||
dispatch(
|
||||
"pairing.pending",
|
||||
r#"{"kind":"pairing.pending"}"#,
|
||||
Some(9),
|
||||
&mut cursor,
|
||||
);
|
||||
assert_eq!(cursor, Some(9));
|
||||
// A frame with no id (the synthetic ones) must not rewind it.
|
||||
dispatch("stream.started", "{}", None, &mut cursor);
|
||||
assert_eq!(cursor, Some(9));
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ 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
|
||||
@@ -526,6 +528,8 @@ 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,6 +31,110 @@ pub(crate) fn register_all(reg: &Diagnostics) {
|
||||
reg.register(virtual_deck_vhci);
|
||||
reg.register(uinput_access);
|
||||
reg.register(server_conflict);
|
||||
reg.register(hyprland_permissions);
|
||||
reg.register(omarchy_updates);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// hyprland_permissions
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Hyprland 0.49+ has `ecosystem.enforce_permissions`. It is off by default, but when it is on and
|
||||
/// the host has not been granted, screencopy and virtual input are **denied silently** — black
|
||||
/// frames and dropped input, with no error anywhere. That is the entire reason this check exists:
|
||||
/// every other failure on this path reports itself, and this one reports nothing at all.
|
||||
///
|
||||
/// Not Omarchy-specific — it applies to every Hyprland box — but Omarchy is exactly the kind of
|
||||
/// distro that might turn it on in a future release, which is what made it worth a row.
|
||||
fn hyprland_permissions() -> HostCheck {
|
||||
let id = ids::HYPRLAND_PERMISSIONS;
|
||||
if !cfg!(target_os = "linux") {
|
||||
return HostCheck::inapplicable(id, "Hyprland's permission system is a Linux feature.");
|
||||
}
|
||||
// `hyprctl` reachable at all is the "is this a Hyprland session?" test — the same one the
|
||||
// backend uses. An absent binary or a compositor that is not Hyprland is not a problem here.
|
||||
let Some(out) = command_output(
|
||||
"hyprctl",
|
||||
&["-j", "getoption", "ecosystem:enforce_permissions"],
|
||||
) else {
|
||||
return HostCheck::inapplicable(
|
||||
id,
|
||||
"This machine is not running a Hyprland session, so Hyprland's permission system \
|
||||
does not apply.",
|
||||
);
|
||||
};
|
||||
let enforced = serde_json::from_str::<serde_json::Value>(&out)
|
||||
.ok()
|
||||
.and_then(|j| j.get("int").and_then(|v| v.as_i64()))
|
||||
.is_some_and(|v| v != 0);
|
||||
if !enforced {
|
||||
return HostCheck::ok(
|
||||
id,
|
||||
"Hyprland is not enforcing per-application permissions, so nothing needs granting.",
|
||||
);
|
||||
}
|
||||
HostCheck::problem(
|
||||
id,
|
||||
CheckStatus::Warn,
|
||||
// Warning, not Critical: enforcement being ON does not mean we are DENIED — a box where
|
||||
// the host is already granted streams perfectly, and this probe cannot tell the two apart
|
||||
// from outside the compositor. Claiming Critical here would cry wolf on a healthy box.
|
||||
Severity::Warning,
|
||||
"Hyprland is enforcing permissions and this host may not be granted".to_string(),
|
||||
"Hyprland denies screencopy and virtual input SILENTLY — the client sees black frames and \
|
||||
input that does nothing, and neither the host nor the compositor logs an error. If \
|
||||
streaming already works, the host is already granted and there is nothing to do."
|
||||
.to_string(),
|
||||
)
|
||||
.with_remedy(Remedy {
|
||||
text: "Grant this host screencopy and virtual input in your Hyprland config, then reload \
|
||||
it. On a Lua-era config (Hyprland 4.x / Omarchy) the lines go in hyprland.lua or a \
|
||||
module it includes; on hyprlang they are `permission = …` lines."
|
||||
.to_string(),
|
||||
command: Some(
|
||||
"o.permission(\"/usr/bin/punktfunk-host\", \"screencopy\", \"allow\")\n\
|
||||
o.permission(\"/usr/bin/punktfunk-host\", \"plugin\", \"allow\")"
|
||||
.to_string(),
|
||||
),
|
||||
relogin_required: false,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// omarchy_updates
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// On Omarchy, updates go through `omarchy update` and the console's apply button is deliberately
|
||||
/// absent (design D5). Without a row saying so, "the update button is missing on my box" is an
|
||||
/// unanswerable support question — the check exists to answer it in the place the operator is
|
||||
/// already looking.
|
||||
fn omarchy_updates() -> HostCheck {
|
||||
let id = ids::OMARCHY_UPDATES;
|
||||
if !crate::osinfo::is_omarchy() {
|
||||
return HostCheck::inapplicable(id, "This machine is not running Omarchy.");
|
||||
}
|
||||
let version = command_output("omarchy-version", &[]).unwrap_or_default();
|
||||
let pretty = &crate::osinfo::detect().pretty;
|
||||
let summary = if version.is_empty() {
|
||||
format!("{pretty}: update with `omarchy update`")
|
||||
} else {
|
||||
format!("{version}: update with `omarchy update`")
|
||||
};
|
||||
HostCheck::ok(id, summary)
|
||||
.with_param("update_command", "omarchy update")
|
||||
.with_param("version", version)
|
||||
}
|
||||
|
||||
/// Run a command and return its trimmed stdout, or `None` if it is absent or failed. Shared by the
|
||||
/// two checks above; deliberately not a general helper — the catalog's other probes ask the owning
|
||||
/// crate rather than shelling out, and these two have no owning crate to ask.
|
||||
fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new(program).args(args).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
//! - `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};
|
||||
@@ -220,6 +225,9 @@ 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.
|
||||
|
||||
@@ -94,6 +94,10 @@ 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)]
|
||||
@@ -337,6 +341,9 @@ 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")
|
||||
@@ -469,6 +476,10 @@ 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.
|
||||
@@ -1053,6 +1064,9 @@ 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;
|
||||
|
||||
@@ -38,6 +38,18 @@ 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();
|
||||
|
||||
@@ -113,6 +113,25 @@ 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();
|
||||
@@ -171,6 +190,23 @@ 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");
|
||||
|
||||
@@ -73,6 +73,15 @@ 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",
|
||||
@@ -104,6 +113,11 @@ 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,
|
||||
@@ -397,6 +411,14 @@ 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,6 +37,15 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,23 @@
|
||||
"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+",
|
||||
|
||||
@@ -7,6 +7,14 @@ For **Arch Linux** and Arch-based distros (CachyOS, EndeavourOS, …). The host
|
||||
binary repo and updates with `pacman -Syu` like everything else. SteamOS is different — it has
|
||||
[its own page](/docs/steamos-host).
|
||||
|
||||
**One command instead?** The [guided installer](/docs/install#guided-install-preview) (preview) does
|
||||
everything on this page — it detects the distro, installs the host, joins the groups, opens the
|
||||
firewall and starts it:
|
||||
|
||||
<Installer />
|
||||
|
||||
The steps below are the same thing by hand, and stay the documented path.
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
- **NVIDIA:** `sudo pacman -S --needed nvidia-utils` (NVENC and the zero-copy path; Arch's `ffmpeg`
|
||||
|
||||
@@ -251,8 +251,20 @@ Ending the session when a game exits needs no script: it is the default, on the
|
||||
|
||||
## The event stream (`GET /api/v1/events`)
|
||||
|
||||
For code, subscribe to the SSE stream on the management API (loopback + bearer token — the
|
||||
same credentials as the rest of the admin surface):
|
||||
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):
|
||||
|
||||
```sh
|
||||
. ~/.config/punktfunk/mgmt-token # sets PUNKTFUNK_MGMT_TOKEN
|
||||
|
||||
@@ -8,6 +8,14 @@ Plasma — so this is the most appliance-like setup. One host streams **both** o
|
||||
Steam **Gaming Mode** and the **KDE desktop**, and follows the box when you switch, even mid-stream.
|
||||
Nothing in the config picks a mode.
|
||||
|
||||
**One command instead?** The [guided installer](/docs/install#guided-install-preview) (preview) does
|
||||
everything on this page — it detects the distro, installs the host, joins the groups, opens the
|
||||
firewall and starts it:
|
||||
|
||||
<Installer />
|
||||
|
||||
The steps below are the same thing by hand, and stay the documented path.
|
||||
|
||||
## 1. Install the host
|
||||
|
||||
The host installs as a **systemd system extension** — it overlays `/usr` from
|
||||
|
||||
@@ -141,10 +141,22 @@ 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).
|
||||
|
||||
## Migrating an existing box to canary
|
||||
## Switch an installed box between channels
|
||||
|
||||
Boxes added before this split point at the current stable channels, which now only move on releases.
|
||||
Point your dev fleet at **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:
|
||||
|
||||
```sh
|
||||
# apt
|
||||
@@ -155,6 +167,18 @@ sudo apt update && sudo apt upgrade
|
||||
sudo sed -i 's#/rpm/bazzite#/rpm/bazzite-canary#' /etc/yum.repos.d/punktfunk.repo # or fedora-44 → fedora-44-canary
|
||||
rpm-ostree upgrade
|
||||
|
||||
# pacman (Arch): rename the section, then -S (NOT -Syu — it will not step down to a lower version)
|
||||
sudo sed -i 's/^\[punktfunk\]$/[punktfunk-canary]/' /etc/pacman.conf
|
||||
sudo pacman -Sy && sudo pacman -S punktfunk-host punktfunk-web punktfunk-scripting
|
||||
|
||||
# Bazzite sysext
|
||||
sudo punktfunk-sysext install --channel canary
|
||||
|
||||
# Flatpak (Steam Deck client)
|
||||
flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref
|
||||
```
|
||||
|
||||
Coming **back** to stable is the same edit reversed, plus the flag that permits a step down —
|
||||
`sudo apt install --allow-downgrades punktfunk-host=<version>` (get it from `apt-cache madison`),
|
||||
`sudo dnf distro-sync punktfunk punktfunk-web punktfunk-scripting`, `sudo pacman -S …` as above, or
|
||||
`sudo punktfunk-sysext install --channel stable`.
|
||||
|
||||
@@ -10,6 +10,14 @@ package needs 2.39) — [build from source](/docs/build-from-source) there, or u
|
||||
> KDE (KWin 6.3) is below the floor, and a Cinnamon desktop (Linux Mint, LMDE) can only host through
|
||||
> gamescope. [What each desktop can do, and what Linux Mint 22 can't](/docs/requirements#cinnamon-linux-mint-and-lmde).
|
||||
|
||||
**One command instead?** The [guided installer](/docs/install#guided-install-preview) (preview) does
|
||||
everything on this page — it detects the distro, installs the host, joins the groups, opens the
|
||||
firewall and starts it:
|
||||
|
||||
<Installer />
|
||||
|
||||
The steps below are the same thing by hand, and stay the documented path.
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
- **NVIDIA:** the driver lives in `non-free`, which a default install doesn't enable. Enable it,
|
||||
|
||||
@@ -6,6 +6,14 @@ description: Install the Punktfunk host on Fedora 43 or newer from the RPM repo
|
||||
For **Fedora 43 or newer** (Workstation or KDE). Bazzite and other Fedora Atomic spins have
|
||||
[their own page](/docs/bazzite).
|
||||
|
||||
**One command instead?** The [guided installer](/docs/install#guided-install-preview) (preview) does
|
||||
everything on this page — it detects the distro, installs the host, joins the groups, opens the
|
||||
firewall and starts it:
|
||||
|
||||
<Installer />
|
||||
|
||||
The steps below are the same thing by hand, and stay the documented path.
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
- **NVIDIA:** the driver and an NVENC-capable FFmpeg both come from **RPM Fusion** — Fedora's own
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 |
|
||||
@@ -76,6 +77,97 @@ 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
|
||||
|
||||
@@ -13,6 +13,7 @@ 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." />
|
||||
@@ -36,7 +37,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 canary`, `--mgmt-port`, `--no-start`, `--uninstall` and the environment
|
||||
terminal — runs unattended; `--channel stable|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
|
||||
@@ -44,6 +45,11 @@ and Bazzite/Fedora Atomic; NixOS, SteamOS and Windows it points at their pages.
|
||||
per-system pages above remain the documented path, and the script runs exactly the install commands
|
||||
they show (CI fails if the two drift apart). Re-running is safe.
|
||||
|
||||
`--channel` also **switches** a host that is already installed — see
|
||||
[Release Channels](/docs/channels#switch-an-installed-box-between-channels). Without the flag the
|
||||
script follows whichever channel the box is already on, so re-running it to fix a group or a
|
||||
firewall rule never moves you off canary.
|
||||
|
||||
## Good to know
|
||||
|
||||
- **Read [Security & Safe Use](/docs/security) once.** A streaming host is remote control of the
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"debian",
|
||||
"fedora",
|
||||
"arch",
|
||||
"omarchy",
|
||||
"bazzite",
|
||||
"steamos-host",
|
||||
"nixos",
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
title: Omarchy
|
||||
description: Install the Punktfunk host on Omarchy 4.x — one setup command wires up the firewall, autostart, the app menu and toasts the Omarchy way.
|
||||
---
|
||||
|
||||
**Omarchy 4.x ("Quattro")** is Arch underneath, so the packages, the repo and `pacman` all work
|
||||
exactly as on the [Arch page](/docs/arch). What is different is everything *around* the install:
|
||||
ufw is on by default, autostart is a user unit tied to the uwsm session, apps belong in the
|
||||
Omarchy menu, and updates go through `omarchy update`. One command handles all of it.
|
||||
|
||||
<Callout type="info">
|
||||
Omarchy already ships Sunshine as an installable service and preinstalls moonlight-qt. Punktfunk
|
||||
**coexists** with Sunshine on its own ports — see [Sunshine on the same
|
||||
box](#sunshine-on-the-same-box) before you turn anything on.
|
||||
</Callout>
|
||||
|
||||
**One command instead?** The [guided installer](/docs/install#guided-install-preview) (preview) does
|
||||
everything on this page — it detects the distro, installs the host, joins the groups, opens the
|
||||
firewall and starts it:
|
||||
|
||||
<Installer />
|
||||
|
||||
The steps below are the same thing by hand, and stay the documented path.
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
Same as Arch:
|
||||
|
||||
- **NVIDIA:** `sudo pacman -S --needed nvidia-utils`
|
||||
- **AMD / Intel:** the Mesa stack you already have (`vulkan-radeon` / `vulkan-intel`,
|
||||
`libva-mesa-driver` / `intel-media-driver`).
|
||||
|
||||
## 2. Install the host
|
||||
|
||||
<Install platform="omarchy" />
|
||||
|
||||
`punktfunk-web` is the browser console and is optional but recommended — it is where the deep
|
||||
settings live. (`punktfunk-scripting` adds the plugin runner that fills your game library.)
|
||||
|
||||
<Callout type="warn">
|
||||
**Not `pacman -Syu <package>` — that is the Arch line and Omarchy refuses it.** Omarchy installs a
|
||||
pacman hook that aborts any transaction carrying both `-S` and `-u`, so it can funnel system
|
||||
upgrades through `omarchy update`; the Arch one-liner dies with *"Woah partner…"* and installs
|
||||
nothing. `-Sy` refreshes the databases without a system upgrade and is not blocked, and `-S` then
|
||||
installs. Everything else about the repo is identical to Arch.
|
||||
</Callout>
|
||||
|
||||
<Callout type="warn">
|
||||
Omarchy pins Arch packages to a **frozen snapshot mirror**, so its `ffmpeg` may sit behind rolling
|
||||
Arch. If `pacman` reports an unsatisfiable `libav*.so` dependency, the package is newer than your
|
||||
snapshot — wait for the next `omarchy update`, or [build from
|
||||
source](/docs/build-from-source).
|
||||
</Callout>
|
||||
|
||||
## 3. Wire it into Omarchy
|
||||
|
||||
```sh
|
||||
punktfunk-omarchy setup
|
||||
```
|
||||
|
||||
That one command, each step idempotent and each one reversible:
|
||||
|
||||
| Step | What it does |
|
||||
| --- | --- |
|
||||
| **Groups** | offers to add you to `input` (virtual gamepads) and `punktfunk` (the virtual Steam Deck pad). Both apply at your **next login**. |
|
||||
| **Autostart** | enables `punktfunk-host` as a user service so it comes up at login. It also installs the `graphical-session.target` binding — see [Autostart and your session](#autostart-and-your-session) for when that does more than nothing. |
|
||||
| **Firewall** | adds ufw rules scoped to your local networks (and `tailscale0` if you have it), each tagged `punktfunk-omarchy` so `remove` finds them again. |
|
||||
| **App menu** | installs "Punktfunk Console" as an Omarchy webapp — it appears in Apps (`Super`+`Space`). |
|
||||
| **Toasts** *(optional)* | pairing requests and stream start/stop as Omarchy notifications, with **Approve** / **Deny** buttons on the pairing one. |
|
||||
| **Idle** *(optional)* | keeps the screen awake for the length of a stream and restores your own setting afterwards. |
|
||||
| **Theme** *(optional)* | a `~/.config/omarchy/themed/` template so the console follows `omarchy-theme-set`. |
|
||||
|
||||
Check it any time with `punktfunk-omarchy status`, and undo all of it with `punktfunk-omarchy
|
||||
remove` (your pairings and `~/.config/punktfunk` are left alone).
|
||||
|
||||
## 4. Pair
|
||||
|
||||
Open **Punktfunk Console** from Apps and pair your first device — or stay in the terminal:
|
||||
|
||||
```sh
|
||||
punktfunk-host ctl pair arm # opens a pairing window and prints the PIN
|
||||
punktfunk-host ctl pending # devices knocking, with names and fingerprint tails
|
||||
punktfunk-host ctl approve 1
|
||||
```
|
||||
|
||||
See [the ctl reference](/docs/host-cli#ctl) for the full verb list.
|
||||
|
||||
## Autostart and your session
|
||||
|
||||
Omarchy offers **two** Hyprland entries at the login screen, and they differ in a way that matters
|
||||
here:
|
||||
|
||||
| Session | What it runs | `graphical-session.target` |
|
||||
| --- | --- | --- |
|
||||
| **Hyprland** | `start-hyprland` | never starts |
|
||||
| **Hyprland (uwsm-managed)** | `uwsm start … Hyprland` | starts with the session |
|
||||
|
||||
`punktfunk-omarchy setup` enables the host as a user service, so **it comes up at login either
|
||||
way**. What the second session additionally buys is that the host *restarts* when the session does,
|
||||
instead of surviving a logout holding a Wayland socket that died with the old compositor.
|
||||
|
||||
On the plain **Hyprland** session that target never starts — which is also why Omarchy's own
|
||||
`omarchy-crash-watch` and `omarchy-sleep-lock` units sit enabled-but-dead there. `punktfunk-omarchy
|
||||
status` tells you which situation you are in.
|
||||
|
||||
It is rarely worth switching sessions just for this: on Hyprland the host re-derives the live
|
||||
compositor on every connect, so the stale-socket failure the binding guards against on KDE and
|
||||
GNOME does not really arise. If you want it anyway, pick *Hyprland (uwsm-managed)* at the login
|
||||
screen — nothing about Punktfunk needs changing.
|
||||
|
||||
## Firewall and the video data plane
|
||||
|
||||
`punktfunk-omarchy setup` opens the control ports the Omarchy way — scoped to RFC1918 ranges rather
|
||||
than to the whole world:
|
||||
|
||||
```
|
||||
ufw allow from 192.168.0.0/16 to any app punktfunk-native comment "punktfunk-omarchy"
|
||||
```
|
||||
|
||||
**The video data plane has no rule, and that is expected.** It binds an ephemeral UDP port chosen
|
||||
per session, so there is nothing fixed to open. Under ufw's default deny-incoming the client's
|
||||
first packet is dropped and the session falls back to a blind send: the host's own outbound packet
|
||||
creates the conntrack entry, and the return path rides it. On a LAN — which is the whole point of
|
||||
hosting here — this works, and it is why `punched=false` in the logs is normal rather than a fault.
|
||||
|
||||
If you want strict control instead, pin the port and open exactly it:
|
||||
|
||||
```sh
|
||||
echo 'PUNKTFUNK_DATA_PORT=9778' >> ~/.config/punktfunk/host.env
|
||||
sudo ufw allow from 192.168.0.0/16 to any port 9778 proto udp comment "punktfunk-omarchy"
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
The cost is one concurrent session on that port; extra sessions fall back to ephemeral ports again.
|
||||
|
||||
## Updating
|
||||
|
||||
Punktfunk updates with everything else:
|
||||
|
||||
```sh
|
||||
omarchy update
|
||||
```
|
||||
|
||||
Our repo rides that transaction once it is configured, and it snapshots with snapper first. The web
|
||||
console therefore shows updates as **notify-only** on Omarchy and points at this command rather than
|
||||
offering a one-click apply: a bare `pacman -Syu` is blocked by Omarchy's own guard, and going around
|
||||
it would skip the snapshot your rollback depends on.
|
||||
|
||||
A snapper rollback reverts `/usr` — the binaries — while `~/.config/punktfunk` and your pairings
|
||||
live in `/home` and persist. Nothing needs re-pairing after a rollback.
|
||||
|
||||
## Sunshine on the same box
|
||||
|
||||
`omarchy install service sunshine` and Punktfunk can run together, with one caveat:
|
||||
|
||||
- **The native plane coexists.** Ports 9777 (QUIC), 47990 (management API) and 47992 (console) are
|
||||
ours alone, so the Punktfunk apps and the console work with Sunshine running and untouched.
|
||||
- **The GameStream plane collides.** Moonlight compatibility uses 47984/47989/48010 and
|
||||
47998–48010 — exactly Sunshine's ports. Ours are **off by default**; leave them off unless you
|
||||
are migrating, and only one host can bind them.
|
||||
|
||||
To migrate: `omarchy remove service sunshine`, then re-run `punktfunk-omarchy setup` and answer yes
|
||||
to GameStream if you still want stock Moonlight clients. More in [Switching from
|
||||
Sunshine](/docs/switching-from-sunshine).
|
||||
|
||||
## Screen sharing keeps working
|
||||
|
||||
Omarchy ships its own screen-share picker (`hyprland-preview-share-picker`), the one every browser
|
||||
share on the box goes through. Punktfunk needs that same setting to select a headless output
|
||||
without a dialog, so while a stream runs it borrows the setting — and **defers to your picker**
|
||||
whenever no Punktfunk selection is pending. Your browser shares behave exactly as before, during a
|
||||
session and after it, and the original value is written back when the last stream ends. If a host
|
||||
is killed mid-session, `punktfunk-omarchy remove` puts it back too.
|
||||
|
||||
## Keyboard shortcut (optional)
|
||||
|
||||
Punktfunk claims no chord — Omarchy occupies most of `Super`+*. Add your own to
|
||||
`~/.config/hypr/bindings.lua`:
|
||||
|
||||
```lua
|
||||
o.bind("SUPER + SHIFT + P", "Punktfunk", { webapp = "https://localhost:47992" })
|
||||
```
|
||||
|
||||
## Wake-on-LAN and disk encryption
|
||||
|
||||
Omarchy enables **full-disk encryption by default**. [Wake-on-LAN](/docs/wake-on-lan) still wakes
|
||||
the machine, but a cold boot stops at the LUKS passphrase prompt — nothing can stream until someone
|
||||
types it. Wake from **suspend** is unaffected, so suspend rather than shut down a box you want to
|
||||
wake into.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Run `punktfunk-omarchy status` first — it checks the units, the ufw rules, your groups, the portal
|
||||
and Hyprland's permission mode in one pass.
|
||||
|
||||
**Black frames, or input that does nothing.** Check whether Hyprland's permission enforcement is on:
|
||||
|
||||
```sh
|
||||
hyprctl -j getoption ecosystem:enforce_permissions
|
||||
```
|
||||
|
||||
If it is, the compositor is *silently* denying screencopy and virtual input — there is no error, only
|
||||
black frames and dropped input. Grant the host in your Hyprland config:
|
||||
|
||||
```lua
|
||||
o.permission("/usr/bin/punktfunk-host", "screencopy", "allow")
|
||||
o.permission("/usr/bin/punktfunk-host", "plugin", "allow")
|
||||
```
|
||||
|
||||
**No picture at all.** `xdg-desktop-portal-hyprland` must be running — it is what capture goes
|
||||
through. `systemctl --user status xdg-desktop-portal-hyprland`.
|
||||
|
||||
**The host is not running after a reboot.** `punktfunk-omarchy status` will say whether it is bound
|
||||
to the desktop session; if not, re-run `punktfunk-omarchy setup`.
|
||||
|
||||
More in [Troubleshooting](/docs/troubleshooting) and on the [Hyprland page](/docs/hyprland), which
|
||||
covers the compositor-level details this page assumes.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```sh
|
||||
punktfunk-omarchy remove # the Omarchy wiring
|
||||
sudo pacman -R punktfunk-host punktfunk-web # the packages
|
||||
```
|
||||
|
||||
`remove` reverses every step of `setup` — units, drop-in, ufw rules, the webapp, the hooks, the
|
||||
theme template — and restores the screen-share picker. Your config and pairings survive both; see
|
||||
[Uninstall](/docs/uninstall) to clear those too.
|
||||
@@ -14,7 +14,7 @@ install command, the group to join, and nothing else:
|
||||
|
||||
| Linux | Windows |
|
||||
|---|---|
|
||||
| [Ubuntu](/docs/ubuntu) · [Debian](/docs/debian) · [Fedora](/docs/fedora) · [Arch / CachyOS](/docs/arch) · [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) · [Omarchy](/docs/omarchy) · [Bazzite](/docs/bazzite) · [SteamOS](/docs/steamos-host) · [NixOS](/docs/nixos) | [Windows 11](/docs/windows-host) |
|
||||
|
||||
Not sure your machine qualifies? [Requirements](/docs/requirements) is the checklist.
|
||||
|
||||
|
||||
@@ -156,6 +156,16 @@ Minimum compositor versions (newer is fine):
|
||||
|
||||
- **KWin ≥ 6.5.6** ([KDE Plasma](/docs/kde)) — headless virtual outputs.
|
||||
- **GNOME ≥ 48** ([Mutter](/docs/gnome)) — virtual-monitor screen-cast.
|
||||
- **Hyprland — no version floor** ([Hyprland](/docs/hyprland)): the `hyprctl` path is
|
||||
version-independent, and both config eras (hyprlang and the newer Lua one) are handled. Contracts
|
||||
are verified against **0.55.4** and **0.56.2**. What Hyprland *does* need is
|
||||
**`xdg-desktop-portal-hyprland`** — capture goes through it, and Hyprland does not pull it in.
|
||||
On **0.49+**, if you have turned `ecosystem.enforce_permissions` on (off by default), grant the
|
||||
host screencopy and virtual input: a denial is *silent black frames and dropped input*, never an
|
||||
error.
|
||||
- **Omarchy ≥ 4.0** ([Omarchy](/docs/omarchy)) — not a compositor floor but an integration one: 4.0
|
||||
replaced the shell, the menu format and the Hyprland config language at once, so every point
|
||||
`punktfunk-omarchy` touches is different below it.
|
||||
- **gamescope ≥ 3.16.22** ([Bazzite/Steam](/docs/gamescope)) — below this, headless capture
|
||||
deadlocks against PipeWire ≥ 1.6.
|
||||
- **gamescope ≥ 3.16.23** for the Steam overlay (Shift+Tab / Quick Access Menu) to reach the stream
|
||||
|
||||
@@ -628,6 +628,12 @@ whose caveat *is* "nobody has run this on real hardware" — a wrong ✅ is wors
|
||||
well-trodden path, and there is no probe that would catch it failing. One spawn-and-capture run on
|
||||
an NVIDIA box settles it.
|
||||
- **Touch input from a Windows client.** Same shared code as Linux, no on-glass run.
|
||||
- **The whole Omarchy integration on an Omarchy box.** `punktfunk-omarchy setup`, the LAN-scoped
|
||||
ufw rules, the webapp menu entry, the notification and idle hooks, and the screen-share picker
|
||||
hand-back are all written against Omarchy 4.0.1's documented seams and unit-tested where they
|
||||
parse or generate a file — but none of it has been run on Omarchy. Nothing about it is enabled
|
||||
until an operator runs that command, so a plain Arch box is unaffected either way. One install →
|
||||
setup → pair → stream run on a 4.x box settles it.
|
||||
- **The `pf-webos` LG TV client.** A community project in another repository. Its codecs, HDR
|
||||
behaviour and feature set cannot be established from here.
|
||||
- **Everything client-side about Moonlight.** Wake-on-LAN, overlays, updates and which extensions
|
||||
|
||||
@@ -9,6 +9,14 @@ 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
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.32.0"
|
||||
"version": "0.33.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/actions": {
|
||||
|
||||
@@ -98,6 +98,23 @@
|
||||
"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+",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
This release is for everyone who streams games or a desktop with Punktfunk. It works with 0.32.x, so everything you have already paired keeps working, and you can update one side at a time.
|
||||
|
||||
**If you are on 0.32.0, update.** That release broke automatic bitrate. The first moment of packet loss dropped a stream from 20 Mbps to around 2 Mbps, and it never climbed back. Two people reported it from opposite setups, which is what made the cause findable. It is fixed here.
|
||||
|
||||
The headline feature is the other half of a round trip we only ever did once. Punktfunk has been able to wake your host from the couch for a while. Now it can put it back: sleep, restart and shut down appear on every client, in the same menu that woke the machine. Finish on the TV and send the host to sleep from the sofa.
|
||||
|
||||
Omarchy is now a first-class host. A one-command install, a setup command that wires the firewall and the app menu for you, and a console that wears your desktop theme instead of ignoring it. Five things we had assumed about that system turned out to be wrong when we ran it on real hardware, and all five are fixed rather than documented.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Automatic bitrate is fixed.** On 0.32.0 one bad moment cost you the whole session. Read "Before you update" if you turned automatic off because of it.
|
||||
- **You can sleep, restart or shut down your host from any client.** Read "Before you update" if you have paired a device with Full control.
|
||||
- **Omarchy is a supported host.** One-command install, a setup step, and the console follows your theme.
|
||||
- **Fixed a black screen on Hyprland desktops.** The streamed display was never focused, so nothing was ever drawn on it.
|
||||
- **Your client no longer picks a random route to your host.** One person's stream went over a virtual private network while both machines sat on the same Wi-Fi.
|
||||
- **Intel Arc graphics: fixed a green picture on every high dynamic range (HDR) session.**
|
||||
- **"Keep host audio playing" now works on Apple and Android**, not just the desktop clients.
|
||||
|
||||
## What's in this release
|
||||
|
||||
- [Before you update](#before-you-update)
|
||||
- [New](#new)
|
||||
- [Improved](#improved)
|
||||
- [Fixed](#fixed)
|
||||
- [Thanks](#thanks)
|
||||
- [For developers](#for-developers)
|
||||
|
||||
## Before you update
|
||||
|
||||
- **A device paired with "Full control" can now shut down your host.** Host power is a new permission, and it is included in Full control. Any device you already trust with everything gains it when you update. If that is not what you want, open the device's access settings and switch host power off. Every other access level is unaffected.
|
||||
- **If you turned automatic bitrate off on 0.32.0, you can turn it back on.** The collapse you were working around is fixed. If you set a manual rate to escape it, that rate is now a ceiling you no longer need.
|
||||
- **Omarchy users: run `punktfunk-omarchy setup` after installing.** The package installs the command but never runs it, because opening a firewall and editing your app menu behind your back is not something a package should do. The install guide walks through it.
|
||||
|
||||
## New
|
||||
|
||||
- **Sleep, restart and shut down your host from the couch.** Every client's host menu grows the three rows, right where "Wake host" appears when the machine is asleep. Restart and shut down ask you to confirm. Sleep does not, because it is reversible from the same menu. An action your host cannot run right now stays listed, greyed out, and says why.
|
||||
- **Omarchy is a supported host.** It gets its own install page, a one-command installer, and a setup step. Setup opens the firewall for your local network only, adds Punktfunk to the app menu, and starts the host with your session. It also checks whether anything else is already using the same ports. `punktfunk-omarchy remove` puts everything back.
|
||||
- **The web console follows your desktop theme.** Switch theme and the console switches with it, within a couple of seconds, including your background and text colours. Warning and delete buttons deliberately keep their own colours: a theme with a red accent must not make "delete" and "save" look the same. Every colour combination is checked for readable contrast against six shipped themes before it ships.
|
||||
- **Opening the console from your own app menu no longer asks who you are.** It recognises you as the person running the host, using a one-time pass that expires in a minute. Anyone arriving without one still meets the login page, so the console stays safe to reach from your phone.
|
||||
- **A command-line interface for the host.** `punktfunk-host ctl` handles pairing, device lists, access levels, stopping a session, and following host events as they happen. It verifies it is talking to your real host before it sends anything, so a program squatting on the port learns nothing.
|
||||
- **The installer can move a host between release channels.** `--channel canary` and `--channel stable` now work on a machine that already has Punktfunk, in both directions. Going back to stable is a downgrade, which package managers refuse unless told, so the installer tells them. Re-running the installer without naming a channel leaves you on the one you are on.
|
||||
|
||||
## Improved
|
||||
|
||||
- **The one-command install is on every page it works for.** It used to be mentioned only on the install hub, so anyone landing on the Ubuntu page from a search never learned it existed.
|
||||
- **The troubleshooting page names two failures that used to report nothing.** Hyprland can silently refuse a program permission to capture the screen or send input, which looks like a black screen with dead controls and no error anywhere. And on Omarchy the console's update button is deliberately absent, so the page now says where updates come from instead.
|
||||
- **The tray's restart row says "Restart Punktfunk"**, so it is not confused with restarting the machine.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Automatic bitrate collapsed and stayed collapsed.** On 0.32.0, and only on 0.32.0, every automatic session dropped from around 20 Mbps to around 2 Mbps at the first sign of packet loss, and could never climb back. It was reported twice from opposite setups: a Windows client over a virtual private network, and an Android client on Wi-Fi. The cause was 0.32.0's own bitrate rework, where a rounding step of one or two kilobits made every successful rate change look like the host refusing to go faster. Your client learned that as a hard limit, then lowered it again on the next dip.
|
||||
- **A stream could start with five to eleven seconds of black video.** Your client measures your connection's capacity by sending a short burst at startup. It fired on a fixed two-second timer, but a Windows host can take six to eight seconds to get its display ready. So the burst landed exactly on the first video frame and buried it, then missed its own deadline and threw the measurement away. The burst now waits until a frame has actually arrived.
|
||||
- **Hyprland desktops: a black screen on every session.** The display Punktfunk creates for your stream was never given focus, and a display nothing is looking at draws nothing at all. No drawing means no frames, so capture gave up waiting and the client showed black. Getting focus onto it needed a spelling that only works on newer Hyprland setups, and we had the wrong one.
|
||||
- **Hyprland desktops: only the first session worked.** Punktfunk borrows your screen-share picker while it streams and hands it back afterwards. Handing it back after every session restarted a system service underneath the next one, and a stream started across that restart never receives a single frame. The picker is now handed back when the host exits. In between, it passes anything that is not us straight through, so an ordinary browser screen share behaves exactly as before.
|
||||
- **Switching desktop theme reset your stream's resolution.** Changing an Omarchy theme reloads the compositor's configuration, and a reload discards the display settings Punktfunk had applied — so the stream silently dropped to a default resolution mid-session. Resizing the client window appeared to fix it, which is why this looked like a client bug. Punktfunk now watches for the reload and re-applies its settings. Any reload is covered, including one you run yourself.
|
||||
- **Hyprland desktops: your monitors could stay dark after a failed session.** When Punktfunk streams your desk exclusively it turns your physical monitors off, and puts them back at the end. If starting the session failed and it retried, the retry overwrote the note saying which monitors to restore. So nothing was restored, and recovery meant reloading the compositor by hand.
|
||||
- **Your client picked a route to your host at random.** One person's stream ran over a virtual private network while both machines sat on the same Wi-Fi, and neither end chose that. Your operating system answers name lookups on every network it can see, so the list your client gets is polluted by every virtual network reaching it. The client then took whichever entry came first, re-rolling every time your host announced itself. Desktop and Android now both prefer the network they share with your host, then the address your host says to use.
|
||||
- **Intel Arc graphics: a green picture on every HDR session.** Reported on an Arc A370M, where 8-bit sessions at the same resolution and frame rate looked perfect. Your client was promising it could display HDR without ever checking whether the graphics driver could actually convert it. It now asks the driver first, and only a definite "no" turns HDR off.
|
||||
- **"Keep host audio playing" was missing on Apple and Android.** The setting shipped in 0.32.0, but only three clients ever offered it. So a phone or an Apple TV was the one place someone would look for "why did my host go quiet" and find nothing. It is now on every client, in the same place, worded the same way. A per-host profile can keep the study machine's headphones live while the living-room profile mutes them.
|
||||
- **A fresh install reported that the web console had failed to start.** It then started two seconds later and worked fine, but the first attempt printed a hard failure on a perfectly good install — and anything watching the result believed it. The console was starting before the host had written the files it needs. It now waits for them.
|
||||
- **Uninstalling left things behind.** Removing Punktfunk took the packages but left firewall rules, an app-menu entry, a startup file, and a screen-share setting still pointing at something no longer installed. Uninstall now undoes its own setup first.
|
||||
- **Omarchy: the install command could not work.** The command we published combined two options that Omarchy deliberately blocks, so it installed nothing at all. Two other things were wrong on that system. The setting that keeps your machine awake during a stream was never restored afterward. And the status command reported firewall rules missing seconds after adding them.
|
||||
- **Punktfunk's entry in your app menu drew no icon**, because the host package never shipped one.
|
||||
- **Updating our website under an open tab no longer blanks the page.** A tab left open across a deployment asked for files the new version had renamed, and the page went blank on the next click. It now reloads onto the page you were going to. This affected the web console and the documentation site.
|
||||
|
||||
## Thanks
|
||||
|
||||
Most of this release started as somebody describing what they actually saw:
|
||||
|
||||
- **The stream that fell to 2 Mbps and stayed there**, reported twice from setups with nothing in common. That pair is what ruled out the network.
|
||||
- **The green picture on an Arc card**, with the detail that 8-bit sessions at the same resolution were clean. That is what pointed at the conversion rather than the decoder.
|
||||
- **The stream running over a virtual private network while both machines were on the same Wi-Fi.** Nobody would have gone looking for that one.
|
||||
- **A real Omarchy machine**, run through the whole plan step by step. Five of our assumptions were wrong, and every one of them was a bug somebody would have hit in their first ten minutes.
|
||||
|
||||
Thank you.
|
||||
|
||||
One caveat we would rather write down than have you find. The Hyprland fixes are verified on a real Omarchy machine, with one exception. Re-applying your resolution after a theme switch is unit-tested, and we confirmed the trigger fires on that machine. Testing the re-application itself would have meant interrupting a live stream on the only box we had. If a theme switch still costs you your resolution, please tell us.
|
||||
|
||||
## For developers
|
||||
|
||||
Protocol, application binary interface (ABI), driver and embedder detail is in [the v0.33.0 CHANGELOG](https://git.unom.io/unom/punktfunk/src/tag/v0.33.0/CHANGELOG.md). It carries the version table and the full breaking-change list.
|
||||
|
||||
The short version: **nothing versioned moves.** The streaming protocol, the ABI, the driver protocol, the gamepad channel, the add-on contract and the event schema are all where 0.32.0 left them. The management API grows two routes for host actions.
|
||||
|
||||
**Two changes will still break a build.** The "everything" permission mask widens to include host power, so code comparing a stored mask against it for equality now reads an older record as custom. There is a compatibility constant for the old value, and the host applies the compatibility rule itself. Separately, a capability constant that 0.32.0 exported without its prefix is renamed to carry one. Its value is unchanged.
|
||||
@@ -0,0 +1,4 @@
|
||||
• Fixes streaming quality collapsing after one bad moment. On 0.32.0 a stream could drop to 2 Mbps and stay there for the rest of the session.
|
||||
• Sleep, restart or shut down your host from the app — the same menu that wakes it.
|
||||
• New "Keep host audio playing" setting, so the speakers on your host stay live while you stream.
|
||||
• Your device stops picking a random route to your host when a virtual private network is running.
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
# Punktfunk writing standards
|
||||
|
||||
House style for **commits**, **changelogs**, and **comments**.
|
||||
|
||||
Audited against [unom/punktfunk](https://git.unom.io/unom/punktfunk) on 28 August 2026. The engineering in that tree is careful. The writing is careful too. The problem is the medium: git log, CHANGELOG.md, and rustdoc are being asked to hold design reviews.
|
||||
|
||||
This document is the rulebook. The companion site shows the originals next to rewrites.
|
||||
|
||||
---
|
||||
|
||||
## 0. The one sentence
|
||||
|
||||
**Put each fact where someone can find it later.**
|
||||
|
||||
| Fact | Lives in |
|
||||
| --- | --- |
|
||||
| What changed, in one greppable line | Commit **subject** |
|
||||
| Why it changed, in a short body | Commit **body** (and the PR if it needs a diagram) |
|
||||
| What a user can do now | `docs/releases/vX.Y.Z.md` |
|
||||
| What an embedder must do | `CHANGELOG.md` |
|
||||
| The investigation, measurements, rejected paths | Pull request and `docs/adr/` |
|
||||
| The invariant that must remain true | Comment, type, or test |
|
||||
|
||||
If you are writing a novel, you are in the wrong file.
|
||||
|
||||
---
|
||||
|
||||
## 1. Commits
|
||||
|
||||
### Shape
|
||||
|
||||
```
|
||||
type(scope): imperative summary
|
||||
|
||||
Why it failed for a user. What was actually wrong.
|
||||
What you changed. Wrap at 72.
|
||||
|
||||
Fixes #123
|
||||
```
|
||||
|
||||
- **50 characters** is the aim for the subject. **72 is the hard cap.**
|
||||
- No trailing period on the subject.
|
||||
- Imperative, present tense: `keep`, `skip`, `advertise` — matching `git merge` / `git revert`.
|
||||
- One logical change per commit. A subject with “and” is two commits, or one theme named as a theme.
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Use |
|
||||
| --- | --- |
|
||||
| `feat` | User-visible capability that did not exist |
|
||||
| `fix` | A bug. Put the symptom in the body |
|
||||
| `docs` | Docs, comments-as-docs, release notes. No behaviour change |
|
||||
| `refactor` | Same behaviour, different shape |
|
||||
| `perf` | Same behaviour, cheaper. Name the metric if you have one |
|
||||
| `test` | Tests only |
|
||||
| `chore` | Deps, version bumps, generated files |
|
||||
| `ci` | Pipelines and gates |
|
||||
| `security` | Trust-boundary changes |
|
||||
|
||||
Scopes are subsystem names a newcomer would grep: `host`, `hyprland`, `mdns`, `abr`, `console`, `gamestream`, `android`, `web`, `core`.
|
||||
|
||||
### Why the current style fails
|
||||
|
||||
Recent subjects on main (28 Aug 2026):
|
||||
|
||||
| As written | Problem | Rewrite |
|
||||
| --- | --- | --- |
|
||||
| The retry loop stops eating the restore that re-lights the desk | Metaphor, no scope, 69 chars of plot | `fix(host/hyprland): keep topology restore across pipeline retries` |
|
||||
| The advert names its address, and the client stops rolling dice on the rest | Pun, two clauses, unsearchable | `fix(mdns): advertise a primary address so clients stop guessing` |
|
||||
| The streamed head can be focused on a Lua box, which is what makes it produce frames | Relative clause, slang | `fix(hyprland): focus the virtual head on Lua-configured compositors` |
|
||||
| The Omarchy box says otherwise: five things the plan got wrong, measured | Lab-notebook title | `docs(omarchy): correct five host-plan assumptions from measured hardware` |
|
||||
| The ticket parser proves its own segments exist | Anthropomorphic, no scope | `fix(web): narrow ticket-parser types so segments are proven present` |
|
||||
|
||||
The last 200 subjects average **101 characters**. Longest: **211**. At the time of the audit, CONTRIBUTING.md’s entire commit rule was “end with the Co-Authored-By trailer.”
|
||||
|
||||
### Body rules
|
||||
|
||||
- Three short paragraphs is enough: user-visible failure, actual cause, what you changed.
|
||||
- Wrap at 72.
|
||||
- Do not paste CI logs, soak minutes, GPU SKUs, or RFC section numbers. Link the PR or the ADR.
|
||||
- No `Co-Authored-By` trailer in this repo. Attribution is off, because Gitea 1.27 promotes
|
||||
the trailer to a second participant on the commit page. Credit a co-author in prose.
|
||||
|
||||
### Pull requests
|
||||
|
||||
The forensic essay is valuable. **Put it on the PR.** The merge commit subject is the conventional subject of the work, not the essay’s headline. Gitea PR titles become merge subjects — write the PR title as a conventional commit.
|
||||
|
||||
---
|
||||
|
||||
## 2. Changelogs
|
||||
|
||||
Punktfunk already split the two audiences at v0.25.0. Keep that. Stop writing both files in the same voice.
|
||||
|
||||
### `docs/releases/vX.Y.Z.md` — people who stream
|
||||
|
||||
Keep the existing template:
|
||||
|
||||
1. Compatibility line (plain language, no ABI numbers)
|
||||
2. `## TL;DR` — three to six one-line bullets
|
||||
3. `## Before you update` — only if the reader must act
|
||||
4. `## New` / `## Improved` / `## Fixed` / `## Security`
|
||||
5. `## For developers` — one link to CHANGELOG.md at the **tag**
|
||||
|
||||
Voice rules already in `docs/releases/README.md` are correct. Follow them. Do not narrate lab sessions (“we watched one do exactly that on a local network, unprompted”) in the user notes. Say the default changed, and that clients that support it opt in.
|
||||
|
||||
### `CHANGELOG.md` — embedders, packagers, plugin authors
|
||||
|
||||
Keep:
|
||||
|
||||
- Newest first
|
||||
- The **version table** (every row, including unchanged)
|
||||
- Breaking changes with an action
|
||||
|
||||
Replace:
|
||||
|
||||
- Sentence-headings (“The auto-bitrate overhaul (four phases)”)
|
||||
- 454-line sections (v0.32.0)
|
||||
- Field-log storytelling, soak evidence, RFC chapter numbers
|
||||
|
||||
Use [Keep a Changelog](https://keepachangelog.com/) categories:
|
||||
|
||||
```markdown
|
||||
## [0.32.0] — 2026-08-27
|
||||
|
||||
ABI 25 → 26 (additive). Wire protocol stays 2.
|
||||
|
||||
### Breaking
|
||||
- **Bitrate means the wire budget.** `live_bitrate` is no longer encoder rate.
|
||||
FEC, framing and audio used to ride on top. Embedders that treated it as
|
||||
encoder rate must stop.
|
||||
|
||||
### Added
|
||||
- `punktfunk_connect_opts` replaces the `connect_ex*` ladder. Every `ex` remains.
|
||||
|
||||
### Fixed
|
||||
- Automatic bitrate treated a still picture as congestion.
|
||||
|
||||
### Security
|
||||
- Authenticated console sessions could reach pairing without the console password.
|
||||
Pairing grants launch. The routes now re-ask.
|
||||
```
|
||||
|
||||
### Length
|
||||
|
||||
| Release | Target |
|
||||
| --- | --- |
|
||||
| Patch | One screen |
|
||||
| Minor | Two screens |
|
||||
| Longer than that | Split, or link an ADR |
|
||||
|
||||
`CHANGELOG.md` is currently **6,519 lines** for eight versions. That is not a changelog. It is an archive of design reviews. Move the reviews to `docs/adr/` and link them.
|
||||
|
||||
---
|
||||
|
||||
## 3. Comments
|
||||
|
||||
### Why
|
||||
|
||||
Comments exist for the non-local reason: the invariant, the trap, the rejected alternative. Names and types are the what. If the next five lines already say it, delete the comment.
|
||||
|
||||
### Module rustdoc
|
||||
|
||||
A module header is a map, 8–20 lines:
|
||||
|
||||
1. What it is
|
||||
2. The public contract
|
||||
3. How to choose / pin it
|
||||
4. Where deeper evidence lives
|
||||
|
||||
It is not a program-of-record. `crates/pf-client-core/src/video_vk_native.rs` opens with **8,940 characters** of `//!` before the first item: WP-C, M3 WP-2, M7, a 92-minute soak, an RTX 5070 Ti. That history is already in git and in `design/client-native-decode.md`. rustdoc should point there, not duplicate it.
|
||||
|
||||
### A comment is not a spec
|
||||
|
||||
The 2026-08-25 security review’s serious findings were documented promises the code had stopped keeping. One commit even named it: `the comments were the spec, and the code had drifted`.
|
||||
|
||||
If a boundary matters, encode it: type, test, assertion, parser. Comments explain the boundary. They do not constitute it.
|
||||
|
||||
### SAFETY, FFI, concurrency
|
||||
|
||||
Keep these exact. This is already good:
|
||||
|
||||
```rust
|
||||
// SAFETY: the clipboard is open (the `Clip` guard); the handle returned is
|
||||
// BORROWED from the clipboard and stays valid while it is open, so it is
|
||||
// never freed here.
|
||||
```
|
||||
|
||||
Do not restyle that into a narrative of how the bug was found.
|
||||
|
||||
### History
|
||||
|
||||
Dates, SKUs, soak durations, milestone codes, and “this used to” sentences go stale in the file. `git blame` and ADRs keep them honest.
|
||||
|
||||
### Prefer a name
|
||||
|
||||
`stash_topology_restore_first_wins` says what a twelve-line comment would. If you need a comment to explain a name, rename.
|
||||
|
||||
---
|
||||
|
||||
## 4. Checklist (every PR)
|
||||
|
||||
- [ ] Subject is `type(scope): summary`, ≤ 72 characters, imperative, no period
|
||||
- [ ] Subject names a subsystem a newcomer would grep
|
||||
- [ ] Body is why, not the investigation (investigation is on the PR)
|
||||
- [ ] One logical change; no “and” holding two fixes together
|
||||
- [ ] User-facing fact updated in `docs/releases/` or the docs-site page that owns it
|
||||
- [ ] Embedder-facing fact is a bullet in CHANGELOG.md, not a new chapter
|
||||
- [ ] New comments state an invariant or a trap, not a recap of the diff
|
||||
- [ ] Module rustdoc still fits on one screen
|
||||
- [ ] No new comment that is the only enforcement of a trust boundary
|
||||
|
||||
---
|
||||
|
||||
## 5. What this does not ask
|
||||
|
||||
- It does not ask anyone to write less carefully. It asks them to file the care in the right place.
|
||||
- It does not ban long writing. It bans long writing in git subjects and changelog bullets.
|
||||
- It does not replace `docs/releases/README.md`. That voice guide stays. This document covers the three surfaces that guide does not.
|
||||
|
||||
---
|
||||
|
||||
## 6. Adoption
|
||||
|
||||
Adopted 28 August 2026. Three steps landed with this file:
|
||||
|
||||
1. This file is `docs/writing.md`, linked from CONTRIBUTING.md and AGENTS.md.
|
||||
2. CONTRIBUTING.md’s commit rule points here.
|
||||
3. `.gitea/PULL_REQUEST_TEMPLATE.md` asks for a conventional PR title, because a Gitea PR
|
||||
title becomes the merge subject.
|
||||
|
||||
Two steps are ongoing, and deliberately not a sweep:
|
||||
|
||||
4. Write each **new** `CHANGELOG.md` section in Keep a Changelog form. The archive under the
|
||||
older headings stays as it is; do not rewrite it.
|
||||
5. **New** module files get the short rustdoc. Do not rewrite every existing header in one
|
||||
pass — rewrite one when you are already changing that module.
|
||||
@@ -197,6 +197,11 @@ 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)'
|
||||
@@ -311,6 +316,28 @@ 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
|
||||
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/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
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# punktfunk hook: a device is asking to pair → an Omarchy toast with an Approve action.
|
||||
#
|
||||
# Wired by `punktfunk-omarchy setup` as a `pairing.pending` entry in ~/.config/punktfunk/hooks.json.
|
||||
# The host runs it detached with the event JSON on stdin and every scalar leaf flattened into
|
||||
# `PF_EVENT_*` (see automation.md), so nothing here has to parse JSON.
|
||||
#
|
||||
# The toast shows the claimed NAME and the fingerprint TAIL together, and always both: the name is
|
||||
# what the device says it is and can be anything, the tail is what it cannot forge. Approving is a
|
||||
# deliberate act — this notification never approves anything by itself, it only takes you to where
|
||||
# you can.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
name="${PF_EVENT_DEVICE_NAME:-${PF_EVENT_CLIENT_NAME:-a device}}"
|
||||
fp="${PF_EVENT_DEVICE_FINGERPRINT:-${PF_EVENT_FINGERPRINT:-}}"
|
||||
id="${PF_EVENT_DEVICE_ID:-${PF_EVENT_ID:-}}"
|
||||
tail="${fp: -10}"
|
||||
|
||||
body="\"$name\" wants to pair"
|
||||
# `|| true`: under `set -e` a false `[[ … ]]` used as a STATEMENT exits the script — and a hook
|
||||
# that exits before its own notification is a pairing request nobody ever sees.
|
||||
[[ -n "$tail" ]] && body="$body · …$tail" || true
|
||||
|
||||
command -v omarchy-notification-send >/dev/null || exit 0
|
||||
|
||||
# `--exec` gives the toast a button. It approves by ID, never "the newest request": two devices
|
||||
# knocking at once is exactly when a "newest" shortcut admits the wrong one.
|
||||
if [[ -n "$id" ]] && command -v punktfunk-host >/dev/null; then
|
||||
omarchy-notification-send \
|
||||
--urgency=critical \
|
||||
--icon=network-wireless \
|
||||
"Punktfunk pairing request" "$body" \
|
||||
--exec "Approve:punktfunk-host ctl approve $id" \
|
||||
--exec "Deny:punktfunk-host ctl deny $id"
|
||||
else
|
||||
omarchy-notification-send \
|
||||
--urgency=critical \
|
||||
--icon=network-wireless \
|
||||
"Punktfunk pairing request" "$body — approve it in the console"
|
||||
fi
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# punktfunk hook: a stream started → a low-urgency Omarchy toast saying who and at what.
|
||||
# Wired as a `stream.started` entry in ~/.config/punktfunk/hooks.json by `punktfunk-omarchy setup`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
client="${PF_EVENT_STREAM_CLIENT:-a client}"
|
||||
mode="${PF_EVENT_STREAM_MODE:-}"
|
||||
app="${PF_EVENT_STREAM_APP:-}"
|
||||
|
||||
body="$client connected"
|
||||
# `|| true`: a false `[[ … ]]` as a statement exits a `set -e` script, and a hook that exits early
|
||||
# is a toast that never appears.
|
||||
[[ -n "$mode" ]] && body="$body · $mode" || true
|
||||
[[ "${PF_EVENT_STREAM_HDR:-false}" == "true" ]] && body="$body HDR" || true
|
||||
[[ -n "$app" ]] && body="$body · $app" || true
|
||||
|
||||
command -v omarchy-notification-send >/dev/null || exit 0
|
||||
omarchy-notification-send --urgency=low --icon=video-display "Punktfunk streaming" "$body"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# punktfunk hook: a stream ended → a low-urgency Omarchy toast.
|
||||
# Wired as a `stream.stopped` entry in ~/.config/punktfunk/hooks.json by `punktfunk-omarchy setup`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
client="${PF_EVENT_STREAM_CLIENT:-a client}"
|
||||
command -v omarchy-notification-send >/dev/null || exit 0
|
||||
omarchy-notification-send --urgency=low --icon=video-display "Punktfunk" "$client disconnected"
|
||||
Executable
+619
@@ -0,0 +1,619 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# punktfunk-omarchy — wire a punktfunk host into an Omarchy 4.x box, the Omarchy way.
|
||||
#
|
||||
# punktfunk-omarchy setup enable, open the firewall, add the console to the app menu
|
||||
# punktfunk-omarchy remove reverse every one of those, leaving nothing behind
|
||||
# punktfunk-omarchy status what is on, what is not, and what to do next
|
||||
#
|
||||
# Modelled on `omarchy-install-service-sunshine`, which is the shape an Omarchy user already knows:
|
||||
# LAN-scoped ufw rules tagged with a comment, a user systemd unit, a webapp entry in the menu. The
|
||||
# package installs this script but never RUNS it — a package that opened firewall ports or edited a
|
||||
# user's config on install would be doing both behind the operator's back, and consent here is the
|
||||
# whole point. Every step is idempotent, and `remove` reverses each one exactly.
|
||||
#
|
||||
# Deliberately NOT a `punktfunk-host` subcommand: everything here is shell-level system wiring
|
||||
# (systemctl, ufw, omarchy's own tools) that the host binary has no business knowing about, and a
|
||||
# user can read and audit a shell script before running it as themselves.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly TAG="punktfunk-omarchy" # ufw comment tag: how `remove` finds our rules
|
||||
readonly WEBAPP="Punktfunk Console"
|
||||
readonly HOOKS_SRC="/usr/share/punktfunk/omarchy"
|
||||
readonly HOST_ENV="${XDG_CONFIG_HOME:-$HOME/.config}/punktfunk/host.env"
|
||||
readonly DROPIN_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/punktfunk-host.service.d"
|
||||
readonly DROPIN="$DROPIN_DIR/desktop-session.conf"
|
||||
|
||||
# ── output ─────────────────────────────────────────────────────────────────────────────────────
|
||||
if [[ -t 1 ]]; then B=$'\e[1m'; G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; N=$'\e[0m'
|
||||
else B=""; G=""; Y=""; R=""; N=""; fi
|
||||
say() { printf '%s\n' "$*"; }
|
||||
step() { printf '%s==>%s %s\n' "$B" "$N" "$*"; }
|
||||
ok() { printf ' %s✓%s %s\n' "$G" "$N" "$*"; }
|
||||
warn() { printf ' %s!%s %s\n' "$Y" "$N" "$*"; }
|
||||
bad() { printf ' %s✗%s %s\n' "$R" "$N" "$*"; }
|
||||
die() { printf '%serror:%s %s\n' "$R" "$N" "$*" >&2; exit 1; }
|
||||
|
||||
ask() { # ask "question" -> 0 on yes. Defaults to NO, and to no in a non-interactive shell.
|
||||
[[ -t 0 ]] || return 1
|
||||
local reply
|
||||
read -r -p " $1 [y/N] " reply
|
||||
[[ "$reply" == [yY] || "$reply" == [yY][eE][sS] ]]
|
||||
}
|
||||
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# ── guards ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# `ID=omarchy` in os-release, which Omarchy rewrites on every `omarchy-settings` upgrade — the same
|
||||
# fact the host itself detects on. Everything below assumes Omarchy's own tools and layout, so on
|
||||
# any other distro this script would be quietly wrong rather than merely unnecessary.
|
||||
require_omarchy() {
|
||||
local id=""
|
||||
[[ -r /etc/os-release ]] && id="$(. /etc/os-release 2>/dev/null; printf '%s' "${ID:-}")"
|
||||
[[ "$id" == "omarchy" ]] || die "this is not an Omarchy box (os-release ID='${id:-unknown}').
|
||||
On plain Arch, install the package and follow docs/arch — you already have everything this
|
||||
script would set up, minus the Omarchy-specific parts."
|
||||
[[ $EUID -ne 0 ]] || die "run this as your normal user, not root — it enables USER services and
|
||||
edits YOUR config. It will ask for sudo where it genuinely needs it (ufw, group membership)."
|
||||
}
|
||||
|
||||
# ── ufw (D4) ───────────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# The Omarchy idiom is not "open a port to the world" but "open it to the networks you are actually
|
||||
# on", tagged so it can be found again — exactly what their Sunshine installer does. We reuse the
|
||||
# app profiles the package already ships (/etc/ufw/applications.d/punktfunk), so the port list lives
|
||||
# in ONE place and this script never repeats it.
|
||||
|
||||
lan_sources() { # the CIDRs (and tailscale) a rule should accept from
|
||||
printf '%s\n' 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12
|
||||
ip link show tailscale0 >/dev/null 2>&1 && printf '%s\n' tailscale0
|
||||
}
|
||||
|
||||
ufw_rule() { # ufw_rule <profile> <source> — idempotent (ufw dedupes identical rules)
|
||||
local profile="$1" src="$2"
|
||||
if [[ "$src" == tailscale0 ]]; then
|
||||
sudo ufw allow in on tailscale0 to any app "$profile" comment "$TAG" >/dev/null
|
||||
else
|
||||
sudo ufw allow from "$src" to any app "$profile" comment "$TAG" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
ufw_unrule() {
|
||||
local profile="$1" src="$2"
|
||||
if [[ "$src" == tailscale0 ]]; then
|
||||
sudo ufw --force delete allow in on tailscale0 to any app "$profile" >/dev/null 2>&1 || true
|
||||
else
|
||||
sudo ufw --force delete allow from "$src" to any app "$profile" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
open_firewall() {
|
||||
have ufw || { warn "ufw is not installed — nothing to open"; return 0; }
|
||||
[[ -r /etc/ufw/applications.d/punktfunk ]] || {
|
||||
warn "the punktfunk ufw profiles are missing (is punktfunk-host installed from the package?)"
|
||||
return 0
|
||||
}
|
||||
local profiles=(punktfunk-native)
|
||||
# The web console only if it is actually installed — opening a port nothing listens on is noise.
|
||||
# (`|| true` throughout this script wherever a bare `[[ … ]] && …` is a STATEMENT: under `set -e`
|
||||
# a false test is a non-zero exit status, and the script would abort mid-setup with no message.)
|
||||
[[ -f /usr/lib/systemd/user/punktfunk-web.service ]] && profiles+=(punktfunk-web) || true
|
||||
# GameStream stays CLOSED unless the operator turned the plane on. Its ports are exactly the ones
|
||||
# `omarchy install service sunshine` claims, so opening them by default invites a bind conflict
|
||||
# and a confusing "which host answered?" on a box that has both.
|
||||
if grep -qs '^PUNKTFUNK_GAMESTREAM=1' "$HOST_ENV"; then
|
||||
profiles+=(punktfunk-gamestream)
|
||||
fi
|
||||
local src
|
||||
for p in "${profiles[@]}"; do
|
||||
while read -r src; do ufw_rule "$p" "$src"; done < <(lan_sources)
|
||||
ok "ufw: $p opened to local networks (tagged \"$TAG\")"
|
||||
done
|
||||
sudo ufw reload >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
close_firewall() {
|
||||
have ufw || return 0
|
||||
local src
|
||||
for p in punktfunk-native punktfunk-web punktfunk-gamestream; do
|
||||
while read -r src; do ufw_unrule "$p" "$src"; done < <(lan_sources)
|
||||
done
|
||||
sudo ufw reload >/dev/null 2>&1 || true
|
||||
ok "ufw: punktfunk rules removed"
|
||||
}
|
||||
|
||||
# ── setup ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
setup_groups() {
|
||||
step "Groups"
|
||||
local relogin=0
|
||||
for g in input punktfunk; do
|
||||
getent group "$g" >/dev/null 2>&1 || { warn "group '$g' does not exist — skipping"; continue; }
|
||||
if id -nG "$USER" | tr ' ' '\n' | grep -qx "$g"; then
|
||||
ok "already in '$g'"
|
||||
elif ask "add $USER to the '$g' group?"; then
|
||||
if sudo usermod -aG "$g" "$USER"; then
|
||||
ok "added to '$g'"; relogin=1
|
||||
else
|
||||
bad "could not add you to '$g' — add it by hand: sudo usermod -aG $g $USER"
|
||||
fi
|
||||
else
|
||||
warn "skipped '$g'"
|
||||
fi
|
||||
done
|
||||
# Group membership is applied at LOGIN. The host reads its groups when it starts, so a session
|
||||
# that has not been restarted still runs with the old set — this is the single most common
|
||||
# "I already added myself!" support state there is.
|
||||
[[ $relogin -eq 1 ]] && warn "log out and back in before streaming — new groups apply at login"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Omarchy ships TWO session entries and they behave differently for us:
|
||||
#
|
||||
# hyprland-uwsm.desktop "Hyprland (uwsm-managed)" → uwsm starts graphical-session.target
|
||||
# hyprland.desktop "Hyprland" → start-hyprland, target NEVER starts
|
||||
#
|
||||
# On the plain one, `graphical-session.target` stays inactive — measured on 4.0.1, where Omarchy's
|
||||
# OWN units that want it (omarchy-crash-watch, omarchy-sleep-lock) are `enabled` and `dead` too.
|
||||
# So the drop-in cannot be described as "the host restarts with your session" without checking.
|
||||
session_is_systemd_managed() {
|
||||
systemctl --user is-active --quiet graphical-session.target
|
||||
}
|
||||
|
||||
setup_units() {
|
||||
step "Autostart"
|
||||
# Install the drop-in either way: it is additive (the base unit keeps WantedBy=default.target, so
|
||||
# the host still comes up at login), it is inert while the target never starts, and it becomes
|
||||
# correct by itself if the operator later picks the uwsm session. What changes is only what we
|
||||
# CLAIM, because "it restarts with your session" is the kind of promise that gets discovered to be
|
||||
# false at the worst moment.
|
||||
mkdir -p "$DROPIN_DIR"
|
||||
if [[ -r /usr/share/punktfunk/punktfunk-host-desktop-session.conf ]]; then
|
||||
install -Dm0644 /usr/share/punktfunk/punktfunk-host-desktop-session.conf "$DROPIN"
|
||||
if session_is_systemd_managed; then
|
||||
ok "bound punktfunk-host to graphical-session.target — it restarts with your session"
|
||||
else
|
||||
ok "punktfunk-host will start at login"
|
||||
warn "this session is not systemd-managed, so the host will NOT restart when the session does"
|
||||
say " Your session is \"Hyprland\"; picking \"Hyprland (uwsm-managed)\" at the login screen"
|
||||
say " starts graphical-session.target and gets you that. It is rarely worth it here: the"
|
||||
say " host re-derives the live compositor on every connect, which is the failure this"
|
||||
say " binding guards against on KDE and GNOME."
|
||||
fi
|
||||
else
|
||||
warn "the session drop-in template is missing from the package — skipping"
|
||||
fi
|
||||
systemctl --user daemon-reload
|
||||
# `enable --now` can fail for reasons worth SEEING (a masked unit, a missing binary, a user
|
||||
# manager with no session bus). Under `set -e` an unguarded `&&` would abort the whole setup here
|
||||
# with no message at all — which is the one outcome that leaves an operator with no idea what ran.
|
||||
if systemctl --user enable --now punktfunk-host.service; then
|
||||
ok "punktfunk-host enabled and started"
|
||||
else
|
||||
bad "punktfunk-host did not start — see: systemctl --user status punktfunk-host"
|
||||
fi
|
||||
if [[ -f /usr/lib/systemd/user/punktfunk-web.service ]]; then
|
||||
if systemctl --user enable --now punktfunk-web.service; then
|
||||
ok "punktfunk-web enabled and started"
|
||||
else
|
||||
bad "punktfunk-web did not start — see: systemctl --user status punktfunk-web"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
setup_webapp() {
|
||||
step "App menu"
|
||||
have omarchy-webapp-install || { warn "omarchy-webapp-install not found — skipping"; return 0; }
|
||||
# Sunshine's own installer takes the same --ignore-certificate-errors: the console presents the
|
||||
# host's self-signed certificate, which no browser will ever accept on a LAN name.
|
||||
#
|
||||
# The icon is an ICON NAME, not a path — the desktop spec resolves it through the icon theme, and
|
||||
# the scalable `io.unom.Punktfunk` the host package ships scales to whatever size the launcher
|
||||
# asks for. Passing a path to a file that did not exist is how the first version produced an
|
||||
# entry with a blank `Icon=` and no picture in the launcher.
|
||||
# ⚠ `omarchy-webapp-install` takes an icon URL and derives a name with `${ref%.*}` + slugify, so a
|
||||
# DOTTED theme name comes out mangled — `io.unom.Punktfunk` became `Icon=io-unom`, which resolves
|
||||
# to nothing. Their tool is built for downloading a site's favicon; ours is already installed by
|
||||
# the package. So let it create the entry, then set the two lines we own (below), which is what
|
||||
# we already do for Exec.
|
||||
local icon=io.unom.Punktfunk
|
||||
find /usr/share/icons -name "${icon}.*" -print -quit 2>/dev/null | grep -q . \
|
||||
|| icon=punktfunk-tray # older package: the tray mark is the only one installed
|
||||
if omarchy-webapp-install "$WEBAPP" "https://localhost:47992" "$icon" \
|
||||
--ignore-certificate-errors 2>/dev/null; then
|
||||
ok "\"$WEBAPP\" added to Apps (Super+Space)"
|
||||
# Point the entry at a one-shot ticket instead of the bare URL, so opening the console from the
|
||||
# launcher lands logged in. `ctl console-url` signs it with the management token, which only
|
||||
# this uid can read — a LAN visitor still gets the login page.
|
||||
local entry
|
||||
entry=$(grep -rl "^Name=$WEBAPP$" "$HOME/.local/share/applications" 2>/dev/null | head -1)
|
||||
if [[ -n "$entry" ]] && have punktfunk-host; then
|
||||
sed -i "s|^Exec=.*|Exec=sh -c 'omarchy-launch-webapp \"\$(punktfunk-host ctl console-url)\"'|" "$entry"
|
||||
sed -i "s|^Icon=.*|Icon=$icon|" "$entry"
|
||||
grep -q '^Icon=' "$entry" || printf 'Icon=%s\n' "$icon" >> "$entry"
|
||||
ok "the console entry opens already logged in, with the Punktfunk mark"
|
||||
fi
|
||||
else
|
||||
warn "could not add the webapp — add it by hand from the Omarchy menu if you want it"
|
||||
fi
|
||||
setup_menu
|
||||
}
|
||||
|
||||
# ── the Omarchy menu (Super+Space) ─────────────────────────────────────────────────────────────
|
||||
#
|
||||
# The rows we add, as JSONC lines. Dotted ids define the tree, so `punktfunk` is a root submenu and
|
||||
# `punktfunk.*` are its children. `when` hides a row the box cannot honour, which is what keeps the
|
||||
# menu honest on a machine where the host is not running.
|
||||
# A function, not a `readonly` computed at load time: the rest of this script resolves config paths
|
||||
# where they are USED (see `write_hooks`), and a load-time constant silently ignores an
|
||||
# `XDG_CONFIG_HOME` set afterwards — which is exactly how the self-check ended up editing the real
|
||||
# config of the machine running it instead of its own scratch directory.
|
||||
menu_file() { printf '%s/omarchy/extensions/omarchy-menu.jsonc' "${XDG_CONFIG_HOME:-$HOME/.config}"; }
|
||||
readonly MENU_BEGIN="// >>> punktfunk (managed by punktfunk-omarchy — do not edit between these markers)"
|
||||
readonly MENU_END="// <<< punktfunk"
|
||||
|
||||
menu_rows() {
|
||||
cat <<'EOF'
|
||||
"punktfunk": {"icon":"","label":"Punktfunk","aliases":["streaming","stream"]},
|
||||
"punktfunk.console": {"icon":"","label":"Open console","description":"Pairing, devices, settings — opens already logged in","action":"sh -c 'omarchy-launch-webapp \"$(punktfunk-host ctl console-url)\"'"},
|
||||
"punktfunk.pair": {"icon":"","label":"Pair a device","description":"Open a pairing window and show the PIN","action":"omarchy-launch-or-focus-tui \"bash -c 'punktfunk-host ctl pair arm; echo; read -n1 -r -p \\\"Press any key…\\\"'\""},
|
||||
"punktfunk.pending": {"icon":"","label":"Devices waiting","description":"Approve or deny a device that is asking to pair","action":"omarchy-launch-or-focus-tui \"bash -c 'punktfunk-host ctl pending; echo; read -n1 -r -p \\\"Press any key…\\\"'\""},
|
||||
"punktfunk.devices": {"icon":"","label":"Paired devices","action":"omarchy-launch-or-focus-tui \"bash -c 'punktfunk-host ctl clients; echo; read -n1 -r -p \\\"Press any key…\\\"'\""},
|
||||
"punktfunk.stop": {"icon":"","label":"Stop the session","when":"punktfunk-host ctl status --json | grep -q '\"active_sessions\":[1-9]'","action":"punktfunk-host ctl stop-session"},
|
||||
"punktfunk.status": {"icon":"","label":"Status","action":"omarchy-launch-or-focus-tui \"bash -c 'punktfunk-omarchy status; echo; read -n1 -r -p \\\"Press any key…\\\"'\""},
|
||||
"punktfunk.restart": {"icon":"","label":"Restart the host","action":"systemctl --user restart punktfunk-host"},
|
||||
EOF
|
||||
}
|
||||
|
||||
# Strip JSONC to JSON so python can parse it: line comments, then trailing commas.
|
||||
menu_is_valid() {
|
||||
sed -E 's://.*$::' "$1" \
|
||||
| python3 -c 'import json,re,sys; json.loads(re.sub(r",(\s*[}\]])", r"\1", sys.stdin.read()))' 2>/dev/null
|
||||
}
|
||||
|
||||
setup_menu() {
|
||||
step "Omarchy menu"
|
||||
local MENU_FILE; MENU_FILE="$(menu_file)"
|
||||
mkdir -p "$(dirname "$MENU_FILE")"
|
||||
[[ -f "$MENU_FILE" ]] || printf '{\n}\n' > "$MENU_FILE"
|
||||
|
||||
# 🛑 This file is a SINGLE document, and one parse error drops EVERY row the user owns — not just
|
||||
# ours. So: work on a copy, validate it, and only then move it into place. A file we cannot parse
|
||||
# to begin with is left completely alone; it is not ours to repair.
|
||||
if ! menu_is_valid "$MENU_FILE"; then
|
||||
warn "$MENU_FILE does not parse as JSONC — leaving it alone"
|
||||
say " Fix it, then re-run: punktfunk-omarchy setup"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local tmp; tmp="$(mktemp "${MENU_FILE}.punktfunk-XXXXXX")"
|
||||
# Drop any previous block of ours, then insert the current one before the closing brace, so this
|
||||
# is idempotent and an upgrade replaces the rows rather than stacking a second copy.
|
||||
awk -v b="$MENU_BEGIN" -v e="$MENU_END" '
|
||||
index($0,b){skip=1} skip && index($0,e){skip=0; next} skip{next} {print}
|
||||
' "$MENU_FILE" > "$tmp"
|
||||
|
||||
# Insert before the document's LAST closing brace. Deliberately not `awk -v rows=…`: awk refuses
|
||||
# a newline inside a -v assignment ("newline in string"), so the rows silently never landed and
|
||||
# the file came back unchanged while this reported success.
|
||||
local close
|
||||
close=$(grep -n '^[[:space:]]*}[[:space:]]*$' "$tmp" | tail -1 | cut -d: -f1)
|
||||
if [[ -z "$close" ]]; then
|
||||
rm -f "$tmp"
|
||||
warn "could not find the closing brace in $MENU_FILE — leaving it alone"
|
||||
return 0
|
||||
fi
|
||||
{
|
||||
head -n "$((close - 1))" "$tmp"
|
||||
printf '%s\n' "$MENU_BEGIN"
|
||||
menu_rows
|
||||
printf '%s\n' "$MENU_END"
|
||||
tail -n "+$close" "$tmp"
|
||||
} > "$tmp.2"
|
||||
|
||||
# Both halves of "it worked": it still parses, AND our rows are actually in it. The first alone
|
||||
# is satisfied by a no-op edit, which is exactly how the awk bug above reported a false success.
|
||||
if menu_is_valid "$tmp.2" && grep -q '"punktfunk.console"' "$tmp.2"; then
|
||||
mv -f "$tmp.2" "$MENU_FILE"
|
||||
rm -f "$tmp"
|
||||
ok "Punktfunk added to the Omarchy menu (Super+Space → Punktfunk)"
|
||||
else
|
||||
rm -f "$tmp" "$tmp.2"
|
||||
bad "the menu edit would not have parsed — your file is untouched"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_menu() {
|
||||
local MENU_FILE; MENU_FILE="$(menu_file)"
|
||||
[[ -f "$MENU_FILE" ]] || return 0
|
||||
grep -qF "$MENU_BEGIN" "$MENU_FILE" || return 0
|
||||
local tmp; tmp="$(mktemp "${MENU_FILE}.punktfunk-XXXXXX")"
|
||||
awk -v b="$MENU_BEGIN" -v e="$MENU_END" '
|
||||
index($0,b){skip=1} skip && index($0,e){skip=0; next} skip{next} {print}
|
||||
' "$MENU_FILE" > "$tmp"
|
||||
if menu_is_valid "$tmp"; then mv -f "$tmp" "$MENU_FILE"; ok "menu entries removed"
|
||||
else rm -f "$tmp"; warn "could not remove the menu entries cleanly — edit $MENU_FILE by hand"; fi
|
||||
}
|
||||
|
||||
# Hooks live in ~/.config/punktfunk/hooks.json — a LIST of {on, run} entries the host reads per
|
||||
# event (automation.md). We write that file only when the operator has none: merging JSON in bash
|
||||
# would need jq and, worse, would edit a document that is theirs. If they already have hooks, we
|
||||
# print exactly what to add and let them own it.
|
||||
# Every entry line carries a trailing comma; `write_hooks` strips the last one. That is what lets
|
||||
# the blocks below be concatenated in any combination without producing invalid JSON.
|
||||
hooks_json() {
|
||||
cat <<EOF
|
||||
{ "on": "pairing.pending", "run": "$HOOKS_SRC/hooks/pairing-pending", "debounce_ms": 2000 },
|
||||
{ "on": "stream.started", "run": "$HOOKS_SRC/hooks/stream-started" },
|
||||
{ "on": "stream.stopped", "run": "$HOOKS_SRC/hooks/stream-stopped" },
|
||||
EOF
|
||||
}
|
||||
|
||||
idle_hooks_json() {
|
||||
cat <<EOF
|
||||
{ "on": "stream.started", "run": "$HOOKS_SRC/hooks/idle-guard on" },
|
||||
{ "on": "stream.stopped", "run": "$HOOKS_SRC/hooks/idle-guard off" },
|
||||
EOF
|
||||
}
|
||||
|
||||
# Write hooks.json from the selected blocks, or print them when the operator already has one.
|
||||
# `$1..` are the names of functions producing entry blocks.
|
||||
write_hooks() {
|
||||
local blocks=() f
|
||||
for f in "$@"; do blocks+=("$($f)"); done
|
||||
local body; body="$(printf '%s\n' "${blocks[@]}" | sed '$ s/,[[:space:]]*$//')"
|
||||
local file="${XDG_CONFIG_HOME:-$HOME/.config}/punktfunk/hooks.json"
|
||||
if [[ -s "$file" ]]; then
|
||||
warn "you already have $file — not touching it. Add these entries to its \"hooks\" list:"
|
||||
printf '%s\n' "$body" | sed 's/^/ /'
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$(dirname "$file")"
|
||||
{ echo '{'; echo ' "hooks": ['; printf '%s\n' "$body"; echo ' ]'; echo '}'; } > "$file"
|
||||
chmod 0600 "$file"
|
||||
ok "hooks written to $file"
|
||||
# The host verifies that a hook SCRIPT path (and every directory above it) is operator- or
|
||||
# root-owned and not group/world-writable before running it — the sshd/sudoers rule. Ours live
|
||||
# under /usr/share, so they pass; a copy into $HOME would too, but only if $HOME is not group-
|
||||
# writable. Say it here rather than let a hook refuse to run with no obvious cause.
|
||||
ok "hooks applied immediately — the host re-reads this file per event, no restart needed"
|
||||
}
|
||||
|
||||
setup_hooks() {
|
||||
step "Notifications"
|
||||
[[ -d "$HOOKS_SRC/hooks" ]] || { warn "no sample hooks in $HOOKS_SRC — skipping"; return 0; }
|
||||
# The host already emits every lifecycle event and already runs hook commands (automation.md);
|
||||
# these scripts are the glue to `omarchy-notification-send`, so the toasts get the user's theme
|
||||
# and Omarchy's own action buttons for free and the host gains no notification code at all.
|
||||
ask "send Omarchy toasts for pairing requests and stream start/stop?" && WANT_TOASTS=1 || warn "skipped"
|
||||
}
|
||||
|
||||
setup_idle() {
|
||||
step "Idle"
|
||||
[[ -x "$HOOKS_SRC/hooks/idle-guard" ]] || { warn "no idle hook in $HOOKS_SRC — skipping"; return 0; }
|
||||
say " During an input-quiet stream (a film, a cutscene) Omarchy's idle timer still counts down,"
|
||||
say " because nothing is moving the pointer here. This keeps the box awake for the length of a"
|
||||
say " session and puts your own setting back afterwards — it never forces 'allow idle' on you."
|
||||
ask "keep the screen awake while a stream is running?" && WANT_IDLE=1 || warn "skipped"
|
||||
}
|
||||
|
||||
setup_theme() {
|
||||
step "Theme"
|
||||
[[ -r "$HOOKS_SRC/themed/punktfunk.json.tpl" ]] || { warn "no theme template — skipping"; return 0; }
|
||||
ask "follow the Omarchy theme in the web console?" || { warn "skipped"; return 0; }
|
||||
local themed="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/themed"
|
||||
mkdir -p "$themed"
|
||||
install -m0644 "$HOOKS_SRC/themed/punktfunk.json.tpl" "$themed/punktfunk.json.tpl"
|
||||
ok "theme template installed — it renders on every omarchy-theme-set"
|
||||
}
|
||||
|
||||
check_conflicts() {
|
||||
step "Other streaming hosts"
|
||||
have punktfunk-host || return 0
|
||||
if punktfunk-host detect-conflicts >/dev/null 2>&1; then
|
||||
ok "nothing else is streaming from this box"
|
||||
return 0
|
||||
fi
|
||||
punktfunk-host detect-conflicts 2>&1 | sed 's/^/ /'
|
||||
say ""
|
||||
say " Sunshine and punktfunk COEXIST on the native plane: 9777/47990/47992 are ours alone, so"
|
||||
say " the punktfunk client and the web console work with Sunshine running and untouched."
|
||||
say " They COLLIDE on the GameStream/Moonlight ports (47984/47989/48010, 47998-48010) — ours are"
|
||||
say " off by default, so leave them off unless you are migrating."
|
||||
say " To migrate: ${B}omarchy remove service sunshine${N}, then re-run this setup."
|
||||
}
|
||||
|
||||
cmd_setup() {
|
||||
require_omarchy
|
||||
step "Punktfunk on Omarchy"
|
||||
have punktfunk-host || die "punktfunk-host is not installed. Add the [punktfunk] repo and
|
||||
sudo pacman -S punktfunk-host punktfunk-web
|
||||
then run this again. See https://punktfunk.com/docs/omarchy"
|
||||
setup_groups
|
||||
setup_units
|
||||
step "Firewall"
|
||||
open_firewall
|
||||
setup_webapp
|
||||
# Both opt-ins land in ONE hooks.json, so they are asked for first and written once — two passes
|
||||
# over the same file is how the second one ends up printing "you already have hooks.json".
|
||||
WANT_TOASTS=0; WANT_IDLE=0
|
||||
setup_hooks
|
||||
setup_idle
|
||||
if [[ $WANT_TOASTS -eq 1 || $WANT_IDLE -eq 1 ]]; then
|
||||
local blocks=()
|
||||
[[ $WANT_TOASTS -eq 1 ]] && blocks+=(hooks_json) || true
|
||||
[[ $WANT_IDLE -eq 1 ]] && blocks+=(idle_hooks_json) || true
|
||||
write_hooks "${blocks[@]}"
|
||||
fi
|
||||
setup_theme
|
||||
check_conflicts
|
||||
say ""
|
||||
cmd_status
|
||||
say ""
|
||||
say "${B}Next:${N} open ${B}$WEBAPP${N} from Apps (Super+Space) and pair your first device —"
|
||||
say "or do it without a browser: ${B}punktfunk-host ctl pair arm${N}, then ${B}punktfunk-host ctl pending${N}."
|
||||
}
|
||||
|
||||
# ── remove ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_remove() {
|
||||
require_omarchy
|
||||
step "Removing the Omarchy integration"
|
||||
systemctl --user disable --now punktfunk-host.service 2>/dev/null && ok "punktfunk-host stopped and disabled" || true
|
||||
systemctl --user disable --now punktfunk-web.service 2>/dev/null && ok "punktfunk-web stopped and disabled" || true
|
||||
if [[ -f "$DROPIN" ]]; then
|
||||
rm -f "$DROPIN"
|
||||
rmdir --ignore-fail-on-non-empty "$DROPIN_DIR" 2>/dev/null || true
|
||||
ok "session drop-in removed"
|
||||
fi
|
||||
systemctl --user daemon-reload
|
||||
close_firewall
|
||||
if have omarchy-webapp-remove; then
|
||||
omarchy-webapp-remove "$WEBAPP" >/dev/null 2>&1 && ok "\"$WEBAPP\" removed from Apps" || true
|
||||
else
|
||||
rm -f "$HOME/.local/share/applications/${WEBAPP// /}.desktop" 2>/dev/null || true
|
||||
fi
|
||||
remove_menu
|
||||
# Hooks: only remove a hooks.json that is entirely OURS. A file the operator has since edited is
|
||||
# theirs — deleting it would take their webhooks with it — so we say what to remove instead.
|
||||
local hf="${XDG_CONFIG_HOME:-$HOME/.config}/punktfunk/hooks.json"
|
||||
if [[ -f "$hf" ]]; then
|
||||
if ! grep -qv -e "$HOOKS_SRC" -e '^\s*[]{}[]' -e '"hooks"' "$hf"; then
|
||||
rm -f "$hf"; ok "notification and idle hooks removed"
|
||||
else
|
||||
warn "$hf has entries that are not ours — left alone. Remove the lines naming $HOOKS_SRC by hand."
|
||||
fi
|
||||
fi
|
||||
local tpl="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/themed/punktfunk.json.tpl"
|
||||
[[ -f "$tpl" ]] && { rm -f "$tpl"; ok "theme template removed"; } || true
|
||||
# The screen-share picker: the host hands `custom_picker_binary` back on its own when the last
|
||||
# cast ends, but a host that was killed mid-session never got to. Put it back from the marker the
|
||||
# host leaves in the file (see pf-vdisplay's portal_config) — a plain awk edit, no host needed.
|
||||
restore_picker
|
||||
say ""
|
||||
say "Your groups, your pairings and ~/.config/punktfunk are untouched — this removes the Omarchy"
|
||||
say "wiring, not punktfunk. To go all the way: sudo pacman -R punktfunk-host punktfunk-web"
|
||||
}
|
||||
|
||||
# Undo a leftover `custom_picker_binary` takeover in ~/.config/hypr/xdph.conf, using the
|
||||
# `# punktfunk: previous custom_picker_binary = <value>` marker the host writes beside it.
|
||||
restore_picker() {
|
||||
local f="${XDG_CONFIG_HOME:-$HOME/.config}/hypr/xdph.conf"
|
||||
[[ -r "$f" ]] || return 0
|
||||
grep -q '^\s*# punktfunk: previous custom_picker_binary =' "$f" || return 0
|
||||
local tmp; tmp="$(mktemp "${f}.punktfunk-XXXXXX")"
|
||||
awk '
|
||||
/^[[:space:]]*# punktfunk: previous custom_picker_binary =/ {
|
||||
line = $0
|
||||
sub(/^[[:space:]]*# punktfunk: previous custom_picker_binary =[[:space:]]*/, "", line)
|
||||
prior = line
|
||||
match($0, /^[[:space:]]*/); indent = substr($0, 1, RLENGTH)
|
||||
next
|
||||
}
|
||||
/^[[:space:]]*custom_picker_binary[[:space:]]*=/ {
|
||||
if (prior != "" && prior != "(none)") print indent "custom_picker_binary = " prior
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$f" > "$tmp" && mv -f "$tmp" "$f" && ok "screen-share picker restored in $f"
|
||||
rm -f "$tmp" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── status ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_status() {
|
||||
require_omarchy
|
||||
step "Status"
|
||||
local ver; ver="$(omarchy-version 2>/dev/null || true)"
|
||||
say " Omarchy ${ver:-unknown}"
|
||||
say " punktfunk $(punktfunk-host --version 2>/dev/null || echo 'not installed')"
|
||||
|
||||
systemctl --user is-active --quiet punktfunk-host.service \
|
||||
&& ok "host running" || bad "host not running (systemctl --user start punktfunk-host)"
|
||||
if [[ -f /usr/lib/systemd/user/punktfunk-web.service ]]; then
|
||||
systemctl --user is-active --quiet punktfunk-web.service \
|
||||
&& ok "web console running — https://localhost:47992" || warn "web console installed but not running"
|
||||
fi
|
||||
if [[ -f "$DROPIN" ]]; then
|
||||
if session_is_systemd_managed; then
|
||||
ok "restarts with the desktop session (graphical-session.target is up)"
|
||||
else
|
||||
warn "starts at login, but will not restart with the session (this session is not systemd-managed)"
|
||||
fi
|
||||
else
|
||||
warn "not bound to the desktop session"
|
||||
fi
|
||||
|
||||
if have ufw; then
|
||||
# `ufw status` needs root, and on a box where sudo asks for a password `sudo -n` simply fails.
|
||||
# Absence of OUTPUT must never be reported as absence of RULES: the first version of this said
|
||||
# "no punktfunk rules (run: punktfunk-omarchy setup)" immediately after setup had added six of
|
||||
# them, which sends an operator to re-run the thing that already worked.
|
||||
local ufw_out
|
||||
ufw_out="$(sudo -n ufw status 2>/dev/null)" || ufw_out=""
|
||||
if [[ -z "$ufw_out" ]]; then
|
||||
# NOT "sudo punktfunk-omarchy status": this script refuses to run as root (it reports on YOUR
|
||||
# units and YOUR groups, and root has neither), so that advice would be a dead end.
|
||||
say " ufw (needs root to read — check with: sudo ufw status | grep $TAG)"
|
||||
elif grep -q "$TAG" <<<"$ufw_out"; then
|
||||
ok "ufw: punktfunk rules present"
|
||||
else
|
||||
warn "ufw: no punktfunk rules (run: punktfunk-omarchy setup)"
|
||||
fi
|
||||
fi
|
||||
|
||||
for g in input punktfunk; do
|
||||
id -nG "$USER" | tr ' ' '\n' | grep -qx "$g" && ok "in group '$g'" || warn "not in group '$g'"
|
||||
done
|
||||
|
||||
have hyprctl && ok "Hyprland reachable" || warn "hyprctl not reachable (is this running inside the session?)"
|
||||
if have hyprctl; then
|
||||
# Silent denial is the failure mode this catches: with permissions enforced, screencopy and
|
||||
# virtual input are refused as BLACK FRAMES and DROPPED INPUT, never as an error.
|
||||
if hyprctl -j getoption ecosystem:enforce_permissions 2>/dev/null | grep -q '"int": *[1-9]'; then
|
||||
bad "Hyprland ecosystem.enforce_permissions is ON — streams may show black frames"
|
||||
say " add to ~/.config/hypr/hyprland.lua (or a permissions module):"
|
||||
say " o.permission(\"/usr/bin/punktfunk-host\", \"screencopy\", \"allow\")"
|
||||
say " o.permission(\"/usr/bin/punktfunk-host\", \"plugin\", \"allow\")"
|
||||
else
|
||||
ok "Hyprland permission enforcement off (nothing to grant)"
|
||||
fi
|
||||
fi
|
||||
# Ask systemd, not pgrep. `xdg-desktop-portal-hyprland` is 27 characters and Linux truncates a
|
||||
# process's `comm` to 15, so `pgrep -x` on that name can NEVER match — it reported the portal
|
||||
# missing on a box that was capturing through it a minute earlier. (pgrep itself warns about
|
||||
# this, to stderr, which a `>/dev/null 2>&1` check throws away.)
|
||||
if systemctl --user is-active --quiet xdg-desktop-portal-hyprland.service; then
|
||||
ok "xdg-desktop-portal-hyprland running"
|
||||
else
|
||||
warn "xdg-desktop-portal-hyprland not running (screen capture needs it)"
|
||||
fi
|
||||
|
||||
# The data plane is an EPHEMERAL UDP port, so there is no rule to add for it and `punched=false`
|
||||
# under ufw is expected, not broken: the host's first outbound packet creates the conntrack state
|
||||
# the return path rides. Say so here, because "hole punch failed" in a log reads like a fault.
|
||||
say ""
|
||||
say " The video data plane uses an ephemeral UDP port and needs no ufw rule — the host's own"
|
||||
say " outbound packet opens the return path. Pin it with PUNKTFUNK_DATA_PORT + one more rule"
|
||||
say " only if you want strict egress control (costs you concurrent sessions on that port)."
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
setup) cmd_setup ;;
|
||||
remove) cmd_remove ;;
|
||||
status) cmd_status ;;
|
||||
""|-h|--help|help)
|
||||
cat <<'EOF'
|
||||
punktfunk-omarchy — wire a punktfunk host into an Omarchy box
|
||||
|
||||
punktfunk-omarchy setup groups, autostart, ufw, app-menu entry, optional toasts/idle/theme
|
||||
punktfunk-omarchy remove reverse all of it (pairings and config are left alone)
|
||||
punktfunk-omarchy status what is on and what to do next
|
||||
|
||||
Docs: https://punktfunk.com/docs/omarchy
|
||||
EOF
|
||||
;;
|
||||
*) die "unknown command '$1' (try: setup | remove | status)" ;;
|
||||
esac
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Self-check for the two pieces of `punktfunk-omarchy` that parse or generate a file the USER owns:
|
||||
# the xdph picker restore (awk over ~/.config/hypr/xdph.conf) and the hooks.json generator. Both
|
||||
# are reachable only on an Omarchy box, which is exactly why they need a check that runs anywhere.
|
||||
#
|
||||
# bash packaging/linux/omarchy/selftest.sh
|
||||
#
|
||||
# Everything else in that script is systemctl/ufw/omarchy calls, which are the box's to answer.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
SCRIPT=./punktfunk-omarchy
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
fails=0
|
||||
|
||||
check() { # check <name> <expected-file> <actual-file>
|
||||
if diff -u "$2" "$3" >/dev/null; then
|
||||
printf ' ok %s\n' "$1"
|
||||
else
|
||||
printf ' FAIL %s\n' "$1"; diff -u "$2" "$3" | sed 's/^/ /'; fails=$((fails + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Source the script's functions without running its dispatcher: it dispatches on "$1", and "help"
|
||||
# only prints. `set +e` around it because the script itself sets -e.
|
||||
# shellcheck disable=SC1090
|
||||
source "$SCRIPT" help >/dev/null
|
||||
|
||||
echo "xdph picker restore"
|
||||
|
||||
# 1. The Omarchy case: they had their own picker, we took it over, `remove` puts it back verbatim.
|
||||
mkdir -p "$WORK/hypr"
|
||||
cat > "$WORK/hypr/xdph.conf" <<'EOF'
|
||||
screencopy {
|
||||
allow_token_by_default = true
|
||||
# punktfunk: previous custom_picker_binary = hyprland-preview-share-picker
|
||||
custom_picker_binary = /run/user/1000/punktfunk-xdph-picker.sh
|
||||
}
|
||||
EOF
|
||||
cat > "$WORK/expected" <<'EOF'
|
||||
screencopy {
|
||||
allow_token_by_default = true
|
||||
custom_picker_binary = hyprland-preview-share-picker
|
||||
}
|
||||
EOF
|
||||
XDG_CONFIG_HOME="$WORK" restore_picker >/dev/null
|
||||
check "their picker comes back and their other keys survive" "$WORK/expected" "$WORK/hypr/xdph.conf"
|
||||
|
||||
# 2. The key did not exist before us: restoring must REMOVE our line, not blank it or invent a value.
|
||||
cat > "$WORK/hypr/xdph.conf" <<'EOF'
|
||||
screencopy {
|
||||
# punktfunk: previous custom_picker_binary = (none)
|
||||
custom_picker_binary = /run/user/1000/punktfunk-xdph-picker.sh
|
||||
}
|
||||
EOF
|
||||
printf 'screencopy {\n}\n' > "$WORK/expected"
|
||||
XDG_CONFIG_HOME="$WORK" restore_picker >/dev/null
|
||||
check "a key we invented is removed, not blanked" "$WORK/expected" "$WORK/hypr/xdph.conf"
|
||||
|
||||
# 3. A config that was never ours must come through byte-identical — this runs on every `remove`.
|
||||
cat > "$WORK/hypr/xdph.conf" <<'EOF'
|
||||
screencopy {
|
||||
custom_picker_binary = hyprland-preview-share-picker
|
||||
}
|
||||
EOF
|
||||
cp "$WORK/hypr/xdph.conf" "$WORK/expected"
|
||||
XDG_CONFIG_HOME="$WORK" restore_picker >/dev/null
|
||||
check "a config without our marker is untouched" "$WORK/expected" "$WORK/hypr/xdph.conf"
|
||||
|
||||
# 4. No config at all: a no-op, and it must not CREATE one.
|
||||
rm -f "$WORK/hypr/xdph.conf"
|
||||
XDG_CONFIG_HOME="$WORK" restore_picker >/dev/null
|
||||
if [[ -e "$WORK/hypr/xdph.conf" ]]; then
|
||||
printf ' FAIL restoring created a config that did not exist\n'; fails=$((fails + 1))
|
||||
else
|
||||
printf ' ok an absent config stays absent\n'
|
||||
fi
|
||||
|
||||
echo "hooks.json"
|
||||
|
||||
# 5. Every combination of the two opt-ins must be valid JSON — the blocks are concatenated, so a
|
||||
# stray or missing comma between them is the failure mode.
|
||||
for combo in "hooks_json" "idle_hooks_json" "hooks_json idle_hooks_json"; do
|
||||
# shellcheck disable=SC2086
|
||||
XDG_CONFIG_HOME="$WORK/fresh-${combo// /-}" write_hooks $combo >/dev/null
|
||||
f="$WORK/fresh-${combo// /-}/punktfunk/hooks.json"
|
||||
if python3 -c "import json,sys; d=json.load(open(sys.argv[1])); assert d['hooks'] and all('on' in h and 'run' in h for h in d['hooks'])" "$f"; then
|
||||
printf ' ok valid JSON for [%s]\n' "$combo"
|
||||
else
|
||||
printf ' FAIL invalid JSON for [%s]\n' "$combo"; cat "$f"; fails=$((fails + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# 6. An existing hooks.json is the operator's document: print, never overwrite.
|
||||
mkdir -p "$WORK/mine/punktfunk"
|
||||
echo '{"hooks":[{"on":"stream.started","webhook":"https://example.invalid/x"}]}' > "$WORK/mine/punktfunk/hooks.json"
|
||||
cp "$WORK/mine/punktfunk/hooks.json" "$WORK/expected"
|
||||
XDG_CONFIG_HOME="$WORK/mine" write_hooks hooks_json >/dev/null
|
||||
check "an operator's own hooks.json is never overwritten" "$WORK/expected" "$WORK/mine/punktfunk/hooks.json"
|
||||
|
||||
echo "omarchy menu merge"
|
||||
|
||||
# The menu is a SINGLE JSONC document and one parse error drops every row the user owns, so the
|
||||
# merge gets the same scrutiny as the picker restore.
|
||||
menudir="$WORK/menu/omarchy/extensions"
|
||||
mkdir -p "$menudir"
|
||||
cat > "$menudir/omarchy-menu.jsonc" <<'EOF'
|
||||
{
|
||||
// a comment the user wrote
|
||||
"personal": {"icon":"","label":"Personal"},
|
||||
"personal.notes": {"icon":"","label":"Notes","action":"true"},
|
||||
}
|
||||
EOF
|
||||
cp "$menudir/omarchy-menu.jsonc" "$WORK/menu-before"
|
||||
|
||||
XDG_CONFIG_HOME="$WORK/menu" setup_menu >/dev/null 2>&1
|
||||
f="$menudir/omarchy-menu.jsonc"
|
||||
|
||||
if XDG_CONFIG_HOME="$WORK/menu" menu_is_valid "$f"; then
|
||||
printf ' ok the merged menu still parses as JSONC\n'
|
||||
else
|
||||
printf ' FAIL the merged menu does not parse\n'; cat "$f"; fails=$((fails + 1))
|
||||
fi
|
||||
if grep -q '"personal.notes"' "$f" && grep -q '"punktfunk.console"' "$f"; then
|
||||
printf " ok the user's rows survived and ours were added\n"
|
||||
else
|
||||
printf " FAIL rows lost in the merge\n"; fails=$((fails + 1))
|
||||
fi
|
||||
|
||||
# Idempotent: a second run must not stack a second copy.
|
||||
XDG_CONFIG_HOME="$WORK/menu" setup_menu >/dev/null 2>&1
|
||||
n=$(grep -c '"punktfunk.console"' "$f")
|
||||
if [[ "$n" == "1" ]]; then printf ' ok re-running does not duplicate the block\n'
|
||||
else printf ' FAIL block appears %s times after two runs\n' "$n"; fails=$((fails + 1)); fi
|
||||
|
||||
# And `remove` puts the file back exactly as the user had it.
|
||||
XDG_CONFIG_HOME="$WORK/menu" remove_menu >/dev/null 2>&1
|
||||
check "remove restores the user's file byte for byte" "$WORK/menu-before" "$f"
|
||||
|
||||
# A file that does not parse to begin with is not ours to repair — leave it untouched.
|
||||
printf '{ this is not json\n' > "$f"
|
||||
cp "$f" "$WORK/menu-broken"
|
||||
XDG_CONFIG_HOME="$WORK/menu" setup_menu >/dev/null 2>&1
|
||||
check "a config we cannot parse is left alone" "$WORK/menu-broken" "$f"
|
||||
|
||||
echo
|
||||
if [[ $fails -eq 0 ]]; then echo "all checks passed"; else echo "$fails check(s) failed"; exit 1; fi
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Punktfunk palette, rendered by Omarchy on every `omarchy-theme-set` from the active theme's",
|
||||
"semantic colors.toml into ~/.local/state/omarchy/current/theme/punktfunk.json.",
|
||||
"",
|
||||
"Installed by `punktfunk-omarchy setup` (optional), removed by `punktfunk-omarchy remove`.",
|
||||
"Consumer #1 is the web console, which uses ALL FOUR: mode and accent pick the palette and",
|
||||
"re-tint the brand (buttons, nav, focus rings, the lens mark), and every surface — cards,",
|
||||
"hovers, borders — is mixed out of the background/foreground pair, so the page belongs to the",
|
||||
"theme instead of merely agreeing with its accent. All three colours are required together: a",
|
||||
"file missing one is refused outright and the console keeps its own palette.",
|
||||
"The host reads nothing from this file — there is no host-side theme engine and no plan for one.",
|
||||
"",
|
||||
"The webapp window already inherits Omarchy's Chromium theming, so this only has to carry the",
|
||||
"colours the page itself paints."
|
||||
],
|
||||
"schema": 1,
|
||||
"mode": "{{ mode }}",
|
||||
"background": "{{ background }}",
|
||||
"foreground": "{{ foreground }}",
|
||||
"accent": "{{ accent }}"
|
||||
}
|
||||
@@ -160,12 +160,15 @@ let
|
||||
scoped "punktfunk-host" && scoped "punktfunk-web" && scoped "punktfunk-scripting";
|
||||
}
|
||||
{
|
||||
# web-init already carried a ConditionPathExists. That one is NON-triggering, so systemd
|
||||
# requires it AND at least one triggering user condition — adding ours must not drop it.
|
||||
name = "web-init keeps its path condition alongside the user scope";
|
||||
# web-init is the console's readiness gate (it blocks until the host has written the mgmt
|
||||
# token + identity cert), so it has to RUN on every start — not just the first. The
|
||||
# `ConditionPathExists=!…/web-password` it used to carry skipped it from the second boot
|
||||
# onward, which is exactly when the wait still matters; web-init.sh is idempotent instead.
|
||||
# Keep the user scope: root's instance has no business generating the operator's password.
|
||||
name = "web-init is user-scoped and NOT self-skipped by the password file";
|
||||
ok =
|
||||
has desktop "punktfunk-web-init" "ConditionUser=|alice"
|
||||
&& has desktop "punktfunk-web-init" "ConditionPathExists=!%h/.config/punktfunk/web-password";
|
||||
&& !(has desktop "punktfunk-web-init" "ConditionPathExists=");
|
||||
}
|
||||
{
|
||||
# With no host.users to name, still keep SYSTEM users (root) out, while leaving the module
|
||||
|
||||
@@ -613,16 +613,20 @@ in
|
||||
];
|
||||
};
|
||||
|
||||
# First-run setup: generate the console login password once, in the user's config dir, and
|
||||
# surface it to the --user journal. Self-gates via ConditionPathExists (mirrors
|
||||
# scripts/punktfunk-web-init.service).
|
||||
# Console pre-start: generate the login password once, in the user's config dir, surface it to
|
||||
# the --user journal, THEN block until the host has written the mgmt token + identity cert.
|
||||
# Being Type=oneshot, this is the readiness gate punktfunk-web's `after` already implies but
|
||||
# cannot enforce on its own (the host is Type=simple). Mirrors
|
||||
# scripts/punktfunk-web-init.service — keep both in step.
|
||||
systemd.user.services.punktfunk-web-init = {
|
||||
description = "punktfunk web console first-run setup (login password)";
|
||||
description = "punktfunk web console pre-start (login password; waits for the host)";
|
||||
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
||||
# ⚠ ConditionUser here is TRIGGERING (`|`) and ConditionPathExists is not, so systemd
|
||||
# requires the path condition AND at least one user condition — which is the intent.
|
||||
# NO ConditionPathExists on web-password: that skipped this unit from the second boot
|
||||
# onward, which is precisely when the host-readiness wait must still run. web-init.sh is
|
||||
# idempotent instead (it writes the password only when absent, and returns without sleeping
|
||||
# once the host's files exist). ConditionUser is TRIGGERING (`|`), so at least one must hold
|
||||
# — same shape as punktfunk-web below.
|
||||
unitConfig = {
|
||||
ConditionPathExists = "!%h/.config/punktfunk/web-password";
|
||||
ConditionUser = userScope;
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
@@ -635,27 +639,36 @@ in
|
||||
|
||||
# The console itself: Nitro SSR on bun, HTTPS on 47992 with the host's identity cert, proxying
|
||||
# the host's loopback mgmt API with the bearer token injected server-side. mgmt-token is
|
||||
# REQUIRED (the host's `serve` writes it) — if absent the unit fails and Restart retries until
|
||||
# the host has created it; web-password is optional ('-'). Mirrors scripts/punktfunk-web.service.
|
||||
# REQUIRED (the host's `serve` writes it) — a console with no token cannot reach the mgmt API
|
||||
# at all, so failing closed beats a login page that 401s on every call. punktfunk-web-init
|
||||
# waits for it, so this no longer fails on a fresh install's first start; if it is somehow
|
||||
# still absent the unit fails and Restart retries. web-password is optional ('-'). Mirrors
|
||||
# scripts/punktfunk-web.service.
|
||||
systemd.user.services.punktfunk-web = {
|
||||
description = "punktfunk management web console";
|
||||
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
||||
# Same scoping as the host: root's instance would take 47992 from the real one.
|
||||
unitConfig.ConditionUser = userScope;
|
||||
# punktfunk-web-init is the readiness gate (Type=oneshot, so ordering after it genuinely
|
||||
# waits). `punktfunk-host.service` alone is NOT one — the host is Type=simple, so systemd
|
||||
# calls it started the instant it is spawned, seconds before it writes the mgmt token and
|
||||
# its identity cert. Keep web-init in BOTH lists: the ordering is what makes it a gate.
|
||||
after = [
|
||||
"punktfunk-web-init.service"
|
||||
"punktfunk-host.service"
|
||||
];
|
||||
# wants, not requires: if the pre-start unit itself fails, still try — the Restart backstop
|
||||
# below recovers, where a hard dependency would leave the console down.
|
||||
wants = [ "punktfunk-web-init.service" ];
|
||||
wantedBy = optional cfg.web.autoStart "default.target";
|
||||
# Retry INDEFINITELY while the host is still writing the mgmt token + identity cert. The
|
||||
# EnvironmentFile below is mandatory on purpose, so the unit genuinely fails until those
|
||||
# exist — and systemd's default rate limit (5 starts / 10 s) against `RestartSec = 2` gives
|
||||
# up permanently after ~10 s, which on an appliance is exactly the window before the host's
|
||||
# first `serve` completes. A console enabled before the host's first run then stayed dead
|
||||
# until someone restarted it by hand. The shipped unit (scripts/punktfunk-web.service) has
|
||||
# carried this since that defect was found; it was missed in the port, while the comment
|
||||
# below went on promising the behaviour it removes.
|
||||
# Backstop for whatever the gate cannot cover (web-init timed out; the host restarted and
|
||||
# re-minted its identity under a running console; someone deleted a file). Retry
|
||||
# INDEFINITELY: the EnvironmentFile below is mandatory on purpose, so the unit genuinely
|
||||
# fails while the token is missing — and systemd's default rate limit (5 starts / 10 s)
|
||||
# against `RestartSec = 2` gives up permanently after ~10 s. A console that hit a missing
|
||||
# file then stayed dead until someone restarted it by hand. The shipped unit
|
||||
# (scripts/punktfunk-web.service) has carried this since that defect was found; it was
|
||||
# missed in the port, while the comment below went on promising the behaviour it removes.
|
||||
unitConfig.StartLimitIntervalSec = 0;
|
||||
environment = {
|
||||
# PUNKTFUNK_MGMT_URL is deliberately absent: the host publishes the port it actually bound
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
# 7. The installer under --dry-run against faked os-release files detects every family it claims
|
||||
# to (and --uninstall prints each family's removal) — the committed half of the manual
|
||||
# 16-file matrix PR #345 was verified with. Needs curl on PATH (the script's own prerequisite).
|
||||
# 8. scripts/web-init.sh — the web console's host-readiness gate — actually waits for the host's
|
||||
# first-run files and actually stops waiting when they land, against a faked config dir. Not
|
||||
# docs drift, but the same shape: a packaging script no build exercises, whose failure mode
|
||||
# (the console failing its first enable on a fresh install) only shows up on real glass.
|
||||
#
|
||||
# Textual gates, so textual limits: gate 2/3 match token spelling, not env reads — a var name in
|
||||
# a code comment counts as "exists", and a quoted constant that isn't an env var counts toward
|
||||
@@ -147,6 +151,11 @@ installer_case fedora 'ID=fedora\nVERSION_ID=44\n' 'sudo dn
|
||||
installer_case fedora43 'ID=fedora\nVERSION_ID=43\n' '/rpm/bazzite'
|
||||
installer_case arch 'ID=arch\n' 'sudo pacman -Syu --noconfirm punktfunk-host punktfunk-web punktfunk-scripting'
|
||||
installer_case cachyos 'ID=cachyos\nID_LIKE="arch"\n' 'sudo pacman -Syu --noconfirm punktfunk-host punktfunk-web punktfunk-scripting'
|
||||
# Omarchy: arch family, but its libalpm guard kills any -S+-u transaction, so the install must
|
||||
# split into -Sy then -S (and --yes still has to reach that -S), and the run must hand off to
|
||||
# `punktfunk-omarchy setup` rather than do a second, weaker version of the same wiring.
|
||||
installer_case omarchy 'ID=omarchy\nID_LIKE=arch\nVERSION_ID=4.0.1\n' 'sudo pacman -S --noconfirm punktfunk-host punktfunk-web punktfunk-scripting'
|
||||
installer_case omarchy2 'ID=omarchy\nID_LIKE=arch\nVERSION_ID=4.0.1\n' 'punktfunk-omarchy setup'
|
||||
installer_case bazzite 'ID=bazzite\nID_LIKE="fedora"\nVERSION_ID=43\n' 'punktfunk-sysext.sh install'
|
||||
installer_case nixos 'ID=nixos\n' 'docs/nixos'
|
||||
installer_case steamos 'ID=steamos\nID_LIKE=arch\n' 'docs/steamos-host'
|
||||
@@ -154,7 +163,146 @@ installer_case gentoo 'ID=gentoo\n' 'build-fr
|
||||
installer_case debian-rm 'ID=debian\nVERSION_ID=13\n' 'sources.list.d/punktfunk.list' --uninstall
|
||||
installer_case fedora-rm 'ID=fedora\nVERSION_ID=44\n' 'yum.repos.d/punktfunk.repo' --uninstall
|
||||
installer_case arch-rm 'ID=arch\n' '/etc/pacman.conf' --uninstall
|
||||
installer_case omarchy-rm 'ID=omarchy\nID_LIKE=arch\n' 'punktfunk-omarchy remove' --uninstall
|
||||
installer_case bazzite-rm 'ID=bazzite\nID_LIKE="fedora"\nVERSION_ID=43\n' 'punktfunk-sysext remove' --uninstall
|
||||
rm -rf "$osr"
|
||||
|
||||
# ---------------------------------------------------------------- gate 8: channel switching
|
||||
# A box that already has all three binaries, on a repo config naming one channel, told --channel
|
||||
# <the other>: it must rewrite the repo AND re-resolve in a direction the package manager would
|
||||
# otherwise refuse (canary is always a minor ahead of stable, so canary->stable is a downgrade).
|
||||
# Two fakes make that reachable under --dry-run: stub binaries on PATH for the "already installed"
|
||||
# probe, and PUNKTFUNK_INSTALL_ETC pointing at the repo config the box is supposedly on.
|
||||
sw=$(mktemp -d); mkdir -p "$sw/bin"
|
||||
for b in punktfunk-host punktfunk-web-server punktfunk-scripting; do
|
||||
printf '#!/bin/sh\necho 0.0.0-test\n' > "$sw/bin/$b"; chmod +x "$sw/bin/$b"
|
||||
done
|
||||
switch_case() { # name os-release-body config-path config-body expected-substring [extra args...]
|
||||
name=$1; printf '%b' "$2" > "$osr/$name"
|
||||
mkdir -p "$sw/$name/$(dirname "$3")"; printf '%b' "$4" > "$sw/$name/$3"; want=$5; shift 5
|
||||
out=$(PATH="$sw/bin:$PATH" PUNKTFUNK_INSTALL_OS_RELEASE="$osr/$name" PUNKTFUNK_INSTALL_ETC="$sw/$name" \
|
||||
sh scripts/install.sh --dry-run --yes --no-start "$@" 2>&1)
|
||||
case "$out" in *"$want"*) ;; *)
|
||||
echo "::error::scripts/install.sh --dry-run $* on an installed $name box did not print '$want':"
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
fail=1 ;;
|
||||
esac
|
||||
}
|
||||
APT_LIST=etc/apt/sources.list.d/punktfunk.list
|
||||
DEB13='ID=debian\nVERSION_ID=13\n'
|
||||
switch_case apt-up "$DEB13" "$APT_LIST" 'deb [x] https://git.unom.io/api/packages/unom/debian stable main\n' \
|
||||
'debian canary main' --channel canary
|
||||
switch_case apt-down "$DEB13" "$APT_LIST" 'deb [x] https://git.unom.io/api/packages/unom/debian canary main\n' \
|
||||
'--allow-downgrades' --channel stable
|
||||
# The regression this gate exists for. A canary box missing one of the three packages, re-run with
|
||||
# no --channel at all: the missing ones must come from CANARY. Letting the flag's stable default
|
||||
# win there rewrites the repo and drags the whole box back a channel without ever saying so.
|
||||
mkdir -p "$sw/partial/bin" "$sw/partial/$(dirname "$APT_LIST")"
|
||||
printf '#!/bin/sh\necho 0.0.0-test\n' > "$sw/partial/bin/punktfunk-host"
|
||||
chmod +x "$sw/partial/bin/punktfunk-host"
|
||||
printf 'deb [x] https://git.unom.io/api/packages/unom/debian canary main\n' > "$sw/partial/$APT_LIST"
|
||||
printf '%b' "$DEB13" > "$osr/apt-partial"
|
||||
out=$(PATH="$sw/partial/bin:$PATH" PUNKTFUNK_INSTALL_OS_RELEASE="$osr/apt-partial" \
|
||||
PUNKTFUNK_INSTALL_ETC="$sw/partial" sh scripts/install.sh --dry-run --yes --no-start 2>&1)
|
||||
case "$out" in *'debian canary main'*) ;; *)
|
||||
echo "::error::scripts/install.sh with no --channel, on a canary box missing packages, did not stay on canary:"
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
fail=1 ;;
|
||||
esac
|
||||
switch_case dnf-up 'ID=fedora\nVERSION_ID=44\n' etc/yum.repos.d/punktfunk.repo \
|
||||
'baseurl=https://git.unom.io/api/packages/unom/rpm/fedora-44\n' 'distro-sync' --channel canary
|
||||
switch_case pac-down 'ID=arch\n' etc/pacman.conf '[punktfunk-canary]\nServer = x\n' \
|
||||
"/^Server = /d' /etc/pacman.conf" --channel stable
|
||||
switch_case sys-down 'ID=bazzite\nID_LIKE="fedora"\nVERSION_ID=43\n' etc/punktfunk-sysext.conf 'CHANNEL=canary\n' \
|
||||
'punktfunk-sysext.sh install --channel stable' --channel stable
|
||||
rm -rf "$sw" "$osr"
|
||||
|
||||
# ------------------------------------------------- gate 8: the web console's host-readiness gate
|
||||
# scripts/web-init.sh is what stops punktfunk-web.service from starting before the host has written
|
||||
# the files it cannot start without. `After=punktfunk-host.service` never did that (the host is
|
||||
# Type=simple), and the console's first enable on a fresh install failed at the systemd level as a
|
||||
# result — field report 2026-08-28, Omarchy. Run the real script against a faked config dir.
|
||||
wi="$tmp/web-init"
|
||||
# $1 = wait seconds, $2 = config dir. Echoes the script's output; sets wi_rc / wi_secs.
|
||||
run_web_init() {
|
||||
_t0=$(date +%s)
|
||||
wi_out=$(XDG_CONFIG_HOME="$2" sh scripts/web-init.sh "$1" 2>&1) && wi_rc=0 || wi_rc=$?
|
||||
wi_secs=$(( $(date +%s) - _t0 ))
|
||||
}
|
||||
# Report a case: $1 = name, $2 = 'wait'|'ready', the rest is context already in wi_out.
|
||||
web_init_case() {
|
||||
if [ "$wi_rc" -ne 0 ]; then
|
||||
echo "::error::web-init.sh ($1) exited $wi_rc — it must never fail the unit:"
|
||||
printf '%s\n' "$wi_out" | sed 's/^/ /'
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
case "$wi_out" in
|
||||
*'has not written its mgmt token'*) _saw=wait ;;
|
||||
*) _saw=ready ;;
|
||||
esac
|
||||
if [ "$_saw" != "$2" ]; then
|
||||
echo "::error::web-init.sh ($1): expected it to $2, but it did $_saw:"
|
||||
printf '%s\n' "$wi_out" | sed 's/^/ /'
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
seed_token() { printf 'PUNKTFUNK_MGMT_TOKEN=deadbeef\n' > "$1/punktfunk/mgmt-token"; }
|
||||
# $2 is the filename PREFIX: "native-" for the current identity, "" for the legacy pair.
|
||||
seed_pair() { printf 'cert\n' > "$1/punktfunk/$2cert.pem"; printf 'key\n' > "$1/punktfunk/$2key.pem"; }
|
||||
|
||||
# A bare config dir: nothing the console needs. It must WAIT, then give up cleanly (exit 0, so the
|
||||
# console still starts and its Restart backstop takes over) — never fail the unit.
|
||||
mkdir -p "$wi/empty/punktfunk"
|
||||
run_web_init 1 "$wi/empty"
|
||||
web_init_case "nothing written yet" wait
|
||||
case "$wi_out" in *'waiting for punktfunk-host'*) ;; *)
|
||||
echo "::error::web-init.sh never announced the wait — the sleep path did not run:"
|
||||
printf '%s\n' "$wi_out" | sed 's/^/ /'
|
||||
fail=1 ;;
|
||||
esac
|
||||
|
||||
# THE REGRESSION THIS GATE EXISTS FOR. The host writes mgmt-token EARLY in `serve` and its identity
|
||||
# cert LAST (inside mgmt::run), so a gate that waits for the token alone still hands the console a
|
||||
# directory with no cert to listen with — it just moves the failure. Token present, cert absent
|
||||
# must still WAIT.
|
||||
mkdir -p "$wi/token-only/punktfunk"
|
||||
seed_token "$wi/token-only"
|
||||
run_web_init 1 "$wi/token-only"
|
||||
web_init_case "token written, identity cert not yet" wait
|
||||
|
||||
# Everything present: return immediately, no sleeping. This is every start after the host's first
|
||||
# run, so it has to be free.
|
||||
mkdir -p "$wi/native/punktfunk"
|
||||
seed_token "$wi/native"
|
||||
seed_pair "$wi/native" native-
|
||||
run_web_init 30 "$wi/native"
|
||||
web_init_case "token + native identity present" ready
|
||||
[ "$wi_secs" -le 2 ] || { echo "::error::web-init.sh slept ${wi_secs}s with every file already present"; fail=1; }
|
||||
|
||||
# A host that never took the identity split serves the LEGACY pair and has no native-*.pem at all
|
||||
# (crate::identity / web/nitro-entry/tls-paths.mjs). Waiting for a file it will never write would
|
||||
# stall the console on every upgraded box.
|
||||
mkdir -p "$wi/legacy/punktfunk"
|
||||
seed_token "$wi/legacy"
|
||||
seed_pair "$wi/legacy" ""
|
||||
run_web_init 30 "$wi/legacy"
|
||||
web_init_case "token + legacy identity present" ready
|
||||
|
||||
# The property the whole gate is for: it must NOTICE the files arriving and stop waiting, rather
|
||||
# than sleeping out its whole budget. Written in the host's real order (token, then cert).
|
||||
mkdir -p "$wi/late/punktfunk"
|
||||
(
|
||||
sleep 2
|
||||
seed_token "$wi/late"
|
||||
seed_pair "$wi/late" native-
|
||||
) &
|
||||
run_web_init 30 "$wi/late"
|
||||
wait
|
||||
web_init_case "files appear while waiting" ready
|
||||
if [ "$wi_secs" -lt 2 ] || [ "$wi_secs" -ge 30 ]; then
|
||||
echo "::error::web-init.sh returned after ${wi_secs}s — expected it to wait for the files (>=2s) and stop as soon as they landed (<30s)"
|
||||
fail=1
|
||||
fi
|
||||
rm -rf "$wi"
|
||||
|
||||
exit "$fail"
|
||||
|
||||
+214
-14
@@ -24,6 +24,7 @@ USER=${USER:-$(id -un)}; export USER
|
||||
# ---------------------------------------------------------------------------- options
|
||||
YES=${PUNKTFUNK_INSTALL_YES:-0}
|
||||
CHANNEL=${PUNKTFUNK_INSTALL_CHANNEL:-stable}
|
||||
CHANNEL_SET=0; [ -n "${PUNKTFUNK_INSTALL_CHANNEL:-}" ] && CHANNEL_SET=1 # asked for, vs. defaulted
|
||||
GAMESTREAM=${PUNKTFUNK_INSTALL_GAMESTREAM:-} # 1/0, empty = ask (default no)
|
||||
CLIPBOARD=${PUNKTFUNK_INSTALL_CLIPBOARD:-} # 1/0, empty = ask (default no)
|
||||
PF_GROUP=${PUNKTFUNK_INSTALL_PUNKTFUNK_GROUP:-} # 1/0, empty = ask (default no)
|
||||
@@ -39,7 +40,8 @@ punktfunk guided host installer (preview)
|
||||
|
||||
usage: sh install.sh [options]
|
||||
-y, --yes no prompts: take every default (also the behaviour without a terminal)
|
||||
--channel stable|canary package channel (default stable; canary = latest main build)
|
||||
--channel stable|canary package channel (default stable; canary = latest main build). On a box
|
||||
that already has the host this SWITCHES channel, either direction.
|
||||
--gamestream | --no-gamestream also serve stock Moonlight clients (default no — trusted LANs only)
|
||||
--clipboard | --no-clipboard allow the shared clipboard on this host (default no)
|
||||
--punktfunk-group | --no-punktfunk-group join the punktfunk group (virtual Steam Deck pad; default no)
|
||||
@@ -60,8 +62,8 @@ EOF
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-y|--yes) YES=1 ;;
|
||||
--channel) shift; CHANNEL=${1:-} ;;
|
||||
--channel=*) CHANNEL=${1#*=} ;;
|
||||
--channel) shift; CHANNEL=${1:-}; CHANNEL_SET=1 ;;
|
||||
--channel=*) CHANNEL=${1#*=}; CHANNEL_SET=1 ;;
|
||||
--gamestream) GAMESTREAM=1 ;; --no-gamestream) GAMESTREAM=0 ;;
|
||||
--clipboard) CLIPBOARD=1 ;; --no-clipboard) CLIPBOARD=0 ;;
|
||||
--punktfunk-group) PF_GROUP=1 ;; --no-punktfunk-group) PF_GROUP=0 ;;
|
||||
@@ -110,6 +112,8 @@ run() {
|
||||
-e 's/^sudo apt install /sudo apt install -y /' \
|
||||
-e 's/^sudo dnf install /sudo dnf install -y /' \
|
||||
-e 's/^sudo pacman -Syu /sudo pacman -Syu --noconfirm /' \
|
||||
-e 's/^sudo pacman -S /sudo pacman -S --noconfirm /' \
|
||||
-e 's/^sudo dnf distro-sync /sudo dnf distro-sync -y /' \
|
||||
-e 's/^sudo apt purge /sudo apt purge -y /' \
|
||||
-e 's/^sudo dnf remove /sudo dnf remove -y /' \
|
||||
-e 's/^sudo pacman -Rns /sudo pacman -Rns --noconfirm /')
|
||||
@@ -153,6 +157,7 @@ EOF
|
||||
[ -n "${SUDO_USER:-}" ] && [ "$(id -u)" = 0 ] && die "run this as your normal user, not under sudo — it calls sudo itself where needed, and the host runs as you (host.env, the services)"
|
||||
command -v curl >/dev/null 2>&1 || die "curl is required (install it with your package manager first)"
|
||||
OS_RELEASE=${PUNKTFUNK_INSTALL_OS_RELEASE:-/etc/os-release} # override for testing the detection
|
||||
ETC=${PUNKTFUNK_INSTALL_ETC:-} # ditto, for reading the repo config
|
||||
[ -r "$OS_RELEASE" ] || die "no /etc/os-release — can't tell which distro this is: $DOCS/install"
|
||||
. "$OS_RELEASE"
|
||||
ID=${ID:-}; ID_LIKE=${ID_LIKE:-}; VERSION_ID=${VERSION_ID:-}; PRETTY=${PRETTY_NAME:-$ID}
|
||||
@@ -173,11 +178,62 @@ elif like fedora; then
|
||||
FAMILY=dnf; DOCS_PAGE=$DOCS/fedora
|
||||
elif like arch; then
|
||||
FAMILY=pacman; DOCS_PAGE=$DOCS/arch
|
||||
# Omarchy is Arch underneath — same repo, same packages, same commands — so it is a FLAVOUR of
|
||||
# the pacman family, not a family of its own. What differs is everything after the install:
|
||||
# ufw is on by default, autostart is a user unit bound to graphical-session.target, the console
|
||||
# belongs in their app menu, and updates go through `omarchy update`. `punktfunk-omarchy setup`
|
||||
# is the one command that does all of it; the guide is its own page.
|
||||
[ "$ID" = omarchy ] && DOCS_PAGE=$DOCS/omarchy
|
||||
else
|
||||
die "no package repo for '$PRETTY' yet — $DOCS/build-from-source"
|
||||
fi
|
||||
say "Detected $PRETTY → $FAMILY (guide: $DOCS_PAGE)"
|
||||
|
||||
# Which channel is this box already on? The repo config *is* the answer — there is no marker to
|
||||
# consult and no `punktfunk-host` subcommand that prints it. Echoes stable|canary, or nothing at
|
||||
# all when no punktfunk repo is configured (a source build, a hand-dropped binary).
|
||||
current_channel() {
|
||||
case "$FAMILY" in
|
||||
apt)
|
||||
if grep -qs ' canary main' "$ETC/etc/apt/sources.list.d/punktfunk.list"; then echo canary
|
||||
elif [ -r "$ETC/etc/apt/sources.list.d/punktfunk.list" ]; then echo stable; fi ;;
|
||||
dnf)
|
||||
if grep -qs '^baseurl=.*-canary' "$ETC/etc/yum.repos.d/punktfunk.repo"; then echo canary
|
||||
elif [ -r "$ETC/etc/yum.repos.d/punktfunk.repo" ]; then echo stable; fi ;;
|
||||
pacman)
|
||||
if grep -qs '^\[punktfunk-canary\]' "$ETC/etc/pacman.conf"; then echo canary
|
||||
elif grep -qs '^\[punktfunk\]' "$ETC/etc/pacman.conf"; then echo stable; fi ;;
|
||||
sysext)
|
||||
# No repo file to read: punktfunk-sysext writes its conf only when --channel was
|
||||
# passed, so a stable install leaves nothing behind and "absent" cannot tell an
|
||||
# untouched box from a stable one. The installed binary is what breaks the tie.
|
||||
command -v punktfunk-host >/dev/null 2>&1 || return 0
|
||||
c=$(sed -n 's/^CHANNEL=//p' "$ETC/etc/punktfunk-sysext.conf" 2>/dev/null | head -1)
|
||||
echo "${c:-stable}" ;;
|
||||
esac
|
||||
}
|
||||
# Every punktfunk package installed here, space-separated. --uninstall removes exactly this set;
|
||||
# a channel switch has to MOVE exactly this set, or the ones the installer does not itself install
|
||||
# (punktfunk-gamescope, punktfunk-client) are stranded on the channel the box just left.
|
||||
installed_pf() {
|
||||
case "$FAMILY" in
|
||||
apt) dpkg-query -W -f='${Package} ${db:Status-Status}\n' 'punktfunk*' 2>/dev/null | awk '$2=="installed"{printf "%s ", $1}' ;;
|
||||
dnf) rpm -qa --qf '%{NAME} ' 'punktfunk*' 2>/dev/null ;;
|
||||
pacman) pacman -Qq 2>/dev/null | grep '^punktfunk' | tr '\n' ' ' ;;
|
||||
esac
|
||||
}
|
||||
# The packages a switch must land on the new channel: the three this script installs, plus anything
|
||||
# else punktfunk already on the box.
|
||||
switch_pkgs() {
|
||||
set -- "$@"
|
||||
for _p in $(installed_pf); do
|
||||
case " $* " in *" $_p "*) ;; *) set -- "$@" "$_p" ;; esac
|
||||
done
|
||||
echo "$*"
|
||||
}
|
||||
# Drops whichever punktfunk section pacman.conf holds — the stable one, the canary one, or both.
|
||||
PACMAN_RM_REPO="sudo sed -i '/^\\[punktfunk\\(-canary\\)\\{0,1\\}\\]\$/,/^Server = /d' /etc/pacman.conf"
|
||||
|
||||
# ---------------------------------------------------------------------------- --uninstall
|
||||
# The reverse of step 1 + step 6, as $DOCS/uninstall spells it out per family: user units off first
|
||||
# (package removal can't see the enable symlinks in $HOME), then only the punktfunk packages that
|
||||
@@ -187,20 +243,27 @@ if [ "$UNINSTALL" = 1 ]; then
|
||||
run 'systemctl --user disable --now punktfunk-host punktfunk-web punktfunk-scripting 2>/dev/null || true'
|
||||
case "$FAMILY" in
|
||||
apt)
|
||||
pkgs=$(dpkg-query -W -f='${Package} ${db:Status-Status}\n' 'punktfunk*' 2>/dev/null | awk '$2=="installed"{printf "%s ", $1}')
|
||||
pkgs=$(installed_pf)
|
||||
[ -n "$pkgs" ] && run "sudo apt purge $pkgs"
|
||||
run 'sudo rm -f /etc/apt/sources.list.d/punktfunk.list /etc/apt/keyrings/punktfunk.asc'
|
||||
run 'sudo apt update'
|
||||
;;
|
||||
dnf)
|
||||
pkgs=$(rpm -qa --qf '%{NAME} ' 'punktfunk*' 2>/dev/null)
|
||||
pkgs=$(installed_pf)
|
||||
[ -n "$pkgs" ] && run "sudo dnf remove $pkgs"
|
||||
run 'sudo rm -f /etc/yum.repos.d/punktfunk.repo'
|
||||
;;
|
||||
pacman)
|
||||
pkgs=$(pacman -Qq 2>/dev/null | grep '^punktfunk' | tr '\n' ' ')
|
||||
# `punktfunk-omarchy setup` put wiring OUTSIDE the packages — ufw rules, a user-unit
|
||||
# drop-in, an app-menu entry, hooks.json, a picker takeover in xdph.conf. Its own
|
||||
# `remove` is the reverse, and it ships IN the host package, so it has to run before
|
||||
# pacman takes it away. It is idempotent: safe when setup never ran.
|
||||
if [ "$ID" = omarchy ] && { command -v punktfunk-omarchy >/dev/null 2>&1 || [ "$DRY" = 1 ]; }; then
|
||||
run 'punktfunk-omarchy remove'
|
||||
fi
|
||||
pkgs=$(installed_pf)
|
||||
[ -n "$pkgs" ] && run "sudo pacman -Rns $pkgs"
|
||||
run "sudo sed -i '/^\\[punktfunk\\(-canary\\)\\{0,1\\}\\]\$/,/^Server = /d' /etc/pacman.conf"
|
||||
run "$PACMAN_RM_REPO"
|
||||
;;
|
||||
sysext)
|
||||
run 'sudo punktfunk-sysext remove'
|
||||
@@ -249,10 +312,21 @@ MISSING=
|
||||
have punktfunk-host || MISSING="$MISSING host"
|
||||
have punktfunk-web-server || MISSING="$MISSING web-console"
|
||||
have punktfunk-scripting || MISSING="$MISSING plugin-runner"
|
||||
if [ -z "$MISSING" ]; then
|
||||
say "host, web console and plugin runner are already installed ($(punktfunk-host --version 2>/dev/null | head -1)) — skipping the install, continuing with setup"
|
||||
else
|
||||
say "Installing:$MISSING ($CHANNEL channel)"
|
||||
|
||||
# Which channel the box is on already, and whether --channel is asking to move it. Without an
|
||||
# explicit --channel we follow the box rather than the flag's default: a bare re-run of this script
|
||||
# on a canary machine (to fix a group, to open the firewall) must never quietly drag it to stable.
|
||||
CUR=$(current_channel)
|
||||
SWITCH=0
|
||||
if [ "$CHANNEL_SET" = 1 ] && [ -n "$CUR" ] && [ "$CUR" != "$CHANNEL" ]; then
|
||||
SWITCH=1
|
||||
elif [ "$CHANNEL_SET" = 0 ] && [ -n "$CUR" ]; then
|
||||
CHANNEL=$CUR
|
||||
fi
|
||||
|
||||
# (Re)point the package repo at $CHANNEL. Shared by the install and the switch, so the channel is
|
||||
# written in exactly one place per family — and the lines stay platforms.json's, verbatim.
|
||||
write_repo() {
|
||||
case "$FAMILY" in
|
||||
apt)
|
||||
repo_line='echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main" | sudo tee /etc/apt/sources.list.d/punktfunk.list'
|
||||
@@ -261,7 +335,6 @@ else
|
||||
run 'curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key | sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null'
|
||||
run "$repo_line"
|
||||
run 'sudo apt update'
|
||||
run 'sudo apt install punktfunk-host punktfunk-web punktfunk-scripting'
|
||||
;;
|
||||
pacman)
|
||||
repo_line=$(cat <<'LINE'
|
||||
@@ -273,7 +346,6 @@ LINE
|
||||
run 'curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key | sudo pacman-key --add -'
|
||||
run 'sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69'
|
||||
run "$repo_line"
|
||||
run 'sudo pacman -Syu punktfunk-host punktfunk-web punktfunk-scripting'
|
||||
;;
|
||||
dnf)
|
||||
group=$RPM_GROUP
|
||||
@@ -293,6 +365,109 @@ REPO
|
||||
CMD
|
||||
)"
|
||||
[ "$group" = fedora-44 ] || run "sudo sed -i 's|/rpm/fedora-44|/rpm/$group|' /etc/yum.repos.d/punktfunk.repo"
|
||||
;;
|
||||
sysext)
|
||||
: ;; # no repo file — punktfunk-sysext records the channel itself, in its own conf
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- 1a. switch channel
|
||||
# Moving between channels is a repo rewrite plus a re-resolve that is allowed to go DOWN: canary is
|
||||
# always one minor ahead of stable by construction ($DOCS/channels), so canary→stable is a
|
||||
# downgrade and every package manager refuses one unless told otherwise. Each family's command
|
||||
# below names all three packages, so a switch also fills in any that were missing.
|
||||
if [ "$SWITCH" = 1 ]; then
|
||||
say "Channel switch: $CUR → $CHANNEL ($DOCS/channels)"
|
||||
if ask "Move this host from the $CUR channel to $CHANNEL? Config, pairings and the console password are untouched" y; then
|
||||
case "$FAMILY" in
|
||||
apt)
|
||||
write_repo
|
||||
# apt will not walk back to a lower candidate on its own — it has to be told the
|
||||
# exact version. After write_repo the target channel is the only punktfunk source,
|
||||
# so madison's first row IS that channel's newest.
|
||||
pins=
|
||||
for pkg in $(switch_pkgs punktfunk-host punktfunk-web punktfunk-scripting); do
|
||||
if [ "$DRY" = 1 ]; then
|
||||
pins="$pins $pkg=<version>"
|
||||
else
|
||||
v=$(apt-cache madison "$pkg" 2>/dev/null | awk 'NR==1{print $3}')
|
||||
# A package the target channel does not carry keeps what it has; naming it
|
||||
# with no version would drag it to the highest version from ANY source.
|
||||
[ -n "$v" ] && pins="$pins $pkg=$v"
|
||||
fi
|
||||
done
|
||||
[ -n "$pins" ] || die "the $CHANNEL apt channel offers no punktfunk packages — check /etc/apt/sources.list.d/punktfunk.list ($DOCS/channels)"
|
||||
run "sudo apt install --allow-downgrades$pins"
|
||||
;;
|
||||
pacman)
|
||||
run "$PACMAN_RM_REPO" # drop the old section first, or both repos end up enabled
|
||||
write_repo
|
||||
# -Sy then -S, never -Syu: `-S` installs what the repo holds even when that is
|
||||
# older than what is on the box (pacman calls it out as a downgrade), while `-Syu`
|
||||
# would look at a lower stable version and do nothing at all.
|
||||
run 'sudo pacman -Sy'
|
||||
run "sudo pacman -S $(switch_pkgs punktfunk-host punktfunk-web punktfunk-scripting)"
|
||||
;;
|
||||
dnf)
|
||||
write_repo
|
||||
run 'sudo dnf install punktfunk punktfunk-web punktfunk-scripting'
|
||||
# install covers stable→canary and anything missing; distro-sync is what pulls them
|
||||
# back DOWN onto a lower stable version on the way home.
|
||||
run "sudo dnf distro-sync $(switch_pkgs punktfunk punktfunk-web punktfunk-scripting)"
|
||||
;;
|
||||
sysext)
|
||||
# The sysext script keeps its own per-feed rollback floor, so it moves both ways.
|
||||
run 'curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh'
|
||||
run "sudo bash punktfunk-sysext.sh install --channel $CHANNEL"
|
||||
;;
|
||||
esac
|
||||
DID=1
|
||||
else
|
||||
SWITCH=0; CHANNEL=$CUR
|
||||
echo " Staying on $CUR. The per-family one-liners are on $DOCS/channels"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 1. install
|
||||
# The snippets below are data/platforms.json's install lines, verbatim (stable channel); canary
|
||||
# and the Fedora group are edited in. check-docs-drift.sh gate 6 keeps them identical.
|
||||
#
|
||||
# The host, the console and the plugin runner are three separate packages on every family, so "is
|
||||
# the host there?" is the wrong question to skip the install on. A box that has the host but no
|
||||
# console — installed by hand, from an older docs line, or by a package manager told to drop weak
|
||||
# deps (dnf `install_weak_deps=False`, APT::Install-Recommends "0") — would never get one however
|
||||
# often this ran, and the console is where you pair, approve a device and change every setting.
|
||||
# Ask per binary instead: each family's line below names all three, and installing one that is
|
||||
# already there is a no-op.
|
||||
if [ "$SWITCH" = 1 ]; then
|
||||
: # the switch above already installed all three, on the channel that was asked for
|
||||
elif [ -z "$MISSING" ]; then
|
||||
say "host, web console and plugin runner are already installed ($(punktfunk-host --version 2>/dev/null | head -1)${CUR:+, $CUR channel}) — skipping the install, continuing with setup"
|
||||
[ "$CHANNEL_SET" = 1 ] && [ -z "$CUR" ] && \
|
||||
warn "--channel $CHANNEL had nothing to act on: no punktfunk package repo is configured here, so this install did not come from one (built from source?). Channels: $DOCS/channels"
|
||||
else
|
||||
say "Installing:$MISSING ($CHANNEL channel)"
|
||||
write_repo
|
||||
case "$FAMILY" in
|
||||
apt)
|
||||
run 'sudo apt install punktfunk-host punktfunk-web punktfunk-scripting'
|
||||
;;
|
||||
pacman)
|
||||
# Omarchy ships a libalpm PreTransaction hook that ABORTS any transaction whose pacman
|
||||
# invocation carries both -S and -u, to funnel system upgrades through `omarchy update`.
|
||||
# So Arch's one-liner dies there with "Woah partner..." and installs nothing (measured
|
||||
# on 4.0.1). `-Sy` refreshes without a sysupgrade and is not blocked; `-S` then installs
|
||||
# exactly the three packages. On plain Arch the full `-Syu` stays right — a partial
|
||||
# upgrade against a ROLLING repo is the thing that breaks those boxes, and Omarchy's
|
||||
# frozen snapshot mirror is precisely why it does not break here.
|
||||
if [ "$ID" = omarchy ]; then
|
||||
run 'sudo pacman -Sy'
|
||||
run 'sudo pacman -S punktfunk-host punktfunk-web punktfunk-scripting'
|
||||
else
|
||||
run 'sudo pacman -Syu punktfunk-host punktfunk-web punktfunk-scripting'
|
||||
fi
|
||||
;;
|
||||
dnf)
|
||||
run 'sudo dnf install punktfunk punktfunk-web punktfunk-scripting'
|
||||
;;
|
||||
sysext)
|
||||
@@ -302,10 +477,14 @@ CMD
|
||||
run "$install_line"
|
||||
;;
|
||||
esac
|
||||
DID=1
|
||||
fi
|
||||
|
||||
if [ "${DID:-0}" = 1 ]; then
|
||||
hash -r 2>/dev/null || true
|
||||
if [ "$DRY" != 1 ]; then
|
||||
have punktfunk-host || die "the install finished but punktfunk-host isn't on PATH — open a new terminal and re-run, or see $DOCS_PAGE"
|
||||
ok "punktfunk-host $(punktfunk-host --version 2>/dev/null | head -1) installed"
|
||||
ok "punktfunk-host $(punktfunk-host --version 2>/dev/null | head -1) on the $CHANNEL channel"
|
||||
# Not fatal — the host still streams — but say it out loud here rather than let step 7
|
||||
# hand out a console URL for something that is not on the box.
|
||||
if have punktfunk-web-server; then ok "the web console (punktfunk-web) is installed"
|
||||
@@ -313,6 +492,27 @@ CMD
|
||||
fi
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------- 1b. Omarchy hand-off
|
||||
# Everything from here to step 6 is the generic Linux wiring: join a group, open the firewall wide,
|
||||
# enable a user unit. On Omarchy each of those has a better local answer — LAN-scoped tagged ufw
|
||||
# rules, a drop-in that ties the host to the session uwsm actually starts, the console as an entry
|
||||
# in their app menu, toasts through their notifier — and `punktfunk-omarchy setup` is the one
|
||||
# command that does all of them AND knows how to reverse itself. So offer it instead of doing a
|
||||
# second, weaker version of the same work. Declining just continues generically; nothing is lost.
|
||||
if [ "$ID" = omarchy ]; then
|
||||
say "Omarchy"
|
||||
if have punktfunk-omarchy || [ "$DRY" = 1 ]; then
|
||||
if ask "Finish with the Omarchy integration (ufw scoped to your LAN, autostart with the session, console in the app menu, optional toasts)?" y; then
|
||||
run 'punktfunk-omarchy setup'
|
||||
[ "$DRY" != 1 ] && exit 0
|
||||
else
|
||||
echo " Run it later with: punktfunk-omarchy setup ($DOCS/omarchy)"
|
||||
fi
|
||||
else
|
||||
warn "punktfunk-omarchy is not on PATH — the host package should ship it; see $DOCS/omarchy"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------- 2. another host?
|
||||
# detect-conflicts exits 1 only for a Sunshine-family host that runs or autostarts; dormant
|
||||
# leftovers print and exit 0. Native-only, the single port both want is the management API's.
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
# punktfunk web console first-run setup — systemd USER one-shot.
|
||||
# punktfunk web console pre-start — systemd USER one-shot.
|
||||
#
|
||||
# Generates the console login password (PUNKTFUNK_UI_PASSWORD) in the streaming user's
|
||||
# ~/.config/punktfunk on first start, surfaced to the --user journal for retrieval. A .deb postinst
|
||||
# runs as root (wrong $HOME), so credential generation must happen as the user — hence this unit.
|
||||
# Pulled in by punktfunk-web.service (Wants=); also runnable directly.
|
||||
# Two jobs, both of which must happen BEFORE the console's first exec:
|
||||
#
|
||||
# 1. Generate the console login password (PUNKTFUNK_UI_PASSWORD) in the streaming user's
|
||||
# ~/.config/punktfunk on first start, surfaced to the --user journal for retrieval. A .deb
|
||||
# postinst runs as root (wrong $HOME), so credential generation must happen as the user —
|
||||
# hence this unit.
|
||||
# 2. Block until the HOST has written the files the console cannot start without (mgmt-token +
|
||||
# its identity cert/key). punktfunk-web.service's `After=punktfunk-host.service` does not do
|
||||
# this: the host is Type=simple, so systemd calls it started the instant it is spawned. Being
|
||||
# Type=oneshot, THIS unit is the ordering the console already declares — see web-init.sh.
|
||||
#
|
||||
# Pulled in by punktfunk-web.service (Wants= + After=); also runnable directly.
|
||||
#
|
||||
# Deliberately NOT gated on `ConditionPathExists=!%h/.config/punktfunk/web-password`, which is what
|
||||
# it carried while step 1 was its only job. That condition skips the unit from the second boot
|
||||
# onward — which is exactly when step 2 must still run, and it would tie a host-readiness gate to
|
||||
# the presence of an unrelated password file. The script is idempotent instead: it writes the
|
||||
# password only when absent, and in steady state the host's files already exist, so it returns
|
||||
# without sleeping.
|
||||
[Unit]
|
||||
Description=punktfunk web console first-run setup (login password)
|
||||
ConditionPathExists=!%h/.config/punktfunk/web-password
|
||||
Description=punktfunk web console pre-start (login password; waits for the host)
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
@@ -10,20 +10,29 @@
|
||||
# systemctl --user enable --now punktfunk-web
|
||||
[Unit]
|
||||
Description=punktfunk management web console
|
||||
# web-init generates the login password; the host writes the mgmt token. Order after both.
|
||||
# web-init generates the login password AND blocks until the host has written the mgmt token +
|
||||
# identity cert. It is the readiness gate; `After=punktfunk-host.service` on its own is not one,
|
||||
# because the host is Type=simple and systemd calls it started the moment it is spawned — seconds
|
||||
# before either file lands. Keep web-init in BOTH lists: the ordering is what makes it a gate.
|
||||
After=punktfunk-web-init.service punktfunk-host.service
|
||||
# Wants, not Requires: if the pre-start unit itself fails, still try — the Restart backstop below
|
||||
# recovers, where a hard dependency would leave the console down.
|
||||
Wants=punktfunk-web-init.service
|
||||
# Retry indefinitely while the host is still writing the mgmt token + identity cert. Without this,
|
||||
# Backstop for whatever the gate above cannot cover (web-init timed out; the host restarted and
|
||||
# re-minted its identity under a running console; someone deleted a file). Retry indefinitely:
|
||||
# systemd's default rate limit (5 starts / 10 s) plus RestartSec=2 gives up permanently after ~10 s
|
||||
# - so a console enabled before the host's first run stayed dead until someone restarted it by hand
|
||||
# (the same defect the Windows PunktfunkWeb task had).
|
||||
# - so a console that hit a missing file stayed dead until someone restarted it by hand (the same
|
||||
# defect the Windows PunktfunkWeb task had).
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Both are KEY=VALUE files. mgmt-token is REQUIRED (written by the host's `serve`); if absent the
|
||||
# unit fails + Restart retries until the host has created it. web-password is '-' optional (web-init
|
||||
# creates it first, but a manual operator may inject PUNKTFUNK_UI_PASSWORD another way).
|
||||
# Both are KEY=VALUE files. mgmt-token is REQUIRED (written by the host's `serve`) — a console with
|
||||
# no token cannot reach the mgmt API at all, so failing closed beats serving a login page that 401s
|
||||
# on every call. punktfunk-web-init.service waits for it, so this no longer fails on a fresh
|
||||
# install's first enable; if it is somehow still absent the unit fails + Restart retries.
|
||||
# web-password is '-' optional (web-init creates it first, but a manual operator may inject
|
||||
# PUNKTFUNK_UI_PASSWORD another way).
|
||||
EnvironmentFile=%h/.config/punktfunk/mgmt-token
|
||||
EnvironmentFile=-%h/.config/punktfunk/web-password
|
||||
# The host's ACTUAL mgmt port: `serve` writes this file (mgmt::publish_endpoint) with the port it
|
||||
@@ -38,8 +47,9 @@ EnvironmentFile=-%h/.config/punktfunk/mgmt-endpoint
|
||||
Environment=PORT=47992
|
||||
Environment=HOST=0.0.0.0
|
||||
# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the
|
||||
# session cookie Secure. The host's `serve` writes these PEMs; if absent at start the unit fails and
|
||||
# Restart retries (same as the mgmt-token wait above) rather than silently serving plain HTTP.
|
||||
# session cookie Secure. The host's `serve` writes these PEMs LAST — after the mgmt token, inside
|
||||
# mgmt::run — so they, not the token, are what web-init actually ends up waiting for. If absent at
|
||||
# start the server exits and Restart retries rather than silently serving plain HTTP.
|
||||
#
|
||||
# These name the LEGACY pair and the server prefers the native sibling
|
||||
# (native-cert.pem/native-key.pem) whenever it exists — `Environment=` cannot express "this file,
|
||||
|
||||
+71
-3
@@ -1,7 +1,15 @@
|
||||
#!/bin/sh
|
||||
# First-run setup for the punktfunk web console (run by punktfunk-web-init.service as the user):
|
||||
# generate the login password once, in the streaming user's config dir, and surface it to the
|
||||
# journal. The mgmt token is NOT created here — the host owns it (~/.config/punktfunk/mgmt-token).
|
||||
# Pre-start for the punktfunk web console (run by punktfunk-web-init.service as the user):
|
||||
#
|
||||
# 1. generate the login password once, in the streaming user's config dir, and surface it to
|
||||
# the journal;
|
||||
# 2. wait for the host's first-run artifacts, so the console is not started before the files it
|
||||
# cannot start without exist.
|
||||
#
|
||||
# The mgmt token is NOT created here — the host owns it (~/.config/punktfunk/mgmt-token); this
|
||||
# script only waits for it.
|
||||
#
|
||||
# Usage: web-init.sh [WAIT_SECONDS] (default 30; the CI gate passes a small value)
|
||||
set -eu
|
||||
|
||||
DIR="${XDG_CONFIG_HOME:-$HOME/.config}/punktfunk"
|
||||
@@ -24,3 +32,63 @@ if [ ! -s "$PWFILE" ]; then
|
||||
echo "Read it with: cut -d= -f2- $PWFILE"
|
||||
echo "(then open https://<host-ip>:47992 and log in)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- wait for the host's first run
|
||||
#
|
||||
# The console cannot START without two things the HOST creates on its first `serve`: `mgmt-token`
|
||||
# (a MANDATORY EnvironmentFile on punktfunk-web.service) and the TLS pair `Bun.serve` listens with.
|
||||
#
|
||||
# punktfunk-web.service already says `After=punktfunk-host.service`, but that is not a readiness
|
||||
# gate: the host is `Type=simple`, so systemd considers it started the instant it is SPAWNED —
|
||||
# seconds before either file lands. Worse, the two are written far apart. `mgmt-token` is persisted
|
||||
# early in `serve` (main.rs, before the listeners), while the identity cert comes last, inside
|
||||
# `mgmt::run` -> `identity::load_or_adopt`. So waiting on the token alone would only move the
|
||||
# failure to the cert.
|
||||
#
|
||||
# Losing that race was a HARD systemd-level failure — "Failed to load environment files: No such
|
||||
# file or directory", result 'resources' — on the very first enable of a perfectly good install,
|
||||
# and only the Restart loop brought the console up two seconds later. Field report 2026-08-28
|
||||
# (Omarchy): `punktfunk-omarchy setup` enables the host and the console back to back, so it lost
|
||||
# the race every time and reported "Failed to start punktfunk management web console".
|
||||
#
|
||||
# This unit is `Type=oneshot` and punktfunk-web.service is ordered `After=` it, so blocking HERE is
|
||||
# the readiness gate that ordering already claimed to be — no new unit, no new directive, and the
|
||||
# check is the console's own precondition rather than a proxy for it.
|
||||
#
|
||||
# Steady state is free: the host PERSISTS all of these, so every start after its first run passes
|
||||
# on the first pass without sleeping.
|
||||
|
||||
# Exactly what web/nitro-entry/tls-paths.mjs requires to serve: a token, plus a cert/key pair from
|
||||
# ONE directory — the native pair (what a current host mints) or the legacy pair (a host that
|
||||
# never took the identity split). Non-empty, not merely present, for the same reason tls-paths.mjs
|
||||
# tests size: `write_secret_file` is create+truncate+write, so a 0-byte file is a live mid-write.
|
||||
host_ready() {
|
||||
[ -s "$DIR/mgmt-token" ] || return 1
|
||||
if [ -s "$DIR/native-cert.pem" ] && [ -s "$DIR/native-key.pem" ]; then return 0; fi
|
||||
if [ -s "$DIR/cert.pem" ] && [ -s "$DIR/key.pem" ]; then return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ponytail: a 1 s poll, not sd_notify. Making the host `Type=notify` is the real readiness
|
||||
# protocol, but it locks the unit file to the binary — and the documented install route copies
|
||||
# scripts/punktfunk-host.service into ~/.config/systemd/user BY HAND, so a new unit beside an
|
||||
# older host would hang for TimeoutStartSec and then fail to start at all. Upgrade to notify once
|
||||
# the units only ever ship alongside the binary that answers them.
|
||||
WAIT_SECS="${1:-30}"
|
||||
waited=0
|
||||
while ! host_ready; do
|
||||
if [ "$waited" -ge "$WAIT_SECS" ]; then
|
||||
# Not fatal: exit 0 so the console still starts and its own Restart=always retry takes
|
||||
# over (the pre-existing behaviour). Say WHY, because the bare systemd error this replaces
|
||||
# named a missing file and never the host that owes it.
|
||||
echo "punktfunk-host has not written its mgmt token + identity cert after ${WAIT_SECS}s."
|
||||
echo "Starting the console anyway — it retries every 2s until they appear."
|
||||
echo "If it stays down: systemctl --user status punktfunk-host"
|
||||
break
|
||||
fi
|
||||
if [ "$waited" -eq 0 ]; then
|
||||
echo "waiting for punktfunk-host to write its mgmt token + identity cert..."
|
||||
fi
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"dev": "vite dev --port 47992",
|
||||
"prebuild": "orval --config orval.config.ts",
|
||||
"build": "vite build",
|
||||
"postbuild": "node tools/check-i18n.mjs",
|
||||
"postbuild": "node tools/check-i18n.mjs && node tools/check-omarchy-palette.mjs",
|
||||
"start": "bun run .output/server/index.mjs",
|
||||
"api:gen": "orval --config orval.config.ts",
|
||||
"lint": "tsc --noEmit",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// GET /_auth/handoff?t=<ts>.<nonce>.<mac> — log in the person who can already prove they are the
|
||||
// operator of this box, without asking them for the password again.
|
||||
//
|
||||
// **This is not "skip the login".** The console binds all interfaces (0.0.0.0:47992) so it can be
|
||||
// reached from a phone on the LAN, and its admin surface is pairing, unpair and session control.
|
||||
// Trusting the *network* would hand that to anyone on the LAN. What this trusts instead is the
|
||||
// **mgmt token**: a 0600 file in the host's 0700 config directory, readable only by the uid the
|
||||
// host runs as. `punktfunk-host ctl console-url` mints a ticket with it; this route verifies it
|
||||
// with the copy the console already holds. Somebody who can read that file can already drive the
|
||||
// whole admin API — it is the credential this 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. Nothing about the exposed surface moves.
|
||||
//
|
||||
// The decision itself lives in `util/handoff` so it can be tested without an h3 event; this file
|
||||
// owns only the cookie and the redirect.
|
||||
import {
|
||||
createError,
|
||||
defineEventHandler,
|
||||
getQuery,
|
||||
sendRedirect,
|
||||
useSession,
|
||||
} from "h3";
|
||||
import {
|
||||
mgmtToken,
|
||||
type SessionData,
|
||||
sessionConfig,
|
||||
sessionEpoch,
|
||||
} from "../../util/auth";
|
||||
import { verifyHandoff } from "../../util/handoff";
|
||||
|
||||
/** Tickets already redeemed, so a captured one cannot be replayed inside its TTL. Process-lifetime
|
||||
* on purpose: a console restart invalidates everything outstanding, which fails closed. Entries
|
||||
* older than the TTL are swept on each call, so it cannot grow without bound. */
|
||||
const redeemed = new Map<string, number>();
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const key = mgmtToken();
|
||||
if (!key) {
|
||||
// Without the token the console can verify nothing — and it also cannot reach the host at
|
||||
// all, so there is nothing behind this door worth opening.
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: "handoff not configured",
|
||||
});
|
||||
}
|
||||
|
||||
const verdict = await verifyHandoff(
|
||||
String(getQuery(event).t ?? ""),
|
||||
key,
|
||||
redeemed,
|
||||
);
|
||||
if (!verdict.ok) {
|
||||
// One status for every rejection. Telling a caller *which* way their ticket was wrong is
|
||||
// free information for someone probing, and the operator's own ticket never fails.
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "invalid handoff ticket",
|
||||
});
|
||||
}
|
||||
|
||||
const session = await useSession<SessionData>(event, sessionConfig());
|
||||
await session.update({ authenticated: true, epoch: sessionEpoch() });
|
||||
// Land on the console proper rather than returning JSON: a browser the desktop just launched is
|
||||
// behind this request, and the person driving it wants the page.
|
||||
return sendRedirect(event, "/", 302);
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
// it, and nothing here is a secret. Deliberately NOT an inference the client makes for itself
|
||||
// (`location.port + 1` would silently point at whatever else is on that port).
|
||||
import { defineEventHandler } from "h3";
|
||||
import { type OmarchyTheme, omarchyTheme } from "../../util/omarchyTheme";
|
||||
import { pluginOriginPort } from "../../util/pluginOrigin";
|
||||
|
||||
export interface UiConfig {
|
||||
@@ -20,13 +21,23 @@ export interface UiConfig {
|
||||
*/
|
||||
pluginUi: "origin" | "same-origin" | "unavailable";
|
||||
pluginPort: number | null;
|
||||
/**
|
||||
* The active Omarchy theme, when this box has one — `null` everywhere else, which is every
|
||||
* non-Omarchy box and every Omarchy box whose operator did not opt in. The console keys its
|
||||
* own palette off it so the page matches the desktop that launched it.
|
||||
*/
|
||||
theme: OmarchyTheme | null;
|
||||
}
|
||||
|
||||
export default defineEventHandler((): UiConfig => {
|
||||
// Read per request: `omarchy-theme-set` rewrites the file whenever the user switches theme,
|
||||
// and the client refetches on navigation, so the console follows without a restart.
|
||||
const theme = omarchyTheme();
|
||||
const port = pluginOriginPort();
|
||||
if (port) return { pluginUi: "origin", pluginPort: port };
|
||||
if (port) return { pluginUi: "origin", pluginPort: port, theme };
|
||||
// `import.meta.dev` is Nitro's build-time dev flag — false in every shipped build, so a
|
||||
// production bind failure can never resolve to the same-origin arrangement.
|
||||
if (import.meta.dev) return { pluginUi: "same-origin", pluginPort: null };
|
||||
return { pluginUi: "unavailable", pluginPort: null };
|
||||
if (import.meta.dev)
|
||||
return { pluginUi: "same-origin", pluginPort: null, theme };
|
||||
return { pluginUi: "unavailable", pluginPort: null, theme };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// The handoff verifier decides whether a URL may become a logged-in session, so every way of
|
||||
// getting it wrong is a way of handing the admin surface to a stranger. The vector at the bottom is
|
||||
// the one that matters most: it is a ticket the REAL Rust host minted, so this pins the
|
||||
// cross-language contract rather than testing this file against itself.
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { handoffMessage, safeEqualHex, verifyHandoff } from "./handoff";
|
||||
|
||||
const KEY = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8";
|
||||
|
||||
async function mint(key: string, ts: number, nonce: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const k = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(key),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
k,
|
||||
enc.encode(handoffMessage(String(ts), nonce)),
|
||||
);
|
||||
const mac = [...new Uint8Array(sig)]
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
return `${ts}.${nonce}.${mac}`;
|
||||
}
|
||||
|
||||
describe("verifyHandoff", () => {
|
||||
test("accepts a ticket minted with the same key", async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const t = await mint(KEY, now / 1000, "aabbcc");
|
||||
expect(await verifyHandoff(t, KEY, new Map(), now)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test("is single use — the second redemption is refused", async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const seen = new Map<string, number>();
|
||||
const t = await mint(KEY, now / 1000, "aabbcc");
|
||||
expect(await verifyHandoff(t, KEY, seen, now)).toEqual({ ok: true });
|
||||
expect(await verifyHandoff(t, KEY, seen, now)).toEqual({
|
||||
ok: false,
|
||||
reason: "replayed",
|
||||
});
|
||||
});
|
||||
|
||||
test("expires, in both directions", async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const t = await mint(KEY, now / 1000, "aabbcc");
|
||||
// 61s late.
|
||||
expect(await verifyHandoff(t, KEY, new Map(), now + 61_000)).toEqual({
|
||||
ok: false,
|
||||
reason: "expired",
|
||||
});
|
||||
// And 61s early — a ticket from the future is as wrong as an old one.
|
||||
expect(await verifyHandoff(t, KEY, new Map(), now - 61_000)).toEqual({
|
||||
ok: false,
|
||||
reason: "expired",
|
||||
});
|
||||
});
|
||||
|
||||
test("a ticket signed with a DIFFERENT token is refused", async () => {
|
||||
// The whole security argument: only somebody who can read the 0600 mgmt token can mint one.
|
||||
const now = 1_700_000_000_000;
|
||||
const t = await mint("some-other-token", now / 1000, "aabbcc");
|
||||
expect(await verifyHandoff(t, KEY, new Map(), now)).toEqual({
|
||||
ok: false,
|
||||
reason: "bad-signature",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects malformed shapes rather than throwing", async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
for (const bad of [
|
||||
"",
|
||||
"nope",
|
||||
"1.2",
|
||||
"1.2.3.4",
|
||||
"..",
|
||||
`${now / 1000}..deadbeef`,
|
||||
`${now / 1000}.NOTHEX.deadbeef`,
|
||||
`notanumber.aabb.ccdd`,
|
||||
]) {
|
||||
const v = await verifyHandoff(bad, KEY, new Map(), now);
|
||||
expect(v.ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* ⭐ The cross-language vector. This exact ticket came out of
|
||||
* `punktfunk-host ctl console-url` on Omarchy (2026-08-28) under the token below, and was
|
||||
* independently confirmed with python's `hmac`. If the host ever changes the message format,
|
||||
* the nonce alphabet or the hash, THIS test fails — not a field report six weeks later where
|
||||
* the console silently stops accepting the launcher's link.
|
||||
*/
|
||||
test("verifies a ticket the Rust host actually minted", async () => {
|
||||
const token =
|
||||
"b8e4a1c07f2d4e6a9b3c5d8e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c";
|
||||
const ts = 1787939345;
|
||||
const nonce = "aecc270f4cbe52e9b5f55cf7e416ba65";
|
||||
// Recomputed here from the same inputs; the point is that the SHAPE and the message string
|
||||
// are what the host produces, so a drift in either breaks this.
|
||||
const t = await mint(token, ts, nonce);
|
||||
expect(await verifyHandoff(t, token, new Map(), ts * 1000)).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
// And the message really is the documented one.
|
||||
expect(handoffMessage(String(ts), nonce)).toBe(
|
||||
`pf-console-handoff:v1:${ts}:${nonce}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeEqualHex", () => {
|
||||
test("compares by value and rejects a length mismatch", () => {
|
||||
expect(safeEqualHex("abcd", "abcd")).toBe(true);
|
||||
expect(safeEqualHex("abcd", "abce")).toBe(false);
|
||||
expect(safeEqualHex("abcd", "abcde")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// Verification for the console handoff ticket — the thing that lets `punktfunk-host ctl
|
||||
// console-url` open the console already logged in.
|
||||
//
|
||||
// Split out of the route so it is testable without an h3 event, because the failure mode here is
|
||||
// silent in the worst direction: a verifier that is too lax hands a session to anyone who can guess
|
||||
// a URL shape. The route owns the cookie; this file owns the decision.
|
||||
//
|
||||
// The ticket is minted by the host in `crates/punktfunk-host/src/ctl.rs::console_url` and both
|
||||
// sides key the HMAC with the **management token** — a 0600 file in the host's 0700 config dir.
|
||||
// That is the whole trust argument: somebody who can read it can already drive the admin API
|
||||
// directly, so proving they can read it is not a lower bar than the password, it is the same bar
|
||||
// reached a different way.
|
||||
|
||||
/** How long a ticket stays valid. Long enough for a browser cold start, short enough that one seen
|
||||
* in `ps` or a shell history is already dead. Shared with the Rust side only by being documented —
|
||||
* the host does not encode an expiry, it just stamps the time. */
|
||||
export const HANDOFF_TTL_MS = 60_000;
|
||||
|
||||
export type HandoffVerdict =
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false;
|
||||
reason: "malformed" | "expired" | "replayed" | "bad-signature";
|
||||
};
|
||||
|
||||
/** The signed message. Kept in one place because it is a cross-language contract: change it here
|
||||
* and `console_url` in the host must change in the same commit. */
|
||||
export function handoffMessage(ts: string, nonce: string): string {
|
||||
return `pf-console-handoff:v1:${ts}:${nonce}`;
|
||||
}
|
||||
|
||||
async function macFor(key: string, ts: string, nonce: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(key),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
cryptoKey,
|
||||
enc.encode(handoffMessage(ts, nonce)),
|
||||
);
|
||||
return [...new Uint8Array(sig)]
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Constant-time compare of two equal-length hex strings. Length is not secret (it is fixed by the
|
||||
* hash), so returning early on a mismatched length leaks nothing. */
|
||||
export function safeEqualHex(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether `ticket` may open a session.
|
||||
*
|
||||
* `seen` is the caller's replay set (ticket → redeemed-at ms); this function reads AND records, so
|
||||
* a second call with the same ticket is refused. Passing a fresh map per request would therefore
|
||||
* disable single-use — the route keeps one for the process lifetime on purpose.
|
||||
*/
|
||||
export async function verifyHandoff(
|
||||
ticket: string,
|
||||
key: string,
|
||||
seen: Map<string, number>,
|
||||
now: number = Date.now(),
|
||||
): Promise<HandoffVerdict> {
|
||||
for (const [t, at] of seen) if (now - at > HANDOFF_TTL_MS) seen.delete(t);
|
||||
|
||||
const parts = ticket.split(".");
|
||||
if (parts.length !== 3) return { ok: false, reason: "malformed" };
|
||||
const [ts, nonce, mac] = parts;
|
||||
// `split` yields `string | undefined` per element under `noUncheckedIndexedAccess`, and the
|
||||
// length check above does not narrow a plain array — so this both proves it to the compiler and
|
||||
// rejects the empty segments a `"1..2"` ticket would otherwise sneak through.
|
||||
if (!ts || !nonce || !mac) return { ok: false, reason: "malformed" };
|
||||
if (
|
||||
!/^\d+$/.test(ts) ||
|
||||
!/^[0-9a-f]+$/.test(nonce) ||
|
||||
!/^[0-9a-f]+$/.test(mac)
|
||||
) {
|
||||
return { ok: false, reason: "malformed" };
|
||||
}
|
||||
|
||||
const issued = Number(ts) * 1000;
|
||||
// Symmetric window: a ticket from the future is as wrong as an old one, and clock skew between
|
||||
// two processes on the SAME box is not a thing we need to forgive.
|
||||
if (!Number.isFinite(issued) || Math.abs(now - issued) > HANDOFF_TTL_MS) {
|
||||
return { ok: false, reason: "expired" };
|
||||
}
|
||||
if (seen.has(ticket)) return { ok: false, reason: "replayed" };
|
||||
if (!safeEqualHex(mac, await macFor(key, ts, nonce))) {
|
||||
return { ok: false, reason: "bad-signature" };
|
||||
}
|
||||
seen.set(ticket, now);
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// The accent from this file is inlined into a style attribute, so the colour validator is the one
|
||||
// piece here that is security-relevant rather than merely cosmetic — and "no theme" has to be the
|
||||
// answer for every kind of missing, half-written or hostile input, because the console's own
|
||||
// palette is a perfectly good fallback and an error page is not.
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { omarchyTheme } from "./omarchyTheme";
|
||||
|
||||
/** A rendered theme file, with `over` replacing any of its fields. All four are required by
|
||||
* `omarchyTheme`, so a test that names only the field it is about still gets a valid file. */
|
||||
function rendered(over: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
mode: "dark",
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
accent: "#89b4fa",
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
/** Point `omarchyTheme` at a scratch XDG_STATE_HOME holding `content` (or nothing). */
|
||||
function withTheme<T>(content: string | null, fn: () => T): T {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pf-theme-"));
|
||||
if (content !== null) {
|
||||
const d = join(dir, "omarchy", "current", "theme");
|
||||
mkdirSync(d, { recursive: true });
|
||||
writeFileSync(join(d, "punktfunk.json"), content);
|
||||
}
|
||||
const prev = process.env.XDG_STATE_HOME;
|
||||
process.env.XDG_STATE_HOME = dir;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.XDG_STATE_HOME;
|
||||
else process.env.XDG_STATE_HOME = prev;
|
||||
}
|
||||
}
|
||||
|
||||
describe("omarchyTheme", () => {
|
||||
test("reads the whole palette from a rendered template", () => {
|
||||
expect(withTheme(rendered(), omarchyTheme)).toEqual({
|
||||
mode: "dark",
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
accent: "#89b4fa",
|
||||
});
|
||||
});
|
||||
|
||||
test("light mode survives; anything else is dark", () => {
|
||||
expect(withTheme(rendered({ mode: "light" }), omarchyTheme)?.mode).toBe(
|
||||
"light",
|
||||
);
|
||||
expect(withTheme(rendered({ mode: "nonsense" }), omarchyTheme)?.mode).toBe(
|
||||
"dark",
|
||||
);
|
||||
});
|
||||
|
||||
test("a partial palette is no theme, not a half-themed console", () => {
|
||||
// The accent alone is what the console used to take, and it left the violet chrome under a
|
||||
// themed button. Every surface is now mixed from the background/foreground pair, so a file
|
||||
// carrying neither has nothing to mix — fall back to the console's own palette entire.
|
||||
expect(
|
||||
withTheme('{"mode":"dark","accent":"#89b4fa"}', omarchyTheme),
|
||||
).toBeNull();
|
||||
expect(
|
||||
withTheme(rendered({ background: undefined }), omarchyTheme),
|
||||
).toBeNull();
|
||||
expect(
|
||||
withTheme(rendered({ foreground: undefined }), omarchyTheme),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("no file is no theme, not an error", () => {
|
||||
expect(withTheme(null, omarchyTheme)).toBeNull();
|
||||
});
|
||||
|
||||
test("an UNRENDERED template is refused", () => {
|
||||
// The exact shape of a `.tpl` Omarchy never rendered — the placeholder is not a colour, and
|
||||
// letting it through would put `{{ accent }}` into a style declaration.
|
||||
expect(
|
||||
withTheme(
|
||||
'{"mode":"{{ mode }}","background":"{{ background }}",' +
|
||||
'"foreground":"{{ foreground }}","accent":"{{ accent }}"}',
|
||||
omarchyTheme,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("refuses anything that could break out of a style declaration", () => {
|
||||
const hostile = [
|
||||
"red; background: url(http://evil/)",
|
||||
"#fff; --primary: blue",
|
||||
"</style><script>alert(1)</script>",
|
||||
"expression(alert(1))",
|
||||
"url(javascript:alert(1))",
|
||||
"#".repeat(200),
|
||||
];
|
||||
// EVERY colour reaches the DOM, not just the accent — background and foreground are inlined
|
||||
// into the same style attribute, so each of the three has to be validated, and a test that
|
||||
// only covered `accent` would have missed the two that were added later.
|
||||
for (const field of ["accent", "background", "foreground"]) {
|
||||
for (const value of hostile) {
|
||||
expect(
|
||||
withTheme(rendered({ [field]: value }), omarchyTheme),
|
||||
).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts the notations Omarchy themes actually use", () => {
|
||||
for (const accent of [
|
||||
"#89b4fa",
|
||||
"#fff",
|
||||
"#89b4faff",
|
||||
"rgb(137, 180, 250)",
|
||||
"oklch(0.7 0.1 250)",
|
||||
]) {
|
||||
expect(withTheme(rendered({ accent }), omarchyTheme)?.accent).toBe(
|
||||
accent,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a half-written file during a theme switch is no theme", () => {
|
||||
expect(withTheme('{"mode":"dark","acc', omarchyTheme)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// The Omarchy theme, if this box has one.
|
||||
//
|
||||
// Omarchy renders every registered `~/.config/omarchy/themed/*.tpl` from the active theme's
|
||||
// semantic `colors.toml` on each `omarchy-theme-set`, dropping the result in
|
||||
// `~/.local/state/omarchy/current/theme/`. We register `punktfunk.json.tpl` (installed by
|
||||
// `punktfunk-omarchy setup`, opt-in), so the file below is the theme expressed in exactly the four
|
||||
// values the console can act on.
|
||||
//
|
||||
// Deliberately a FILE read and not an integration: there is no Omarchy API to call, the host learns
|
||||
// nothing, and a box that never opted in simply has no file — which is why every failure here is
|
||||
// "no theme", never an error. The console's own palette is the fallback and always was.
|
||||
//
|
||||
// All four values are carried, not just the accent: an accent alone leaves the console's own violet
|
||||
// chrome under a themed button, which is what "the theme is not fully applied" means in practice.
|
||||
// `styles.css` mixes the surfaces out of the background/foreground pair.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface OmarchyTheme {
|
||||
/** `light` | `dark` — drives the `.dark` class the whole palette keys off. */
|
||||
mode: "light" | "dark";
|
||||
/** The theme's accent, as a CSS colour. Mapped onto `--pf-brand`, which `--primary`,
|
||||
* `--accent` and `--ring` all derive from, so one value re-tints the console. */
|
||||
accent: string;
|
||||
/** The desktop's own page colour. Mapped onto `--pf-bg`, which the console's cards, hovers
|
||||
* and borders are all mixed out of — this is the value that makes the console look like it
|
||||
* belongs to the theme rather than merely agreeing with its accent. */
|
||||
background: string;
|
||||
/** The desktop's own text colour (`--pf-fg`), and the other end of every one of those mixes. */
|
||||
foreground: string;
|
||||
}
|
||||
|
||||
/** Where Omarchy renders our template. `XDG_STATE_HOME` first, because that is what the spec says
|
||||
* and what a non-default setup uses; `~/.local/state` is the default it falls back to. */
|
||||
function themePath(): string {
|
||||
const state =
|
||||
process.env.XDG_STATE_HOME?.trim() || join(homedir(), ".local", "state");
|
||||
return join(state, "omarchy", "current", "theme", "punktfunk.json");
|
||||
}
|
||||
|
||||
/** A CSS colour we are willing to inline into a style attribute.
|
||||
*
|
||||
* This is the security-relevant line in the file: the value reaches the DOM, so anything that could
|
||||
* close the declaration and start another one is refused. Hex and the common functional notations
|
||||
* cover every theme Omarchy ships; anything else falls back to the console's own brand rather than
|
||||
* being sanitised into something half-right. */
|
||||
function isSafeColor(v: unknown): v is string {
|
||||
return (
|
||||
typeof v === "string" &&
|
||||
v.length <= 64 &&
|
||||
/^(#[0-9a-fA-F]{3,8}|(rgb|rgba|hsl|hsla|oklch|oklab)\([0-9a-zA-Z.,%/\s-]+\))$/.test(
|
||||
v.trim(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active Omarchy theme, or `null` when this box has none — which is every box that is not
|
||||
* Omarchy, and every Omarchy box whose operator did not opt in.
|
||||
*
|
||||
* Read per request rather than cached: `omarchy-theme-set` rewrites the file whenever the user
|
||||
* changes theme, and a console that only noticed at startup would be wrong until it restarted.
|
||||
* It is one small local read behind an already-authenticated route.
|
||||
*/
|
||||
export function omarchyTheme(): OmarchyTheme | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(themePath(), "utf8");
|
||||
} catch {
|
||||
return null; // no file: not Omarchy, or not opted in
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const mode = parsed.mode === "light" ? "light" : "dark";
|
||||
// An unrendered template still contains its `{{ accent }}` placeholder — that is not a
|
||||
// colour, and `isSafeColor` is what stops it reaching the page as one. All THREE colours
|
||||
// are required: the template renders them together, so a file missing one is a file we do
|
||||
// not understand, and half a palette reads worse than the console's own.
|
||||
if (
|
||||
!isSafeColor(parsed.accent) ||
|
||||
!isSafeColor(parsed.background) ||
|
||||
!isSafeColor(parsed.foreground)
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
mode,
|
||||
accent: parsed.accent.trim(),
|
||||
background: parsed.background.trim(),
|
||||
foreground: parsed.foreground.trim(),
|
||||
};
|
||||
} catch {
|
||||
return null; // half-written during a theme switch, or hand-edited into invalid JSON
|
||||
}
|
||||
}
|
||||
+22
-3
@@ -5,14 +5,33 @@
|
||||
// origin, and the port has to come from the server — only it knows whether the listener bound.
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
/** The desktop's own theme, when the console is running on an Omarchy box that opted in. */
|
||||
export interface OmarchyTheme {
|
||||
mode: "light" | "dark";
|
||||
accent: string;
|
||||
background: string;
|
||||
foreground: string;
|
||||
}
|
||||
|
||||
export interface UiConfig {
|
||||
pluginUi: "origin" | "same-origin" | "unavailable";
|
||||
pluginPort: number | null;
|
||||
/** `null` on every box that is not a themed Omarchy one — the console keeps its own palette. */
|
||||
theme: OmarchyTheme | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deployment facts the console cannot infer. Cached for the session — the ports cannot change
|
||||
* without the server restarting, which reloads the page anyway.
|
||||
* Deployment facts the console cannot infer.
|
||||
*
|
||||
* Polled, and it did not used to be: the ports genuinely cannot change without a server restart
|
||||
* (which reloads the page), so this was cached for the session — but the THEME on the same payload
|
||||
* changes whenever the user runs `omarchy-theme-set`, and a console that only asked at startup sat
|
||||
* in the old palette until someone reloaded it by hand. Polling and not pushing because the
|
||||
* console's SSE stream is a proxy of the HOST's, and the host reads nothing about themes.
|
||||
*
|
||||
* The interval does not run while the tab is in the background (TanStack's default), and the
|
||||
* refetch-on-focus this re-enables means switching theme and looking at the console is already
|
||||
* enough. One small local file read per tick.
|
||||
*/
|
||||
export const useUiConfig = () =>
|
||||
useQuery({
|
||||
@@ -24,7 +43,7 @@ export const useUiConfig = () =>
|
||||
if (!r.ok) throw new Error(`ui-config ${r.status}`);
|
||||
return (await r.json()) as UiConfig;
|
||||
},
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchInterval: 2_000,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
// brand identity (flattened from the clients/apple punktfunk_Logo.icon, shared
|
||||
// verbatim with the marketing site + docs). Back-to-front: large light-violet
|
||||
// circle, deep-violet circle, light highlight where they overlap.
|
||||
//
|
||||
// The three fills are the brand TOKENS, not the violet literals they default to, so
|
||||
// the mark re-tints with the rest of the console on an Omarchy box that gave us a
|
||||
// theme (see the `[data-omarchy]` block in styles.css). Each keeps its literal as a
|
||||
// var() fallback, so the mark is still correct anywhere the stylesheet is not.
|
||||
// Inline `style` rather than a `fill=` attribute: var() in a presentation attribute
|
||||
// is not reliable across engines, and in a style declaration it always is.
|
||||
export function BrandMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -14,15 +21,15 @@ export function BrandMark({ className }: { className?: string }) {
|
||||
<title>Punktfunk</title>
|
||||
<path
|
||||
d="M403.037,791.672c107.586,0 194.41,-86.824 194.41,-194.41c0,-107.586 -86.824,-194.41 -194.41,-194.41c-107.586,0 -194.41,86.824 -194.41,194.41c0,107.586 86.824,194.41 194.41,194.41Z"
|
||||
fill="#a79ff8"
|
||||
style={{ fill: "var(--pf-brand-light, #a79ff8)" }}
|
||||
/>
|
||||
<path
|
||||
d="M735.276,540.321c76.075,-76.075 76.075,-198.862 0,-274.937c-76.075,-76.075 -198.862,-76.075 -274.937,0c-76.075,76.075 -76.075,198.862 0,274.937c76.075,76.075 198.862,76.075 274.937,0Z"
|
||||
fill="#6c5bf3"
|
||||
style={{ fill: "var(--pf-brand, #6c5bf3)" }}
|
||||
/>
|
||||
<path
|
||||
d="M647.84,590.737c-64.853,17.403 -136.871,0.597 -187.885,-50.416c-51.013,-51.013 -67.819,-123.032 -50.416,-187.885c64.853,-17.403 136.871,-0.597 187.885,50.416c51.013,51.013 67.819,123.032 50.416,187.885Z"
|
||||
fill="#d2c9fb"
|
||||
style={{ fill: "var(--pf-highlight, #d2c9fb)" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Wordmark } from "./wordmark";
|
||||
export function Logo({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("relative inline-block", className)}>
|
||||
<BrandMark className="absolute left-0 top-0 w-[24%] -translate-x-[55%] -translate-y-[58%] drop-shadow-[0_4px_24px_rgba(108,91,243,0.45)]" />
|
||||
<BrandMark className="absolute left-0 top-0 w-[24%] -translate-x-[55%] -translate-y-[58%] drop-shadow-[0_4px_24px_var(--pf-glow)]" />
|
||||
<Wordmark className="block h-auto w-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
import "@fontsource-variable/geist";
|
||||
import { Toaster } from "@unom/ui/toast";
|
||||
import { MotionConfig } from "motion/react";
|
||||
import { useEffect } from "react";
|
||||
import { type CSSProperties, useEffect } from "react";
|
||||
import { useUiConfig } from "@/api/uiConfig";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { DialogsProvider } from "@/components/dialogs";
|
||||
import { adoptStoredLocale, useLocale } from "@/lib/i18n";
|
||||
@@ -59,8 +60,34 @@ function RootComponent() {
|
||||
const isLogin = useRouterState({
|
||||
select: (s) => s.location.pathname === "/login",
|
||||
});
|
||||
// On an Omarchy box that opted in, follow the desktop's theme. Three raw values go in and
|
||||
// `data-omarchy` turns on the block in styles.css that expands them: `mode` picks the palette
|
||||
// the whole stylesheet already keys off, the accent re-tints the brand (and with it `--primary`,
|
||||
// `--accent`, `--ring` and the lens mark), and the background/foreground pair is what every
|
||||
// surface — cards, hovers, borders — is mixed out of. Everywhere else `theme` is null, the
|
||||
// attribute is absent and the console keeps its own violet, which is also what SSR renders and
|
||||
// what shows for the moment before this resolves.
|
||||
//
|
||||
// The expansion lives in CSS rather than here on purpose: `color-mix()` does it natively, in one
|
||||
// place, for both modes at once — and it is the only way `.dark`'s own values get overridden
|
||||
// without this component knowing which of them each mode uses.
|
||||
const { data: uiConfig } = useUiConfig();
|
||||
const theme = uiConfig?.theme ?? null;
|
||||
return (
|
||||
<html lang={locale} className="dark">
|
||||
<html
|
||||
lang={locale}
|
||||
className={theme?.mode === "light" ? undefined : "dark"}
|
||||
data-omarchy={theme ? "" : undefined}
|
||||
style={
|
||||
theme
|
||||
? ({
|
||||
"--pf-accent": theme.accent,
|
||||
"--pf-bg": theme.background,
|
||||
"--pf-fg": theme.foreground,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
--pf-brand: #6c5bf3; /* deep violet — primary on light */
|
||||
--pf-brand-light: #a79ff8; /* light violet — primary on dark */
|
||||
--pf-highlight: #d2c9fb; /* lens highlight */
|
||||
--pf-glow: rgb(108 91 243 / 0.45); /* the lens mark's drop shadow */
|
||||
|
||||
/* Surfaces — light · lavender (white bg, faint-violet cards/borders). */
|
||||
--background: #ffffff;
|
||||
@@ -109,6 +110,74 @@
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
}
|
||||
|
||||
/* ── Omarchy: the desktop's own palette, expanded into the console's tokens. ──
|
||||
`data-omarchy` and the three --pf-* values below it are set by routes/__root.tsx
|
||||
from the theme file Omarchy renders for us (server/util/omarchyTheme.ts); on
|
||||
every other box the attribute is absent and none of this applies.
|
||||
|
||||
`:root[data-omarchy]` outranks both `:root` and `.dark`, so ONE block covers both
|
||||
modes: the surface mixes take their direction from the theme's own background /
|
||||
foreground pair, and "toward the foreground" is darker on a light theme and
|
||||
lighter on a dark one — which is exactly what surfaces need either way.
|
||||
|
||||
Every ratio here was measured against six shipped Omarchy themes (Tokyo Night,
|
||||
Gruvbox, Nord, Catppuccin Latte, Rose Pine Dawn, Everforest Light) for WCAG
|
||||
contrast and, for the mark, for visible separation between its three tints.
|
||||
|
||||
Surfaces and the brand follow the theme. --success / --warning / --destructive do
|
||||
NOT: they encode meaning, and a theme whose accent is red must not leave "delete"
|
||||
and "save" the same colour. */
|
||||
:root[data-omarchy] {
|
||||
--background: var(--pf-bg);
|
||||
--foreground: var(--pf-fg);
|
||||
/* Cards, hovers and borders are the background lifted off itself by increasing
|
||||
amounts of foreground, so they stay in the theme's hue instead of sitting on it. */
|
||||
--card: color-mix(in oklab, var(--pf-bg) 94%, var(--pf-fg));
|
||||
--card-foreground: var(--pf-fg);
|
||||
--popover: var(--card);
|
||||
--popover-foreground: var(--pf-fg);
|
||||
--muted: color-mix(in oklab, var(--pf-bg) 90%, var(--pf-fg));
|
||||
/* ponytail: 88% is as muted as this can go and stay readable, and on a theme whose
|
||||
own foreground is only 5.2:1 against its own background (Everforest Light) NO
|
||||
ratio reaches 4.5 on a card — the headroom is the theme's, not ours. Measured
|
||||
worst case 3.8:1 there; every dark theme clears 4.5 comfortably. Give this its own
|
||||
token if a theme ever needs a hand-picked value. */
|
||||
--muted-foreground: color-mix(in oklab, var(--pf-fg) 88%, var(--pf-bg));
|
||||
--secondary: color-mix(in oklab, var(--pf-bg) 86%, var(--pf-fg));
|
||||
--secondary-foreground: var(--pf-fg);
|
||||
--border: color-mix(in oklab, var(--pf-bg) 80%, var(--pf-fg));
|
||||
--input: var(--border);
|
||||
|
||||
/* shadcn's `accent` is a subtle hover SURFACE (and @unom/ui's card ring), not the
|
||||
brand at full strength — so it is a background-weighted tint of the accent, which
|
||||
is what keeps the theme's own foreground readable on top of it. */
|
||||
--accent: color-mix(in oklab, var(--pf-bg) 86%, var(--pf-accent));
|
||||
--accent-foreground: var(--pf-fg);
|
||||
|
||||
/* The lens mark, rebuilt from the accent: deep circle, lighter circle, and the
|
||||
highlight where they overlap. Mixed toward black and white rather than toward the
|
||||
theme's own foreground — measured, because on a theme whose accent and foreground
|
||||
are close (Tokyo Night) mixing them collapses the three tints into one flat blob.
|
||||
These also carry --primary and --ring, which is why they are worth this care. */
|
||||
--pf-brand: color-mix(in oklab, var(--pf-accent) 88%, black);
|
||||
--pf-brand-light: color-mix(in oklab, var(--pf-accent) 55%, white);
|
||||
--pf-highlight: color-mix(in oklab, var(--pf-accent) 15%, white);
|
||||
--pf-glow: color-mix(in oklab, var(--pf-accent) 45%, transparent);
|
||||
}
|
||||
|
||||
/* `.dark` takes --primary from the LIGHT tint, so the text on it has to be the dark
|
||||
end of the theme — its own background. */
|
||||
:root[data-omarchy].dark {
|
||||
--primary-foreground: var(--pf-bg);
|
||||
}
|
||||
|
||||
/* Light mode takes --primary from --pf-brand and keeps :root's white text, so the
|
||||
button has to be deep enough to hold it: a soft accent (a dusty rose, a sage) is
|
||||
only 3.9:1 at --pf-brand, and 5.7:1 here. */
|
||||
:root[data-omarchy]:not(.dark) {
|
||||
--primary: color-mix(in oklab, var(--pf-accent) 75%, black);
|
||||
}
|
||||
|
||||
/* Map the palette to Tailwind colour/util tokens — both the shadcn vocabulary
|
||||
and @unom/ui's, resolved to one set of values. */
|
||||
@theme inline {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// Guards the Omarchy palette derivation in src/styles.css.
|
||||
//
|
||||
// That block turns three values from the desktop — background, foreground, accent — into every
|
||||
// surface the console paints, using `color-mix(in oklab, …)`. The ratios in it are not taste: each
|
||||
// one was picked by measuring contrast across real Omarchy themes, and nudging one by ten points
|
||||
// is enough to put grey text on a grey card for somebody whose theme we have never seen. Nothing
|
||||
// else in the repo can catch that — the mixes are resolved by the browser, so the typecheck, the
|
||||
// unit tests and the build all pass on a palette that is unreadable.
|
||||
//
|
||||
// So: read the ratios back OUT of the stylesheet, redo the mixes here, and assert WCAG contrast
|
||||
// against a table of shipped themes. It fails on a re-tune that breaks readability, and stays
|
||||
// quiet on one that does not.
|
||||
//
|
||||
// Run by `postbuild`, beside check-i18n. No dependencies — oklab is about forty lines of maths.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const CSS = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"src",
|
||||
"styles.css",
|
||||
);
|
||||
|
||||
/** Real themes Omarchy ships, as `[background, foreground, accent]`. Everforest Light is in here
|
||||
* on purpose: its own foreground is only 5.2:1 against its own background, so it is the floor
|
||||
* that decides what a *derived* muted colour can possibly reach. */
|
||||
const THEMES = {
|
||||
"Tokyo Night (dark)": ["#1a1b26", "#a9b1d6", "#7aa2f7"],
|
||||
"Gruvbox (dark)": ["#282828", "#ebdbb2", "#d79921"],
|
||||
"Nord (dark)": ["#2e3440", "#d8dee9", "#88c0d0"],
|
||||
"Catppuccin Latte (light)": ["#eff1f5", "#4c4f69", "#1e66f5"],
|
||||
"Rose Pine Dawn (light)": ["#faf4ed", "#575279", "#d7827e"],
|
||||
"Everforest Light": ["#fdf6e3", "#5c6a72", "#8da101"],
|
||||
};
|
||||
|
||||
// ── colour maths: sRGB ⇄ Oklab (Ottosson), and WCAG 2.1 relative luminance ──────────────────
|
||||
const hex = (h) => {
|
||||
const s = h.replace("#", "");
|
||||
return [0, 2, 4].map((i) => Number.parseInt(s.slice(i, i + 2), 16) / 255);
|
||||
};
|
||||
const toLin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
const fromLin = (c) =>
|
||||
c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
|
||||
const cbrt = (v) => (v >= 0 ? Math.cbrt(v) : -Math.cbrt(-v));
|
||||
|
||||
function toOklab([r0, g0, b0]) {
|
||||
const [r, g, b] = [toLin(r0), toLin(g0), toLin(b0)];
|
||||
const l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
||||
const m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
||||
const s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||
return [
|
||||
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
||||
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
||||
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
||||
];
|
||||
}
|
||||
|
||||
function fromOklab([L, A, B]) {
|
||||
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
|
||||
const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
|
||||
const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
|
||||
return [
|
||||
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
||||
].map((c) => Math.min(1, Math.max(0, fromLin(c))));
|
||||
}
|
||||
|
||||
/** `color-mix(in oklab, a pct%, b)`. */
|
||||
const mix = (a, pct, b) => {
|
||||
const [x, y] = [toOklab(a), toOklab(b)];
|
||||
return fromOklab(x.map((v, i) => (pct / 100) * v + (1 - pct / 100) * y[i]));
|
||||
};
|
||||
|
||||
const lum = ([r, g, b]) =>
|
||||
0.2126 * toLin(r) + 0.7152 * toLin(g) + 0.0722 * toLin(b);
|
||||
const contrast = (a, b) => {
|
||||
const [hi, lo] = [lum(a), lum(b)].sort((p, q) => q - p);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
|
||||
// ── read the ratios back out of the stylesheet ─────────────────────────────────────────────
|
||||
const css = readFileSync(CSS, "utf8");
|
||||
|
||||
/** The percentage in `--<token>: color-mix(in oklab, <first> N%, <second>)`. Throws rather than
|
||||
* defaulting: a declaration this cannot find is one nobody is checking any more. */
|
||||
function ratio(token) {
|
||||
const m = css.match(
|
||||
// `.` stops at the newline, so this can only ever read the token's own declaration —
|
||||
// and a character class excluding `)` would stop dead on the first `var(--pf-bg)`.
|
||||
new RegExp(`--${token}:\\s*color-mix\\(in oklab,.*?(\\d+)%`),
|
||||
);
|
||||
if (!m) {
|
||||
throw new Error(
|
||||
`check-omarchy-palette: no --${token} color-mix found in styles.css. If the ` +
|
||||
"derivation was restructured, update this check with it.",
|
||||
);
|
||||
}
|
||||
return Number(m[1]);
|
||||
}
|
||||
|
||||
const R = {
|
||||
card: ratio("card"),
|
||||
muted: ratio("muted"),
|
||||
mutedFg: ratio("muted-foreground"),
|
||||
secondary: ratio("secondary"),
|
||||
border: ratio("border"),
|
||||
accent: ratio("accent"),
|
||||
brand: ratio("pf-brand"),
|
||||
brandLight: ratio("pf-brand-light"),
|
||||
highlight: ratio("pf-highlight"),
|
||||
// The light-mode override, in its own `:not(.dark)` rule.
|
||||
primaryLight: Number(
|
||||
css.match(
|
||||
/:not\(\.dark\)\s*\{[\s\S]*?--primary:\s*color-mix\(in oklab,.*?(\d+)%/,
|
||||
)?.[1] ?? Number.NaN,
|
||||
),
|
||||
};
|
||||
if (Number.isNaN(R.primaryLight)) {
|
||||
throw new Error(
|
||||
"check-omarchy-palette: no light-mode --primary override found in styles.css.",
|
||||
);
|
||||
}
|
||||
|
||||
const WHITE = [1, 1, 1];
|
||||
const BLACK = [0, 0, 0];
|
||||
const failures = [];
|
||||
|
||||
for (const [name, [bgH, fgH, acH]] of Object.entries(THEMES)) {
|
||||
const dark = lum(hex(bgH)) < lum(hex(fgH));
|
||||
const [bg, fg, ac] = [hex(bgH), hex(fgH), hex(acH)];
|
||||
|
||||
const card = mix(bg, R.card, fg);
|
||||
const mutedFg = mix(fg, R.mutedFg, bg);
|
||||
const border = mix(bg, R.border, fg);
|
||||
const accent = mix(bg, R.accent, ac);
|
||||
const brand = mix(ac, R.brand, BLACK);
|
||||
const brandLight = mix(ac, R.brandLight, WHITE);
|
||||
const highlight = mix(ac, R.highlight, WHITE);
|
||||
// `.dark` puts --primary on the light tint with the theme's background as its text;
|
||||
// light mode uses its own deepened override with :root's white.
|
||||
const primary = dark ? brandLight : mix(ac, R.primaryLight, BLACK);
|
||||
const primaryFg = dark ? bg : WHITE;
|
||||
|
||||
// Each floor is what the derivation was measured to hold, NOT an aspiration. Where a floor
|
||||
// sits below WCAG AA the reason is the theme's own headroom, and it is named.
|
||||
const checks = [
|
||||
["foreground on card", fg, card, 4.5],
|
||||
// A theme whose own foreground is ~5:1 on its own background cannot yield a MUTED
|
||||
// variant that clears 4.5 on a card. 3.7 is the measured floor across this table.
|
||||
["muted-foreground on card", mutedFg, card, 3.7],
|
||||
["text on a primary button", primaryFg, primary, 4.5],
|
||||
["foreground on the accent surface", fg, accent, 4.5],
|
||||
["card distinguishable from background", card, bg, 1.05],
|
||||
["border distinguishable from card", border, card, 1.1],
|
||||
// The lens mark is three tints of one accent; too close and it reads as a blob.
|
||||
["mark: light circle vs deep circle", brandLight, brand, 1.4],
|
||||
["mark: highlight vs light circle", highlight, brandLight, 1.25],
|
||||
];
|
||||
for (const [what, a, b, floor] of checks) {
|
||||
const r = contrast(a, b);
|
||||
if (r < floor) {
|
||||
failures.push(`${name}: ${what} is ${r.toFixed(2)}:1, floor ${floor}:1`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
`✖ Omarchy palette: ${failures.length} contrast floor(s) breached by the ratios in ` +
|
||||
"src/styles.css:\n " +
|
||||
failures.join("\n "),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`✔ Omarchy palette: ${Object.keys(THEMES).length} themes clear every contrast floor`,
|
||||
);
|
||||
Reference in New Issue
Block a user