PySide6 実践講座 #5 トレイ常駐とリマインダー — 閉じても死なないアプリ

読了 5分

習慣トラッカーは、開いているときに役立つアプリではなく、忘れているときに現れてこそ役立つアプリです。そのためには、ウィンドウを閉じてもバックグラウンドに残り、決まった時刻に通知を送る常駐構造が必要です。今回はデスクトップアプリをデスクトップらしくする 3 つ、トレイアイコン、リマインダー、単一インスタンスを実装します。

QSystemTrayIcon — 常駐の拠点 #

システムトレイ(Mac のメニューバー、Windows のタスクバーの隅)にアイコンを置くと、ウィンドウがなくてもアプリが生きている印と接点が生まれます。

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("開く", menu)
    show_action.triggered.connect(window.show_and_raise)
    quit_action = QAction("終了", 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

activated シグナルの Trigger はアイコンのクリックです。クリックならウィンドウ復元、右クリックならメニューという、トレイアプリの標準文法をそのまま踏襲しました。window.show_and_raise は show() のあとに raiseWindow() と activateWindow() まで呼ぶヘルパーとして作っておきます。隠れていたウィンドウは show だけでは前面に出てこないプラットフォームがあるからです。

閉じる = 隠す — closeEvent の再定義 #

常駐アプリの核心の転換がこれです。ウィンドウの X ボタンがアプリを殺さないようにします。

src/daily/ui/main_window.py
# MainWindow に追加
from PySide6.QtGui import QCloseEvent


class MainWindow(QMainWindow):
    def closeEvent(self, event: QCloseEvent) -> None:
        if self._quit_requested:          # トレイメニューの「終了」でだけ本当に終了
            event.accept()
            return
        event.ignore()                    # 終了を止めて
        self.hide()                       # 非表示に置き換え

一緒にそろえる部品が 2 つあります。第一に、QApplication は最後のウィンドウが閉じるとデフォルトで終了するので、app.setQuitOnLastWindowClosed(False) をアプリ起動時に入れます。第二に、トレイメニューの「終了」は _quit_requested フラグを立ててから閉じる経路にして、本当の終了と非表示を区別します。

UX 観点の注意も書いておきます。「閉じたのに終わらない」は、ユーザーによっては裏切りに感じる動作です。最初に隠れるときにトレイ通知で「バックグラウンドで実行を続けます」と一度知らせ、第 6 回の設定画面に「閉じるボタンの動作: 隠す/終了」のオプションを置くのが成熟した処理です。常駐は機能ではなく、ユーザーとの合意です。

リマインダー — QTimer で時刻を見張る #

「毎日 21 時に、今日チェックしていない習慣があれば通知」を実装します。デスクトップアプリで最も単純で頑丈な方式は、1 分ごとに起きて時刻を確認するタイマーです。

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    # 今日すでに鳴らしたか

        self._timer = QTimer(self)
        self._timer.setInterval(60 * 1000)   # 1 分
        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"まだの習慣: {', '.join(undone)}",
                QSystemTrayIcon.MessageIcon.Information,
            )
        self._fired_on = today

「21:00:00 ちょうどに実行」ではなく「時刻を過ぎた最初のティックで発火 + 当日の発火記録」と設計したのがポイントです。スリープから目覚めたノートパソコンも、21 時を過ぎてから起動したケースも自然に処理され、秒単位の精度はこの用途に必要ありません。時刻をハードコードせずコンストラクタの引数で受けたのは、第 6 回の設定画面への伏線です。

showMessage のトレイ通知は OS の通知センター経由で表示されますが、見た目と動作(表示時間、クリックへの反応)がプラットフォームごとに違い、ユーザーが OS の設定でオフにすることもできます。通知は補助手段であり、アプリの中でも未完了の状態が見えるべきという原則で設計するのが安全です。

単一インスタンス — 2 つ立ち上がる事故を防ぐ #

常駐アプリの古典的な事故があります。ウィンドウが隠れているのに気づかず、ユーザーがアプリをもう一度起動して、プロセス 2 つが同じ DB をつかむ状況です。解決の標準パターンはローカルソケットによる先取り確認です。

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:
    """最初のインスタンスならサーバーを返し、すでに起動中なら合図だけ送って None。"""
    probe = QLocalSocket()
    probe.connectToServer(_NAME)
    if probe.waitForConnected(200):
        probe.write(b"show")             # 既存インスタンスに「ウィンドウを見せて」
        probe.waitForBytesWritten(200)
        return None

    server = QLocalServer()
    QLocalServer.removeServer(_NAME)     # 異常終了の残骸を掃除
    server.listen(_NAME)
    return server

アプリ起動時に acquire_or_notify() が None ならそのまま終了し、サーバーを得た最初のインスタンスは newConnection シグナルで “show” メッセージを受けてウィンドウを復元します。2 回目の起動が「新しいアプリ」ではなく「既存ウィンドウを開く」として動く、ユーザーの期待に合った結果になります。

まとめ #

  • トレイアイコンは常駐の拠点です。クリック = ウィンドウ復元、右クリック = メニューという標準文法に従います。
  • 閉じる = 隠すは closeEvent の ignore + hide で作り、setQuitOnLastWindowClosed(False) と本当の終了経路(フラグ)をセットでそろえます。そしてこの動作はユーザーに告知し、設定として開いておきます。
  • リマインダーは 1 分タイマー + 「時刻経過後の最初のティックで発火 + 当日記録」パターンが単純で頑丈です。通知は補助手段として設計します。
  • 単一インスタンスはローカルソケットの先取りで確認し、再起動を既存ウィンドウの復元に変えます。
  • 次回は設定画面です。QSettings でリマインダー時刻と閉じる動作、テーマをユーザーに渡します。
X