この記事は Rust Advent Calendar 2023 - Qiita 18日目の記事です1!
GUIフレームワークのTauriと、Microsoft公式が出しているWindows API用クレートのwindows-rsを使用し、sleepy-lockerというWindows用スリープ防止ユーティリティアプリを作成したので、その仕組の解説記事になります!
作ったアプリ「sleepy-locker」のリポジトリ↓
- ダウンロードページ: https://github.com/anotherhollow1125/sleepy-locker/releases/
- Assets以下の sleepy-locker_0.0.2_x64_ja-JP.msi がインストーラになります
地味...!...しかも、昨年と記事構成も技術スタックもほぼ一緒やないかい! 修論に追われ、アドカレに向けて新規性のあるネタを用意できませんでした[]
作った動機と機能説明
筆者は執筆時点で大学院生のため、PCで論文PDFをじーっと眺めることがよくあります。
すると"ヤツ"は突然訪れます。そう、画面スリープです!
その度にマウスをくるくる...動画などは再生中であることを検出してスリープくんは忖度してくれますが、いくらAIが発達した2023年でも「画面を眺める」という人間の行為はPCに認識してもらえません2。
設定を変えればいい?確かにそのとおりです。
...
今度はモニタがずっと点きっぱなしになってしまいました。自分でそうしたわけですが...
筆者はPCをシャットダウンもスリープもしない派3なので、365日モニタが点きっぱなしになってしまいます。電力を無駄に消費しますし、モニタが傷んでしまいますね。
一方で、シェアハウスに住んでいたりする関係でデスクトップPCも人目に触れる機会が多いため、外出や睡眠といった離席時にPCロックはよく掛けます。
ロック時はモニタが点いている必要はありません。つまりロック時は消灯していてほしいのです。
色々悩んだ末、最終的に流れ着いたのが、手動でスリープ防止を切り替えられるアプリケーションの利用でした。
- パソコンがスリープ(休止状態)になるのを防止するソフト「Sleep Preventer」 | フリーソフトラボ.com
- Windows 用の PowerToys Awake ユーティリティ | Microsoft Learn
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を参考に、どうやってスリープを防止しているかを調べてみました。
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
関数を試してみました。
[dependencies.windows]
version = "0.52.0"
features = [
"Win32_System_Power",
# ...
]
Win32_System_Power
featureは、windows::Win32::System::Power::SetThreadExecutionState
を使うために必要なfeatureです。
本アプリのlib.rs
以下に、次の関数を定義しました。
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
ディレクトリを活用するのが便利です4。Cargo.toml
と同じ階層に examples
ディレクトリを置き、そこにエントリポイント(main
)を持つプログラムを置きます。
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化してみました!
#![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_enabled
をmanage
関数によって登録します。すると、tauri::State
型の変数を通して各コマンドは引数でその不変参照を得られます!std::sync::Mutex
で包むことによって排他的に可変参照を得て、set_sleep_prevent_enabled
から値を変更できるようにしています
コマンドを晒せば後はTypeScript & ReactでよしなにGUIを作れます!今回UIにはmuiを採用しました
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_enabled
はhandleChange
にて呼び出しています。現在有効であるかを最初に取得する目的で、 get_sleep_prevent_enabled
を useEffect
から呼び出しています。
プロトタイプの時点でほぼ完成形と同じものができました!見た目はほぼ完成ですが、一方でまだロック中でもスリープ防止が機能しています。あとはロックに合わせてスリープ防止機能のオンオフができれば良さそうです!
ロックイベントの取得 ~ ロックを舐めるな!🎸⚡ ~
ここまで、
- Rust側で機能作成&テスト
- Tauri向けにコマンド作成
- TS & ReactでGUI化
という流れで実装を行いました。単純な機能はほぼこのパターンで全て実装できます!が、たまに曲者がいまして今回はこの ロックイベントの取得 が大きめの峠になります。
「Windowsをロックした/ロック解除した」というイベント通知をどうやって受け取るか、検索したりChatGPTに聞いたりしたところ、Win32API(厳密にはwtsapi32
?)では、WTSRegisterSessionNotification
関数というリモートデスクトップサービス向けに用意された関数を使用すればよいようでした。
.NETを使う方針ならば別な手も取れたみたいでしたが、windows-rsでゴリ押したい場合この関数一択のようです。
WTSRegisterSessionNotification
の引数にあるhwnd
には、ロック/ロック解除通知を受け取りたいウィンドウのウィンドウハンドルを渡します。リンク先でも言及されている通り、どうしてもウィンドウが必要らしいです...
ここで最初、「我々にはTauriのウィンドウがあるじゃあないか!」と考え、Tauriウィンドウのハンドルを得るメソッドの使用を試みたのですが、どうやらイベント通知はウィンドウプロシージャに直接送られるらしく、詳細は省きます5がイベント通知を受け取れないことが判明しました。
結局、通知を受け取るための専用のウィンドウ及びスレッドが必要なようです。面倒だったのでChatGPTに実装してもらいました[]
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
が便利ですね。スッキリと書くことができています!
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
の一つのスレッドに集約させてしまいます。
mpsc
のSender
側はクローンして set_sleep_prevent_enabled
が manage
メソッドを通して得られるように変更しておき、ユーザの操作イベントを Receiver
側へ飛ばすようにします。 Receiver
によってイベントを一つのスレッドに集約し、受け取ったイベントに従って状態を変化させたのち、スリープ防止の有効化/無効化を操作します。
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_sleep
とallow_sleep
で呼ばれるSet
ThreadExecutionState
関数は名前の通り、全ての実行が同一スレッド内で行われないと適切に動作しないため、同じスレッドに集約する必要がある。prevent_sleep
等はウィンドウプロシージャwndproc
等で呼んでしまうとスレッドが異なるので適切な動作にならない
必要があってそうしたのですが、mpsc
を介すように書けたことで結果的にスッキリした見た目になりました!
その他細かい工夫
ここまでで欲しかった機能を実装し終えることができました。この後はアイコンを作ったりタスクトレイやスタートアップ機能の追加を行ったりしました。詳しくは以前書いた記事に小ネタとして書いてありますので、参照していただけると恐悦至極です
まとめ・所感
いままでTauriについて「丁寧なハンズオン」や「何が作れるか」、「どんな面白い特徴があるか・小ネタ集」といった話題で記事を書いてきましたが、今回は「どんな感覚でTauriによりアプリケーションをDIYするか」という切り口で記事を書いてみました。
筆者としては「Tauriなら、ちょっとずつ作りたいところを固めながら作れる」という特徴を本記事で示せたと信じたいのですが、どうでしたでしょうか?今回は(Rustアドカレということもあって)Rust中心の話題でしたが、今後はTypeScriptやReactといったフロントエンドの話題も拡充させたいなぁと思います。
ここまで読んでいただき誠にありがとうございました!良いお年を!
参考・引用
記事中にリンクを載せなかった参考文献です。
-
毎年のことながら遅刻してしまい申し訳ありませんm(_ _)m 前日の19日はaobatさんのprivateなcrateの扱いについてという記事でした。
git
のリポジトリとコミットIDを使用してバージョン固定できるやり方神ですよね、筆者はリポジトリをPublicにしがちなので認証で困ることはあまりなく何度も救われています。 ↩ -
人感センサー付きモニターなんかもありますが筆者は使ってないです。 ↩
-
シャットダウンやスリープから復帰する時にSSDの読み書き回数が消耗されるという迷信を信じているのが一つ、モニタは別として、PC本体に関しては点けっぱなしでもあまり問題ないと聞いたのが一つ、そしてPCは日常的に使用するため起動時間が待てないというのが一番大きい理由です。でもまぁこれは人それぞれ考えが異なるものだと思います。( わりと本記事の核心に近い内容なのになぜ注釈に書いた[] ) ↩
-
本来の使い方ではないかもしれないですが...
bin
ディレクトリを使う方法もあります。 ↩ -
GetMessageW
関数のループに送信されるならば、SetWindowsHookExW
関数によってGetMessageW
にフックを仕掛けることができるのでTauriのウィンドウでも捕捉できます。しかしウィンドウプロシージャに直接イベント通知が行くのでフックでは捕捉できないのでした。→ フックできると思って実装したコードのコミット ↩