PySide6 in Practice #5 Tray Residency and Reminders: An App That Survives Its Window
A habit tracker is not an app that is useful while open — it is an app that must appear when you have forgotten it. That takes residency: staying alive in the background after the window closes and sending a notification at the appointed hour. This part builds the three things that make a desktop app truly desktop: a tray icon, reminders, and single-instance behavior.
QSystemTrayIcon: the base of residency #
An icon in the system tray (macOS menu bar, Windows taskbar corner) gives the app a visible sign of life and a point of contact even with no window.
# src/daily/ui/tray.py
from PySide6.QtGui import QAction, QIcon
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
def create_tray(window) -> QSystemTrayIcon:
tray = QSystemTrayIcon(QIcon(":/icons/daily.png"), parent=window)
menu = QMenu()
show_action = QAction("Open", menu)
show_action.triggered.connect(window.show_and_raise)
quit_action = QAction("Quit", menu)
quit_action.triggered.connect(QApplication.instance().quit)
menu.addAction(show_action)
menu.addSeparator()
menu.addAction(quit_action)
tray.setContextMenu(menu)
tray.activated.connect(
lambda reason: window.show_and_raise()
if reason == QSystemTrayIcon.ActivationReason.Trigger
else None
)
tray.show()
return trayTrigger on the activated signal is an icon click. Click restores the window, right-click opens the menu — the standard grammar of tray apps, followed as-is. Make window.show_and_raise a helper that calls show() then raiseWindow() and activateWindow() — on some platforms a hidden window does not come to front on show alone.
Close = hide: overriding closeEvent #
This is residency’s key switch: the window’s X button must stop killing the app.
# added to MainWindow
from PySide6.QtGui import QCloseEvent
class MainWindow(QMainWindow):
def closeEvent(self, event: QCloseEvent) -> None:
if self._quit_requested: # real quit only via tray menu
event.accept()
return
event.ignore() # veto the close
self.hide() # hide insteadTwo companion pieces: QApplication quits by default when the last window closes, so app.setQuitOnLastWindowClosed(False) goes in at startup; and the tray menu’s Quit sets the _quit_requested flag before closing, separating real exit from hiding.
A UX note belongs here too. “I closed it but it didn’t quit” feels like a betrayal to some users. The mature treatment: show a one-time tray message (“still running in the background”) on first hide, and expose a “close button behavior: hide/quit” option in part 6’s settings. Residency is not a feature — it is an agreement with the user.
Reminders: watching the clock with QTimer #
We implement “at 21:00 daily, notify if any habit is unchecked.” The simplest robust approach in a desktop app is a timer that wakes every minute and checks the clock.
# src/daily/ui/reminder.py
from datetime import date, datetime
from PySide6.QtCore import QObject, QTimer
from PySide6.QtWidgets import QSystemTrayIcon
class Reminder(QObject):
def __init__(self, repo, tray: QSystemTrayIcon, hour: int = 21) -> None:
super().__init__()
self._repo = repo
self._tray = tray
self._hour = hour
self._fired_on: str | None = None # already fired today?
self._timer = QTimer(self)
self._timer.setInterval(60 * 1000) # one minute
self._timer.timeout.connect(self._tick)
self._timer.start()
def _tick(self) -> None:
now = datetime.now()
today = date.today().isoformat()
if now.hour < self._hour or self._fired_on == today:
return
undone = [
h.name for h in self._repo.active_habits()
if today not in self._repo.checked_days(h.id)
]
if undone:
self._tray.showMessage(
"Daily", f"Still unchecked: {', '.join(undone)}",
QSystemTrayIcon.MessageIcon.Information,
)
self._fired_on = todayThe design point is “fire on the first tick after the hour, plus a fired-today record” rather than “run at exactly 21:00:00.” A laptop waking from sleep and an app launched after 21:00 are both handled naturally, and second-level precision is unnecessary here. Taking the hour as a constructor argument instead of hardcoding is foreshadowing for part 6’s settings screen.
showMessage surfaces through the OS notification center, whose appearance and behavior (duration, click response) vary by platform — and users can disable it at the OS level. The safe design principle: notifications are a secondary channel; unfinished state must also be visible inside the app.
Single instance: preventing the two-copies accident #
The classic resident-app accident: the user, unaware the window is hidden, launches the app again — two processes now hold the same database. The standard fix is claim-checking over a local socket.
# src/daily/single_instance.py
from PySide6.QtNetwork import QLocalServer, QLocalSocket
_NAME = "daily-app-instance"
def acquire_or_notify() -> QLocalServer | None:
"""Return a server if we are first; signal the running instance and return None otherwise."""
probe = QLocalSocket()
probe.connectToServer(_NAME)
if probe.waitForConnected(200):
probe.write(b"show") # ask the existing instance to show its window
probe.waitForBytesWritten(200)
return None
server = QLocalServer()
QLocalServer.removeServer(_NAME) # clean leftovers from a crash
server.listen(_NAME)
return serverAt startup, a None from acquire_or_notify() means exit immediately; the first instance holds the server and, on its newConnection signal, receives “show” and restores the window. A second launch behaves not as “a new app” but as “open the existing window” — exactly what users expect.
Summary #
- The tray icon is residency’s base: click = restore window, right-click = menu, per the standard grammar.
- Close = hide is closeEvent’s ignore + hide, paired with setQuitOnLastWindowClosed(False) and a real-quit path (flag). Disclose the behavior and expose it as a setting.
- Reminders use the one-minute-timer + “fire on first tick past the hour + record the day” pattern — simple and robust. Treat notifications as a secondary channel.
- Single instance is a local-socket claim, turning relaunch into restoring the existing window.
- Next part: the settings screen — handing the reminder hour, close behavior, and theme over to the user with QSettings.