10
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

RustAdvent Calendar 2023

Day 18

Tauri + windows-rsでスリープ防止アプリを作った話またはその制作手順を公開しTauri DIYを布教する的な何か

Last updated at Posted at 2023-12-18

この記事は Rust Advent Calendar 2023 - Qiita 18日目の記事です1

GUIフレームワークのTauriと、Microsoft公式が出しているWindows API用クレートのwindows-rsを使用し、sleepy-lockerというWindows用スリープ防止ユーティリティアプリを作成したので、その仕組の解説記事になります!

作ったアプリ「sleepy-locker」のリポジトリ↓

image.png

地味...!...しかも、昨年と記事構成も技術スタックもほぼ一緒やないかい! 修論に追われ、アドカレに向けて新規性のあるネタを用意できませんでした[]

作った動機と機能説明

筆者は執筆時点で大学院生のため、PCで論文PDFをじーっと眺めることがよくあります。

すると"ヤツ"は突然訪れます。そう、画面スリープです!

その度にマウスをくるくる...動画などは再生中であることを検出してスリープくんは忖度してくれますが、いくらAIが発達した2023年でも「画面を眺める」という人間の行為はPCに認識してもらえません2

設定を変えればいい?確かにそのとおりです。

image.png

...

今度はモニタがずっと点きっぱなしになってしまいました自分でそうしたわけですが...

筆者はPCをシャットダウンもスリープもしない派3なので、365日モニタが点きっぱなしになってしまいます。電力を無駄に消費しますし、モニタが傷んでしまいますね。

一方で、シェアハウスに住んでいたりする関係でデスクトップPCも人目に触れる機会が多いため、外出や睡眠といった離席時にPCロックはよく掛けます。

ロック時はモニタが点いている必要はありません。つまりロック時は消灯していてほしいのです。

色々悩んだ末、最終的に流れ着いたのが、手動でスリープ防止を切り替えられるアプリケーションの利用でした。

PowerToysのAwakeは最近知ったもので、しばらくは1つ目のSleep Preventerというフリーソフトを利用していました。

しかしSleep Preventerには、「ロック中だけ防止無効化」といった便利機能はありません。

結果、離着席のたびに操作が必要になりました。「Sleep Preventerを無効化」 → 「Win + L (ロックのショートカット)」 → 「ロック解除」 → 「Sleep Preventarを有効化」...

別に短時間であればモニタが点きっぱなしでも全然構わないのですが、一々Sleep Preventerのオンオフを気にしていたのでは筆者の脳の消費電力が上がってしまいます。

そこで、「ロック解除後はスリープ防止をするけども、ロック中は普通に画面スリープをする(システムの電源オプションに従う)」アプリケーションが欲しくなりました。

Just, Do It Yourself!

Don't let your dreams be dreams...

「スリープ防止を行える既存アプリがあるならば、その仕組みを少し流用するだけでお望みのアプリを作れるのでは?」

と考え、Microsoft公式が出しているPowerToys Awakeを参考に、どうやってスリープを防止しているかを調べてみました。

C#
using Microsoft.Win32;

// 省略

private static bool SetAwakeState(ExecutionState state)
{
    try
    {
        var stateResult = Bridge.SetThreadExecutionState(state);
        return stateResult != 0;
    }
    catch
    {
        return false;
    }
}

おや? SetThreadExecutionState関数 を呼び出しているだけで案外シンプル...?これなら自分でできそう...!

以降ここからは調査しながら手を動かしながらアプリをDIYしていきました。本記事の残りでは、sleepy-lockerを作った軌跡をコミット等を参考にまとめて行きたいと思います!

以降読みやすさの都合上パス等は明記せず、必要な手順を含め省略を多用します。正確なコードや位置関係はリポジトリを参考にしていただけると幸いです。

とりあえずまずスリープ防止

windows-rs クレートを使って、実際に SetThreadExecutionState 関数を試してみました。

Cargo.toml
[dependencies.windows]
version = "0.52.0"
features = [
    "Win32_System_Power",
    # ...
]

Win32_System_Power featureは、windows::Win32::System::Power::SetThreadExecutionState を使うために必要なfeatureです。

本アプリのlib.rs以下に、次の関数を定義しました。

Rust
use windows::Win32::System::Power::{
    SetThreadExecutionState, ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED,
};

pub fn prevent_sleep() {
    unsafe {
        SetThreadExecutionState(ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED | ES_CONTINUOUS);
    }
}

pub fn allow_sleep() {
    unsafe {
        SetThreadExecutionState(ES_CONTINUOUS);
    }
}

ES_*の意味は公式ドキュを参考にした感じ次のような意味を持つみたいです。

説明
ES_CONTINUOUS 次の呼び出しまで同設定を永続的に有効化
ES_SYSTEM_REQUIRED システムを動作状態のままにする。スリープ防止
ES_DISPLAY_REQUIRED ディスプレイを点けたままにする。画面スリープ防止

これらの値はフラグとして足し合わせることが可能になっているので、|BitOrを取っています。

prevent_sleepを呼び出したスレッドはまるで動画再生のような「ディスプレイの点灯が必要」なスレッドとシステムに認識されます。allow_sleepを呼び出すことで、フラグはリセットされます。

作った機能のお試しには examples ディレクトリを活用するのが便利です4Cargo.tomlと同じ階層に examples ディレクトリを置き、そこにエントリポイント(main)を持つプログラムを置きます。

sleep_prevent.rs
use dialoguer::Select;
use sleepy_locker::sleep_prevent::{allow_sleep, prevent_sleep};

fn main() {
    let items = ["prevent", "allow", "exit"];

    loop {
        let selection = Select::new()
            .with_prompt("Select Sleep Mode")
            .items(&items)
            .default(0)
            .interact()
            .unwrap();

        match selection {
            0 => {
                prevent_sleep();
                println!("Sleep prevention enabled");
            }
            1 => {
                allow_sleep();
                println!("Sleep prevention disabled");
            }
            _ => return,
        };
    }
}

機能を試すにあたり、dialoguerクレートを使用しています。こんなシンプルな記述だけでリッチなCLIが生えるのでとてもオススメです!

> cargo run --example sleep_prevent
    Finished dev [unoptimized + debuginfo] target(s) in 0.34s
     Running `target\debug\examples\sleep_prevent.exe`
Select Sleep Mode:
> prevent
  allow
  exit

examplesディレクトリにあるプログラムは、cargo run --example ファイル名で実行できます。実行を通し、 prevent を選ぶと allow を選ぶか exit するまでスリープされなくなることが確認できました!

TauriでサクッとGUIを生やしてみる

もうSleep Preventerと同じ機能を持ったアプリケーションなら作れそう!と感じたので、早速TauriでGUI化してみました!

main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use sleepy_locker::sleep_prevent::{allow_sleep, prevent_sleep};
use std::sync::Mutex;

#[tauri::command]
fn set_sleep_prevent_enabled(
    prevent_sleep_enabled: tauri::State<'_, Mutex<bool>>,
    enabled: bool,
) -> Result<bool, String> {
    let mut prevent_sleep_enabled = prevent_sleep_enabled
        .lock()
        .map_err(|e| format!("failed to lock prevent_sleep_enabled: {}", e))?;
    *prevent_sleep_enabled = enabled;
    if enabled {
        prevent_sleep();
        dbg!("Sleep prevention enabled");
    } else {
        allow_sleep();
        dbg!("Sleep prevention disabled");
    }
    Ok(*prevent_sleep_enabled)
}

#[tauri::command]
fn get_sleep_prevent_enabled(
    prevent_sleep_enabled: tauri::State<'_, Mutex<bool>>,
) -> Result<bool, String> {
    let prevent_sleep_enabled = prevent_sleep_enabled
        .lock()
        .map_err(|e| format!("failed to lock prevent_sleep_enabled: {}", e))?;
    Ok(*prevent_sleep_enabled)
}

fn main() {
    let prevent_sleep_enabled = Mutex::new(false);

    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            set_sleep_prevent_enabled,
            get_sleep_prevent_enabled
        ])
        .manage(prevent_sleep_enabled)
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

ごちゃついてますが注目ポイントは2点です!

  • invoke_handler によるコマンド定義: set_sleep_prevent_enabled 及び get_sleep_prevent_enabled を登録しています。TypeScript側で invoke を使うことで呼び出せるようになります
  • manage メソッドによるリソース管理: 防止が有効になっているかどうかを保持する変数 prevent_sleep_enabledmanage 関数によって登録します。すると、tauri::State 型の変数を通して各コマンドは引数でその不変参照を得られます! std::sync::Mutexで包むことによって排他的に可変参照を得て、set_sleep_prevent_enabled から値を変更できるようにしています

コマンドを晒せば後はTypeScript & ReactでよしなにGUIを作れます!今回UIにはmuiを採用しました

SleepPreventSwitch.tsx
import Switch from "@mui/material/Switch";
import { useEffect, useState, ChangeEvent } from "react";
import { invoke } from "@tauri-apps/api";

export default function SleepPreventSwitch() {
  const [checked, setChecked] = useState(false);
  const handleChange = async (event: ChangeEvent<HTMLInputElement>) => {
    setChecked(event.target.checked);
    const new_state = event.target.checked;
    await invoke<boolean>("set_sleep_prevent_enabled", { enabled: new_state });
    setChecked(new_state);
  };

  useEffect(() => {
    let cancel = false;
    (async () => {
      const res = await invoke<boolean>("get_sleep_prevent_enabled");
      if (!cancel) {
        setChecked(res);
      }
    })();

    return () => {
      cancel = true;
    };
  }, []);

  return (
    <Switch
      checked={checked}
      onChange={handleChange}
      inputProps={{ "aria-label": "controlled" }}
    />
  );
}

各コマンドを invoke で呼び出しています。スリープ防止有効化という副作用を起こすコマンド set_sleep_prevent_enabledhandleChangeにて呼び出しています。現在有効であるかを最初に取得する目的で、 get_sleep_prevent_enableduseEffect から呼び出しています。

プロトタイプの時点でほぼ完成形と同じものができました!見た目はほぼ完成ですが、一方でまだロック中でもスリープ防止が機能しています。あとはロックに合わせてスリープ防止機能のオンオフができれば良さそうです!

image.png

ロックイベントの取得 ~ ロックを舐めるな!🎸⚡ ~

ここまで、

  1. Rust側で機能作成&テスト
  2. Tauri向けにコマンド作成
  3. TS & ReactでGUI化

という流れで実装を行いました。単純な機能はほぼこのパターンで全て実装できます!が、たまに曲者がいまして今回はこの ロックイベントの取得 が大きめの峠になります。

「Windowsをロックした/ロック解除した」というイベント通知をどうやって受け取るか、検索したりChatGPTに聞いたりしたところ、Win32API(厳密にはwtsapi32?)では、WTSRegisterSessionNotification関数というリモートデスクトップサービス向けに用意された関数を使用すればよいようでした。

.NETを使う方針ならば別な手も取れたみたいでしたが、windows-rsでゴリ押したい場合この関数一択のようです。

WTSRegisterSessionNotificationの引数にあるhwndには、ロック/ロック解除通知を受け取りたいウィンドウのウィンドウハンドルを渡します。リンク先でも言及されている通り、どうしてもウィンドウが必要らしいです...

ここで最初、「我々にはTauriのウィンドウがあるじゃあないか!」と考え、Tauriウィンドウのハンドルを得るメソッドの使用を試みたのですが、どうやらイベント通知はウィンドウプロシージャに直接送られるらしく、詳細は省きます5がイベント通知を受け取れないことが判明しました。

結局、通知を受け取るための専用のウィンドウ及びスレッドが必要なようです。面倒だったのでChatGPTに実装してもらいました[]

lock_hooks.rs
fn dummy_window_for_detect_lock() -> Result<()> {
    unsafe {
        let instance = GetModuleHandleA(None)?;
        debug_assert!(instance.0 != 0);

        // dw stands for dummy_window
        let window_class = s!("sleepy_locker_dw");

        let wc = WNDCLASSA {
            hInstance: instance.into(),
            lpszClassName: window_class,
            lpfnWndProc: Some(wndproc), // (1)
            ..Default::default()
        };

        let atom = RegisterClassA(&wc);
        debug_assert!(atom != 0);

        // (2)
        let hwnd = CreateWindowExA(
            WINDOW_EX_STYLE::default(),
            window_class,
            s!("Dummy Window"),
            WS_OVERLAPPEDWINDOW,
            0,
            0,
            0,
            0,
            None,
            None,
            instance,
            None,
        );

        // (3)
        WTSRegisterSessionNotification(hwnd, NOTIFY_FOR_THIS_SESSION)?;

        let mut message = MSG::default();

        // (4)
        while GetMessageA(&mut message, None, 0, 0).into() {
            DispatchMessageA(&message);
        }

        Ok(())
    }
}
  • (1): wndproc がウィンドウプロシージャで、この関数内でロック関連通知である WM_WTSSESSION_CHANGE メッセージを処理します
  • (2): CreateWindowExA で、大きさのないダミーウィンドウを生成しています
  • (3): ダミーウィンドウを WTSRegisterSessionNotification 関数によって登録することで、WM_WTSSESSION_CHANGE メッセージをウィンドウプロシージャに通知してもらうようにします。NOTIFY_FOR_THIS_SESSIONの意味は筆者もよくわかっていませんが、おそらく現在のログインセッションを意味するなら今回の目的では ALL ではなくTHISで十分でしょう

ダミーウィンドウはTauriとは独立したスレッドで動かすことにします。肝心のウィンドウプロシージャにて WM_WTSSESSION_CHANGE を受け取った時は、std::sync::mpsc::Sender を利用して別スレッドに通知を飛ばすことにしました。

ところで、グローバル変数を避けがちなRustですが、ウィンドウプロシージャみたいに融通が利かない関数相手の時は OnceLock が便利ですね。スッキリと書くことができています!

lock_hooks.rs
use std::sync::{mpsc::Sender, OnceLock};

// 省略

// どこかでSenderをsetしておく
static SNDR: OnceLock<Sender<Event>> = OnceLock::new();

// 省略

extern "system" fn wndproc(window: HWND, message: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
    unsafe {
        match message {
            WM_WTSSESSION_CHANGE => {
                let Some(tx) = SNDR.get() else {
                    return DefWindowProcA(window, message, wparam, lparam);
                };

                match wparam.0 as u32 {
                    WTS_SESSION_LOCK => {
                        tx.send(Event::Lock)
                            .expect("failed to send LockEvent::Lock");
                    }
                    WTS_SESSION_UNLOCK => {
                        tx.send(Event::Unlock)
                            .expect("failed to send LockEvent::Unlock");
                    }
                    _ => {}
                }

                LRESULT(0)
            }
            _ => DefWindowProcA(window, message, wparam, lparam),
        }
    }
}

これでロック通知を受け取れるようになり、そしてTauri側にロック通知を知らせられるようになりました!

モード切り替え集約スレッド

最後です!スリープ防止モード切り替え部分を新機能に迎合させます!もともとはmain.rs#[tauri::command]がついたコマンド関数内で直接切り替えを行っていましたが、これをlib.rsの一つのスレッドに集約させてしまいます。

mpscSender側はクローンして set_sleep_prevent_enabledmanage メソッドを通して得られるように変更しておき、ユーザの操作イベントを Receiver 側へ飛ばすようにします。 Receiver によってイベントを一つのスレッドに集約し、受け取ったイベントに従って状態を変化させたのち、スリープ防止の有効化/無効化を操作します。

lib.rs
pub(crate) fn execute_prevent_or_allow(lock_state: &LockState) {
    match lock_state {
        LockState::Unlock(Mode::Prevent) => prevent_sleep(),
        _ => allow_sleep(),
    }
}

pub fn init_control_thread() -> (Sender<Event>, Arc<Mutex<LockState>>) {
    let (tx, rx) = channel();
    let tx1 = tx.clone();
    // ↓ ダミーウィンドウを生成してロックイベントを受け取るスレッドの初期化
    detect_lock_init(tx1).unwrap();
    let state = Arc::new(Mutex::new(LockState::Unlock(Mode::Allow)));
    let st = state.clone();

    std::thread::spawn(move || {
        for event in rx {
            let mut state = st.lock().unwrap();
            match event {
                // 以下2つは wndproc より
                Event::Lock => {
                    state.lock();
                }
                Event::Unlock => {
                    state.unlock();
                }

                // 以下2つは set_sleep_prevent_enabled より
                Event::Prevent => {
                    state.set_enabled(true);
                }
                Event::Allow => {
                    state.set_enabled(false);
                }
            }
            // スリープ防止モード切り替え
            execute_prevent_or_allow(&state);
        }
    });

    // ↓ main.rs側に渡す
    (tx, state)
}

新しいスレッドを作成し、その中で prevent_sleep 及び allow_sleep が呼ばれるようにしているのには理由があります。

  • for event in rx で、 Sender が生きている間ずっとイベントを処理し続けるため
  • prevent_sleepallow_sleepで呼ばれるSet Thread ExecutionState 関数は名前の通り、全ての実行が同一スレッド内で行われないと適切に動作しないため、同じスレッドに集約する必要がある。prevent_sleep等はウィンドウプロシージャ wndproc 等で呼んでしまうとスレッドが異なるので適切な動作にならない

必要があってそうしたのですが、mpscを介すように書けたことで結果的にスッキリした見た目になりました!

その他細かい工夫

ここまでで欲しかった機能を実装し終えることができました。この後はアイコンを作ったりタスクトレイやスタートアップ機能の追加を行ったりしました。詳しくは以前書いた記事に小ネタとして書いてありますので、参照していただけると恐悦至極です:bow:

まとめ・所感

いままでTauriについて「丁寧なハンズオン」や「何が作れるか」、「どんな面白い特徴があるか・小ネタ集」といった話題で記事を書いてきましたが、今回は「どんな感覚でTauriによりアプリケーションをDIYするか」という切り口で記事を書いてみました。

筆者としては「Tauriなら、ちょっとずつ作りたいところを固めながら作れる」という特徴を本記事で示せたと信じたいのですが、どうでしたでしょうか?今回は(Rustアドカレということもあって)Rust中心の話題でしたが、今後はTypeScriptやReactといったフロントエンドの話題も拡充させたいなぁと思います。

ここまで読んでいただき誠にありがとうございました!良いお年を!

参考・引用

記事中にリンクを載せなかった参考文献です。

  1. 毎年のことながら遅刻してしまい申し訳ありませんm(_ _)m 前日の19日はaobatさんのprivateなcrateの扱いについてという記事でした。gitのリポジトリとコミットIDを使用してバージョン固定できるやり方神ですよね、筆者はリポジトリをPublicにしがちなので認証で困ることはあまりなく何度も救われています。

  2. 人感センサー付きモニターなんかもありますが筆者は使ってないです。

  3. シャットダウンやスリープから復帰する時にSSDの読み書き回数が消耗されるという迷信を信じているのが一つ、モニタは別として、PC本体に関しては点けっぱなしでもあまり問題ないと聞いたのが一つ、そしてPCは日常的に使用するため起動時間が待てないというのが一番大きい理由です。でもまぁこれは人それぞれ考えが異なるものだと思います。( わりと本記事の核心に近い内容なのになぜ注釈に書いた[] )

  4. 本来の使い方ではないかもしれないですが... bin ディレクトリを使う方法もあります。

  5. GetMessageW関数のループに送信されるならば、SetWindowsHookExW関数によってGetMessageWにフックを仕掛けることができるのでTauriのウィンドウでも捕捉できます。しかしウィンドウプロシージャに直接イベント通知が行くのでフックでは捕捉できないのでした。→ フックできると思って実装したコードのコミット

10
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?