Skip to content

feat: run in the background behind a tray item, and survive suspend - #33

Merged
emir-hasanbegovic merged 6 commits into
mainfrom
feat/run-in-background
Aug 21, 2026
Merged

feat: run in the background behind a tray item, and survive suspend#33
emir-hasanbegovic merged 6 commits into
mainfrom
feat/run-in-background

Conversation

@emir-hasanbegovic

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

Copy link
Copy Markdown
Contributor

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/delay lock buys time on PrepareForSleep to 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 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 rather than pretending otherwise.

Why the tray is hand-rolled. QSystemTrayIcon lives in QtWidgets, and main.cpp deliberately 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 owns org.kde.StatusNotifierWatcher — not IsStatusNotifierHostRegistered, which Plasma, GNOME and XFCE all hardcode to true. Status is pinned Active, because a Passive item may be hidden and a hidden item is a running Dish nobody can quit. The menu is load-bearing: GNOME's AppIndicator extension ignores ItemIsMenu, never delivers Activate on 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 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 someone's panel.

Flatpak gains the watcher, notification and login1 names. The sandbox was already missing login1, so it had silently lost the existing idle inhibit too.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactor (code change that neither fixes a bug nor adds a feature)
  • Documentation update

How Has This Been Tested?

73 new TEST_CASEs / 215 assertions, all through fakes — no bus, no socket, no panel.

  • Pure: the decideWindowCloseAction 2×2 (only enabled and tray-available hides), the announce-once predicate, deriveTrayPresentation including a negative-count clamp, and the reduceSleepCycle 2×2 with named cases for the two edges that must not act — a repeated PrepareForSleep(true) when suspend escalates to hibernate, and a resume for a suspend never seen.
  • Store: key literals pinned, defaults, persistence across reopen, and distinct-until-changed via StateSourceProbe.
  • Controllers/coordinators: fake TrayIcon, fake SleepMonitor, 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 Qt6LinguistTools so no .qm files get built), ASan+UBSan, TSan, clang-format 22.1.4, the full clang-tidy --warnings-as-errors='*' sweep over src/, the QML literal scanner, qmllint, and check-translations.sh with Qt 6.9 lupdate.

The Release leg earned its keep: -Wodr at LTO link time caught dish::reducer::CloseAction already existing in CloseNotify.h. Two different enums with one fully-qualified name — invisible to Debug and to every test. Renamed to WindowCloseAction.

  • Unit tests (scripts/build.sh debug test)
  • Manual testing on Linux (Wayland and/or X11)
  • Tested against live Satellite server

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.md gains 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 in systemd-inhibit --list. Those need a real desktop and a real satellite.

Checklist

  • My code follows the project's style guidelines (clang-format clean, clang-tidy clean)
  • I have performed a self-review of my code
  • I have commented my code in hard-to-understand areas
  • I have made corresponding changes to documentation
  • My changes generate no new warnings (-Werror build is clean)
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

## 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.
@emir-hasanbegovic
emir-hasanbegovic merged commit e9fbdb3 into main Aug 21, 2026
13 checks passed
@emir-hasanbegovic
emir-hasanbegovic deleted the feat/run-in-background branch August 21, 2026 11:41
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.

2 participants