Compare commits

..
Author SHA1 Message Date
enricobuehler 8f9c72877e Merge pull request 'An SDK fix could never reach an installed plugin — the runner now carries it' (#117) from worktree-runner-sdk-reconcile into main
ci / bun-nix (push) Successful in 24s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
ci / rust-arm64 (push) Successful in 2m28s
deb / build-publish-client-arm64 (push) Successful in 1m35s
deb / build-publish-host (push) Successful in 4m13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m59s
sdk-publish / publish (push) Successful in 1m52s
docker / builders-arm64cross (push) Successful in 15s
deb / build-publish (push) Successful in 7m16s
ci / rust (push) Successful in 6m52s
arch / build-publish (push) Successful in 7m29s
docker / deploy-docs (push) Failing after 3m54s
windows-host / package (push) Successful in 15m55s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
nix / flake (push) Successful in 14m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m11s
Reviewed-on: #117
2026-08-08 12:41:58 +00:00
enricobuehler 8f32976349 Merge pull request 'Library source settings never opened — the drawer still called the old plugin origin' (#118) from worktree-library-settings-origin-split into main
ci / bun-nix (push) Successful in 31s
arch / build-publish (push) Canceled after 1m8s
ci / rust (push) Canceled after 52s
ci / docs-site (push) Canceled after 1m12s
ci / rust-arm64 (push) Canceled after 1m15s
ci / web (push) Canceled after 1m15s
deb / build-publish (push) Canceled after 55s
deb / build-publish-host (push) Canceled after 45s
deb / build-publish-client-arm64 (push) Canceled after 8s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 17s
docker / builders-arm64cross (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 26s
windows-host / package (push) Canceled after 1m54s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
Reviewed-on: #118
2026-08-08 12:40:44 +00:00
enricobuehler deef5e4382 fix(console): library source settings 404'd — the drawer still called the old plugin origin
ci / bun-nix (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m18s
ci / rust-arm64 (pull_request) Successful in 3m22s
ci / rust (pull_request) Successful in 4m30s
Opening a library source's settings did nothing, for every library plugin. Confirmed on
`.21` against the running console:

    console origin :47992  /plugin-ui/lutris/__config -> 404
    plugin  origin :47993  /plugin-ui/lutris/__config -> 401

The drawer fetches a RELATIVE `/plugin-ui/<id>/__config`, so it resolves against the
console's own origin — where `middleware/auth.ts` answers 404 for `/plugin-ui/**`
unconditionally and by design. That refusal is the 2026-08-05 review's origin split
(H-3): plugin UIs moved to their own listener, and neither origin may serve the other's
paths. The drawer is the only consumer of `/plugin-ui` that is NOT an iframe — every
other caller builds an absolute URL from `pluginOriginFrom(uiConfig)` — so it was the
one thing the split broke, and nothing failed loudly enough to notice.

The fix is deliberately not to point the drawer at the plugin origin. That needs CORS
plus cross-site cookies, and it would put a plugin-controlled response inside a
credentialed cross-origin fetch — reopening exactly the hole the split closed. What
this drawer needs is DATA, not an embedded UI: `/api/plugin-config/<id>` reads the
plugin's `__config` server-side over loopback and returns the JSON same-origin, so no
plugin markup or script is ever served from the console origin and the per-boot secret
stays on the server, as with the `/plugin-ui` proxy.

`/api/**` is always session-gated (`isPublicPath`), so the new route inherits the gate
and answers 401 as JSON rather than redirecting to /login — which is what a `fetch`
needs and what the old path could never give it. It forwards only GET and PUT, reads
the body BEFORE the stale-credential retry (`readRawBody` drains the stream, so a
retried PUT would have saved `{}` over the operator's config), and passes the plugin's
own body through untouched so a 400's decode issue still reaches the operator.

Verified against the real built server: `/api/plugin-config/lutris` answers 401 — the
route resolves and is gated, and the BFF catch-all at `api/[...]` does not swallow it —
while `/plugin-ui/lutris/__config` still answers 404 on the console origin, i.e. the
split is intact. `/api/v1/status` still reaches the BFF. tsc clean, production build
clean, i18n 633 messages across en+de, biome clean on both touched files (the one
warning in SourceSettings.tsx pre-dates this change).
2026-08-08 14:17:58 +02:00
enricobuehler 32cc8dd529 fix(runner): an SDK fix could never reach an installed plugin
ci / bun-nix (pull_request) Successful in 21s
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 3m5s
ci / rust (pull_request) Successful in 4m31s
nix / flake (pull_request) Failing after 11m38s
Publishing `@punktfunk/host@0.1.3` — the release that lets a library scanner register
`category`, so Lutris and Heroic stay out of the console nav — reached **no existing
install**. Measured on `.21`: the only thing that moved it was deleting `bun.lock` by
hand over ssh. A fix that needs an ssh session is not a fix.

**Why nothing reached it.** Every plugin resolves the SDK from the plugins tree, and
`bun.lock` pins it to an exact version with an integrity hash. Nothing in any
user-facing flow re-resolves that pin: installing a plugin, reinstalling it, and even
updating it to a newer release all leave the SDK alone, because the plugin's `^0.1.x`
range is already satisfied by what is locked. `bun update` does not help either — the
plugins are pinned exactly in the root manifest, so there is no direct dependency to
update through.

**Where the fix belongs.** The runner. It is bundled from this same `sdk/` at the
host's release commit (`packaging/arch/PKGBUILD` builds `src/runner-cli.ts` into the
punktfunk-scripting package), so `SDK_VERSION` is by construction the SDK matching the
host now on disk. A host upgrade is therefore the one moment that can carry an SDK fix
to already-installed plugins, and now it does — before any plugin loads, and with no
operator action at all.

**Why it re-resolves the whole lockfile** rather than pinning the SDK at the root: a
targeted `bun add @punktfunk/host@<v>` does NOT work while plugins declare the SDK in
their own `dependencies` (all six scanners do, though none import it). bun honours
their locked resolution and gives each a private nested copy that then SHADOWS the
root — measured, 5 nested copies, which is how I first "fixed" the box while leaving
every plugin still importing 0.1.2. A lockless resolve hoists one copy for everyone.
Once the plugins drop that spurious dependency this can become the targeted form.

Safety, because this runs unattended at boot on a tree the operator's plugins load
from: plugin versions are pinned exactly in the root manifest so a re-resolve cannot
move them (verified — lutris stays 0.1.0); the lockfile is backed up and restored if
the install fails or fails to deliver; and every failure is logged and swallowed, so a
dependency refresh can never stop working plugins from starting. The no-op path is the
one that runs on every healthy box, so it is tested first: same version, or no SDK at
all, touches nothing and logs nothing.

The SDK is bumped to 0.1.4 because its published content changed. Republishing 0.1.3
is impossible, and letting source drift from a published version is precisely the
defect that produced this whole chain — 0.1.2 was published before it forwarded
`category`, then the source changed underneath it without a bump. `version.test.ts`
fails if `SDK_VERSION` and `package.json` ever disagree.

Verified end to end on `.21` against a tree seeded from the operator's real pre-fix
backup: 0.1.2 → 0.1.3 automatically, one hoisted copy, no nested copies, plugin
versions preserved, and a second run is a silent no-op. SDK 79 tests pass (5 new),
typecheck clean.
2026-08-08 14:07:11 +02:00
enricobuehler 4b514cc07c Merge pull request 'An OLED palette, and split WHETHER the gamepad UI is offered from WHEN it appears' (#116) from worktree-oled-theme-gamepad-ui-split into main
apple / swift (push) Successful in 1m33s
ci / rust-arm64 (push) Successful in 2m51s
ci / web (push) Successful in 3m13s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m45s
ci / bun-nix (push) Successful in 45s
ci / docs-site (push) Successful in 1m30s
ci / rust (push) Successful in 4m55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 40s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 1m3s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m51s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m2s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 40s
deb / build-publish-client-arm64 (push) Successful in 2m42s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m18s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m17s
deb / build-publish-host (push) Successful in 6m16s
docker / builders-arm64cross (push) Successful in 11s
release / apple (push) Successful in 9m52s
docker / deploy-docs (push) Successful in 36s
android / android (push) Successful in 13m40s
deb / build-publish (push) Successful in 9m17s
arch / build-publish (push) Successful in 14m36s
flatpak / build-publish (push) Successful in 7m20s
apple / screenshots (push) Successful in 6m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m21s
Reviewed-on: #116
2026-08-08 11:22:34 +00:00
enricobuehler 30bd10e301 feat(clients): an OLED palette, and split WHETHER the gamepad UI is offered from WHEN it appears
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Successful in 4m7s
android / android (pull_request) Successful in 5m1s
ci / docs-site (pull_request) Successful in 1m47s
ci / bun-nix (pull_request) Successful in 42s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 14m32s
Four changes to the client interface, kept together because two of them touch the same rows
and the last is a bug the first would have made far more visible.

A thirteenth `ui_palette` entry, `oled`. The palette table is hand-mirrored in three languages
(`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`), so it goes into
all three at index 1, directly after the brand default — which keeps `PALETTES[0]` the unknown-id
fallback and keeps the dark-to-pale cycling order intact. What earns the name is arithmetic, not
a darker shade of violet: the ramp's first two stops are literally (0,0,0) and the ground is pure
black, so the shaded half of the field is pixels switched off rather than "very dark grey", and
the calm mix the form screens sit under lifts toward nothing at all. Mean cell luminance is 0.019
against Violet's 0.254. The bright corner keeps a faint indigo-to-violet ember so the backdrop is
still a field with somewhere to go, and that ember carries enough chroma at that luminance
(60 degrees of hue travel across 13 of the 16 cells) to satisfy the existing multi-tone assertion
without adding `oled` to the near-neutral exemption Graphite and Opal take. Each port gains an
`oled_is_actually_black` test that measures the claim — pure-black corner cells, a mean under half
the darkest other field's — rather than restating the table.

A new device key, `gamepad_ui_mode`. The gamepad-UI switch had been deciding two things at once:
whether to offer the controller-optimized interface at all, and that it appears only while a pad
is attached. A user asked for the second half to stop applying. `"connected"` (the default, and
exactly what the lone Bool meant) and `"always"` separate them, surfaced as a "Show it" row
directly under the switch on all five settings surfaces and built only while that switch is on —
a picker whose every option decides nothing is worse than no picker. `GamepadUIEnvironment.isActive`
takes the mode with NO default argument on purpose: a call site that forgot it would silently
strand everyone who chose Always back on "only with a controller", which is the one bug this
parameter exists to make impossible. An unrecognized value waits for a controller, so a mode a
newer client wrote can never trap an older one in a layout it has no way back out of. It stays a
device preference on both platforms, never part of a profile: which interface this device wears
has nothing to do with how a host streams to it.

The smoothness buffer is hidden under Lowest latency, not dimmed. Everywhere else already hid it
— the GTK and WinUI shells, the Apple touch and tvOS screens, the Android touch screen — because
under that intent it names a quantity that does not exist. Two surfaces disagreed: Apple's gamepad
settings screen left the row live and steppable, and the desktop console dimmed it, having no way
to drop a row from a fixed list. That list is now rebuilt each frame through a `row_applies`
filter. The concern about a vanishing row moving everything under the cursor does not apply here
and the new test says why: the row it drops sits directly BELOW the row that drops it, so the only
cursor that can be present when the list shrinks is the one on the intent row, which does not
move. Two latent hazards went with it — `apply_row` had been indexing the row list on the
assumption the cursor is always in range, and nothing re-clamped that cursor when another writer
changed the intent behind the screen's back.

Pale palettes were unreadable on tvOS, reported from the field. `GamepadInk` was never the
problem: it flips correctly for a pale field, it is not platform-gated, and every tvOS gamepad
entry point already published it. The cause is that this app sets `preferredColorScheme` nowhere
and declares no `UIUserInterfaceStyle`, so every SYSTEM-derived colour landing on those screens —
a `.secondary` placeholder, a `.bordered` button's chrome, a NavigationStack title, a material's
frost — resolved against the DEVICE appearance, which the palette cannot reach. On iPhone, iPad
and Mac a great many users sit in Light mode, so under a pale palette those colours came out dark
and the theme looked correct by accident; an Apple TV is Dark essentially always, so every one of
them rendered white on a light field. The mirror image was broken too and had simply never been
reported: a dark palette on a Light-mode iPhone was already drawing dark on dark. The scheme is
now published beside the ink, once, in `GamepadInkModifier`, because the two are halves of one
decision and publishing only the ink silently loses every colour the frameworks draw on the app's
behalf. Two structural amplifiers went with it: `ConsoleGlass` had been scoping the scheme to the
fill inside its `.background {}` on the tvOS and pre-26 branches while the 26 branch put it on the
content, so no console row's own content ever saw it on tvOS; and `LibraryView`'s navigation
chrome and its loading, error and empty states sit above `LibraryCoverflowView` and so were never
inked at all on tvOS and macOS, where that view is presented directly rather than through the
iOS-only `GamepadLibraryScreen` wrapper.

That last one exposed a second tvOS gap worth closing in the same breath: `ui_palette` had no row
in tvOS's ordinary Settings, and the gamepad settings screen that owns it everywhere else needs an
extended-profile controller to open on tvOS. An Apple TV driven by the Siri Remote alone could not
reach the palettes at all, which would now include the OLED one. `SettingsView.tvBody` carries a
Background row.

Verified: pf-console-ui builds, passes `clippy --all-targets -D warnings` and runs 74 tests clean
under linux/amd64 (a Mac `cargo check` of that crate is vacuous — every module is cfg'd to
linux/windows); `cargo fmt --check` clean for it and pf-client-core. Android `:app` runs 80 tests
with 0 failures, including four new `gamepadUiActive` cases and the palette parity table. The
Apple package builds for macOS AND tvOS and its 9 palette/gamepad-UI tests pass — the tvOS
typecheck is possible because the checked-in xcframework already carries a `tvos-arm64` slice. The
tvOS RENDERING fix is compile-verified only; an on-glass Apple TV check under a pale palette is
still owed, and is the one thing here that a build cannot answer.
2026-08-08 12:57:12 +02:00
36 changed files with 1116 additions and 139 deletions
@@ -69,11 +69,14 @@ fun App(forceGamepadUi: Boolean = false) {
// later manual Back out of the library is not undone by a stale value.
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
// connect/disconnect — unless the mode is Always, where it simply stays.
val tv = remember { isTvDevice(context) }
val controllerConnected by rememberControllerConnected()
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
val gamepadUi = gamepadUiActive(
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
)
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
@@ -67,7 +67,7 @@ class GamepadPalette(
)
/**
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
* The thirteen shipped palettes: the brand default, six more dark fields, then six pale
* ones. Cycling order runs dark → light, so stepping the row walks the range one way.
*/
val ALL = listOf(
@@ -77,6 +77,22 @@ class GamepadPalette(
ground = Triple(0.075, 0.060, 0.160),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no
// glow, no power. The first two stops are literally (0,0,0), so the shaded half
// of the field is genuinely off rather than "very dark grey", and the ground is
// pure black too: the calm mix on the form screens lifts toward nothing. What is
// left is a faint indigo→violet ember in the bright corner. The accent stays the
// brand violet — focus has to be findable on black.
"oled", "OLED",
listOf(
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
Triple(0.120, 0.024, 0.130),
),
ground = Triple(0.0, 0.0, 0.0),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
"nebula", "Nebula",
@@ -665,6 +665,21 @@ internal fun buildSettingsRows(
"Turn off to use the touch interface even with a controller connected.",
s.gamepadUiEnabled,
) { update(s.copy(gamepadUiEnabled = it)) },
) + listOfNotNull(
// WHEN the switch above takes over. Built only while it is ON: turn the switch off from
// this very screen and the row under the cursor would otherwise be one deciding nothing,
// on a screen that is itself about to disappear.
if (s.gamepadUiEnabled) {
choice(
"gamepadUIMode", GpTab.INTERFACE, null, "Show it",
"With a controller: the touch interface comes back when the last one " +
"disconnects. Always keeps this layout either way — for a device that lives " +
"docked to a TV. A TV itself is always in this mode regardless.",
GAMEPAD_UI_MODE_OPTIONS, s.gamepadUiMode,
) { update(s.copy(gamepadUiMode = it)) }
} else {
null
},
)
}
@@ -16,15 +16,35 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.unom.punktfunk.kit.Gamepad
/**
* [Settings.gamepadUiMode]: take over only while a controller is attached. The default, and what
* the switch meant when it was a lone Boolean.
*/
const val GAMEPAD_UI_WHEN_CONNECTED = "connected"
/**
* [Settings.gamepadUiMode]: take over whenever the switch is on, pad or no pad — for a phone or
* tablet that lives docked to a TV, where the console layout is the one wanted and the pad is not
* always awake.
*/
const val GAMEPAD_UI_ALWAYS = "always"
/**
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
* console UI (as long as the setting is on).
* the user's [enabled] setting AND (the [mode] is [GAMEPAD_UI_ALWAYS] OR a controller is attached
* OR this is a TV OR the dev [forced] flag). A TV counts unconditionally — its remote/gamepad is
* the only input, so it's always the console UI (as long as the setting is on), which is why the
* mode row means nothing there. An unrecognized [mode] waits for a controller, so a value a newer
* client wrote can never strand this one in a layout it has no way back out of.
*/
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
enabled && (controllerConnected || tv || forced)
fun gamepadUiActive(
enabled: Boolean,
mode: String,
controllerConnected: Boolean,
tv: Boolean,
forced: Boolean,
): Boolean = enabled && (mode == GAMEPAD_UI_ALWAYS || controllerConnected || tv || forced)
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
fun isTvDevice(context: Context): Boolean {
@@ -94,11 +94,20 @@ data class Settings(
val touchMode: TouchMode = TouchMode.TRACKPAD,
/**
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
* gamepad chrome) — mirrors the Apple client's `gamepadUIEnabled`. On by default; turn it off
* to keep the touch UI even with a pad attached. WHEN it takes over is [gamepadUiMode].
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
*/
val gamepadUiEnabled: Boolean = true,
/**
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
* has always meant) waits for a controller; `"always"` keeps the console UI with no pad in
* reach, for a phone or tablet that lives docked to a TV. Read only while [gamepadUiEnabled]
* is on, which is why both settings screens hide the row when the switch is off. Anything
* unrecognized resolves to `"connected"`. A TV ignores it — it is always in console mode.
*/
val gamepadUiMode: String = GAMEPAD_UI_WHEN_CONNECTED,
/**
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
@@ -107,9 +116,10 @@ data class Settings(
val libraryEnabled: Boolean = true,
/**
* Which colour family the console (gamepad) UI's living backdrop drifts through — the
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
* desktop console's and the Apple client's under the same names. Presentation only: nothing
* cross-client `ui_palette` key: `"violet"` (the brand default), then `"oled"`, `"nebula"`,
* `"abyss"`, `"ember"`, `"moss"`, `"graphite"`, then the six pale fields. See
* [GamepadPalette], whose table and maths mirror the desktop console's and the Apple
* client's under the same names. Presentation only: nothing
* about a stream depends on it, so it is a device preference and never part of a profile.
* An unknown value reads as the default rather than failing — a newer client may have shipped
* a palette this build doesn't know.
@@ -303,6 +313,8 @@ class SettingsStore(context: Context) {
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
?: GAMEPAD_UI_WHEN_CONNECTED,
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
@@ -344,6 +356,7 @@ class SettingsStore(context: Context) {
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putString(K_UI_PALETTE, s.uiPalette)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
@@ -384,6 +397,7 @@ class SettingsStore(context: Context) {
const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
const val K_LIBRARY = "library_enabled"
const val K_UI_PALETTE = "ui_palette"
@@ -778,6 +792,13 @@ fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
)
}
/** (stored value, label) for when the console UI takes over — the Apple client's table verbatim.
* Only offered while [Settings.gamepadUiEnabled] is on; a TV is in console mode either way. */
val GAMEPAD_UI_MODE_OPTIONS = listOf(
GAMEPAD_UI_WHEN_CONNECTED to "With a controller",
GAMEPAD_UI_ALWAYS to "Always",
)
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
@@ -592,11 +592,24 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
SettingsGroup("Interface") {
ToggleRow(
title = "Controller-optimized UI",
subtitle = "Switch to the console home when a controller is connected. A TV " +
"always uses it.",
subtitle = "Swap the touch home for the console home — the host carousel and " +
"gamepad chrome. A TV always uses it.",
checked = s.gamepadUiEnabled,
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
)
// Only decides anything while the switch above is on, so it is HIDDEN rather than
// dimmed when it isn't — a picker whose every option changes nothing is worse than
// no picker, and this group is short enough that nothing jumps far.
if (s.gamepadUiEnabled) {
SettingDropdown(
label = "Show it",
options = GAMEPAD_UI_MODE_OPTIONS,
selected = s.gamepadUiMode,
caption = "With a controller: the touch home comes back when the last one " +
"disconnects. Always keeps the console home either way — for a device " +
"that lives docked to a TV.",
) { v -> update(s.copy(gamepadUiMode = v)) }
}
}
}
}
@@ -33,14 +33,14 @@ class GamepadPaletteTest {
fun tableMatchesTheOtherClients() {
assertEquals(
listOf(
"violet", "nebula", "abyss", "ember", "moss", "graphite",
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal",
),
GamepadPalette.ALL.map { it.id },
)
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
assertEquals(6, firstLight)
assertEquals(7, firstLight)
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
// An unknown name is a newer client's palette, not an error.
assertEquals("violet", GamepadPalette.named("chartreuse").id)
@@ -72,6 +72,25 @@ class GamepadPaletteTest {
}
}
/**
* OLED is the one palette whose selling point is measurable: it has to be genuinely black,
* not merely the darkest of the dark fields. The blob field this client draws samples the
* ramp at 0.15/0.40/0.65/0.90, so its darkest blob lands in the all-black head of the ramp.
*/
@Test
fun oledIsActuallyBlack() {
val oled = GamepadPalette.named("oled")
assertEquals(Triple(0.0, 0.0, 0.0), oled.ground)
assertEquals(0f, oled.blobColors[0].red, 1e-6f)
assertEquals(0f, oled.blobColors[0].green, 1e-6f)
assertEquals(0f, oled.blobColors[0].blue, 1e-6f)
val mean = oled.stops.sumOf { luma(it) } / oled.stops.size
val darkestOther = GamepadPalette.ALL
.filter { it.id != "oled" && it.stops.isNotEmpty() }
.minOf { p -> p.stops.sumOf { luma(it) } / p.stops.size }
assertTrue("oled means $mean, barely under $darkestOther", mean < darkestOther / 2)
}
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
@Test
fun palettesAreHonestAboutLightness() {
@@ -95,4 +95,47 @@ class GamepadSettingsRowsTest {
// Drawn as a switch, and reading the persisted default.
assertEquals(true, row(on, "dsCapture").toggled)
}
/**
* The activation-mode row is a sub-setting of the Controller-optimized UI switch, so it is
* OFFERED only while that switch is on hidden rather than dimmed, because with the switch
* off this whole screen is about to be replaced by the touch UI and a dimmed row there would
* be one last thing to step past on the way out.
*/
@Test
fun `the activation-mode row follows the switch it belongs to`() {
fun ids(enabled: Boolean) = buildSettingsRows(
Settings(gamepadUiEnabled = enabled),
hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) {}.map { it.id }
val on = ids(enabled = true)
assertTrue("the mode row is missing", "gamepadUIMode" in on)
assertEquals(
"the mode belongs directly under the switch it qualifies",
on.indexOf("gamepadUI") + 1,
on.indexOf("gamepadUIMode"),
)
val off = ids(enabled = false)
assertFalse("the mode row must not outlive its switch", "gamepadUIMode" in off)
assertTrue("the switch itself stays, or it could never be turned back on", "gamepadUI" in off)
}
/** Stepping the mode row writes the shared `gamepad_ui_mode` value, and wraps on A. */
@Test
fun `the activation-mode row steps the shared key`() {
var s = Settings()
fun mode() = buildSettingsRows(
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) { s = it }.first { it.id == "gamepadUIMode" }
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
assertEquals("With a controller", mode().value)
assertFalse("already the first = thud", mode().adjust(-1))
assertTrue(mode().adjust(1))
assertEquals(GAMEPAD_UI_ALWAYS, s.gamepadUiMode)
// A from the last entry wraps home.
mode().activate()
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
}
}
@@ -0,0 +1,53 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* [gamepadUiActive] is pure table-tested over its inputs, and the mirror of the Apple client's
* `GamepadUIEnvironmentTests`. The two clients share the stored `gamepad_ui_mode` values, so a
* disagreement here is a device that behaves differently from the same setting.
*/
class GamepadUiTest {
/** The default mode is what the switch meant when it was a lone Boolean. */
@Test
fun whenConnectedWaitsForAPad() {
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
assertFalse(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
// A TV is in console mode whatever the mode says — its remote is the only input.
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = true, forced = false))
}
/** Always drops the controller from the decision but never the switch, which is the one
* way back to the touch UI. */
@Test
fun alwaysIgnoresThePadButNotTheSwitch() {
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
}
/** A value a newer client wrote waits for a pad rather than stranding this build in a
* layout it has no way back out of. */
@Test
fun anUnknownModeWaitsForAPad() {
assertFalse(gamepadUiActive(true, "whenever-i-say-so", false, tv = false, forced = false))
assertTrue(gamepadUiActive(true, "whenever-i-say-so", true, tv = false, forced = false))
assertFalse(gamepadUiActive(true, "", false, tv = false, forced = false))
}
/** The shipped default: the console UI still waits for a controller. */
@Test
fun theDefaultIsUnchangedBehaviour() {
val s = Settings()
assertTrue(s.gamepadUiEnabled)
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
assertFalse(gamepadUiActive(s.gamepadUiEnabled, s.gamepadUiMode, false, tv = false, forced = false))
}
}
@@ -77,6 +77,7 @@ class ProfilesTest {
// Device-scope settings are not in the overlay at all, so no profile can move them.
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
assertEquals(base.gamepadUiMode, out.gamepadUiMode)
assertEquals(base.libraryEnabled, out.libraryEnabled)
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
assertEquals(base.sc2Capture, out.sc2Capture)
@@ -99,6 +99,10 @@ struct ContentView: View {
// with no (extended) controller attached tvOS falls back to HomeView as before.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
/// When the switch above takes over "connected" (default) or "always". See
/// `GamepadUIEnvironment`.
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
/// Auto-wake on connect (Settings General). On (default): a dial to an offline saved host
/// fires Wake-on-LAN up front and falls into the "Waking" wait if the dial fails. Off: connects
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
@@ -113,7 +117,8 @@ struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
mode: gamepadUIMode)
}
// The body is split in two `driven` (the screen plus its lifecycle drivers and sheets) and
@@ -85,16 +85,40 @@ extension EnvironmentValues {
}
extension View {
/// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the
/// gamepad screens' common root so no individual view has to read the setting.
func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) }
/// Resolve the stored `ui_palette` and publish its ink AND the matching colour scheme to
/// everything below. Applied by the gamepad screens' common root so no individual view has to
/// read the setting.
///
/// `active` exists for the one surface that is the same view in both worlds: `LibraryView`
/// renders the coverflow under the gamepad UI and a plain grid without it. Passing `false`
/// publishes nothing, because the touch/desktop layouts sit on the SYSTEM background, where a
/// palette's scheme would invert their own system colours instead of matching them.
func gamepadPaletteInk(_ active: Bool = true) -> some View {
modifier(GamepadInkModifier(active: active))
}
}
private struct GamepadInkModifier: ViewModifier {
var active = true
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
/// The ambient scheme from ABOVE this modifier what gets republished unchanged when the
/// gamepad UI isn't the one drawing, so `active: false` is a true no-op rather than a branch
/// that would change this view's identity.
@Environment(\.colorScheme) private var systemScheme
func body(content: Content) -> some View {
content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID)))
let palette = GamepadPalette.named(paletteID)
return content
.environment(\.gamepadInk, active ? GamepadInk.of(palette) : .dark)
// The ink alone was never enough. Every SYSTEM-derived colour that lands on these
// screens `.secondary` in a placeholder, a `.bordered` button's chrome, a
// NavigationStack's title, a material's frost resolves against the DEVICE's
// appearance, which no part of this app had ever set. On iPhone and Mac that is often
// Light, so the pale palettes looked correct by accident; an Apple TV is Dark
// essentially always, so on tvOS every one of them came out WHITE on a pale field and
// the interface was unreadable. Publishing the scheme here once, beside the ink it
// has to agree with is what makes a pale palette mean "light" to UIKit too.
.environment(\.colorScheme, active ? (palette.light ? .light : .dark) : systemScheme)
}
}
@@ -32,9 +32,12 @@ struct LibraryView: View {
// setting off) every platform keeps the plain-grid presentation of this same view.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
mode: gamepadUIMode)
}
#endif
@@ -78,6 +81,16 @@ struct LibraryView: View {
}
}
#endif
#if os(iOS) || os(macOS) || os(tvOS)
// Published HERE, not just inside the coverflow, because the coverflow is only one of
// four things this view renders: the loading spinner, the error state and the empty
// state sit above it, as do the navigation title and toolbar. On iOS those are wrapped
// by GamepadLibraryScreen, which inks the whole thing; tvOS and macOS present this view
// directly in a NavigationStack, so under a pale palette every one of them kept the
// system's own (dark, on an Apple TV) chrome over a light field. Off when the gamepad
// UI isn't drawing the plain grid belongs to the system background.
.gamepadPaletteInk(gamepadUIActive)
#endif
}
@ViewBuilder private var content: some View {
@@ -81,6 +81,9 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
/// When the switch above takes over the row is only built while it is on.
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
/// The gamepad UI's background colour family the backdrop BEHIND this screen re-colours as
/// the row steps, which is why the picker lives here and not in a sheet.
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
@@ -659,6 +662,21 @@ struct GamepadSettingsView: View {
detail: "Turn off to use the touch interface even with a controller connected.",
value: $gamepadUIEnabled),
]
// WHEN the switch above takes over. Built only while it is on: with the switch off this
// screen is unreachable in the first place (no gamepad UI to open it from), so a row
// that decides nothing would exist purely to be found in a screenshot.
if gamepadUIEnabled, let at = list.firstIndex(where: { $0.id == "gamepadUI" }) {
list.insert(
choiceRow(
id: "gamepadUIMode", tab: .interface, icon: "gamecontroller.circle",
label: "Show it",
detail: "With a controller: the touch interface comes back when the last one "
+ "disconnects. Always keeps this layout either way — for a device that "
+ "lives on a TV.",
options: SettingsOptions.gamepadUIModes, current: gamepadUIMode
) { gamepadUIMode = $0 },
at: at + 1)
}
#if os(macOS)
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
// the Video tab) macOS only, mirroring the touch SettingsView's Presentation row
@@ -707,6 +725,14 @@ struct GamepadSettingsView: View {
at: anchor + 1)
}
#endif
// The smoothness buffer only decides anything under Smoothness. Every other settings
// surface touch, tvOS, the GTK and WinUI shells hides it under Lowest latency; this
// screen alone left it live and steppable, which is a row that thuds or silently stores
// a value nothing reads. Removed here rather than omitted from the literal above so the
// macOS safe-present insertion can still anchor on it.
if presentPriority != "smooth" {
list.removeAll { $0.id == "smoothBuffer" }
}
return list + profileRows
}
@@ -53,6 +53,14 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// When the gamepad UI takes over (`DefaultsKey.gamepadUIMode`) only meaningful while
/// `gamepadUIEnabled` is on, so every surface that offers it hides the row when the switch
/// is off rather than showing a picker that decides nothing.
static let gamepadUIModes: [(label: String, tag: String)] = [
("With a controller", GamepadUIEnvironment.modeWhenConnected),
("Always", GamepadUIEnvironment.modeAlways),
]
/// Presentation intent (`DefaultsKey.presentPriority` the 2026-07 rebuild that replaced
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
@@ -724,11 +724,24 @@ extension SettingsView {
#endif
#if !os(tvOS)
if !inProfileScope {
described("With a controller connected, the host list and library switch to a "
+ "controller-friendly layout — larger focus targets, a swipeable cover "
+ "browser.") {
described("The host list and library switch to a controller-friendly layout — "
+ "larger focus targets, a swipeable cover browser.") {
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
}
// Only meaningful while the switch above is on, so it is HIDDEN rather than
// disabled when it isn't: a picker whose every option decides nothing is worse
// than no picker, and this Section is short enough that nothing jumps far.
if gamepadUIEnabled {
described("With a controller: the touch interface comes back when the last "
+ "one disconnects. Always keeps the controller-friendly layout either "
+ "way — for a device that lives on a TV.") {
Picker("Show it", selection: $gamepadUIMode) {
ForEach(SettingsOptions.gamepadUIModes, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
}
}
}
#endif
#if DEBUG && !os(tvOS)
@@ -75,6 +75,13 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
@ObservedObject var gamepads = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
/// When the switch above takes over read (and shown) only while it is on.
@AppStorage(DefaultsKey.gamepadUIMode) var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
/// The gamepad UI's background palette. Edited here on tvOS only (see `tvBody`) every other
/// platform reaches it through the gamepad settings screen, which an Apple TV without a
/// controller cannot open.
@AppStorage(DefaultsKey.uiPalette) var uiPalette = "violet"
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
@@ -488,6 +495,22 @@ struct SettingsView: View {
TVSelectionRow(
title: "Gamepad-optimized browsing",
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
// Hidden while the switch above is off see the touch settings' identical gate.
if gamepadUIEnabled {
TVSelectionRow(
title: "Show it",
options: SettingsOptions.gamepadUIModes, selection: $gamepadUIMode)
// The Apple TV's ONLY route to the shared `ui_palette`. Everywhere else the
// Background row lives on the gamepad settings screen, which is reached from
// the gamepad launcher and on tvOS that launcher needs an extended-profile
// controller, so an Apple TV driven by the Siri Remote alone could not reach
// the palettes at all. It belongs beside "Show it" because both describe the
// same interface: this row is what that interface looks like once it is up.
TVSelectionRow(
title: "Background",
options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) },
selection: $uiPalette)
}
tvCaption(Self.controllersFooter)
NavigationLink("About") { AboutView() }
.padding(.top, 8)
@@ -95,24 +95,19 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) }
func body(content: Content) -> some View {
// The scheme goes on the WHOLE modified view, not just the fill inside `.background {}`.
// Scoped to the fill it frosts the material correctly and stops there, so a system colour
// in the row's own content (a `.secondary` label, a `.bordered` button) still resolved
// against the device appearance which is how the pale palettes came out light-on-light
// on tvOS, whose appearance is always Dark. The 26 branch had it right all along; the
// tvOS and pre-26 branches were the odd ones out.
#if os(tvOS)
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
// Apple TV's GPU (same class of call GlassProminentButton already makes glass fights
// the 10-foot platform). The wash and tint ride overlays two flat fills, no GPU cost.
content.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(materialWash) }
.overlay {
if let tint { shape.fill(tint) }
}
}
#else
if #available(iOS 26, macOS 26, *) {
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
} else {
content.background {
content
.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(materialWash) }
@@ -120,6 +115,21 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
if let tint { shape.fill(tint) }
}
}
.environment(\.colorScheme, scheme)
#else
if #available(iOS 26, macOS 26, *) {
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
} else {
content
.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(materialWash) }
.overlay {
if let tint { shape.fill(tint) }
}
}
.environment(\.colorScheme, scheme)
}
#endif
}
@@ -173,11 +183,14 @@ private struct ConsoleGlassBackground<S: Shape>: ViewModifier {
in: shape)
.environment(\.colorScheme, scheme)
} else {
content.background {
shape.fill(.regularMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
}
// Same hoist as ConsoleGlass: the content needs the scheme too, not only the frost.
content
.background {
shape.fill(.regularMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
}
.environment(\.colorScheme, scheme)
}
}
}
@@ -3,20 +3,40 @@
// layouts). A pure function, not a singleton: the reactivity comes from callers already observing
// `GamepadManager.shared` and the `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same
// local-read pattern SettingsView already uses for GamepadManager), so this stays the single place
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
// the inputs combine without adding a second ObservableObject or an environment key nobody else needs.
import Foundation
import PunktfunkShared
public enum GamepadUIEnvironment {
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
/// `DefaultsKey.gamepadUIMode`: take over only while a controller is attached. The default,
/// and what the switch meant when it was a lone Bool.
public static let modeWhenConnected = "connected"
/// `DefaultsKey.gamepadUIMode`: take over whenever the switch is on, pad or no pad asked
/// for by people driving a TV-connected iPad or a couch Mac, where the console layout is the
/// one they want and the pad is not always awake.
public static let modeAlways = "always"
/// `enabledSetting` is the user's Settings switch (`DefaultsKey.gamepadUIEnabled`) off means
/// the touch/desktop UI, full stop. `mode` is `DefaultsKey.gamepadUIMode`, and only matters
/// once the switch is on: `modeAlways` takes over unconditionally, anything else (including a
/// value a newer client wrote) waits for a controller.
///
/// `gamepadConnected` is `GamepadManager.shared.active != nil` active only once a usable
/// controller is actually attached (a non-extended-profile device leaves `active` nil, which
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's
/// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable
/// without a real `GCController` (which XCTest can't construct).
public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool {
enabledSetting && (gamepadConnected || forced)
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function
/// has nothing else to inspect, and it keeps the helper testable without a real `GCController`
/// (which XCTest can't construct).
/// `mode` carries no default on purpose: a call site that forgot it would silently strand
/// everyone who picked Always back on "only with a controller", which is exactly the bug
/// this parameter exists to make impossible.
public static func isActive(
gamepadConnected: Bool,
enabledSetting: Bool,
mode: String
) -> Bool {
guard enabledSetting else { return false }
return mode == modeAlways || gamepadConnected || forced
}
/// Dev-only escape hatch (like ContentView's `PUNKTFUNK_AUTOCONNECT`): pretend a controller is
@@ -176,16 +176,23 @@ public enum DefaultsKey {
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
public static let hudPlacement = "punktfunk.hudPlacement"
/// iOS/iPadOS/macOS: switch the host list, settings and game library to a controller-friendly
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library).
/// On by default; WHEN it takes over is `gamepadUIMode`. See `GamepadUIEnvironment.isActive`.
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
/// When `gamepadUIEnabled` actually takes over: `"connected"` (the default only while a
/// usable controller is attached, the behaviour this switch has always had) or `"always"`,
/// for someone who prefers the console layout with no pad in reach (a TV-connected iPad, a
/// Mac driven from the couch). Read only while `gamepadUIEnabled` is on, which is why the
/// settings rows hide it when the switch is off. Anything unrecognized reads as
/// `"connected"`. A device preference, never part of a stream profile.
public static let gamepadUIMode = "punktfunk.gamepadUIMode"
/// Which colour family the gamepad UI's living backdrop drifts through a
/// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/
/// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the
/// Android client carry the same table under the same names. Presentation only, so it is
/// a device preference and never part of a stream profile. An unknown value reads as the
/// default rather than failing a newer client may have shipped a palette this build
/// doesn't know.
/// `GamepadPalette` id ("violet" = the brand default, then "oled"/"nebula"/"abyss"/"ember"/
/// "moss"/"graphite", then the pale ones). The cross-client `ui_palette` key: the desktop
/// console and the Android client carry the same table under the same names. Presentation
/// only, so it is a device preference and never part of a stream profile. An unknown value
/// reads as the default rather than failing a newer client may have shipped a palette this
/// build doesn't know.
public static let uiPalette = "punktfunk.uiPalette"
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
/// device's own Taptic Engine for phone-clip pads that ship without rumble motors, where
@@ -65,13 +65,25 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable {
SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96),
]
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale
/// ones. Cycling order runs dark light, so stepping the row walks the whole range one way.
public static let all: [GamepadPalette] = [
// --- dark fields (white ink) ---
GamepadPalette(
id: "violet", name: "Violet", stops: [],
ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false),
GamepadPalette(
// For OLED and AMOLED panels, where a black pixel is a pixel switched off no glow,
// no power. The first two stops are literally (0,0,0), so the shaded half of the
// field is genuinely off rather than "very dark grey", and the ground is pure black
// too: the calm mix on the form screens lifts toward nothing. What is left is a
// faint indigoviolet ember in the bright corner. The accent stays the brand violet
// focus has to be findable on black.
id: "oled", name: "OLED",
stops: [SIMD3(0.000, 0.000, 0.000), SIMD3(0.000, 0.000, 0.000),
SIMD3(0.010, 0.020, 0.100), SIMD3(0.045, 0.016, 0.115),
SIMD3(0.120, 0.024, 0.130)],
ground: SIMD3(0, 0, 0), accent: SIMD3(0.525, 0.471, 0.961), light: false),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
id: "nebula", name: "Nebula",
@@ -46,12 +46,29 @@ final class GamepadPaletteTests: XCTestCase {
func testTableMatchesTheOtherClients() {
XCTAssertEqual(
GamepadPalette.all.map(\.id),
["violet", "nebula", "abyss", "ember", "moss", "graphite",
["violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal"])
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
let firstLight = GamepadPalette.all.firstIndex { $0.light }
XCTAssertEqual(firstLight, 6)
XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light))
XCTAssertEqual(firstLight, 7)
XCTAssertTrue(GamepadPalette.all.dropFirst(7).allSatisfy(\.light))
}
/// OLED is the one palette whose selling point is measurable: it has to be genuinely black,
/// not merely the darkest of the dark fields.
func testOLEDIsActuallyBlack() {
let oled = GamepadPalette.named("oled")
XCTAssertEqual(oled.ground, SIMD3(0, 0, 0), "the calm lift must be nothing")
let cells = oled.meshColors
XCTAssertGreaterThanOrEqual(
cells.filter { luma($0) == 0 }.count, 3,
"the shaded corner has to be switched off, not dimmed")
let mean = cells.map(luma).reduce(0, +) / Double(cells.count)
let darkestOther = GamepadPalette.all
.filter { $0.id != "oled" }
.map { p in p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count) }
.min() ?? 0
XCTAssertLessThan(mean, darkestOther / 2, "oled is barely darker than \(darkestOther)")
}
/// A palette must read as SEVERAL hues, not one hue at several brightnesses that was
@@ -1,14 +1,58 @@
// GamepadUIEnvironment.isActive is a pure AND table-tested exhaustively over its 2x2 inputs.
// GamepadUIEnvironment.isActive is pure table-tested exhaustively over its inputs.
import XCTest
@testable import PunktfunkKit
final class GamepadUIEnvironmentTests: XCTestCase {
func testActiveOnlyWhenEnabledAndConnected() {
XCTAssertTrue(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: true))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: false))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: true))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: false))
private let connected = GamepadUIEnvironment.modeWhenConnected
private let always = GamepadUIEnvironment.modeAlways
/// The default mode is the behaviour the switch had when it was a lone Bool, so an install
/// that never sees the new row is exactly where it was.
func testWhenConnectedIsAPlainAnd() {
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: true, mode: connected))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: false, mode: connected))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: connected))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: false, mode: connected))
}
/// Always drops the controller from the decision entirely but NOT the switch, which stays
/// the one way back to the touch UI.
func testAlwaysIgnoresTheControllerButNotTheSwitch() {
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: always))
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: true, mode: always))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: false, mode: always))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: false, mode: always))
}
/// A value a newer client wrote must wait for a controller, never strand this build in a
/// layout it has no way back out of.
func testUnknownModeWaitsForAController() {
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: "whenever-i-say-so"))
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: true, mode: "whenever-i-say-so"))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: ""))
}
}
+6 -6
View File
@@ -1174,12 +1174,12 @@ pub struct Settings {
/// mirrors the Apple client's "Show game library" toggle, default off.
pub library_enabled: bool,
/// Which colour family the gamepad UI's living backdrop drifts through — the shared
/// `ui_palette` key (`"violet"` = the brand default, then `tide`/`forest`/`ember`/
/// `rose`/`graphite`; see `pf-console-ui`'s palette table, and the Apple/Android
/// clients' twins). Presentation only: nothing about a stream depends on it, which is
/// why it is a device preference and never part of a settings profile. An unknown
/// name reads as the default rather than erroring — a newer client may have shipped a
/// palette this binary doesn't know.
/// `ui_palette` key (`"violet"` = the brand default, then `oled`/`nebula`/`abyss`/
/// `ember`/`moss`/`graphite`, then the six pale fields; see `pf-console-ui`'s palette
/// table, and the Apple/Android clients' twins). Presentation only: nothing about a
/// stream depends on it, which is why it is a device preference and never part of a
/// settings profile. An unknown name reads as the default rather than erroring — a
/// newer client may have shipped a palette this binary doesn't know.
#[serde(default = "default_ui_palette")]
pub ui_palette: String,
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
+50 -4
View File
@@ -246,17 +246,34 @@ const CELL_RAMP: [f64; 16] = [
-0.10, 0.08, -0.06, 0.12,
];
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale ones.
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale ones.
/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction.
/// Adding one here adds it to every console settings screen; the Apple and Android tables must
/// gain the same entry to keep the `ui_palette` key portable.
#[rustfmt::skip]
pub const PALETTES: [Palette; 12] = [
pub const PALETTES: [Palette; 13] = [
// --- dark fields (white ink) ---
Palette {
id: "violet", name: "Violet", stops: None,
ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false,
},
Palette {
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no glow,
// no power. The ramp's first two stops are literally (0,0,0), so the whole shaded half
// of the field is genuinely off rather than "very dark grey", and the ground is pure
// black too: the calm mix on the form screens lifts toward nothing, so settings and
// pairing sit on an unlit panel. What is left is a faint indigo→violet ember in the
// bright corner, dim enough to stay under a tenth of the other dark fields' mean
// luminance while keeping the backdrop a field with somewhere to go rather than a
// dead rectangle. The accent stays the brand violet — focus has to be findable on
// black.
id: "oled", name: "OLED",
stops: Some(&[
(0.000, 0.000, 0.000), (0.000, 0.000, 0.000), (0.010, 0.020, 0.100),
(0.045, 0.016, 0.115), (0.120, 0.024, 0.130),
]),
ground: (0.0, 0.0, 0.0), accent: (0.525, 0.471, 0.961), light: false,
},
Palette {
// Deep indigo climbing through violet into a hot magenta.
id: "nebula", name: "Nebula",
@@ -857,7 +874,7 @@ mod tests {
assert_eq!(
ids,
[
"violet", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
"bloom", "dawn", "mint", "opal",
]
);
@@ -867,7 +884,36 @@ mod tests {
.position(|p| p.light)
.expect("some are light");
assert!(PALETTES[first_light..].iter().all(|p| p.light));
assert_eq!(first_light, 6);
assert_eq!(first_light, 7);
}
/// OLED is the one palette whose selling point is measurable: it has to be genuinely
/// black, not merely the darkest of the dark fields. Pure black corners, a mean well
/// under every other field's, and a ground that lifts to nothing on the form screens.
#[test]
fn oled_is_actually_black() {
let luma = |c: (f64, f64, f64)| 0.2126 * c.0 + 0.7152 * c.1 + 0.0722 * c.2;
let oled = palette("oled");
assert_eq!(
oled.ground,
(0.0, 0.0, 0.0),
"the calm lift must be nothing"
);
let cells = oled.mesh_colors();
assert!(
cells.iter().filter(|c| luma(**c) == 0.0).count() >= 3,
"the shaded corner has to be switched off, not dimmed"
);
let mean = cells.iter().map(|c| luma(*c)).sum::<f64>() / 16.0;
let darkest_other = PALETTES
.iter()
.filter(|p| p.id != "oled")
.map(|p| p.mesh_colors().iter().map(|c| luma(*c)).sum::<f64>() / 16.0)
.fold(f64::MAX, f64::min);
assert!(
mean < darkest_other / 2.0,
"oled means {mean:.3}, only half a stop under {darkest_other:.3}"
);
}
/// Every colour a palette produces stays in gamut, and a pale palette really is pale —
+158 -44
View File
@@ -258,11 +258,17 @@ impl SettingsScreen {
}
}
/// The rows of the CURRENT tab. Profiles is built from the catalog: one row per
/// profile, or the explainer placeholder while there are none.
fn row_ids(&self) -> Vec<RowId> {
/// The rows of the CURRENT tab, minus any whose setting has nothing to act on (see
/// [`row_applies`]). Profiles is built from the catalog: one row per profile, or the
/// explainer placeholder while there are none.
fn row_ids(&self, ctx: &Ctx) -> Vec<RowId> {
if self.tab != PROFILES_TAB {
return TABS[self.tab].1.to_vec();
return TABS[self.tab]
.1
.iter()
.copied()
.filter(|id| row_applies(*id, ctx.settings))
.collect();
}
if self.profiles.is_empty() {
vec![RowId::NoProfiles]
@@ -271,6 +277,16 @@ impl SettingsScreen {
}
}
/// Pull the cursor back onto the list. Every tab but Profiles used to be a fixed length,
/// so this only mattered on entry ([`show_tab`]); the smoothness buffer's row now comes
/// and goes, and another writer (a desktop shell, a session's match-window persist) can
/// take it away between frames while this screen is open.
fn clamp_cursor(&mut self, len: usize) {
if self.list.cursor >= len {
self.list.jump_to(len.saturating_sub(1));
}
}
#[cfg(test)]
pub(crate) fn tab_for_test(&self) -> usize {
self.tab
@@ -278,21 +294,22 @@ impl SettingsScreen {
/// L1/R1 (and Tab/PgUp/PgDn) — move one tab, wrapping (the strip is a ring, like A's
/// value cycle), keeping each tab's own cursor.
fn switch_tab(&mut self, delta: i32) -> Option<MenuPulse> {
fn switch_tab(&mut self, delta: i32, ctx: &Ctx) -> Option<MenuPulse> {
let n = TABS.len() as i32;
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize)
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize, ctx)
}
/// Show `tab`, parking the cursor the outgoing tab was on. Also the pointer's path in:
/// a press on a pill names a tab outright rather than a direction to step in.
fn show_tab(&mut self, tab: usize) -> Option<MenuPulse> {
fn show_tab(&mut self, tab: usize, ctx: &Ctx) -> Option<MenuPulse> {
if tab >= TABS.len() {
return None;
}
self.tab_cursors[self.tab] = self.list.cursor;
self.tab = tab;
// Clamp the remembered cursor: the Profiles tab's length follows the catalog.
let len = self.row_ids().len();
// Clamp the remembered cursor: the Profiles tab's length follows the catalog, and
// Video's follows whether the smoothness buffer is offered.
let len = self.row_ids(ctx).len();
self.list
.jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1)));
Some(MenuPulse::Move)
@@ -302,10 +319,11 @@ impl SettingsScreen {
/// there is never meant for a row.
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
if let Some(tab) = self.strip.pointer(p) {
self.show_tab(tab);
self.show_tab(tab, ctx);
return true;
}
let ids = self.row_ids();
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let (msg, pulse) = self.list.pointer(p, ids.len());
if matches!(msg, ListMsg::None) && pulse.is_none() {
return false;
@@ -325,11 +343,12 @@ impl SettingsScreen {
fx.pop();
return None;
}
MenuEvent::JumpBack => return self.switch_tab(-1),
MenuEvent::JumpForward => return self.switch_tab(1),
MenuEvent::JumpBack => return self.switch_tab(-1, ctx),
MenuEvent::JumpForward => return self.switch_tab(1, ctx),
_ => {}
}
let ids = self.row_ids();
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let (msg, pulse) = self.list.menu(ev, ids.len());
self.apply_row(msg, pulse, &ids, ctx, fx)
}
@@ -344,8 +363,14 @@ impl SettingsScreen {
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
// A cursor with no row under it can only mean the list shrank between the clamp above
// and here, which nothing does today — but indexing on the assumption would turn that
// into a panic in a shipping console rather than a dropped keypress.
let Some(&focused) = ids.get(self.list.cursor) else {
return pulse;
};
// The Profiles rows navigate instead of editing the settings file.
match ids[self.list.cursor] {
match focused {
RowId::Profile(i) => {
return match msg {
ListMsg::Activate => {
@@ -378,7 +403,7 @@ impl SettingsScreen {
}
match msg {
ListMsg::Adjust(delta) => {
let changed = adjust(ids[self.list.cursor], delta, false, ctx);
let changed = adjust(focused, delta, false, ctx);
if changed {
ctx.settings.save();
Some(MenuPulse::Move)
@@ -388,7 +413,7 @@ impl SettingsScreen {
}
ListMsg::Activate => {
// A cycles forward WRAPPING, so every option is reachable one-handed.
if adjust(ids[self.list.cursor], 1, true, ctx) {
if adjust(focused, 1, true, ctx) {
ctx.settings.save();
}
pulse
@@ -397,8 +422,8 @@ impl SettingsScreen {
}
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
let ids = self.row_ids();
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
let ids = self.row_ids(ctx);
// The shoulders always change section, so that hint leads on every row.
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
hints.extend(match ids.get(self.list.cursor) {
@@ -445,7 +470,8 @@ impl SettingsScreen {
rect.right,
rect.bottom - detail_h as f32,
);
let ids = self.row_ids();
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let rows: Vec<RowSpec> = ids
.iter()
.map(|id| row_spec(*id, ctx, &self.profiles))
@@ -466,6 +492,24 @@ impl SettingsScreen {
}
}
/// Whether a row is OFFERED at all, as opposed to offered-but-inert.
///
/// The two are a real distinction. Echo cancellation and the pad rows follow a switch the user
/// can see a line or two above them, so dimming them shows the relationship — dropping them
/// would just make settings appear and disappear as the switch flips. The smoothness buffer is
/// different: it is not a sub-setting of a switch, it is a knob on ONE of two intents, and
/// under Lowest latency it names a quantity that doesn't exist. Every other settings surface —
/// the GTK and WinUI shells, the Apple touch/tvOS screens, the Android touch screen — hides it
/// there. This screen was the lone exception because its row list was fixed; it is rebuilt from
/// this filter each frame now, and the row it drops sits directly BELOW the row that drops it,
/// so the cursor is never under anything that moves.
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
match id {
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
}
}
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
@@ -497,18 +541,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
_ => {}
}
let s = &ctx.settings;
// Several rows follow another: echo cancellation only means anything while the mic
// streams, the pad rows only while any controller is forwarded at all, and the
// smoothness buffer only while that intent is chosen. All go dim and inert otherwise
// — the same relationship the desktop shells draw by greying a row out (they hide the
// buffer row entirely; a fixed row list can't, and a row that vanished mid-list would
// move everything under the cursor).
// Two rows follow a switch a line or two above them: echo cancellation only means
// anything while the mic streams, and the pad rows only while any controller is
// forwarded at all. Both go dim and inert otherwise — the same relationship the desktop
// shells draw by greying a row out, and dimming (not dropping) is what shows the
// relationship. The smoothness buffer used to be listed here too; it is dropped from the
// list instead now — see [`row_applies`] for why that one is different.
let enabled = match id {
RowId::EchoCancel => s.mic_enabled,
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
s.gamepad_forwarding
}
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
};
let (header, label, value): (Option<&'static str>, &str, String) = match id {
@@ -848,7 +891,10 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
}
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
// Under Lowest latency the row isn't offered at all ([`row_applies`]), so this branch
// is only reachable if another writer flipped the intent between the frame that built
// the list and the keypress that lands here — a boundary thud, not a stored value
// nothing will read.
RowId::SmoothBuffer => {
if s.present_priority == "smooth" {
let cur = SMOOTH_BUFFERS
@@ -1093,9 +1139,6 @@ mod tests {
fake_home();
let mut s = SettingsScreen::with_profiles(Vec::new());
rendered(&mut s);
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
// window: one field, one unambiguous effect to assert on.
assert_eq!(s.row_ids()[0], RowId::Resolution);
let first = s.list.row_rect(0).expect("the list drew its rows");
let (mut settings, pads) = ctx_parts();
settings.save(); // seat the fake HOME's file — `apply_row` rebases on it
@@ -1109,6 +1152,9 @@ mod tests {
device_name: "t",
t: 0.0,
};
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
// window: one field, one unambiguous effect to assert on.
assert_eq!(s.row_ids(&ctx)[0], RowId::Resolution);
let mut fx = Outbox::default();
assert!(!ctx.settings.match_window);
assert!(s.pointer(press(first), &mut ctx, &mut fx));
@@ -1232,13 +1278,12 @@ mod tests {
assert!(ctx.settings.echo_cancel);
}
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
/// row list dims it, because a row vanishing mid-list would shift everything under the
/// cursor.
/// The smoothness buffer is OFFERED only under Smoothness — under Lowest latency it names
/// a quantity that doesn't exist, so the row is gone from the Video tab rather than sitting
/// there dimmed. This is what the GTK and WinUI shells and the Apple/Android screens have
/// always done; this screen was the exception until its row list stopped being fixed.
#[test]
fn smoothness_buffer_follows_the_intent() {
fn smoothness_buffer_is_offered_only_under_smoothness() {
let (mut settings, pads) = ctx_parts();
assert_eq!(settings.present_priority, "latency", "the shipped default");
let library = crate::library::LibraryShared::default();
@@ -1251,24 +1296,93 @@ mod tests {
device_name: "t",
t: 0.0,
};
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
let mut s = SettingsScreen::with_profiles(Vec::new());
s.tab = TABS
.iter()
.position(|(name, _)| *name == "Video")
.expect("the Video tab");
let video = s.row_ids(&ctx);
assert!(
!video.contains(&RowId::SmoothBuffer),
"latency hides the buffer row: {video:?}"
);
assert!(video.contains(&RowId::PresentPriority), "the intent stays");
// Even reached out of band it writes nothing — the list it came from is a frame old.
assert!(
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
"latency intent = thud"
);
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
// Stepping the intent to Smoothness brings the buffer row to life.
// Stepping the intent to Smoothness brings the row into the list, directly under it.
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "smooth");
assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
let video = s.row_ids(&ctx);
let intent = video
.iter()
.position(|id| *id == RowId::PresentPriority)
.expect("the intent row");
assert_eq!(
video.get(intent + 1),
Some(&RowId::SmoothBuffer),
"the row that comes and goes sits BELOW the row that decides it, so the cursor \
never has anything move out from under it"
);
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
assert_eq!(ctx.settings.smooth_buffer, 1);
// The intent wraps back and the row goes inert again.
// The intent wraps back and the row leaves again — with the cursor parked on the
// intent row, which is where a user who just stepped it necessarily is.
s.list.cursor = intent;
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "latency");
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
let video = s.row_ids(&ctx);
assert!(!video.contains(&RowId::SmoothBuffer));
assert_eq!(
video.get(s.list.cursor),
Some(&RowId::PresentPriority),
"the cursor is still on the row the user was stepping"
);
}
/// A cursor parked past the end of a list that shrank underneath it is pulled back rather
/// than indexed with — the console must not panic because another writer changed the
/// presentation intent while its settings screen was open.
#[test]
fn a_shrinking_list_pulls_the_cursor_back() {
// `apply_row` rebases on the FILE before acting, so this has to be seated — and
// seated with the SHRUNKEN list's intent, which is the state being tested.
fake_home();
let (mut settings, pads) = ctx_parts();
settings.present_priority = "latency".into();
settings.save();
settings.present_priority = "smooth".into();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = SettingsScreen::with_profiles(Vec::new());
s.tab = TABS
.iter()
.position(|(name, _)| *name == "Video")
.expect("the Video tab");
// Park on the last row while the buffer row is still there…
s.list.cursor = s.row_ids(&ctx).len() - 1;
let parked = s.list.cursor;
// …then take it away behind the screen's back, as a desktop shell would.
ctx.settings.present_priority = "latency".into();
let mut fx = Outbox::default();
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(pulse.is_some(), "the press was routed, not dropped");
assert!(s.list.cursor < parked, "the cursor came back onto the list");
assert!(fx.nav.is_none());
}
#[test]
@@ -1392,7 +1506,7 @@ mod tests {
("p2".into(), "Game".into()),
]);
s.tab = PROFILES_TAB;
let ids = s.row_ids();
let ids = s.row_ids(&ctx);
assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]);
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
@@ -1438,7 +1552,7 @@ mod tests {
};
let mut s = SettingsScreen::with_profiles(Vec::new());
s.tab = PROFILES_TAB;
let ids = s.row_ids();
let ids = s.row_ids(&ctx);
assert_eq!(ids, vec![RowId::NoProfiles]);
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
assert!(!spec.enabled);
+1 -1
View File
@@ -329,7 +329,7 @@ fn dump_console_screens() {
for _ in 0..5 {
s.handle_menu(MenuEvent::JumpForward);
}
for id in ["violet", "ember", "abyss", "holo", "sunset", "mint"] {
for id in ["violet", "oled", "ember", "abyss", "holo", "sunset", "mint"] {
s.settings.ui_palette = id.to_string();
dump(&mut s, 40, 8, &format!("03-settings-{id}"), true);
}
+47 -5
View File
@@ -96,11 +96,12 @@ those hiccups out, at that buffer's worth of added delay. Linux and Windows apps
home; the Apple and Android apps have carried the same setting for a while, and it is stored
under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device.
**Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How
many frames are held back before showing. Each frame absorbs roughly one screen refresh of network
hiccup and costs one refresh of delay — so on a 120 Hz screen, two frames is about 17 ms of extra
delay bought against 17 ms of jitter. If you never see stutter, you don't need this. Wherever
**Prioritize** is offered, and greyed out until you pick Smoothness.
**Smoothness buffer** — *default: Automatic (two frames).* How many frames are held back before
showing. Each frame absorbs roughly one screen refresh of network hiccup and costs one refresh of
delay — so on a 120 Hz screen, two frames is about 17 ms of extra delay bought against 17 ms of
jitter. If you never see stutter, you don't need this. The row appears wherever **Prioritize** is
offered, and only once you have picked **Smoothness** — under Lowest latency there are no held
frames for it to count, so it isn't shown at all.
**V-Sync** — *default: on.* Tear-free presentation. Turning it off asks the GPU to show each frame
the instant it's ready instead of waiting for the screen's next refresh: the lowest delay a display
@@ -263,6 +264,43 @@ when you return to the host list. The console home carries the row for the deskt
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
and Android have no equivalent.
## Interface
These change how the client itself looks and behaves. None of them touches a stream, so none of them
can live in a [profile](/docs/profiles-and-links) — they are decisions about the device in front of
you.
**Gamepad-optimized browsing** — *default: on.* Swaps the touch or desktop home for the
controller-optimized one: the host carousel, larger focus targets, a swipeable cover browser, and
settings you can step with a thumbstick. The Apple and Android apps have this switch. Turn it off to
stay in the touch interface even with a pad in your hands. On Linux, Windows and the Steam Deck the
controller-optimized home is a separate entry point rather than a switch, so there is nothing to
turn off. An Android TV is always in this mode — its remote is the only input it has.
**Show it** — *default: With a controller.* Only shown while the switch above is on, and it decides
*when* that switch takes effect. **With a controller** is the long-standing behaviour: the
controller-optimized home appears as a pad connects and the touch interface returns when the last one
disconnects. **Always** keeps the controller-optimized home either way — for a phone or tablet that
lives docked to a TV, where the pad isn't always awake but the couch layout is always the one you
want. Apple and Android. (An Android TV is in that mode regardless, so the choice changes nothing
there.)
**Background** — *default: Violet.* The colour family the controller-optimized home's living backdrop
drifts through. Thirteen of them: seven dark fields — **Violet**, **OLED**, **Nebula**, **Abyss**,
**Ember**, **Moss**, **Graphite** — then six pale ones, **Holo**, **Sunset**, **Bloom**, **Dawn**,
**Mint** and **Opal**, which flip the whole interface to dark text on a light field. The backdrop
recolours as you step the row, so pick by looking. **OLED** is the one with a practical point rather
than a decorative one: it is true black — most of the frame is pixels switched off, which on an OLED
or AMOLED panel means no glow and no power drawn, with only a faint violet ember left in one corner.
Stored under the same name on every client, so a phone, a Deck and a desktop set to Mint all look
alike. Appearance only — nothing about a stream depends on it.
The row lives in the controller-optimized settings themselves — the screen you reach with **X** from
the controller-optimized home — on every platform that has one, which includes the Steam Deck and the
Linux and Windows console home. The Apple TV is the exception: it carries **Background** in its
ordinary Settings instead, next to **Show it**, because its controller-optimized home needs a real
controller to open and the palettes would otherwise be unreachable from the Siri Remote.
## Overlay
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
@@ -292,6 +330,10 @@ stay global and **cannot be put in a settings profile**:
profile forwards.
- **Auto-wake on connect** and **Show game library** — decisions about this device and this network,
not about how a given host is streamed.
- Everything under **Interface****Gamepad-optimized browsing**, **Show it** and **Background**.
How this client looks and which layout it wears has nothing to do with how a host streams to it,
so binding them to a host would only make the same device change appearance depending on what it
connected to.
One switch you might expect here isn't in Settings at all: **Share clipboard** lives in a saved
host's own edit sheet, because handing a machine your clipboard is a decision about that one host —
+12 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/host",
"version": "0.1.3",
"version": "0.1.4",
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
"type": "module",
"license": "MIT OR Apache-2.0",
@@ -13,7 +13,13 @@
"bugs": {
"url": "https://git.unom.io/unom/punktfunk/issues"
},
"keywords": ["punktfunk", "game-streaming", "automation", "sdk", "effect"],
"keywords": [
"punktfunk",
"game-streaming",
"automation",
"sdk",
"effect"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
@@ -29,7 +35,10 @@
"bin": {
"punktfunk-scripting": "./dist/runner-cli.js"
},
"files": ["dist", "README.md"],
"files": [
"dist",
"README.md"
],
"publishConfig": {
"registry": "https://git.unom.io/api/packages/unom/npm/"
},
+104
View File
@@ -6,6 +6,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { configDir } from "./config.js";
import { SDK_VERSION } from "./version.js";
/** The `@punktfunk` package registry (Gitea's npm registry for the `unom` org). */
export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/";
@@ -187,6 +188,109 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void =
}
};
/** The SDK version installed in a plugins tree, or undefined if it isn't installed at all. */
export const installedSdkVersion = (
dir = pluginsDirDefault(),
): string | undefined => {
try {
const manifest = path.join(
dir,
"node_modules",
"@punktfunk",
"host",
"package.json",
);
const v = (
JSON.parse(fs.readFileSync(manifest, "utf8")) as { version?: string }
).version;
return typeof v === "string" ? v : undefined;
} catch {
return undefined;
}
};
/**
* Bring the plugins tree's `@punktfunk/host` up to the version THIS runner was built from.
*
* **Why this exists.** The SDK is the seam every plugin registers through, but each plugin resolves
* it from the plugins tree, and `bun.lock` pins it to an exact version with an integrity hash. No
* user-facing flow re-resolves that pin: installing a plugin, reinstalling it, even updating it to a
* newer release all leave the SDK where it is, because the plugin's `^0.1.x` range is already
* satisfied. Measured on 2026-08-08 publishing `@punktfunk/host@0.1.3` (the release that lets a
* library scanner register `category`, so it stays out of the console nav) reached **no existing
* install**, and the only thing that moved it was deleting the lockfile by hand over ssh. Shipping a
* fix that needs an ssh session is not shipping a fix.
*
* The runner is the right owner: it is bundled from this same `sdk/` at the host's release commit
* (`packaging/arch/PKGBUILD` builds `src/runner-cli.ts` into the punktfunk-scripting package), so
* `SDK_VERSION` is by construction the SDK that matches the host now on disk. A host upgrade then
* carries the SDK with it and nobody touches a runner.
*
* **Why the whole lockfile.** A targeted `bun add @punktfunk/host@<v>` at the root does NOT work
* while plugins still declare the SDK in their own `dependencies` (they do, though none import it):
* bun honours their locked resolution and gives each plugin a private nested copy, which then
* SHADOWS the root measured, 5 nested copies. A lockless resolve hoists one copy for everyone,
* also measured. Once the plugins drop that spurious dependency this can become the targeted form.
*
* Safety: the plugins' own versions are pinned exactly in the root `package.json`, so a re-resolve
* cannot move them; only shared transitive deps float within their declared ranges. The lockfile is
* backed up first and restored if the install fails, and any failure is logged and swallowed a
* dependency refresh must never stop the plugins that are already working from loading.
*/
export const reconcileSharedSdk = (
dir = pluginsDirDefault(),
log: (line: string) => void = (l) => console.log(l),
): void => {
const have = installedSdkVersion(dir);
// Nothing installed = no plugins yet; the first `bun add` resolves the current SDK on its own.
if (have === undefined || have === SDK_VERSION) return;
const lock = path.join(dir, "bun.lock");
const backup = `${lock}.pf-bak`;
log(
`[plugins] @punktfunk/host ${have} installed, this host ships ${SDK_VERSION} — refreshing`,
);
let restore = false;
try {
if (fs.existsSync(lock)) {
fs.copyFileSync(lock, backup);
fs.rmSync(lock);
restore = true;
}
const res = Bun.spawnSync([process.execPath, "install", "--ignore-scripts"], {
cwd: dir,
stdio: ["inherit", "inherit", "inherit"],
});
if (!res.success) {
throw new Error(`bun install exited ${res.exitCode ?? "?"}`);
}
const now = installedSdkVersion(dir);
if (now !== SDK_VERSION) {
// The install "succeeded" and still did not deliver the version — better to sit on the
// known-good tree than to keep a half-resolved one.
throw new Error(`still ${now ?? "absent"} after install`);
}
restore = false;
if (fs.existsSync(backup)) fs.rmSync(backup);
log(`[plugins] @punktfunk/host is now ${SDK_VERSION}`);
} catch (e) {
log(
`[plugins] WARNING: could not refresh @punktfunk/host (${
e instanceof Error ? e.message : e
}) plugins keep running against ${have}`,
);
if (restore && fs.existsSync(backup)) {
try {
fs.copyFileSync(backup, lock);
fs.rmSync(backup);
} catch {
// The backup is still on disk under its own name; say so rather than pretend.
log(`[plugins] the previous lockfile is at ${backup}`);
}
}
}
};
/** Install one or more plugins by friendly name or package. */
export const addPlugins = (names: string[], opts: PkgOpts = {}): void => {
const pkgs = names.map((n) => resolvePackage(n, opts));
+13 -1
View File
@@ -23,7 +23,12 @@
// package that may live on somebody else's registry — but they are ordinary CLI flags too.
import { Effect, Fiber } from "effect";
import { installLogShipper } from "./log-ship.js";
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
import {
addPlugins,
listInstalled,
reconcileSharedSdk,
removePlugins,
} from "./plugins.js";
import { discoverUnits, runner } from "./runner.js";
const arg = (flag: string): string | undefined => {
@@ -166,6 +171,13 @@ const keepAlive = setInterval(() => {}, 2 ** 31 - 1);
// a plugin failing to load are the first ones out.
const shipper = installLogShipper();
// Before any plugin loads: make the tree's shared SDK the one this runner was built from. A host
// upgrade is the only moment that can deliver an SDK fix to already-installed plugins, and this is
// that moment — see `reconcileSharedSdk`. Deliberately AFTER the log shipper so the operator can
// read what it did from the console's Logs page, and BEFORE `runner()` so plugins import the
// refreshed copy rather than the one they were started with.
reconcileSharedSdk(options.pluginsDir);
const fiber = Effect.runFork(runner(options));
let stopping = false;
const shutdown = (signal: string) => {
+11
View File
@@ -0,0 +1,11 @@
/**
* The version of this SDK, as a value the bundled runner can read about ITSELF.
*
* A constant rather than an import of `package.json`: `tsconfig.build.json` sets `rootDir: "src"`,
* so reaching one directory up breaks the npm build, and the runner ships as a single bundled
* `runner-cli.js` with no `package.json` beside it (`/usr/share/punktfunk-scripting/`), so there is
* nothing to read at runtime either. Inlining it at build time is the only form that survives both.
*
* `version.test.ts` fails if this and `package.json` disagree, so the duplication cannot rot.
*/
export const SDK_VERSION = "0.1.4";
+90
View File
@@ -0,0 +1,90 @@
// `reconcileSharedSdk` runs on EVERY runner start, so its no-op path is the safety-critical one:
// a false positive deletes a working lockfile and re-resolves the whole tree on a box that was
// fine. These tests pin the decision, not the install (which needs a registry).
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, test } from "bun:test";
import { installedSdkVersion, reconcileSharedSdk } from "../src/plugins.js";
import { SDK_VERSION } from "../src/version.js";
const dirs: string[] = [];
/** A plugins tree whose installed `@punktfunk/host` is `version` (omit for "not installed"). */
const tree = (version?: string): string => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-reconcile-"));
dirs.push(dir);
fs.writeFileSync(path.join(dir, "package.json"), '{"private":true}\n');
fs.writeFileSync(path.join(dir, "bun.lock"), "ORIGINAL-LOCK\n");
if (version !== undefined) {
const host = path.join(dir, "node_modules", "@punktfunk", "host");
fs.mkdirSync(host, { recursive: true });
fs.writeFileSync(
path.join(host, "package.json"),
JSON.stringify({ name: "@punktfunk/host", version }),
);
}
return dir;
};
afterEach(() => {
for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
});
describe("installedSdkVersion", () => {
test("reads the installed version, and is undefined when absent", () => {
expect(installedSdkVersion(tree("0.1.2"))).toBe("0.1.2");
expect(installedSdkVersion(tree())).toBeUndefined();
});
test("is undefined rather than throwing on a corrupt manifest", () => {
const dir = tree("0.1.2");
fs.writeFileSync(
path.join(dir, "node_modules", "@punktfunk", "host", "package.json"),
"{ not json",
);
expect(installedSdkVersion(dir)).toBeUndefined();
});
});
describe("reconcileSharedSdk", () => {
// The common case, every start, on every healthy box: touch nothing.
test("is a silent no-op when the installed SDK already matches", () => {
const dir = tree(SDK_VERSION);
const lines: string[] = [];
reconcileSharedSdk(dir, (l) => lines.push(l));
expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe(
"ORIGINAL-LOCK\n",
);
expect(lines).toEqual([]);
});
// A tree with no SDK has no plugins yet — the first `bun add` resolves the current one, so
// there is nothing to refresh and nothing to log about.
test("is a silent no-op when no SDK is installed at all", () => {
const dir = tree();
const lines: string[] = [];
reconcileSharedSdk(dir, (l) => lines.push(l));
expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe(
"ORIGINAL-LOCK\n",
);
expect(lines).toEqual([]);
});
// The failure path matters as much as the happy one: this runs unattended at boot, and the
// tree it just took the lockfile away from is the one the operator's plugins load from. The
// install cannot succeed here (the fake package.json resolves nothing), so this exercises the
// real rollback.
test("restores the lockfile and keeps going when the refresh fails", () => {
const dir = tree("0.0.1-not-a-real-version");
const lines: string[] = [];
expect(() => reconcileSharedSdk(dir, (l) => lines.push(l))).not.toThrow();
expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe(
"ORIGINAL-LOCK\n",
);
expect(lines.join("\n")).toContain("WARNING");
// And it names both versions, so the log says what it was trying to do.
expect(lines.join("\n")).toContain("0.0.1-not-a-real-version");
expect(fs.existsSync(path.join(dir, "bun.lock.pf-bak"))).toBe(false);
});
});
+23
View File
@@ -0,0 +1,23 @@
// The one thing that keeps `SDK_VERSION` honest. The runner compares it against the SDK actually
// installed in the plugins tree and reinstalls on a mismatch, so a stale constant would either
// reinstall forever (constant behind) or never deliver a fix (constant ahead of a release).
import { readFileSync } from "node:fs";
import { describe, expect, test } from "bun:test";
import { SDK_VERSION } from "../src/version.js";
describe("SDK_VERSION", () => {
test("matches package.json — bump both or neither", () => {
// Read rather than import: `tsconfig.build.json` pins `rootDir: "src"`, so a JSON import of
// the manifest would not compile for the npm build even though bun would run it fine.
const pkg = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
) as { version: string };
expect(SDK_VERSION).toBe(pkg.version);
});
test("is a plain semver triple", () => {
// The runner compares it to an installed version string, so anything with a range operator
// (`^0.1.3`) would never compare equal and would reinstall on every start.
expect(SDK_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
});
});
@@ -0,0 +1,91 @@
// GET/PUT /api/plugin-config/<id> — a plugin's `__config`, readable from the CONSOLE origin.
//
// The Library section's "Game sources" settings drawer renders a form from a library plugin's
// `__config` (the kit's generic settings surface, so a scanner needs no SPA of its own). It fetched
// `/plugin-ui/<id>/__config` same-origin — and that stopped working the moment plugin UIs moved to
// their own origin (2026-08-05 review H-3): `middleware/auth.ts` answers 404 for `/plugin-ui/**` on
// the console origin, unconditionally and by design. The drawer is the only NON-IFRAME consumer of
// that path, so nothing else noticed, and settings silently failed to open for every library plugin.
//
// The fix is deliberately not "point the drawer at the plugin origin". That needs CORS plus
// cross-site cookies, and it would put a plugin-controlled response inside a credentialed
// cross-origin fetch — reopening the hole the split exists to close. What the drawer needs is DATA,
// not an embedded UI: this reads the JSON server-side over loopback and returns it same-origin, so
// no plugin HTML or JS is ever served from the console origin.
//
// Auth: `/api/**` is always session-gated (`isPublicPath`), so reaching here means a logged-in
// operator, and it answers 401 as JSON rather than redirecting — which is what a `fetch` needs. The
// plugin's per-boot secret stays server-side, exactly as in the `/plugin-ui` proxy.
import {
defineEventHandler,
getRouterParam,
readRawBody,
setResponseStatus,
} from "h3";
import {
bustCredential,
fetchUiCredential,
PLUGIN_ID_RE,
} from "../../../util/pluginProxy";
/** `GET` reads schema + current value; `PUT` validates and saves. Nothing else is forwarded. */
const ALLOWED = new Set(["GET", "PUT"]);
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
if (!id || !PLUGIN_ID_RE.test(id)) {
setResponseStatus(event, 404);
return { error: "not a valid plugin id" };
}
const method = event.method;
if (!ALLOWED.has(method)) {
setResponseStatus(event, 405);
return { error: "method not allowed" };
}
// Read the body BEFORE the retry below: `readRawBody` drains the stream, so a second attempt
// would forward an empty PUT and quietly save `{}` over the operator's config.
const body =
method === "PUT"
? ((await readRawBody(event, false)) as Uint8Array | undefined)
: undefined;
const attempt = async (bustCache: boolean): Promise<Response | null> => {
const cred = await fetchUiCredential(id, { bustCache });
if (!cred) return null;
try {
return await fetch(`http://127.0.0.1:${cred.port}/__config`, {
method,
headers: {
authorization: `Bearer ${cred.secret}`,
...(method === "PUT" ? { "content-type": "application/json" } : {}),
},
body: body as BodyInit | undefined,
});
} catch {
return null;
}
};
// A plugin's secret rotates when its process restarts, which happens well inside the credential
// cache's TTL — so a 401 here means "stale credential", not "denied". Same one-shot retry the
// `/plugin-ui` proxy does, for the same reason.
let res = await attempt(false);
if (res?.status === 401) {
bustCredential(id);
res = await attempt(true);
}
if (!res) {
setResponseStatus(event, 502);
return { error: `plugin ${id} is not reachable` };
}
setResponseStatus(event, res.status);
// Pass the plugin's own body through untouched: a 400 from `__config` carries the decode issue
// the drawer shows the operator, and rewriting it would throw away the only useful part.
const text = await res.text();
try {
return JSON.parse(text) as unknown;
} catch {
return { error: text || `plugin ${id} answered ${res.status}` };
}
});
+11 -5
View File
@@ -26,9 +26,15 @@ import { m } from "@/paraglide/messages";
* A library source's settings, rendered as a **generic form** from the plugin's own JSON Schema.
*
* The point (design D7, closing G8): a scanner plugin ships no SPA at all. It serves
* `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. Everything
* goes through the existing session-gated `/plugin-ui/<id>/…` proxy, so there is **zero new host
* surface** the browser never learns the plugin's port or secret.
* `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. The browser
* never learns the plugin's port or secret the console reads it server-side over loopback.
*
* That read goes through `/api/plugin-config/<id>` on the CONSOLE origin, not the `/plugin-ui/…`
* proxy this used to call. Plugin UIs live on their own origin (2026-08-05 review H-3) and the
* console origin now answers 404 for `/plugin-ui/**` by design, which broke this drawer for every
* library plugin it is the one consumer of that path that is not an iframe. What it needs is
* DATA, not an embedded UI, so it gets JSON same-origin and no plugin markup ever reaches the
* console origin.
*
* Fields the derivation can't express fall back to a raw JSON editor. That fallback is what bounds
* the risk of the whole approach: worst case the drawer is a validated textarea, and the PUT still
@@ -51,7 +57,7 @@ export const SourceSettingsDialog: FC<{
let cancelled = false;
(async () => {
try {
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
const res = await fetch(`/api/plugin-config/${pluginId}`, {
credentials: "same-origin",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -77,7 +83,7 @@ export const SourceSettingsDialog: FC<{
const save = async (value: JsonObject) => {
setSaving(true);
try {
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
const res = await fetch(`/api/plugin-config/${pluginId}`, {
method: "PUT",
credentials: "same-origin",
headers: { "content-type": "application/json" },