PySide6 in Practice #5 Tray Residency and Reminders: An App That Survives Its Window

5 min read

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
# 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 tray

Trigger 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.

src/daily/ui/main_window.py
# 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 instead

Two 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
# 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 = today

The 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
# 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 server

At 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.
X