Skip to content

fix: XDG config, keyring-backed pairing keys, named transport failures — plus configurable keep-awake - #34

Merged
emir-hasanbegovic merged 3 commits into
mainfrom
fix/xdg-config-keyring-error-flows
Aug 23, 2026
Merged

fix: XDG config, keyring-backed pairing keys, named transport failures — plus configurable keep-awake#34
emir-hasanbegovic merged 3 commits into
mainfrom
fix/xdg-config-keyring-error-flows

Conversation

@emir-hasanbegovic

@emir-hasanbegovic emir-hasanbegovic commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Four related cleanups to how Dish stores its state, explains a failed
connection, and handles a crash — plus the fourteen CMake warnings the build
printed on every configure.

Config: one file, in an XDG location

Every store and repository default-constructed QSettings("Dish","Dish") — a
Windows registry path (HKCU\Software\Dish\Dish) carried over verbatim from
the dish-windows port. On Linux that resolves to ~/.config/Dish/Dish.conf,
while anything default-constructing QSettings after main() set the
organization landed in ~/.config/TinkerNorth/Dish.conf.

So the app wrote two files, and the one the README documented held a single
UI flag while the other held the device id, the remembered satellites and the
pairing keys. Someone deleting the documented file to reset Dish would have
deleted almost nothing and left their keys behind.

repository/AppSettings is now the only place that decides the location:
$XDG_CONFIG_HOME/com.tinkernorth.Dish/dish.conf, keyed by the same
reverse-DNS app id as the .desktop entry, the AppStream metainfo and the
Flatpak. Both old files are folded in on first run, key by key, never
overwriting one the new file already has, then renamed .migrated so a bad
migration stays recoverable by hand.

Pairing keys move to the desktop keyring

A pairing key authenticates every REST call and is the HKDF input for the
session key that encrypts input packets. It sat in plaintext in a file created
0644-minus-umask — readable by every other account on the machine, and by
anything that backs up or syncs ~/.config.

Keys now live in the desktop keyring over org.freedesktop.secrets, which
gnome-keyring and KWallet both implement. No new dependency: this is QtDBus,
already linked for the screensaver inhibit and the BlueZ probe. Where no Secret
Service answers — headless, bare WMs, some Flatpak sandboxes — the config file
is still used, now 0600.

Two ways a plaintext copy could have outlived the move are closed:

  • the legacy wifi_shared_key/ entries are now removed once migrated, rather
    than left sitting beside the current namespace
  • a key found in the file while the keyring already holds one is swept, rather
    than skipped by the early return on a successful keyring read

Verified end to end against a real gnome-keyring: after migration the config
file contains no key material at all.

Transport failures are named

RawReply collapsed every pre-HTTP failure into status == 0, so host-down,
connection-refused, timed-out and a TLS handshake failure were indistinguishable
to the UI, the log and the retry logic.

TransportFailure carries the cause through to the session FSM. It is
strictly diagnostic: classifyRest still folds every value into
Unreachable, and test_transport_failure.cpp asserts the verdict is identical
for all seven causes, so adding a cause can never quietly change a retry
decision.

What changes for a user is the sentence: a refused connection now says the
satellite is not running on that machine, instead of the generic "check it's
powered on" line that actively points away from the real cause.

What changes in the log is the volume. A remembered-but-absent satellite writes
one line per cause change rather than one per backoff tick:

dish.net: satellite wifi:10.0.0.2:9876: connection refused; retrying in 1s

Per-tick detail stays available behind QT_LOGGING_RULES="dish.net.debug=true".

This also fixes QIODevice::read (QNetworkReplyHttpImpl): device not open,
which a remembered satellite printed on every single retry. The finished
handler read the reply without checking whether it was still open — which it is
not after an abort or a failed connect. Traced with QT_FATAL_WARNINGS=1 under
gdb rather than guessed at.

Crash reports are local and explicit

The Settings toggle read "Share crash reports — anonymous crash reports help fix
bugs" while nothing was ever transmitted, and the backend logged "no backend
wired", which read as unfinished. Local-only collection is the finished
design, so the copy now says so.

UI/CrashReport turns the backtrace into a report the user reads before
deciding to send it. redact() strips the home directory, IPv4 literals,
.local hostnames and any hex run of 32 characters or more — so a pairing key
that happened to be on the stack cannot be pasted into a public issue tracker.
The report shown on screen and the report the issue is prefilled with are the
same string; there is no richer version sent behind the user's back.

Translation contexts (pre-existing bug)

QCoreApplication::translate was called with kTrContext, a variable.
lupdate cannot resolve one, so none of the user-facing strings in
WifiConnectionManager or PairingClient were ever extracted — every one of
them shipped untranslated in all six languages, silently, and the translation
gate stayed green because the catalogues matched what lupdate produced.

Using the literal at each call site recovers 12 strings, nine of which predate
this branch
. Catalogues go 910 → 922.

They are extracted but not yet translated. Coverage is reported and not
enforced, so this is green, but those nine strings still render in English until
someone translates them.

CMake warnings

Configuring printed fourteen warnings. All fourteen are gone; neither was
silenced by lowering a gate.

Thirteen came from qt6_import_qml_plugins, which links a QML plugin only
when that plugin's target is a STATIC_LIBRARY — so against a shared Qt the
scan can never produce anything linkable. Debian and Ubuntu additionally default
QT_SKIP_AUTO_QML_PLUGIN_INCLUSION to ON (their own comment in
Qt6QmlPlugins.cmake reads "Distributions should probably change this
default"), so the Qt6::<plugin> targets are never created and each scanned
plugin warns.

Turning that default back off is not viable — Ubuntu ships no
QmlAssetDownloader module, so auto-inclusion sets Qt6Quick_FOUND to false
and fails the configure outright. The scan is instead skipped exactly where it
can do nothing, on a shared Qt, via Qt's own QT_QML_MODULE_NO_IMPORT_SCAN
early-out. A static Qt still scans and still links. The property is set after
qt_add_qml_module, which writes it from its own argument and would otherwise
clobber it.

The fourteenth was true, not noise. DISH_UDEV_RULES_DIR was relative, so
it was only correct under the packaging prefix. Under CMake's default prefix it
resolved to /usr/local/lib/udev/rules.d, which udev does not scan — meaning a
plain sudo cmake --install laid the hidraw rule down somewhere inert and every
USB-direct claim failed PermissionDenied afterwards. The default now depends
on the prefix and uses /etc/udev/rules.d off /usr. Confirmed by staged
install at both prefixes; the packaging path is byte-for-byte unchanged.

rsvg-convert is optional and its absence is a STATUS notice rather than a
warning, but it was missing from the documented dependencies. It is listed now.

Also fixed: two pre-existing clang-tidy failures

scripts/ci_local.sh did not pass on Ubuntu 26.04 (clang-tidy 21) before this
branch — five errors on main, from checks newer than the CI runner's
clang-tidy. Both are false positives on required idioms and are suppressed with
written rationale rather than by bending the code:

  • four in StatusNotifierTrayIcon.cpp, where returning the const reference
    parameter is the signature QtDBus requires of a demarshalling operator
  • one in HidrawGateway.h, where from_chars is already being passed a
    (first, last) pair — the check simply does not model it as size-aware

Testing

scripts/ci_local.sh passes end to end (exit 0): clang-format, action pin lint,
Debug configure/build/test, qmllint, QML literal scanner, translation
catalogues, clang-tidy, Release configure/build/test, hardening flags.

1693 tests pass, up from 1672 — 21 added across four new files covering the
transport-failure invariant, the no-clobber config merge, the keyring round trip
and the crash-report redaction.

The keyring tests run against a real Secret Service and SKIP where none
answers, so they exercise the D-Bus marshalling on a developer desktop and stay
green on a CI runner. A hand-written (oayays) struct is exactly the sort of
thing that compiles cleanly and then returns garbage.

Migration notes for reviewers

  • First launch moves both config files and, where a keyring is present, moves
    the pairing keys into it. Old files are renamed, never deleted.
  • The keyring is wired only on the production path. A test injecting its own
    QSettings gets file-backed behaviour and never touches the developer's login
    keyring — which is why the secret store is injected rather than constructed
    inside the repository.

Configurable keep-awake

Second, unrelated change on this branch: keep-awake becomes a user setting, and
stops holding the display by default.

What was wrong

Dish held both the machine and the display awake, unconditionally, whenever
any slot was streaming. There was no setting for it anywhere — the only surface
was a header pill reading "Streaming · display kept awake", and no way to
disable it. Forwarding a gamepad needs the computer awake, not the panel, and a
pad left on the desk pinned the machine indefinitely.

A keepAwakeCount() override input existed on AppModel as a designed seam
with nothing wired into it — permanently zero, and documented as "never bind UI
to it". That seam is now the real preference.

The setting

Settings gains a Power section:

Mode Holds while
Never
While playing (default) a slot is streaming and a controller was actuated inside the idle window
While connected a slot is streaming, however long the pad sits still

plus an idle window of 1–180 minutes (5 by default, shown only in While
playing
) and a Keep the display awake too switch, off by default. The
switch widens a hold; it never creates one.

The streaming pill now names the reach it actually holds — Streaming,
· computer kept awake, or · display kept awake — and carries a
Configure button through to the setting.

The default changes behaviour for existing users in two ways, deliberately: the
display is no longer held, and an untouched pad releases the machine after five
minutes. Both are recoverable from the new section.

Design

The decision is pure and lives in core/reducer/KeepAwake.h — mode, idle
window, display reach, and the storage-key round trip, all constexpr, pinned
as a truth table in test_keep_awake.cpp. WakeStateComposer now folds three
upstreams (streaming count, controller activity, preferences) into a
KeepAwakeReach, and WakeStateController drives the inhibitor off it.

The inhibitor moves from src/Util/ to src/source/system/ beside
SleepMonitor: it owns two D-Bus handles, which per ARCHITECTURE.md makes it
a Gateway, not a leaf helper. Its surface collapses from acquire/release
into one idempotent, absolute apply(reach, reason) — callers pass the
reach they want, not a delta. System is logind's idle/block inhibit alone;
SystemAndDisplay adds org.freedesktop.ScreenSaver.Inhibit. held() reports
the greatest reach actually satisfied rather than the one asked for, so a
degraded logind cannot make it overclaim.

Detecting "actually playing"

Nothing in the codebase knew when input last happened, and the naive answers are
both wrong:

  • Report rate won't do it. A USB-direct pad streams at its full poll rate
    whether or not anyone is holding it — publish() bumps its counter on
    identical reports, which test_gamepad_input_processor.cpp already pinned.
  • Equality won't do it either. Deadzones are installed per SDL device id,
    and a USB-direct slot's id is a synthetic (vid,pid) key that never appears
    in the bridge's device list — so those slots are published with no deadzone
    at all
    , and a resting stick dithers by a wire LSB (257 counts) every report.

So publish() compares the post-deadzone state against a per-device
reference that only advances when it trips, with a stick/trigger epsilon.
Jitter around a fixed reference never accumulates; a slow deliberate push does.
Motion is deliberately excluded — a stationary gyro is never numerically
constant — while touchpad samples always count, being genuinely event-driven.

The hot path gains one hash lookup and a few integer compares inside the mutex
it already holds, and bumps a process-wide atomic. No clock, no Observable, no
Qt — ARCHITECTURE.md's hot-path rule is intact. ControllerActivitySource
samples that counter on the existing 1 Hz GUI tick, against an injected
clock, so every test is exact rather than timing-dependent.

Also

  • The quit confirm now gates on streamingSlotCount > 0 rather than on the
    keep-awake hold. Otherwise setting keep-awake to Never would silently remove
    the "a controller is still streaming" guard too.
  • Kit.SliderRow gains minValue, valueText and a read-only displayValue;
    its readout was hard-coded to a % suffix. Callers format displayValue
    because a drag breaks the inbound value binding.

Testing

scripts/ci_local.sh passes end to end (exit 0), every gate including
clang-tidy and both build configurations.

1767 tests pass, up from 1693 — 74 added. The heart of it is
test_gamepad_input_processor.cpp's activity block: a 1000-report jittery
resting pad never trips, and a slow drift against the fixed reference does.

Docs updated: QML_CONTRACT.md (the four properties and the new signal),
ARCHITECTURE.md, QML_UI_KIT.md, PACKAGING.md, PARITY.md, README.md,
CHANGELOG.md, and a new Keep awake section in the manual smoke checklist
systemd-inhibit --list is the oracle for the rows CI cannot reach.

The same feature lands in dish-windows (#53), where System and
SystemAndDisplay are ES_SYSTEM_REQUIRED and +ES_DISPLAY_REQUIRED. All
shared files are byte-identical across the two repos.

@emir-hasanbegovic
emir-hasanbegovic force-pushed the fix/xdg-config-keyring-error-flows branch 2 times, most recently from 42f6093 to 16cd474 Compare August 21, 2026 13:51
…lures

Four related cleanups to how Dish stores its state, reports why a connection
failed, and handles a crash, plus the CMake warnings the build printed on
every configure.

Config: one file, in an XDG location

Every store and repository default-constructed QSettings("Dish","Dish"), a
Windows registry path (HKCU\Software\Dish\Dish) carried over verbatim from the
dish-windows port. On Linux that resolved to ~/.config/Dish/Dish.conf, while
anything that default-constructed QSettings after main() set the organization
landed in ~/.config/TinkerNorth/Dish.conf instead. The app wrote two files, and
the one the README documented held a single UI flag while the other held the
device id, the remembered satellites and the pairing keys.

repository/AppSettings is now the single place that decides the location:
$XDG_CONFIG_HOME/com.tinkernorth.Dish/dish.conf, keyed by the same reverse-DNS
app id as the .desktop entry, the AppStream metainfo and the Flatpak. Both old
files are folded in on first run, key by key, never overwriting one the new
file already has, then renamed .migrated so a bad migration stays recoverable.

Pairing keys move to the desktop keyring

A pairing key authenticates every REST call and is the HKDF input for the
session key, and it sat in plaintext in a file created 0644-minus-umask. It now
lives in the desktop keyring over org.freedesktop.secrets, which gnome-keyring
and KWallet both implement; QtDBus was already linked, so this adds no
dependency. Where no Secret Service answers the config file is still used, now
0600.

Two ways a plaintext copy could have survived the move are closed: the legacy
wifi_shared_key/ entries are removed once migrated rather than left beside the
new namespace, and a key found in the file while the keyring already holds one
is swept instead of being skipped by the early return.

Transport failures are named

RawReply collapsed every pre-HTTP failure into status 0, so host-down, refused,
timed-out and a TLS handshake failure were indistinguishable to both the UI and
the log. TransportFailure carries the cause through to the session FSM. It is
strictly diagnostic: classifyRest still folds every value into Unreachable, and
a test asserts the verdict is identical for all of them, so a cause can never
change a retry decision.

The user-visible effect is that a refused connection now says the satellite is
not running rather than suggesting the machine is powered off. The log effect
is that a remembered-but-absent satellite writes one line per cause CHANGE
instead of one per backoff tick, with per-tick detail behind
QT_LOGGING_RULES="dish.net.debug=true".

This also fixes the "QIODevice::read (QNetworkReplyHttpImpl): device not open"
warning that a remembered satellite printed on every retry: the finished
handler read the reply without checking whether it was still open, which it is
not after an abort or a failed connect.

Crash reports are local and explicit

The Settings toggle said crash reports were shared and helped fix bugs while
nothing was ever transmitted. Collection is local and always was, so the copy
now says that, and UI/CrashReport turns the backtrace into a report the user
reads before deciding to send. redact() strips the home directory, IPv4
literals, .local hostnames and any hex run of 32 characters or more, so a
pairing key on the stack cannot be pasted into a public issue tracker.

Translation contexts

QCoreApplication::translate was called with kTrContext, a variable. lupdate
cannot resolve one, so none of the user-facing strings in WifiConnectionManager
or PairingClient were ever extracted and all of them shipped untranslated in
every language. Using the literal at each call site recovers 12 strings, nine
of which predate this change.

CMake warnings

Configuring printed fourteen warnings.

Thirteen came from qt6_import_qml_plugins, which links QML plugins only when
their target is a STATIC_LIBRARY; against a shared Qt the scan cannot produce
anything linkable. Debian and Ubuntu additionally default
QT_SKIP_AUTO_QML_PLUGIN_INCLUSION to ON, so the plugin targets do not exist and
each one warns. Turning that default back off is not viable: Ubuntu ships no
QmlAssetDownloader module, and auto-inclusion makes Qt6Quick_FOUND false and
fails the configure outright. The scan is now skipped where it cannot do
anything, on a shared Qt, via Qt's own QT_QML_MODULE_NO_IMPORT_SCAN early-out.
A static Qt still scans and still links.

The fourteenth was true, not noise: DISH_UDEV_RULES_DIR was relative, so it was
only correct under the packaging prefix. Under CMake's default prefix it
resolved to /usr/local/lib/udev/rules.d, which udev does not scan, and
`sudo cmake --install` laid the rule down somewhere inert. The default now
depends on the prefix and uses /etc/udev/rules.d off /usr, so the rule installs
somewhere udev reads. The packaging path is unchanged.

rsvg-convert is optional and its absence is a notice rather than a warning, but
it was missing from the documented dependencies; it is listed now.
Keep-awake was unconditional, undocumented in Settings, and held the display
as well as the machine whenever any slot streamed. Forwarding a gamepad needs
the computer awake, not the panel, and a pad left on the desk had no way to
let the machine sleep.

Settings gains a Power section with three modes — Never, While playing
(streaming AND a controller actuated inside a 1..180 minute idle window, 5 by
default) and While connected — plus an opt-in that widens the hold to the
display. The streaming pill names the reach it actually holds and carries a
Configure button through to the setting.

The decision is pure and exhaustively tested (core/reducer/KeepAwake.h). The
inhibitor moves from Util/ to source/system/ as the Gateway it is, and its
surface becomes one idempotent, absolute apply(reach): "system" is logind's
idle inhibit, "display" adds the screensaver one.

Activity is detected in GamepadInputProcessor::publish, post-deadzone, against
a per-device reference that only advances when it trips. That matters because
USB-direct slots get no deadzone profile, so their resting sticks dither by a
wire LSB every report — plain equality would read an untouched pad as played
with forever, while an epsilon against a moving reference would miss a slow
deliberate push. A process-wide atomic counter carries it off the hot path;
ControllerActivitySource samples it on the GUI tick against an injected clock.

The quit confirm now gates on the stream rather than on the hold, so turning
keep-awake off does not also remove the guard before a live stream dies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@emir-hasanbegovic emir-hasanbegovic changed the title fix: XDG config, keyring-backed pairing keys, and named transport failures fix: XDG config, keyring-backed pairing keys, named transport failures — plus configurable keep-awake Aug 21, 2026
Coverage was reported and never enforced, on the reasoning that extracting a
string and translating it are separate acts. In practice that meant 228 of 940
entries shipped in English to every non-English user, silently — an untranslated
entry is not a blank, it is the wrong language, and no other gate can see it.

All six catalogues are now complete: bs, de, es, fr and pt_BR gained the 228
missing entries each, translated against the terminology already in the
catalogue rather than in isolation.

dish_en.ts is the case that made a uniform gate awkward. English is the source
language, so Qt falls back to <source> and the app has always read correctly —
but that fallback is invisible to a coverage count, which would have left the
source catalogue permanently "untranslated" and the gate permanently off for the
one language nobody has to translate. seed-source-language.py fills its
non-plural entries from their own source text, so the count means the same thing
in all six. Plural entries are deliberately left for a human: a source string
carries one form, and seeding those would ship "1 slots free".

check-translations.sh now runs the seeder and fails on any unfinished entry,
listing what still needs words.

lupdate had also marked 161 entries unfinished where its same-text heuristic or
a changed source had left carried-over text behind. Those are retranslated
against the current source rather than confirmed blind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
emir-hasanbegovic added a commit to TinkerNorth/dish-windows that referenced this pull request Aug 23, 2026
…p-awake (#53)

Clicking the `⋯` on a Connections host card, or opening a slot row's
context
menu on Home, appears to do nothing.

The menu is in fact opening. It takes focus and swallows the next click
— it
just has no width to draw into, which is indistinguishable from a dead
button.

## Cause

A `Menu` takes its width from its **background**, not from its items.
The
style's default background carries `implicitWidth: 200`. Both of these
menus
replace that background with a bare `Rectangle` in order to restyle it,
and a
bare `Rectangle` has `implicitWidth: 0` — so the menu collapses.

Reproduced with the exact declaration from `ConnectionsPage.qml`:

```
opened = true   visible = true   count = 1   width = 0   height = 32
```

With a width restored on the background, the same probe reports `width =
180`.

## Scope

| Menu | `implicitWidth` on background | |
|---|---|---|
| `kit/ComboButton.qml` | `Math.max(control.width, 140)` | already
correct |
| `pages/ConnectionsPage.qml` — host `⋯` | absent | **broken** |
| `pages/HomePage.qml` — slot row menu | absent | **broken** |

`ComboButton` already restated a width for exactly this reason, which is
what
makes it the pattern to follow rather than an argument for a constant.

## Fix

Both menus are sized to their widest item rather than to a fixed number,
so a
longer translation is not clipped — *Forget* is one word in English and
three in
some catalogues — over a shared `Tokens.menuMinWidth` floor so a
one-word menu
is not a sliver. `menuMinWidth` is a new token in `TokensBridge.h`,
since that
is where this project's design tokens live.

`HomePage`'s menu is `Repeater`-driven, so its widest-item measure is a
small
loop keyed on `count`, which is the dependency that makes it re-evaluate
when
the list is rebuilt. `ConnectionsPage` has a single static item and
binds
straight to it.

## What is and is not verified

Verified: the zero width and the fix, reproduced against
`QtQuick.Controls`
Basic — the style both menus import, whose layout is
platform-independent —
and `qmllint` is clean on both files.

**Not** verified by running the Windows build. I do not have a Windows
toolchain here, so the Windows CI on this PR is the first time it
compiles.
The change is three QML sizing bindings and one `constexpr`-style token
getter,
so the risk is low, but it deserves a click-through on the smoke
checklist
entry added here before merge.

## Cross-repo

The same defect exists in `dish-linux` — byte-identical QML, same two
menus,
same already-correct `ComboButton` — and is fixed there in
TinkerNorth/dish-linux#34. It came through the original port. `dish-mac`
and
`dish-android` are worth the same check.


---

# Configurable keep-awake

Second, unrelated change on this branch, landing in lockstep with
dish-linux #34.

## What was wrong

Dish asserted `ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED`
unconditionally, whenever any slot was streaming. There was no setting
for it
anywhere — the only surface was a header pill reading *"Streaming ·
display kept
awake"*, and no way to disable it. Forwarding a gamepad needs the
computer
awake, not the panel, and a pad left on the desk pinned the machine
indefinitely.

A `keepAwakeCount()` override input existed on `AppModel` as a designed
seam
with nothing wired into it — permanently zero, and documented as "never
bind UI
to it". That seam is now the real preference.

## The setting

Settings gains a **Power** section:

| Mode | Holds while |
|---|---|
| Never | — |
| While playing *(default)* | a slot is streaming **and** a controller
was actuated inside the idle window |
| While connected | a slot is streaming, however long the pad sits still
|

plus an idle window of 1–180 minutes (5 by default, shown only in *While
playing*) and a **Keep the display awake too** switch, off by default.
The
switch widens a hold; it never creates one.

The streaming pill now names the reach it actually holds — `Streaming`,
`· computer kept awake`, or `· display kept awake` — and carries a
**Configure** button through to the setting.

The default changes behaviour for existing users in two ways,
deliberately: the
display is no longer held, and an untouched pad releases the machine
after five
minutes. Both are recoverable from the new section.

## Design

The decision is pure and lives in `core/reducer/KeepAwake.h` — mode,
idle
window, display reach, and the storage-key round trip, all `constexpr`,
pinned
as a truth table in `test_keep_awake.cpp`. `WakeStateComposer` now folds
three
upstreams (streaming count, controller activity, preferences) into a
`KeepAwakeReach`, and `WakeStateController` drives the inhibitor off it.

The inhibitor moves from `src/Util/` to `src/source/system/`: it owns an
OS
power assertion, which per `ARCHITECTURE.md` makes it a Gateway, not a
leaf
helper. Its surface collapses from `acquire`/`release` into one
idempotent,
**absolute** `apply(reach, reason)` — callers pass the reach they want,
not a
delta. `System` is `ES_CONTINUOUS | ES_SYSTEM_REQUIRED`;
`SystemAndDisplay` adds
`ES_DISPLAY_REQUIRED`; `None` is `ES_CONTINUOUS` alone, which clears our
own
flags without disturbing another application's assertion.
`ES_AWAYMODE_REQUIRED`
stays omitted, as before.

## Detecting "actually playing"

Nothing in the codebase knew when input last happened, and the naive
answers are
both wrong:

- **Report rate won't do it.** A USB-direct pad streams at its full poll
rate
  whether or not anyone is holding it — `publish()` bumps its counter on
identical reports, which `test_gamepad_input_processor.cpp` already
pinned.
- **Equality won't do it either.** Deadzones are installed per *SDL
device id*,
and a USB-direct slot's id is a synthetic `(vid,pid)` key that never
appears
in the bridge's device list — so those slots are published with **no
deadzone
at all**, and a resting stick dithers by a wire LSB (257 counts) every
report.

So `publish()` compares the post-deadzone state against a per-device
**reference that only advances when it trips**, with a stick/trigger
epsilon.
Jitter around a fixed reference never accumulates; a slow deliberate
push does.
Motion is deliberately excluded — a stationary gyro is never numerically
constant — while touchpad samples always count, being genuinely
event-driven.

The hot path gains one hash lookup and a few integer compares inside the
mutex
it already holds, and bumps a process-wide atomic. No clock, no
Observable, no
Qt — `ARCHITECTURE.md`'s hot-path rule is intact.
`ControllerActivitySource`
samples that counter on the existing 1 Hz GUI tick, against an
**injected**
clock, so every test is exact rather than timing-dependent.

## Also

- The quit confirm now gates on `streamingSlotCount > 0` rather than on
the
keep-awake hold. Otherwise setting keep-awake to Never would silently
remove
  the "a controller is still streaming" guard too.
- `Kit.SliderRow` gains `minValue`, `valueText` and a read-only
`displayValue`;
its readout was hard-coded to a `%` suffix. Callers format
`displayValue`
  because a drag breaks the inbound `value` binding.
- The three new preferences live in `HKCU\Software\TinkerNorth\Dish`,
the
Windows-desktop-only hive, **not** the cross-client `Software\Dish\Dish`
  schema that dish-android also defines.

## Testing, and a caveat worth reading

**Every file this feature shares with dish-linux is byte-identical** —
the
reducer, the composer, the controller, the activity source,
`GamepadInputProcessor`, `AppSettingsMaps`, `SliderRow.qml`, and all six
shared
test files. Only the leaf inhibitor, the store's hive, and the AppModel
/
AppViewModel / QML wiring differ, as they must.

That matters because it means dish-linux's verification covers this
logic too.
There, the full local CI passed end to end and **1767 tests pass, up
from 1693**
— 74 added — plus clean ASan/UBSan and TSan legs. The heart of it is
`test_gamepad_input_processor.cpp`'s activity block: a 1000-report
jittery
resting pad never trips, and a slow drift against the fixed reference
does.

**The caveat:** the Windows side could not be compiled or tested locally
— it is
MSVC + vcpkg + `windows.h` only. What *was* verified locally, on the
actual
files in this repo: `clang-format --dry-run --Werror` clean; a real
`g++ -fsyntax-only` pass over every new/changed header,
`WakeInhibitor.cpp`,
`AppSettingsMaps.cpp` and all eight wake/keep-awake test translation
units,
against Qt 6.10 with a Win32 stub — zero diagnostics; `qmllint` silent
on all
five changed QML files; brace balance; the QML literal scanner; and
`lupdate`
reproducing all six `.ts` catalogues with zero diff. Windows CI remains
the
authority for MSVC `/W4 /WX` and clang-tidy.

Docs updated: `QML_CONTRACT.md` (the four properties and the new
signal),
`ARCHITECTURE.md`, `QML_UI_KIT.md`, `PARITY.md`, `README.md`,
`CHANGELOG.md`,
and a new **Keep awake** section in the manual smoke checklist —
`powercfg
/requests` is the oracle for the rows CI cannot reach.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@emir-hasanbegovic
emir-hasanbegovic merged commit 2c442b1 into main Aug 23, 2026
13 checks passed
@emir-hasanbegovic
emir-hasanbegovic deleted the fix/xdg-config-keyring-error-flows branch August 23, 2026 11:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant