Tauriを使った常駐アプリケーションでWindowsの通知を飛ばす

## はじめに
以前にElectronでアプリケーション作ってみていたので今度はTauriで作ってみました。
TauriもRustもSvelteも使うのも初めてです。

Tauri自身はクロスプラットフォーム対応ですがWindowsだけを対象としています。

v1で作り始めてしばらく経ったらv2が出ていたので途中でバージョンを上げています。

## 技術スタック
フレームワーク：Tauri
フロントエンド：TypeScript, Svelte, daisyUI, Tailwind CSS
バックエンド：Rust
その他：NSIS
アイコン：ImageFXで生成

下記に記載しているソースは一部なので自作関数については省略しているところもあります。
もしご覧になりたい場合は最下部のGitHubから。

## 機能・動作イメージ
タスクトレイに常駐し、1分ごとにiCalendarを読み込んで指定した時間(1分~1時間)の前にWindowsの通知を飛ばします。
~~まあこんなことしなくてもOutlookとかGoogleカレンダーとかに読み込ませればいいんですけどね。。。~~

設定画面
![画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/389699/064a9af6-eb2b-4327-a613-3f9b187b0e9e.png)

通知
![通知.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/389699/312d66aa-9ae3-4a79-be47-9ba07d735d04.png)

## タイマー処理
`main.rs`から呼ぶ`start_ical_timer`でタイマー処理をします。
まずは今の時間と次の分の0秒までの時間をミリ秒単位で取得して(`total_millisecond`)タイマーを実行します。
これによってまずは0秒に合わせます。

その後は`time::interval(time::Duration::from_secs(60))`で1分ごとに処理が実行されるようにします。
1分ごとに設定されているiCal URLからiCalendarを取得して通知タイミングの設定と付き合わせて通知する予定があれば通知します。

```rust:main.rs
async fn start_ical_timer(app: tauri::AppHandle) -> Result<(), String> {
    let total_millisecond = primitive::get_total_millisecond_2_next_minute();

    tokio::spawn(async move {
        time::sleep(time::Duration::from_millis(total_millisecond as u64)).await;

        let client = Client::new();

        let mut interval = time::interval(time::Duration::from_secs(60));
        loop {
            interval.tick().await;

            let now_timer = primitive::get_current_datetime_jst();

            let (ical_url, notice_timing) = {
                let config = Arc::new(Mutex::new(ical_config::get_config().clone()));

                let config_guard = config.lock().expect("CONFIG のロックに失敗しました。");
                (
                    config_guard.ical.ical_url.clone(),
                    config_guard.ical.notice_timing.clone(),
                )
            };

            let second = convert_preference::convert_timing_2_minutes(&notice_timing);

            match ical_writer::download_ical(&ical_url, true, &client).await {
                Ok(ical_temp_path) => {
                    if let Some(path_str) = ical_temp_path.to_str() {
                        match ical_reader::read_ical(path_str) {
                            Ok(cal_infos) => {
                                notify_upcoming_events(app.clone(), cal_infos, now_timer, second)
                                    .await;
                            }
                            Err(e) => {
                                logger::logging_error(&e);
                            }
                        }
                    } else {
                        logger::logging_error("iCalendar の読込に失敗しました。");
                    }
                }
                Err(e) => {
                    logger::logging_error(&e);
                }
            }
        }
    });

    Ok(())
}
```

構造体`Config`に設定を保持しています。

```rust:ical_config.rs
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Config {
    pub ical: Cal,
}

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Cal {
    pub ical_url: String,
    pub notice_timing: String,
}

lazy_static! {
    pub static ref CONFIG: Mutex<Config> = Mutex::new(Config {
        ical: Cal {
            ical_url: String::new(),
            notice_timing: "20".to_string(),
        },
    });
}

pub fn get_config() -> std::sync::MutexGuard<'static, Config> {
    CONFIG.lock().expect("CONFIG のロックに失敗しました。")
}
```

本当はiCalendarをファイルとして保存したくなかったのですが、保存せずにうまく読めなかったので諦めて保存するようにしています。
iCal URLが通るかテストする機能も設けているのでぶつからないようファイル名は別になるようにしています。

```rust:ical_writer.rs
pub async fn download_ical(ical_url: &str, save: bool, client: &Client) -> Result<PathBuf, String> {
    let body = ical_downloader::fetch_ical(ical_url, &client)
        .await
        .map_err(|e| format!("{}", e))?;

    let file_name = get_file_name(save);
    let ical_temp_path = paths::get_appdata_path().join(file_name);

    save_ical_to_file(ical_temp_path.clone(), &body)
        .map_err(|e| format!("iCalendar の保存に失敗しました。: {}", e))?;

    Ok(ical_temp_path)
}

fn save_ical_to_file(file_path: PathBuf, content: &str) -> io::Result<()> {
    let mut file = File::create(&file_path)?;
    file.write_all(content.as_bytes())?;

    Ok(())
}

fn get_file_name(save: bool) -> String {
    if save {
        String::from("tmp.ical")
    } else {
        String::from("test.ical")
    }
}
```

実際どうなるかちゃんと検証しているわけではないのですが、0秒に合わせてタイマー動かしてもPC重くなったらずれるのでは？ということで例えば5分前で設定してた時、きっかり5分前なら通知する、とはしていません。
設定した時間よりさらに1分前前よりは後できっかりの時間までの範囲なら、としています。
文字で書くと分かりにくいですが、5分前の設定なら241～300秒前なら通知するようにしています。
```rust:main.rs
async fn notify_upcoming_events(
    app: tauri::AppHandle,
    cal_infos: Vec<ical_reader::CalInfo>,
    now: DateTime<FixedOffset>,
    second: i64,
) {
    for info in cal_infos {
        match primitive::parse_datetime(&info.date_start) {
            Ok(date) => {
                let duration = primitive::calculate_duration(now, date);

                let s = duration.num_seconds();

                if s > second - 60 && s <= second {
                    let start = primitive::convert_datetime_2_timeonly(&info.date_start)
                        .unwrap_or_default();
                    let end =
                        primitive::convert_datetime_2_timeonly(&info.date_end).unwrap_or_default();

                    let title = format!("[{}-{}] {}", &start, &end, &info.summary);

                    notice_toast(app.clone(), &title, &info.description);
                }
            }
            Err(e) => {
                logger::logging_error(&e);
            }
        }
    }
}
```

```rust:primitive.rs
pub fn get_total_millisecond_2_next_minute() -> i64 {
    let jst_offset = FixedOffset::east_opt(9 * 3600).unwrap();

    let jst_now: DateTime<FixedOffset> = Utc::now().with_timezone(&jst_offset);

    let seconds = jst_now.second() as i64;
    let millisecond = (jst_now.nanosecond() / 1_000_000) as i64;

    let total_seconds = 60 - seconds;

    (total_seconds * 1000) - millisecond
}

pub fn get_current_datetime_jst() -> DateTime<FixedOffset> {
    let utc_now = Utc::now();
    let jst_offset =
        FixedOffset::east_opt(9 * 3600).expect("JST の FixedOffset 作成に失敗しました。");

    let jst_now = utc_now.with_timezone(&jst_offset);

    // ミリ秒を落とす
    let formatted = jst_now.format("%Y-%m-%d %H:%M:%S").to_string();
    let n = NaiveDateTime::parse_from_str(&formatted, "%Y-%m-%d %H:%M:%S").unwrap();
    let remove_millisecond_datetime: DateTime<FixedOffset> =
        jst_offset.from_local_datetime(&n).unwrap();

    remove_millisecond_datetime
}
```

## トースト通知
バックエンド側からでもフロントエンド側からでも通知を送ることができます。
`body`を加工しているのは改行コードが含まれていた時にそのままコードがでないよう半角スペースに置き換えています。
これを書いてて思いましたが別の処理でiCalendarのデータをVecに入れている時に本来は加工しておくべきでした。。。

### バックエンドからの通知
```rust:main.rs
fn notice_toast(app: tauri::AppHandle, title: &str, body: &str) {
    match app
        .notification()
        .builder()
        .title(title)
        .body(body.replace("\\r", " ").replace("\\n", " "))
        .show()
    {
        Ok(_) => {}
        Err(e) => logger::logging_error(&format!("トースト通知に失敗しました。: {}", e)),
    }
}
```

### フロントエンドからの通知
バックエンド側からイベントを発行します。

```rust:main.rs
#[derive(Deserialize, Serialize, Clone)]
struct ToastNotice {
    title: String,
    body: String,
}

#[command]
fn notice_toast(app: tauri::AppHandle, title: &str, body: &str) {
    let payload = ToastNotice {
        title: title.to_string(),
        body: body.replace("\\r", " ").replace("\\n", " ").to_string(),
    };

    app.emit("event-listen", payload).unwrap_or_else(|e| {
        logger::logging_error(&format!("トースト通知に失敗しました。: {}", e));
    });
}
```

フロントエンド側ではイベントを受信して通知を送ります。

```typescript:+page.svelte
	listen('event-listen', (event) => {
		const notice = event.payload as ToastNotice;
		showNotification(notice.title, notice.body);
	});
```

フロントエンド側から通知を送るには下記サイトのように許可する必要があります。

https://v2.tauri.app/reference/javascript/notification/#example-12

```typescript:notification.ts
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';

export async function initNotification() {
    await checkPermission();
}

async function checkPermission(): Promise<boolean> {
    let permissionGranted = await isPermissionGranted();

    if (!permissionGranted) {
        const permission = await requestPermission();
        permissionGranted = permission === 'granted';
    }

    return permissionGranted;
}

export async function showNotification(title: string, body: string) {
    if (!(await checkPermission())) {
        return;
    }

    await sendNotification({
        title,
        body,
    });
}
```

## v2にバージョン上げたら発生した事象

### デバッグビルドで通知が表示されなくなった
色々調べてる中でどこかのサイトを参考に記述してましたが、むしろv1の時の記述が横着していた感じでした。

```toml:Config.toml (修正前)
tauri-plugin-notification = "2"
```

```toml:Config.toml (修正後)
tauri-plugin-notification = "2.0.0"
```

### システムトレイにアイコンが2つ表示されるようになった
元々、`main.rs`でシステムトレイに追加していましたが、v2に上げたところシステムトレイにアイコンが2つ表示されるようになりました。

```rust:main.rs
    tauri::Builder::default()
        .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
            let _ = app
                .get_webview_window("main")
                .expect("'main' ウィンドウが存在しません。")
                .set_focus();
        }))
        .plugin(tauri_plugin_notification::init())
        .setup(|app| {
            let separator = PredefinedMenuItem::separator(app)?;
            let quit = MenuItem::with_id(app, "quit", "終了", true, None::<&str>)?;
            let show = MenuItem::with_id(app, "show", "設定", true, None::<&str>)?;
            let menu = Menu::with_items(app, &[&show, &separator, &quit])?;

            let _tray = TrayIconBuilder::new()
                .menu(&menu)
                .show_menu_on_left_click(true)
                .on_menu_event(|app, event| match event.id.as_ref() {
                    "quit" => {
                        app.exit(0);
                    }
                    "show" => {
                        let window = app.get_webview_window("main").unwrap();
                        window.show().unwrap();
                        window.set_focus().unwrap();
                    }
                    _ => {}
                })
                .icon(app.default_window_icon().unwrap().clone())
                .build(app)?;

            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            send_ical_url,
            get_initial_params,
            notice_toast
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

```

```json:tauri.conf.json (修正前)
{
  "app": {
    ...
    "trayIcon": {
      "iconPath": "icons/icon.png",
      "iconAsTemplate": true
    },
    ...
  }
}
```

どうやらv2からは`trayIcon`が設定されていると自動でシステムトレイに追加されるようです。
多分これで追加されたトレイアイコンを取得してメニュー追加することも可能だと思いますが、手間なので今回は`tauri.conf.json`から設定値を削除しています。

```json:tauri.conf.json (修正後)
{
  "app": {
    ...
  }
}
```

## NSISスクリプト
スタートアップに登録してWindows起動時に自動起動するようにしたり、インストール後にアプリケーションを自動起動するようにしたかったのでスクリプトを使ってインストーラを構築しようとしました。

Tauriは`tauri.conf.json`の`template`にスクリプトを指定することでインストーラの全体、`installerHooks`にスクリプトを指定することで下記のタイミングに処理を差し込むことができます。

* `NSIS_HOOK_PREINSTALL`：ファイルコピー、レジストリキーの設定、ショートカットの作成前。
* `NSIS_HOOK_POSTINSTALL`：ファイルコピー、レジストリキーの設定、ショートカットの作成後。
* `NSIS_HOOK_PREUNINSTALL`：ファイル、レジストリキー、ショートカットを削除する前
* `NSIS_HOOK_POSTUNINSTALL`：ファイル、レジストリキー、ショートカットが削除された後

### Windowsで通知を許可するためには
Windowsで通知を表示するにはAUMIDが登録されている必要があります。
また、この設定値は`tauri.conf.json`の`identifier`と一致していなければなりません。

デバッグビルドの場合はPowerShellの機能を使って通知が行われるようであるため意識しておく必要がありませが、上記がうまくいっていないとリリースビルドで作ったインストーラ使った途端に通知が出ないという状態が発生します。
※デバッグビルドではPowerShellの機能使っているので通知上もPowerShell扱いです。

登録されているAUMIDはPowerShellで下記コマンドを実行することで確認できます。

```powershell:コマンド
Get-StartApps
```

```json:tauri.conf.json
{
  ...
  "identifier": "com.mo.noticeical",
  ...
}
```

![PowerShell.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/389699/246cfe06-b1d9-46dd-a3ce-0ae12f10a799.png)

ただ、下記スクリプトを使ってインストーラを使っていましたがどうしてもAUMIDが正しく登録されず。。。
メモ帳は`{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\notepad.exe`って値なんですが、なんぜかこんな感じの形式に。。。

```json:tauri.conf.json
    "windows": {
      ...
      "nsis": {
        "languages": ["Japanese"],
        "template": "installer.nsi"
      }
    }
```

```installer.nsi
Unicode true

!include "MUI2.nsh"

# アプリケーション定義
!define APP_NAME "notice-ical"
!define APP_EXE "${APP_NAME}.exe"
!define APP_IDENTIFIER "com.mo.noticeical"

# 製品情報定義
!define PRODUCT_NAME "${APP_NAME}"
!define PRODUCT_VERSION "1.0.0"
!define PRODUCT_PUBLISHER "xxx.xxx"
!define PRODUCT_URL "https://github.com/helvetica822/notice-ical"

# レジストリのパス定義
!define REG_KEY_HKCU "Software\Microsoft\Windows\CurrentVersion\Run"
!define REG_KEY_HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}"

Name "${PRODUCT_NAME} ${PRODUCT_VERSION}"

# 出力されるファイル名
#OutFile "setup_${PRODUCT_NAME}.exe"
OutFile "..\..\bundle\nsis\notice-ical_${PRODUCT_VERSION}_x64-setup.exe"

# デフォルトのインストールパス
InstallDir "$PROGRAMFILES\${APP_NAME}"

# UAC表示
RequestExecutionLevel admin

# アプリケーション情報
!define MUI_PRODUCT "${PRODUCT_NAME}"
!define MUI_VERSION "${PRODUCT_VERSION}"
!define MUI_COPYRIGHT "Copyright © 2025 ${PRODUCT_PUBLISHER}"
!define MUI_URL "${PRODUCT_URL}"
!define MUI_INSTALLDIR "$INSTDIR"

# インストール画面
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH

# アンインストール画面
!insertmacro MUI_UNPAGE_WELCOME
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!define MUI_UNFINISHPAGE_NOAUTOCLOSE
!insertmacro MUI_UNPAGE_FINISH

# 日本語UI
!insertmacro MUI_LANGUAGE "Japanese"

Section "Install"
    SetOutPath "$INSTDIR"
    File "..\..\notice-ical.exe"

    # 64ビットレジストリを明示
    SetRegView 64

    # レジストリに自動起動を登録
    WriteRegStr HKCU "${REG_KEY_HKCU}" "${APP_NAME}" "$INSTDIR\${APP_EXE}"

    # レジストリに製品情報を登録(プログラムと機能に表示するため)
    WriteRegStr HKLM "${REG_KEY_HKLM}" "DisplayName" "${PRODUCT_NAME}"
    WriteRegStr HKLM "${REG_KEY_HKLM}" "UninstallString" "$INSTDIR\uninstall.exe"
    WriteRegStr HKLM "${REG_KEY_HKLM}" "DisplayVersion" "${PRODUCT_VERSION}"
    WriteRegStr HKLM "${REG_KEY_HKLM}" "Publisher" "${PRODUCT_PUBLISHER}"
    WriteRegStr HKLM "${REG_KEY_HKLM}" "URL" "${PRODUCT_URL}"

    # スタートメニューへの登録
    CreateDirectory "$SMPROGRAMS\${APP_NAME}"
    CreateShortCut "$SMPROGRAMS\${APP_NAME}\${APP_NAME}.lnk" "$INSTDIR\notice-ical.exe" "" "" "" SW_SHOWNORMAL "" "${APP_IDENTIFIER}"

    # アンインストーラの作成
    WriteUninstaller "$INSTDIR\Uninstall.exe"

    # アプリケーションを起動
    Exec "$INSTDIR\${APP_EXE}"

    Sleep 1000
SectionEnd

Section "Uninstall"
    # インストールしたファイルを削除
    Delete "$INSTDIR\${APP_EXE}"
    Delete "$INSTDIR\Uninstall.exe"
    # フォルダも消すなら
    RMDir "$INSTDIR"

    # レジストリから自動起動の登録を削除
    DeleteRegKey HKCU "${REG_KEY_HKCU}\${APP_NAME}"
    # レジストリから製品情報削除
    DeleteRegKey HKLM "${REG_KEY_HKLM}"
    
    # スタート メニューから削除
    Delete "$SMPROGRAMS\${APP_NAME}\${APP_NAME}.lnk"
    RMDir "$SMPROGRAMS\${APP_NAME}"
SectionEnd
```

諦めてフックでスタートアップへの登録だけ差し込むようにしました。

```json:tauri.conf.json
    "windows": {
      ...
      "nsis": {
        "languages": ["Japanese"],
        "installerHooks": "hooks.nsi"
      }
    }
```

```hooks.nsi
!define APP_NAME "notice-ical"
!define APP_EXE "${APP_NAME}.exe"

!define REG_KEY_HKCU "Software\Microsoft\Windows\CurrentVersion\Run"

!macro NSIS_HOOK_PREINSTALL
!macroend

!macro NSIS_HOOK_POSTINSTALL
  WriteRegStr HKCU "${REG_KEY_HKCU}" "${APP_NAME}" "$INSTDIR\${APP_EXE}"
!macroend

!macro NSIS_HOOK_PREUNINSTALL
!macroend

!macro NSIS_HOOK_POSTUNINSTALL
  DeleteRegKey HKCU "${REG_KEY_HKCU}\${APP_NAME}"
!macroend
```

## 参考にしたサイト

シングルトン

https://qiita.com/takavfx/items/d0335f47c80ff58e9c5b

アップグレード

https://m-lab-tech.com/posts/2024-11-upgrade-tauri-v2/

環境構築

https://m-lab-tech.com/posts/2024-03-tauri-svelte-daisyui-setup-guide/

フレームレス

https://qiita.com/takavfx/items/9bf518a5bc3cc7c19509

## GitHub

https://github.com/helvetica822/notice-ical