はじめに
コロナ禍の大学の授業はZoomによるものが多く,自宅で受講しているとどうしてもお布団の引力に負けてしまいます。開始時に自動的に参加するのはURLと時刻をあらかじめ取得しておけば簡単に実装できますが,いい感じのタイミングで抜けるのはそうはいきません。そこで,それっぽいタイミングでZoomから自動的に退出してくれるアプリを作成しました。
これは「技術的には可能」であることを紹介する記事であって,オンライン授業で教授の呪文に負けて寝落ちしてしまっても教授と二人きりにならないようにするなどの悪用を想定したものではありません。
環境
Windows 10のAPIを多用しているのでWin10(>=10.0.10240.0)限定です。Win11は知りません。
諸事情により.NET Framework 4.7.2,C# 9で作成しました。
実装
Zoomの画面をキャプチャして,参加者数を読み取って減ってきた頃(最大時の人数×一定の割合以下まで減った時)に退出します。
準備
NuGetパッケージのMicrosoft.Windows.SDK.Contractsを追加します。
パッケージの管理形式がPackages.configになっている場合は予めPackageReferenceに変更しておきます。
PresentationCore
とWindowsBase
への参照を追加します。
.NET 5であればプロジェクトファイルに<UseWPF>true</UseWPF>
を追加します(詳細はこのあたりを参照)。
IntPtr
と何かしらのデータをセットで持っておきたいことがあるのでクラスを用意しておきます。
using System;
namespace Qiita
{
internal class MeetingData<T>
{
internal IntPtr HWnd { get; }
internal T Data { get; }
internal MeetingData(IntPtr hWnd, T window)
{
this.HWnd = hWnd;
this.Data = window;
}
}
}
画面キャプチャ
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Linq;
using System.Runtime.InteropServices;
using Point = System.Drawing.Point;
namespace Qiita
{
internal static class CaptureUtil
{
#region Win32
[StructLayout(LayoutKind.Sequential)]
public struct RECT
{
public int Left; // x position of upper-left corner
public int Top; // y position of upper-left corner
public int Right; // x position of lower-right corner
public int Bottom; // y position of lower-right corner
}
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool GetWindowRect(HandleRef hWnd, out RECT lpRect);
[DllImport("User32.dll")]
internal extern static bool PrintWindow(IntPtr hwnd, IntPtr hDC, uint nFlags);
[DllImport("gdi32.dll")]
internal static extern IntPtr CreateRectRgn(int x1, int y1, int x2, int y2);
[DllImport("user32.dll")]
internal static extern int GetWindowRgn(IntPtr hWnd, IntPtr hRgn);
#endregion Win32
/// <summary>
/// アプリケーション名からウィンドウハンドルを取得
/// </summary>
/// <param name="name">アプリケーション名</param>
/// <returns>ウィンドウハンドルたち</returns>
/// <remarks>2回目以降の呼び出し時にその前の画面キャプチャの結果から必要と思われるハンドルを優先的に返すなどの改善もできるが長くなるので省略</remarks>
private static IEnumerable<IntPtr> GetHWnds(string name)
=> Process.GetProcessesByName(name).Select(proc => proc.MainWindowHandle);
/// <summary>
/// 指定したアプリケーション名に対応するウィンドウをキャプチャする
/// </summary>
/// <param name="name">アプリケーション名</param>
/// <param name="scaling">画像の拡大倍率</param>
/// <returns>ウィンドウのハンドルとそのキャプチャした画像</returns>
/// <remarks>画面が小さいと文字が潰れてうまく読めないことがあるので適当な<paramref name="scaling"/>を指定して拡大する</remarks>
internal static IEnumerable<MeetingData<Bitmap>> GetWindows(string name, float scaling = 1)
{
foreach (var hWnd in GetHWnds(name))
yield return GetWindow(hWnd, scaling);
}
/// <summary>
/// 指定されたハンドルのウィンドウをキャプチャする
/// </summary>
/// <param name="hWnd">ウィンドウハンドル</param>
/// <param name="scaling">画像の拡大倍率</param>
/// <returns>ウィンドウのハンドルとそのキャプチャした画像</returns>
internal static MeetingData<Bitmap> GetWindow(IntPtr hWnd, float scaling = 1)
{
// https://stackoverflow.com/questions/37931433/capture-screen-of-window-by-handle
if (hWnd == IntPtr.Zero) return null;
if (!GetWindowRect(new(null, hWnd), out var rect)) return null;
var region = new Rectangle()
{
X = rect.Left,
Y = rect.Top,
Width = rect.Right - rect.Left,
Height = rect.Bottom - rect.Top,
};
if (region.Width * region.Height == 0) return null;
var bitmap = new Bitmap(region.Width, region.Height, PixelFormat.Format32bppArgb);
using var graphics = Graphics.FromImage(bitmap);
IntPtr hdcBitmap;
try
{
hdcBitmap = graphics.GetHdc();
}
catch
{
return null;
}
var succeeded = PrintWindow(hWnd, hdcBitmap, 0);
graphics.ReleaseHdc(hdcBitmap);
if (!succeeded)
{
graphics.FillRectangle(new SolidBrush(Color.Gray), new Rectangle(Point.Empty, bitmap.Size));
}
var hRgn = CreateRectRgn(0, 0, 0, 0);
var reg = Region.FromHrgn(hRgn);
if (!reg.IsEmpty(graphics))
{
graphics.ExcludeClip(region);
graphics.Clear(Color.Transparent);
}
if (scaling == 1) return new(hWnd, bitmap);
var w = (int)(region.Width * scaling);
var h = (int)(region.Height * scaling);
var scaled = new Bitmap(w, h);
using var g = Graphics.FromImage(scaled);
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.DrawImage(bitmap, 0, 0, w, h);
return new(hWnd, scaled);
}
}
}
OCR
Windows.Media.Ocr.OcrEngine
という大変便利なクラスがあるのですが,こいつがWindows.Media.Ocr.SoftwareBitmap
という形式の画像しか受け取ってくれないのでSystem.Drawing.Bitmap
から頑張って変換しています。OCRよりもこの変換の処理の方が長いですね…
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Globalization;
using System.IO;
using System.Threading.Tasks;
using System.Windows.Media.Imaging;
using Windows.Globalization;
using Windows.Graphics.Imaging;
using SysBitmapFrame = System.Windows.Media.Imaging.BitmapFrame;
using WinBitmapDecoder = Windows.Graphics.Imaging.BitmapDecoder;
using WinOcrEngine = Windows.Media.Ocr.OcrEngine;
namespace Qiita
{
public class OcrEngine
{
private readonly WinOcrEngine ocrEngine;
public OcrEngine(Language language)
{
this.ocrEngine = WinOcrEngine.TryCreateFromLanguage(language);
}
public OcrEngine(string language) : this(new Language(language)) { }
internal OcrEngine() : this(CultureInfo.CurrentUICulture.Name) { }
/// <summary>
/// 画像から文字列を読み取る
/// </summary>
/// <param name="bitmap">画像</param>
/// <returns>読み取った文字列</returns>
async public Task<string> GetString(Bitmap bitmap)
{
if (bitmap == null) return null;
using var ms = new MemoryStream();
bitmap.Save(ms, ImageFormat.Bmp);
ms.Seek(0, SeekOrigin.Begin);
var source = SysBitmapFrame.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(SysBitmapFrame.Create(source));
using var tmp = new MemoryStream();
encoder.Save(tmp);
using var converted = tmp.AsRandomAccessStream();
var decoder = await WinBitmapDecoder.CreateAsync(converted);
var image = await decoder.GetSoftwareBitmapAsync();
return await GetString(image);
}
/// <summary>
/// 画像から文字列を読み取る
/// </summary>
/// <param name="bitmap">画像</param>
/// <returns>読み取った文字列</returns>
async public Task<string> GetString(SoftwareBitmap bitmap)
{
var res = await this.ocrEngine.RecognizeAsync(bitmap);
return res.Text;
}
}
}
ミーティングの状態を管理する
参加者数を管理して,一定の場合に終了した旨のイベントを発火します。
using System;
namespace Qiita
{
internal delegate void MeetingOverEventHandler(object sender, MeetingOverEventArgs e);
internal sealed class MeetingOverEventArgs : EventArgs
{
internal IntPtr HWnd { get; }
internal int Maximum { get; }
internal int Current { get; }
internal MeetingOverEventArgs(IntPtr hWnd, int maximum, int current)
{
this.HWnd = hWnd;
this.Maximum = maximum;
this.Current = current;
}
}
internal class MeetingState
{
private const float THRESHOLD = 0.5f;
private MeetingData<int> participants;
private int maximum;
internal event MeetingOverEventHandler MeetingSeemsToBeOver;
/// <summary>
/// ミーティングの参加者
/// </summary>
internal MeetingData<int> Participants
{
get => this.participants;
set
{
if (value == null)
value = new(IntPtr.Zero, -1);
if (this.participants == value) return;
if (value.Data < 0)
{
Reset();
return;
}
this.participants = value;
this.maximum = Math.Max(this.maximum, value.Data);
if (this.participants.Data <= this.maximum * THRESHOLD)
{
MeetingSeemsToBeOver?.Invoke(this, new(value.HWnd, this.maximum, value.Data));
Reset();
}
}
}
/// <summary>
/// ミーティングの情報をリセットする
/// </summary>
internal void Reset()
{
this.participants = null;
this.maximum = -1;
}
}
}
Zoomを監視する
Zoomを監視して,ミーティングが終了したと判断された場合には退出します。
using System;
using System.Drawing;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows.Forms;
using Timer = System.Timers.Timer;
namespace Qiita
{
public static class ZoomObserver
{
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool SetForegroundWindow(IntPtr hWnd);
private const float SCALING = 2.236f; // 面積で5倍くらいのつもり
private static readonly Regex re_ws = new(@"\s");
private static readonly Regex re_participants1 = new(@"(\d+)参加者退出");
private static readonly Regex re_participants2 = new(@"参加者[((](\d+)[))]");
private static readonly OcrEngine ocrEngine = new();
private static readonly MeetingState meetingState = new();
private static void Main()
{
var timer = new Timer()
{
Interval = 30_000, // 30,000 ms = 30 s
AutoReset = true,
Enabled = true,
};
timer.Elapsed += (sender, e) => _ = CheckMeetingState();
Application.Run(new Form());
}
static ZoomObserver()
{
meetingState.MeetingSeemsToBeOver += EscapeMeeting;
}
async internal static Task CheckMeetingState()
=> meetingState.Participants = await GetParticipants();
/// <summary>
/// 画像から参加者数を読み取る
/// </summary>
/// <param name="bitmap">画像</param>
/// <returns>読み取った参加者数。ログイン画面等の関係ないウィンドウであれば<c>0</c>,読み取りに失敗した場合は<c>-1</c></returns>
async private static Task<int> GetParticipants(Bitmap bitmap)
{
if (bitmap == null) return -1;
var text = await ocrEngine.GetString(bitmap);
text = re_ws.Replace(text, string.Empty);
if (text.Contains("Zoomクラウドミーティング")) return 0;
var mc = re_participants1.Matches(text);
if (mc.Count == 0)
{
mc = re_participants2.Matches(text);
if (mc.Count == 0) return -1;
}
return int.Parse(mc[0].Groups[1].Value);
}
async private static Task<MeetingData<int>> GetParticipants()
{
var bmps = CaptureUtil.GetWindows("zoom", SCALING).Where(bmp => bmp != null);
foreach (var bmp in bmps)
{
var p = await GetParticipants(bmp.Data);
if (p > 0) return new(bmp.HWnd, p);
else if (p == 0) continue;
// [参加者]を表示させてから読めるかどうか確認
ToggleParticipants(bmp.HWnd);
var tmp = CaptureUtil.GetWindow(bmp.HWnd, SCALING);
p = await GetParticipants(tmp.Data);
if (p < 0) continue;
return new(bmp.HWnd, p);
}
return null;
}
/// <summary>
/// 退出する
/// </summary>
private static void EscapeMeeting(object sender, MeetingOverEventArgs e)
{
SetForegroundWindow(e.HWnd);
SendKeys.SendWait("%(q)~"); // Alt+Q, Enter
}
/// <summary>
/// [参加者]を表示させる
/// </summary>
/// <param name="hWnd">ウィンドウハンドル</param>
private static void ToggleParticipants(IntPtr hWnd)
{
SetForegroundWindow(hWnd);
SendKeys.SendWait("%u"); // Alt+U
}
}
}
今回はめんどくさかったのでフォームを表示しましたが,タスクトレイにアイコンだけ出しておいてバックグラウンドで処理させるようにするといい感じになると思います。