タスクを定期実行する常駐アプリとそのインストーラを作る

# 目的

Python を用いて一定時間ごとに処理を実行する常駐アプリを作成する。
他者に配布しやすいようにアプリのインストーラも作成する。

# 使用ライブラリ

- 常駐アプリ化するために使用
    - pystray
    - Pillow
- インストーラを生成するために使用
    - cx_Freeze == 6.15.0

インストーラとして `.msi` を作成するために cx_Freeze を利用しました。
cx_Freeze は対応する Python のバージョンが範囲指定されているので Python のバージョンに合わせて cx_Freeze のバージョンを選択してください。
[cx_Freeze 6.15 の場合は Python3.7～3.11に対応しています。](https://cx-freeze.readthedocs.io/en/6.15.5/)

# 常駐アプリの作成

大まかな流れは

1. アプリのメニュー (常駐アプリのアイコンを右クリックすると出てくる選択肢) を設定
1. 定期実行するタスクを並列処理で起動
    - 定期実行タスクが要らない場合は省略可
1. 常駐アプリの起動

です。

以下の折り畳み内にサンプルコードの全体を記述しています。
25分毎に小休憩をとるよう促すメッセージを表示、休憩時間終了後に再開を促すメッセージを表示する機能を持ちます。

<details>
<summary>全体のコード例</summary>

```python:main.py
import threading
import time
from tkinter import Tk, messagebox

import schedule
from PIL import Image
from pystray import Icon, MenuItem, Menu

app_name = 'resident_app'


def tk_topmost():
    tk = Tk()
    # tkinter で表示するメッセージボックスが最前面に表示されるようにする
    tk.attributes('-topmost', True)
    tk.withdraw()


def showinfo(title, msg):
    tk_topmost()
    messagebox.showinfo(title, msg)


class TaskTray:

    def __init__(self, image):
        self.status = False
        self.rest_cnt = 0
        image = Image.open(image)
        # 常駐アプリのアイコンを右クリックした際のメニュー設定
        menu = Menu(
            MenuItem('終了', self.stop),
        )
        self.icon = Icon(name=app_name, title=app_name, icon=image, menu=menu)

    def run(self):
        self.status = True

        # 定期実行タスクを並列処理で実行開始
        task_thread = threading.Thread(target=self.run_schedule)
        task_thread.start()

        self.icon.run()

    def run_schedule(self):
        # タスクスケジュール設定
        schedule.every(25).minutes.do(self.do_task)
        while self.status:
            # タスク実行
            schedule.run_pending()
            time.sleep(1)

    def stop(self):
        self.status = False
        self.icon.stop()

    def do_task(self):
        self.rest_cnt += 1
        rest_minute = 5
        if self.rest_cnt % 5 == 0:
            rest_minute = 15

        showinfo(app_name, f'{rest_minute}分の休憩をとりましょう')
        time.sleep(60 * rest_minute)
        if self.status:
            showinfo(app_name, '作業を再開しましょう')


if __name__ == '__main__':
    system_tray = TaskTray(image='./icon.png')
    system_tray.run()
```

</details>

## アプリのメニュー設定

```python
        # アプリのアイコン画像
        image = Image.open(image)
        # 常駐アプリのアイコンを右クリックした際のメニュー設定
        menu = Menu(
            MenuItem('終了', self.stop),
        )
        self.icon = Icon(name=app_name, title=app_name, icon=image, menu=menu)
```

メニュー設定は Icon に Menu を渡すことで行います。
この例の場合は「終了」というメニューが設定され、それを選択した際は stop() が実施されます。
MenuItem にはメニュー名と処理内容を渡しますが、この時 `stop()` のように**カッコを付けてしまうとこの時点で実行されて意図せぬ動作になる**ので注意が必要です (IDE の補完機能に頼っているとよくやらかします (n敗))。

複数のメニューを設定したい場合は MenuItem をカンマ区切りで指定してください。
```python
menu = Menu(
    MenuItem('メニュー1', self.task1),
    MenuItem('メニュー2', self.task2),
    ...
)
```

## 定期実行するタスクの並列処理と常駐アプリの起動

```python
    def run(self):
        self.status = True

        # 定期実行タスクを並列処理で実行開始
        task_thread = threading.Thread(target=self.run_schedule)
        task_thread.start()
        # 常駐アプリとして起動
        self.icon.run()

    def run_schedule(self):
        # タスクスケジュール設定
        schedule.every(25).minutes.do(self.do_task)
        while self.status:
            # タスク実行
            schedule.run_pending()
            time.sleep(1)
```

`self.icon.run()` 以外の部分は同時に定期実行タスクを動かすための設定です。
`run_schedule(self)` で25分ごとに `do_task()` を実行するよう設定しています。
複数のタスクを設定したい場合は同様の記述を行います。

```python
schedule.every(10).minutes.do(task1) # 10分ごとに task1() 実施
schedule.every(1).hours.do(task2) # 1時間ごとに task2() 実施
...
```

`self.icon.run()` が実行されるとこのように常駐アプリとして表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/801825/b35489c0-108a-c8d7-a53b-fa41bab52372.png)

# インストーラの作成

cx_Freeze 実行用のスクリプトファイルを作成し、それを実行することで exe や msi などを作成可能です。
詳細は[公式ドキュメント](https://cx-freeze.readthedocs.io/en/6.15.5/setup_script.html)をご参照ください。
今回は msi を作成する例を示します。

```python:setup.py
import sys

from cx_Freeze import setup, Executable

# base="Win32GUI" should be used only for Windows GUI app
base = "Win32GUI" if sys.platform == "win32" else None

build_exe_options = {
    "include_files": [
        # .py 以外に含めたいファイルがある場合に指定
        "icon.png", # アプリのアイコン画像
    ],
    "include_msvcr": True,
    "silent": True,
}

shortcut_common_setting = ("常駐アプリ", "TARGETDIR", "[TARGETDIR]main.exe",
                           None, None, None, None, None, None, 'TARGETDIR')
shortcut_table = [
    # アプリショートカットをスタートメニューに追加
    ("ProgramMenuShortcut", "ProgramMenuFolder") + shortcut_common_setting,
    # アプリショートカットを shell:startup に追加 (PC起動時に自動起動されるようにする)
    ("StartupShortcut", "StartupFolder") + shortcut_common_setting,
    ]

msi_data = {"Shortcut": shortcut_table}
bdist_msi_options = {
    'data': msi_data,
    # 任意の UUID を生成して設定
    'upgrade_code': '{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}',
}

setup(
    name="常駐アプリ",
    version="1.0",
    description="常駐アプリのサンプル",
    options={
        "build_exe": build_exe_options,
        "bdist_msi": bdist_msi_options,
    },
    executables=[Executable(
        "main.py",
        base=base,
        shortcut_name="residentApp",
    )],
)
```

上記 setup.py をプロジェクトのルート直下に用意したうえで

```bash
python setup.py bdist_msi
```

を実行します。
すると dist ディレクトリが生成され、その配下に `.msi` が生成されます。
msi を実行するとアプリとしてインストールされます。
アンインストールしたい場合は一般的なアプリ同様に Windows の「設定」>「アプリと機能」からアンインストール可能です。