Outlook予定表の通知が気に食わなかったので通知アプリを自作した話

# 導入

こんにちは、GxPの土田です。
この記事は [グロースエクスパートナーズ Advent Calendar 2024](https://qiita.com/advent-calendar/2024/gxp) の25日目です。
めりーくりすます

<br>

弊社では今年の4月に、自社製品の予定表からOutlook予定表に移行しました。
私は忘れっぽく予定に遅れがちだったので、旧予定表の時は予定表を定期的にヘッドレススクレイピングして予定の数分前に通知をするアプリを自作していました。

その旧通知アプリは予定表移行に伴って使えなくなってしまったので、さっそくOutlookの通知を試そう！と意気込んで色々といじくりまわした結果、
「Outlookの通知は使いづらい！こりゃダメだ。」と結論付けました。
具体的には次の点。

1. リマインドと通知時間設定が使いづらい。
   通知時間の設定を見ると、「イベントの時間・5分前・15 分前・30 分前・1 時間前・…」と選択肢が。
   そうじゃない、1,2分前とかに通知がほしいんだよなぁ。それに1回じゃなく、複数回お好みの時間に通知してほしい。
   一応、通知が出たらn分後にリマインドとかができるらしいが、何も言わなくても決まった時間に通知してほしい。
   しかも予定の通知時間をいじると参加者全員の通知時間が変わっちゃう？（ここは正直使っていないのでわからない）
2. クライアントアプリ起動が必須。
   これが一番大きい。×でアプリを終了してしまうと二度と通知が来なくなる。
3. WindowsのUXに従え。
   Teamsもそうだが、なぜWindowsのトースト通知を使わないのだろうか、通知センターから過去通知も追えるのに。
   UXに従わないのならば、既存のそれよりも遥かに使い易くなっていないと許されないと思うのだ。
   あと単純にウィンドウ扱いなのもAlt+Tabを阻害して邪魔。

# 検討
Outlookの通知の問題点や、旧通知アプリの反省点などから検討を進めました。

## 動作環境
Windowsのみを対象とする。
社内にはMacユーザも一定数いるが、私は使っていないので知ったこっちゃありません。そもそもMac持ってないので作れません。
しかしMac版開発者がいればすぐに対応できるよう、将来的なMac対応も視野に入れた技術選択をしておきます。

## アプリ形式
> 2.クライアントアプリ起動が必須。
  　これが一番大きい。×でアプリを終了してしまうと二度と通知が来なくなる。

この問題を解消するため、フォアグラウンドで動作するアプリは論外になります。
バックグラウンドで動作することが大前提です。

## スケジューリング方式

旧通知アプリでは、Windowsの「タスク スケジューラ」をメインに使用したアプリを作成していました。
「予定表情報取得＆通知のスケジューラ登録」「トースト通知表示」の2つのスクリプトをPythonで記述し、pyinstallerでexe化、
タスクスケジューラによってヘッドレスモードスクレイピングの予定表情報取得を定期実行し、取得した予定表情報をもとにトースト通知表示をスケジューラに登録させていました。
これでも十分使い物になっていたが、pyinstallerでexe化したスクリプトは走り出しにかなりのラグがあるという問題がありました。これのせいで30秒遅れで通知が来ることがざらにあった。
また、管理者権限の必要なスケジューリング（スリープ中に実行等）も不可能であったため、自由度が低いという問題もありました。
これらを解消するため、単発のスクリプトを定期実行するのではなく、常駐するアプリとする。

## 開発技術

旧通知アプリはPython(pyinstaller), Playwrightをメインに開発していました。
スピード重視であれば予定表情報取得の部分だけ差し替えればよいですが、
前述のような問題点もあるし、せっかくなので新規技術の習得ついでに一新してしまう事とします。

# 主要技術

検討を踏まえ、最終的には次のような技術で形にしました。

- TypeScript (node.js)：ある程度しっかりとしたツールにしたかったため、型定義が欲しかった。
- Electron：常駐アプリが簡単に作れるらしい、皆さんご存じVSCodeやらSlackやらもこれで作られているので将来的には何かしらのリッチなウィンドウを作ることも可能かも。
- electron-builder：インストーラ配布が楽々、Windows対象と言いつつ実はハードさえあればMac対応もできなくもない。
- electron-windows-notifications：を使いたかった。インストールがどうしてもうまくいかず断念。どなたかやり方を教えてください・・・
- Microsoft Graph API：予定表情報はMSGraphから取得します。
- Open ID Connect：認証はOIDCです。各ユーザがログインして自分の情報を取得可能になります。
- Gist：おまけ。のちに語りますが、更新通知表示用です。

他諸々ライブラリ省略

# 機能

- アプリはタスクトレイ（が正式名称かはわからない）に常駐する。
- 起動はPC起動時に自動でされるものとする。これは「スタートアップ アプリ」にアプリのショートカットを貼って置くことで実現します。
- 数分おきに予定表情報を取得し、通知をスケジューリングする。
- 予定通知の時間は複数回カスタマイズ可能。
- 予定通知のクリックアクションが登録可能。予定をOutlookで開く・予定に含まれるURLを開く などから選択可能。
- 最後に表示した通知のクリックアクションを実行するグローバルショートカットキーが登録可能。
- 毎日1回アプリの更新を確認し、あった場合は通知する。ただしパブリックに公開できないので再インストールの手順を踏む必要あり。

# 手順
手順と知見や重要なコードの一部は備忘録がてらここに全て書いておきます。
一部、参考サイトに騙されて行った不要な手順もありますが、その後の選択に重要な知見も含まれるため飛ばさず記載しておきます。

## AzurePortalでアプリを登録
参考にしたサイトはもはや忘れてしまいました。

[アプリの登録 - Microsoft Azure](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) にアクセスする。

![アプリの登録](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/52cf78ba-9a5f-1e4d-6b35-649b26cc5f8f.png)

上部の 新規登録 から アプリケーションの登録 ページへ行く。

![アプリケーションの登録](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/152e99ce-2983-5124-0574-55dfe479ba23.png)

- 名前：適当に付けます。他の人に勝手に消されないよう、わかりやすい名前にしておきましょう。
- サポートされているアカウントの種類：この組織ディレクトリのみに含まれるアカウント にします。これでこのテナントに権限のある全社員がこのアプリを使えるようになります。
- リダイレクトURI
    - プラットフォーム：パブリッククライアント/ネイティブ（モバイルとデスクトップ）
    - URI：localhostの適当な空きポートに設定します。

![アプリケーションの登録 入力済み](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/f9e01344-40e7-03d8-54af-4eeac13adb1e.png)

登録するとなぜかAzurePortalトップ画面に戻されるので、アプリの登録から先に登録したアプリを開く。
![登録済みアプリ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/c7e5a693-1402-21b8-1caf-c894ad627ed9.png)


## シークレットの登録
Webアプリ等では 管理>証明書とシークレット からシークレットを登録するようですが、クライアントアプリから直接叩く場合においてはその手順は不要です。
逆にクライアントアプリからシークレットを指定してリクエストをするとセキュリティエラーを吐かれます。

## APIのアクセス許可を登録
これは不要な作業です。当時は必要と思い設定していましたが、この設定が無くともAPIを叩くことができます。
しかし、APIごとの権限は今後必要となる知識であるため、一応触れておきます。

管理>APIのアクセス許可 から アクセス許可の追加 をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/23318c3f-03fb-b548-829b-b2645be41713.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/c0b0763a-16da-654e-c20c-03c96d39da5b.png)


今回使用するのは Microsoft Graph です。
選択すると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/82d6f485-f002-72cd-dfa9-56797a6f12ea.png)


> - 委任されたアクセス許可
>   アプリケーションは、サインインしたユーザとしてAPIにアクセスする必要があります。
> - アプリケーションの許可
>   アプリケーションは、サインインしたユーザなしで、バックグラウンドサービスまたはデーモンとして実行されます。

の2種類があると分かります。
簡単に言えばユーザの区別のありなしが違います。
前者はログイン済みのユーザアカウントでアクセス許可に同意をすることになるため、当然そのユーザ権限で取得できる範囲の情報にしかアクセスできません。
後者はユーザに関わらずどんな情報でも取れるようにするアクセス許可なので全APIにテナント管理者権限の許可が必要になります。
今回はユーザの予定が取得したいだけなので前者を使用します。

委任されたアクセス許可を選択してみると、これまた管理者の同意が必要なAPIも一部あることが分かります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/8f21c572-7327-587a-6f07-b57fc1c7ec13.png)


今回使用したい予定取得系がどうかというと、calendarsで検索をしてみると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/d7cf9aa6-9efa-c918-7f5f-dd26d927cbf0.png)


全て管理者権限無しで使えることが分かります。
この権限にチェックを付ける操作は不要ですが、この権限名は後々のスコープの指定に必要になるため、必要な権限に目星をつけておく必要があります。
今回は管理者権限を考慮しないで良いことが分かったのでここまでで良しとします。具体的な選び方はのちに説明しますが、いったん `Calendars.Read` を使用することとします。

## トークンを取得する

ここまででAzure側での準備は完了。あとはAPIを叩く。
参考サイトはcurlやらPostmanやらで叩くものしか見つからず、具体的なコードをChatGPT様様に聞きながら進めていく。
まずはコンソールアプリで叩けるところを目指す。

OIDCクライアントライブラリは探してみましたが、大して楽にならなかったので今回は使いません。（リダイレクトURLの待ち受け処理までサポートしたライブラリあると楽なんだけどなぁ）

```typescript
import axios from "axios";
import open from "open";
import http from "http";

type TokenResponse = {
    token_type: string,
    scope: string,
    expires_in: number,
    ext_expires_in: number,
    access_token: string,
    refresh_token: string,
};

const CLIENT_ID = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
const TENANT_ID = 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy';
const PORT = 12345;
const REDIRECT_URI = `http://localhost:${PORT}`;
const SCOPE = [
    'offline_access',
    'Calendars.Read',
].join(' ');

export default async function getAccessToken(): Promise<TokenResponse> {
    const AUTHORIZE_URL = `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/authorize`;
    const TOKEN_URL = `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`;

    const AUTH_PARAMS = {
        client_id: CLIENT_ID,
        response_type: 'code',
        redirect_uri: REDIRECT_URI,
        scope: SCOPE,
        response_mode: 'query'
    } as const;
    const auth_url = new URL(AUTHORIZE_URL);
    auth_url.search = new URLSearchParams(AUTH_PARAMS).toString();

    open(auth_url.toString());

    function waitAuthRedirect(): Promise<{ code: string }> {
        return new Promise((resolve, reject) => {
            const server = http.createServer((req, res) => {
                if (req.url!.split('?')[0] === '/') {
                    res.writeHead(200, { 'Content-Type': 'text/plain' });
                    res.end('Authorized! You can close this tab now.');

                    const query_components = new URL(req.url!, REDIRECT_URI);
                    const code = query_components.searchParams.get('code')!;

                    server.close(() => {
                        resolve({ code: code });
                    });
                } else {
                    res.writeHead(404);
                    res.end();
                }
            });
            server.listen(PORT, 'localhost');

            setTimeout(() => {
                server.close(() => {
                    reject(new Error('Authorization timeout. Server closed after 5 minutes.'));
                });
            }, 5 * 60 * 1000);
        });
    }

    const { code } = await waitAuthRedirect();

    const res = await axios.post<TokenResponse>(TOKEN_URL, {
        grant_type: 'authorization_code',
        client_id: CLIENT_ID,
        code: code,
        redirect_uri: REDIRECT_URI,
        scope: SCOPE
    }, {
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        }
    });

    return res.data;
}

```

定数の値はそれぞれ下記の通り
- CLIENT_ID: アプリケーション (クライアント) ID
- TENANT_ID: ディレクトリ (テナント) ID
- PORT: リダイレクト URI に指定したURIのポート番号

コンソールアプリなのでCtrl＋Cで終了できますが、常駐アプリへの組み込みを想定しているので数分後に自動でサーバを閉じる処理も追加しておきます。



これを実行すると↓のようなMicrosoftの認証画面がデフォルトのブラウザアプリで開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/fd82e501-1508-eb48-442f-183129ab2794.png)

承諾を選択すると指定したリダイレクト先にクエリパラメータ付きでリダイレクトします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/12b41be3-6c68-55ca-3e73-b4fc545df79f.png)

```json
{
    "token_type": "Bearer",
    "scope": "Calendars.Read openid profile email",
    "expires_in": 5172,
    "ext_expires_in": 5172,
    "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "refresh_token": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
}
```

その後このようなレスポンスがコンソールに出力されます。ここから `access_token`, `refresh_token` が取得できます。

OIDCの流れについては詳しく解説している記事が他にあると思うので超ざっくりと、

1. 必要な権限を `scope` に指定してMicrosoftの認証画面をブラウザで開く
2. ユーザが認証する
3. リダイレクトURIに指定した先へリダイレクトされる
    a. このリダイレクトのgetリクエストを待ち受ける（今回はhttpで簡易サーバを実装）
    b. このURLのクエリパラメータに `code` が含まれている
4. 3-bで取得した `code` を使ってMicrosoftのトークン取得APIを叩く
5. 取得したトークンを使ったGraphAPIを叩く
6. `access_token` の有効期限は短く、期限切れになった場合 `refresh_token` を使ってトークンを更新する

access_tokenは最悪流出しても数時間使われるだけだが、refresh_tokenは流出すると無限に更新できてしまうため厳重に扱う必要があります。

### scopeの選び方

前手順で適当に `Calendars.Read` を選びましたが、本来の選び方をここで確認しておきます。
まずは [Microsoft Graph REST API v1.0 エンドポイント リファレンス - Microsoft Graph v1.0 | Microsoft Learn](https://learn.microsoft.com/ja-jp/graph/api/overview?view=graph-rest-1.0&preserve-view=true) から欲しい情報が取得できそうなAPIを探します。

ナビゲーションを見ると「API v1.0 リファレンス > 予定表 > 予定表 > 予定表ビューを一覧表示する」というのがあります。おそらくこれでしょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/c9953028-ca05-c1e4-8bed-3021f05caa0c.png)

説明には
> ユーザーの既定の予定表(../me/calendarView)またはユーザーが所有する他の予定表を元に、時間範囲で定義した予定表ビューから、イベントの発生、例外、および単一のインスタンスを取得します。

とあります。わかりづらいですが、つまりは繰り返し予定などが各日各時間に展開された状態（カレンダー表示で見えるものをそのまま）取得できるAPIです。
下のイベント一覧ではそれができません。
もちろん繰り返し予定分レスポンス量はかさみますが、複雑なロジックなしに使える状態のリストが取得できるためこれを使用します。

「アクセス許可」のセクションを見ると次のような表があります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/51e6361a-8572-ae46-52d2-256d7dd51a0f.png)

「アクセス許可の種類」に関わらずすべて同じなのでさらっとだけ、会社のAzureなので「委任 (職場または学校のアカウント)」でしょう。「委任」とは先に軽く触れましたが認証したユーザが持っている権限に依存するAPI許可のことです。

最小特権アクセス許可を見ると `Calendars.ReadBasic`、より高い権限に `Calendars.Read` があります。
「どうせこのAPIしか叩かないし最小だけつけておけばいいや」と安直な考えで行くとあとで困ります。（そうです。お察しの通り実際にこれのせいで一度困りました。）

[Microsoft Graph のアクセス許可のリファレンス - Microsoft Graph | Microsoft Learn](https://learn.microsoft.com/ja-jp/graph/permissions-reference?WT.mc_id=DT-MVP-5000708#calendarsreadbasic) のページで権限の詳細を確認します。

説明に

> 本文、…などのプロパティを除き、アプリでユーザーカレンダーのイベントを読み取ることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/4f3c599f-9034-a1a4-6a41-1b015c9ce51a.png)

はい、本文が抜かれてしまっては困ります。通知には予定の詳細も可能であれば表示したいのです。
タイトルと時間場所などのみを表示できるので良ければBasicでもOKです。

`Calendars.Read` の方には

> アプリは、ユーザーの予定表内のイベントを読み取ることができます。

とあるため、scopeに指定すべきは `Calendars.Read` であるという事が分かりました。

`Shared` が何を指しているのかはよくわかっていません。説明には「委任されたカレンダーと共有のカレンダー」とありますが実際のOutlook予定表画面上で何を指しているのかが全く分かりませんでした。
いったん `Calendars.Read` を使用して困ったことがあれば `Calendars.Read.Shared` を試すことにします。

## リフレッシュトークンでアクセストークンを更新する

アクセストークンは取得できましたがこの有効期限はかなり短いです。期限が切れると毎回再認証する必要があります。
それは面倒なので期限が切れていたらリフレッシュする仕組みを先に作っておきます。

```typescript
async function refreshAccessToken(refreshToken: string) {
    const res = await axios.post(`https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`, {
        grant_type: 'refresh_token',
        client_id: CLIENT_ID,
        refresh_token: refreshToken,
        scope: SCOPE,
    }, {
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        }
    });

    return res.data;
}
```

とは言ってもリフレッシュの処理自体はこれだけ。

あとは↓のように制御してあげればOK。

```typescript
try {
    // api access
} catch (e: any) {
    if (e.response?.status === 401) {
        try {
            refreshAccessToken();
        } catch(e: any){
            if (e.response?.status === 401) {
                getAccessToken();
            }
        }
        // retry api access
    }
}
```

各トークンの有効期限も同時に取れるため、それを検証すればtry-catchは不要ですが、その検証が間違っていた李などを考え出すと面倒なのでエラーを吐かれたら再取得にしてしまいます。

## APIを叩いてみる

`@microsoft/microsoft-graph-client` というMS Graph APIのクライアントライブラリもあるらしいが、ぱっと見なんか使いづらそうだったので使い慣れているaxiosを引き続き使います。

再度 [calendarView を一覧表示する - Microsoft Graph v1.0 | Microsoft Learn](https://learn.microsoft.com/ja-jp/graph/api/calendar-list-calendarview?view=graph-rest-1.0&tabs=http) を確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/cde4fad1-2807-f43f-14bf-d21f17829d5a.png)

URLは2つずつあるようですが、それぞれの2行目は（おそらく、試した限りでは）委任されたアクセス許可では叩けません。
自分の予定が取りたいだけなので1つ目で十分でしょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/c11d0bed-dd0b-8b4d-fed9-bc37696d3f63.png)

要求ヘッダです。Authorizationの値がとんでもない翻訳をされてしまっていますが、要するに 'Authorization': `Bearer ${access_token}` のように指定するという事です。よく見るやつですね。

`outlook.timezone` に `Tokyo Standard Time` を指定すれば日本時間で返してくれるようです。こちら側で変換しなくてよいのは楽なので指定しておきます。優先はPreferのこと。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/e1e81055-1b9d-2a74-1306-a7190a5900bd.png)

クエリパラメータには、取得範囲の開始終了日時を指定する必要があるようです。タイムゾーンも柔軟に扱ってくれるそうです。
OData クエリ パラメーター というのはレスポンスの項目を絞ることができるもののようです。パフォーマンスを追い求めたかったらこれでレスポンス量を最低限に絞ることができます。

さて、APIの仕様が分かったので早速叩いてみることとします。

```typescript
const startDateTime = new Date();
const endDateTime = new Date();
endDateTime.setHours(endDateTime.getHours() + 24);

const res = await axios.get(`https://graph.microsoft.com/v1.0/me/calendarView`, {
    params: {
        startDateTime: startDateTime,
        endDateTime: endDateTime,
        top: 1000,
    },
    headers: {
        'Authorization': `Bearer xxxxxxxxxxxxxxxxx_token_xxxxxxxxxxxxxxxxx`,
        'Prefer': 'outlook.timezone="Tokyo Standard Time"',
    },
});

console.log(JSON.stringify(res.data));
```

↑のようなスクリプトを実行すると、↓のようなレスポンスが返ってきます。（めちゃ長いので色々省略しています。）

```jsonc
{
    "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('略')/calendarView",
    "value": [
        {
            "subject": "予定タイトル",
            "bodyPreview": "予定本文",
            "isAllDay": false,
            "webLink": "https://outlook.office365.com/owa/?itemid=略&exvsurl=1&path=/calendar/item",
            "body": {
                "contentType": "html",
                "content": "<html>...略...</html>\r\n"
            },
            "start": {
                "dateTime": "2024-11-04T18:00:00.0000000",
                "timeZone": "Tokyo Standard Time"
            },
            "end": {
                "dateTime": "2024-11-04T19:00:00.0000000",
                "timeZone": "Tokyo Standard Time"
            },
            "location": {
                "displayName": "https://online-meeting-url~~~",
                "locationType": "default",
                "uniqueId": "https://online-meeting-url~~~",
                "uniqueIdType": "private"
            },
            "locations": [
                {
                    "displayName": "https://online-meeting-url~~~",
                    "locationType": "default",
                    "uniqueId": "https://online-meeting-url~~~",
                    "uniqueIdType": "private"
                }
            ],
            "attendees": [
                {
                    "type": "required",
                    "status": {
                        "response": "none",
                        "time": "0001-01-01T00:00:00Z"
                    },
                    "emailAddress": {
                        "name": "土田 翼矢",
                        "address": "h.tsuchida@gxp.co.jp"
                    }
                },
                {
                    // ...略
                }
            ],
            "organizer": {
                "emailAddress": {
                    "name": "土田 翼矢",
                    "address": "h.tsuchida@gxp.co.jp"
                }
            }
        }
    ]
}
```

レスポンスの詳細については

> 応答
  成功した場合、このメソッドは 200 OK 応答コードと、応答本文で event オブジェクトのコレクションを返します。
  要求セットが複数ページにまたがる場合、calendarView は、結果の次ページへの URL を格納する 
  @odata.nextLink プロパティを応答で返します。 詳細については、ページングを参照してください。

という記述があり、リンク先に各プロパティが一覧表記されています。一部プロパティ名が和訳されてしまっているため、型定義に起こすときは英語原文を見に行くのが良いでしょう。

## 常駐アプリの作成

ここまでで予定が取得できました。あとはアプリに組み込むだけです。いよいよelectronで作っていくぜぇ～

起動状況が分からないと困るのと、いろいろメニューを持たせたいのでWindowsタスクバー右の
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/ea3e9e92-2ed1-d723-a31b-c942db9328b0.png)

こいつらの仲間入りをさせます。

プロジェクトの初期化手順は省略します。

```typescript
import { app } from "electron";

app.on('ready', () => {
    const tray = new Tray(`${process.env.USERPROFILE}/AppData/Local/Programs/${APP_NAME}/resources/icon.png`);
    const contextMenu = Menu.buildFromTemplate([
        { type: 'normal', label: 'Refetch Events', click: () => { /* fetch events */ } },
        { type: 'normal', label: 'Quit', click: () => { app.quit(); } },
    ]);
    tray.on('click', () => {
        tray.popUpContextMenu();
    });
    tray.setToolTip('app_name');
    tray.setContextMenu(contextMenu);
});
```
アイコンの参照先をAppDataにしているのは、electron-builderで作ったインストーラにアプリの資源を展開させるのがそのフォルダからです。

これでタスクトレイに生き続けます。あとは予定取得を定期実行させるだけ、簡単だね！

## 予定通知

Windowsのトースト通知を出すには electron の Notification を使用します。

```typescript
import { Notification } from "electron";

const notification = new Notification({
  icon: 'path/to/icon.png',
  title: 'notif title',
  body: 'notification body',
});
notification.on('click', () => { /* click actions */ });
```

しかしこの通知、バグがあります。
Windowsのトースト通知は画面右下に表示され、しばらく放置すると引っ込んで通知センターに貯まります。Electronで出力した通知センターにある通知をクリックするとクリックイベントが発火しないのです。

[通知 | Electron](https://www.electronjs.org/ja/docs/latest/tutorial/notifications) を見ると

> 通知の発展的な使用方法​
  Windows ではカスタムテンプレート、画像、その他の柔軟な要素を使用した高度な通知が可能です。
  これらの通知をメインプロセスから送信するには、`ToastNotification` と `TileNotification` オブジェクトを送るネイティブ Node アドオンを使用する、`electron-windows-notifications` というユーザーランドのモジュールが利用できます。
  ボタンを含む通知は `electron-windows-notifications` で機能しますが、返信を処理するには `electron- 
  windows-interactive-notifications` を使用する必要があります。これにより、必要な COM コンポーネントを登録し、入力したユーザーデータを使用して Electron アプリを呼び出すことができます。

要するに「通知もっと使いたいならライブラリあるからそっちつかってくれや」という事らしい。
ライブラリ入れれば解消するならまぁいいかと思いきや、このライブラリは3年以上メンテナンスされていません。
インストールしようにも一向に解消しないエラー、導入したよという技術記事も特に見つからない。
[issues](https://github.com/felixrieseberg/electron-windows-notifications/issues/26) を見るとそれっぽい解消法がありますが、どうもelectronのバージョンを9まで下げないとインストールできない様です。今の最新v33だぞ？
何が動かなくなるかわかったものではないし、調べる限り後継も見つからないので断念しました。

クリックイベントがこのアプリのメイン機能ではないと割り切ります。（でも解消できたら嬉しいのでいい方法ご存知の方いれば教えてください。）

通知にはアプリ名が表示されますが、その値はデフォルトでは `package.json > build.appId` に依存します。
これは `app.setAppUserModelId('app name');` のように記述することで変更できます。

## スケジューリング

スケジューリングには `node-schedule` を使用します。

主な役割は2つ、定期実行と単発実行予約。前者は予定表情報取得に、後者は予定の通知出力に使用します。

```typescript
import schedule from 'node-schedule';

const job = schedule.scheduleJob('0 0 12 * * *', () => {
  // actions
});

// job.cancel();
```

scheduleJobの第一引数に cron式 を渡すことで定期実行、Date型を渡すことで単発実行を予約できます。
登録時の戻り値を受け取って適当なストアに入れておけば後からキャンセルもできます。

あとは設定ファイルなどに外出ししておいたユーザの設定から、何分前に通知するかを読み取り予定のn分前に通知の予約をするだけです。
具体的なDate型の計算を行って予約する部分については省略します。

## グローバルショートカット

[キーボード ショート カット | Electron](https://www.electronjs.org/ja/docs/latest/tutorial/keyboard-shortcuts#%E3%82%B0%E3%83%AD%E3%83%BC%E3%83%90%E3%83%AB%E3%82%B7%E3%83%A7%E3%83%BC%E3%83%88%E3%82%AB%E3%83%83%E3%83%88)

これはおまけですが、常駐アプリなのでいつでもどこでも呼び出し可能なグローバルショートカットキーを定義できます。 `Windows+O` を押したら最後に通知した予定のURLを開きたい、などをお好みで設定できます。

[globalShortcut | Electron](https://www.electronjs.org/ja/docs/latest/api/global-shortcut)
[Accelerator | Electron](https://www.electronjs.org/ja/docs/latest/api/accelerator)
Windowsキーは`Super`にあたります。OSの違いをSuperという名前で吸収しているようです。
`Windows+O` の場合は `Super+O` と記述します。

```typescript
import { globalShortcut } from "electron";

globalShortcut.register('Super+O', () => {
  // action
});
```

## 更新通知

ユーザにアプリ更新を通知するため、定期的に最新バージョンを確認して自己のバージョンと照らし合わせ、違っていたら通知するという事をしたい。
しかしその最新バージョンはどこに書いておけばいいのか、JSON1つだけ返せればいいのにサーバ借りるのも重いし、どうしたものかと考えていたところ、
ふと、[チュートリアル: メッセージ作成 Outlook アドインのビルド - Office Add-ins | Microsoft Learn](https://learn.microsoft.com/ja-jp/office/dev/add-ins/tutorials/outlook-tutorial?tabs=jsonmanifest) でGistというものを使ったことを思い出しました。

スニペットを公開し誰からも参照可能にできるのでこれを使用します。

[Create a new Gist](https://gist.github.com/) からGistを作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/6d0811ee-166e-59b8-02e3-7c2bcce4d76c.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/ce53b08d-3646-0aba-33dd-caf0fbef84e5.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586144/06edc460-8c28-e0d0-63ba-b6337e372d8f.png)

このURLは常に(Gistの)最新バージョンを返し、バージョン情報を返すAPIとして機能します。
無料で煩雑な設定いらず、サーバを借りたりしないので難しい知識もいらない。十分すぎますね。

この更新通知にはNotificationではなくDialogを使用します。

```typescript
import { dialog } from "electron";

const result = await dialog.showMessageBox({
  type: 'info',
  icon: 'path/to/icon.png',
  title: 'dialog title',
  message: 'dialog messages',
  buttons: ['action 1', 'action 2', 'action 3'],
});

switch(result.response) {
  case 0:
    action_1();
    break;
  case 1:
    action_2();
    break;
  case 2:
    action_3();
    break;
}
```

ボタンとアクションの紐づけはラップ関数を作ってあげないと少し実装が汚くなります。

## アプリの配布

electron-builderでアプリをexe化し、インストーラを作成します。

package.jsonに追記

```jsonc
{
  // ...
  "build": {
    "appId": "app-name",
    "win": {
      "target": "nsis",
      "icon": "./icon.png"
    },
    "files": [
      "dist/**/*",
      "icon.png",
      "config-sample.json",
      "token-sample.json"
    ],
    "extraResources": [
      {
        "from": "./icon.png",
        "to": "./icon.png"
      }
    ],
    "nsis": {
      "createDesktopShortcut": false,
      "include": "./installer.nsi"
    }
  },
}
```

プロジェクト直下に `installer.nsi` を作成し、下記のようにします。

```nsi
!macro customInstall
  CreateShortCut "$SMSTARTUP\.lnk" "$INSTDIR\AppName.exe"
!macroend

!macro customUnInstall
  Delete "$SMSTARTUP\AppName.lnk"
!macroend
```

インストーラの実行時とアンインストール時に行いたい処理を記述します。
上記のように記述することでStartupフォルダにアプリへのショートカットを作成・削除できます。
これでユーザはインストーラを実行するだけで、Windows起動時に自動でアプリも立ち上がるようになります。

```jsonc
{
    "scripts": {
        // ...
        "build": "del \"./dist\" && tsc && electron-builder --win"
    }
}
```

package.jsonには上記のように記述することで、 `npm run build` でインストーラの生成ができます。
先にdelを行っているのは、これが無いと成果物が貯まり続け、インストーラのサイズが大きくなりすぎてしまうからです。

ここまでできれば社内のユーザにインストーラを共有してあげるだけです。
既に何人かのユーザがいますが、特段問題なくインストールできているためelectron-builderは本当に便利です。

# おわり

これで快適な業務を送ることができるようになりました。
あまり同じ予定の通知を複数回出しすぎると、アラート疲れでまた予定をすっぽかすことになりかねないのでご注意ください。