feat: run in the background behind a tray item, and survive suspend - #33
Merged
Conversation
## Why Closing the window quit Dish, so a controller could only stream while a window sat on screen. Suspending the machine left the satellite sessions to die by heartbeat timeout ~10 s later, and the 15 s auto-reconnect sweep to notice afterwards. ## Running in the background Closing the window now leaves Dish streaming behind a StatusNotifierItem, whose menu is both the way back and the way out. A one-time desktop notification says so the first time it happens, because a window that vanishes without a word reads as a crash — and it has to be a desktop notification rather than the shell's toast host, since by then there is no window left to read it in. The hide is gated on a StatusNotifier host actually owning the watcher name. `reducer::decideCloseAction` takes the preference AND availability, so bare GNOME — which ships no host without the AppIndicator extension — keeps quitting on close rather than stranding a running process with no window and no menu. The same guard reversed: if the panel dies while the window is hidden, the window comes back. Hiding skips the wizard leave guard and the keep-awake confirm. Both exist because closing was a leave; a hide discards nothing and the stream is meant to survive it. ## Suspend and resume A logind `sleep`/`delay` lock buys time on `PrepareForSleep` to close the sessions before the machine goes down; the lock is dropped inside the handler, since logind suspends the moment the last one closes. A resume rescans and re-establishes. Tearing down first is load-bearing rather than tidy. A session the machine slept through comes back `Faltering`, which passes both `autoReconnectAll`'s not-Live test and `connectTo`'s Live-or-Linking guard, so a bare reconnect would open a second socket beside the frozen one and strand whichever the old client's death handler did not claim. `resumeFromSleep` also clears the retry curve, which would otherwise be measuring wall clock the machine spent asleep. A closed lid still suspends, and no application can change that: `LidSwitchIgnoreInhibited` defaults to yes, so logind runs the lid action even against a `handle-lid-switch` inhibitor. README documents the `logind.conf` setting instead of pretending otherwise. ## Shape Pure decisions in `core/reducer/` (`BackgroundMode`, `TrayPresentation`, `SleepCycle`), platform edges as gateways in `source/` behind abstract interfaces so they fake in tests, derivation in `TrayComposer`, effects in `TrayController`, commands in `BackgroundCoordinator` and `SleepCoordinator`, wired once in `AppModel`. The tray and the sleep monitor start in `start()` rather than the constructor: both touch D-Bus, and building an AppModel must not register an item on the panel. Flatpak gains the watcher, notification and login1 names — the sandbox was already missing login1, so it had silently lost the idle inhibit too.
Hand-rolled over QtDBus rather than QSystemTrayIcon, which would drag QtWidgets into a process that deliberately keeps it out. The item owns its own bus connection and registers under that connection's unique name, so no well-known name has to be claimed and the Flatpak proxy has nothing to refuse. Availability follows whether anything owns org.kde.StatusNotifierWatcher, not IsStatusNotifierHostRegistered, which Plasma, GNOME and XFCE all hardcode to true; a lifetime QDBusServiceWatcher re-registers the item when the panel restarts, which is routine on GNOME. Status is pinned Active: a Passive item may be hidden by the host, and a hidden item is a running Dish nobody can quit. The menu is load-bearing rather than decoration. GNOME's AppIndicator extension ignores ItemIsMenu, never delivers Activate on a single left click, and does nothing at all when the menu is empty, so Show Dish is the only dependable way back to a closed window. IconPixmap ships alongside IconName because icon-theme lookup fails under Flatpak and the AppImage installs nothing into the host theme.
English is a real catalogue, not the untranslated fallback: a %n source string carries one form and the catalogue owes one per plural category.
…tify dish::reducer::CloseAction already existed: CloseNotify.h maps a SESSION_CLOSE reason byte to a session follow-up. A second enum of the same fully-qualified name is an ODR violation, and only the Release leg sees it — -Wodr fires at LTO link time, so the Debug build and the whole test suite were silent on it. Renamed to WindowCloseAction, which is also the more honest name.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Closing the window quit Dish, so a controller could only stream while a window sat on screen. Suspending the machine left the satellite sessions to die by heartbeat timeout ~10 s later, with the 15 s auto-reconnect sweep noticing afterwards.
Running in the background. Closing the window now leaves Dish streaming behind a StatusNotifierItem, whose menu is both the way back and the way out. A one-time desktop notification says so the first time — a window that vanishes without a word reads as a crash. It has to be a desktop notification rather than the shell's toast host, because by then there is no window left to read it in.
The hide is gated on a StatusNotifier host actually owning the watcher name, not on the preference alone. Bare GNOME ships no host without the AppIndicator extension, so there it keeps quitting on close rather than stranding a running process with no window and no menu. The same guard in reverse: if the panel dies while the window is hidden, the window comes back.
Hiding skips the wizard leave guard and the keep-awake confirm. Both exist because closing was a leave; a hide discards nothing and the stream is meant to survive it.
Suspend and resume. A logind
sleep/delaylock buys time onPrepareForSleepto close the sessions before the machine goes down; the lock drops inside the handler, since logind suspends the moment the last one closes. A resume rescans and re-establishes.Tearing down first is load-bearing rather than tidy. A session the machine slept through comes back
Faltering, which passes bothautoReconnectAll's not-Live test andconnectTo's Live-or-Linking guard, so a bare reconnect would open a second socket beside the frozen one and strand whichever the old client's death handler did not claim.resumeFromSleepalso clears the retry curve, which would otherwise be measuring wall clock the machine spent asleep.A closed lid still suspends, and no application can change that:
LidSwitchIgnoreInhibiteddefaults toyes, so logind runs the lid action even against ahandle-lid-switchinhibitor. README documents thelogind.confsetting rather than pretending otherwise.Why the tray is hand-rolled.
QSystemTrayIconlives in QtWidgets, andmain.cppdeliberately keeps that module out of the process. The item owns its own bus connection and registers under that connection's unique name, so nothing has to claim a well-known name. Availability follows whether anything ownsorg.kde.StatusNotifierWatcher— notIsStatusNotifierHostRegistered, which Plasma, GNOME and XFCE all hardcode totrue.Statusis pinnedActive, because aPassiveitem may be hidden and a hidden item is a running Dish nobody can quit. The menu is load-bearing: GNOME's AppIndicator extension ignoresItemIsMenu, never deliversActivateon a single left click, and does nothing at all when the menu is empty.Shape. Pure decisions in
core/reducer/(BackgroundMode,TrayPresentation,SleepCycle), platform edges as gateways insource/behind abstract interfaces so they fake in tests, derivation inTrayComposer, effects inTrayController, commands inBackgroundCoordinatorandSleepCoordinator, wired once inAppModel. The tray and the sleep monitor start instart()rather than the constructor: both touch D-Bus, and building anAppModelmust not register an item on someone's panel.Flatpak gains the watcher, notification and
login1names. The sandbox was already missinglogin1, so it had silently lost the existing idle inhibit too.Type of Change
How Has This Been Tested?
73 new
TEST_CASEs / 215 assertions, all through fakes — no bus, no socket, no panel.decideWindowCloseAction2×2 (only enabled and tray-available hides), the announce-once predicate,deriveTrayPresentationincluding a negative-count clamp, and thereduceSleepCycle2×2 with named cases for the two edges that must not act — a repeatedPrepareForSleep(true)when suspend escalates to hibernate, and a resume for a suspend never seen.StateSourceProbe.TrayIcon, fakeSleepMonitor, fake notifier. Covers start-applies-immediately, stop-releases, re-arm after stop, null-tolerance on every borrowed pointer, the notice firing exactly once across restarts, and the panel-died-while-hidden restore.Ran locally: Debug and Release suites (1667/1672 — the 5 failures are pre-existing in my environment, which has no
Qt6LinguistToolsso no.qmfiles get built), ASan+UBSan, TSan,clang-format22.1.4, the fullclang-tidy --warnings-as-errors='*'sweep oversrc/, the QML literal scanner,qmllint, andcheck-translations.shwith Qt 6.9 lupdate.The Release leg earned its keep:
-Wodrat LTO link time caughtdish::reducer::CloseActionalready existing inCloseNotify.h. Two different enums with one fully-qualified name — invisible to Debug and to every test. Renamed toWindowCloseAction.scripts/build.sh debug test)Not run, and worth a reviewer's attention: none of the tray or suspend behaviour is reachable from CI, which runs offscreen with no session bus and no panel.
docs/QML_MANUAL_SMOKE_CHECKLIST.mdgains two sections covering it — tray appears, close hides and says so once, Show restores, Quit really quits, no-tray-no-trap on bare GNOME, panel restart re-registers, suspend/resume reconnects fast, resume on another network relearns, and no leaked delay lock insystemd-inhibit --list. Those need a real desktop and a real satellite.Checklist
clang-formatclean,clang-tidyclean)-Werrorbuild is clean)