私です。お母様とこれを観ている方々、お元気ですか。
ふと「WebView2を試したいなぁ」と思い立って検索してみると、XAMLデザインによる方法はたらふく出てくるのですが、ソースレベルになると極端に情報が薄くなってきます。
偶に見つけてもWinForms
による方法だったりで残念です。
前準備
本稿はhow toモノではありません。Microsoft Edge WebView2の基礎が少しある方への応用になります。
- 対象フレームワークは
.NET5.NET6(LTS) です。 - UIフレームワークは
WPF
をメインとします。WinForms
でもUIのコードと関係ない箇所なら共通する部分は多いです。-
WPF
をメインとするならプロジェクトファイルのUseWindowsForms
要素はfalse
にするか削除するべきです。私はごちゃ混ぜにして泣かされました。混乱の元です。
-
なおソースのライセンスはパブリックドメインとしますから、コピペして自由に使って下さい(名前空間をそのまま使うと私は両手を挙げて喜びを表します、参照エラーには気をつけて)。
専用のクラスを作って流れを観る
本稿ではUIについてほとんど触れず、やりたいことの大半はWebView2に関するソースを書くことに成ります。
主要機能を以下の専用クラスにまとめます。
- 専用クラスは
WebView2Controller
と命名します。 - メインのウインドウは
MainWindow
と命名します。 - UIのパッケージに
Microsoft.Web.WebView2.Wpf
を使います(私はココでも泣きました)。
また
- ウインドウタイトル制御は
MainWindow.xaml.cs
ファイル - WebView2の制御は
WebView2Controller.cs
ファイル
このように仕事内容を分けておきます1。
とりあえず、WebView2Controller
のコアなコードを書いていきます。
using Microsoft.Web.WebView2.Core;
using System;
using System.Diagnostics;
namespace STSynthe.WebView2
{
public class WebView2Controller
{
private readonly Microsoft.Web.WebView2.Wpf.WebView2 webView2 = new();
public WebView2Controller()
{
this.webView2.CoreWebView2InitializationCompleted += this.CoreWebView2Initialization;
}
private void CoreWebView2Initialization(object sender, CoreWebView2InitializationCompletedEventArgs e)
{
if (e.IsSuccess)
{
Debug.WriteLine("初期化成功");
}
else
{
Debug.Fail("CoreWebView2の初期化に失敗しました。");
}
Debug.WriteLine("CoreWebView2InitializationCompleted");
}
public async void Navigate(string uri)
{
if (this.webView2.CoreWebView2 is null)
await this.webView2.EnsureCoreWebView2Async();
this.webView2.CoreWebView2.Navigate(uri);
Debug.WriteLine("Navigate");
}
public Microsoft.Web.WebView2.Wpf.WebView2 GetWebView2()
{
return this.webView2;
}
}
}
必要最低限だとこのぐらい。コメント付けると長くなるので諸々の説明は後記にて。
この段階でプログラムの流れも観てみたいのでMainWindow.xaml
にMainWindow
ウインドウを実装していく。
<Window x:Class="STSynthe.SampleWebView2.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:STSynthe.SampleWebView2"
mc:Ignorable="d"
Title="ぶらうざ" Width="1280" Height="720"/>
今は最低限の実装に留めるため、Window
以外の要素は挿入しない。
次にMainWindow.xaml.cs
にMainWindow
を制御するコードを実装する。
using STSynthe.WebView2;
using System.Windows;
using System.Windows.Controls;
namespace STSynthe.SampleWebView2
{
public partial class MainWindow : Window
{
private readonly DockPanel dockPanel = new();// DockPanelのインスタンス生成
private readonly WebView2Controller webView2Controller = new();// WebView2Controllerのインスタンス生成
public MainWindow()
{
// DockPanelの子要素にWebView2を追加する
this.dockPanel.Children.Add(this.webView2Controller.GetWebView2());
// Windowの子要素にDockPanelを追加する
this.AddChild(this.dockPanel);
// ナビゲートを指定
this.webView2Controller.Navigate("https://www.google.com/");
this.InitializeComponent();
}
}
}
横着してるが、DockPanelの子要素に既存のWebView2要素を追加する事で、ウインドウサイズを自動で合わせてくる。
わざわざイベント作ってリサイズさせるコードを省くことはできる。
これで最低限の実装は完了したので、デバッグを走らせると意図したデバッグメッセージがコンソールに出力されている。
そしてブラウザ画面、Googleが表示されるウインドウが立ち上がってくる。
最初は動かないCoreWebView2を使えるようにする
CoreWebView2は名前の通り、WebView2のコアに相当するAPI。
しかしそのまま使うことはできない。
備考によるとEdgeブラウザーの起動は重く、時間を要する。
なのでWebView2
インスタンス生成直後のWebView2.CoreWebView2
はnull
を返すから、そのままでは使えない。
WebView2.CoreWebView2
を介す処理は、初期化完了まで待つ。そうすることでnull
に成らない。待ってあげないと「Microsoft.Web.WebView2.Wpf.WebView2.CoreWebView2.get が null を返しました
」と怒られるからだ。
初期化完了を待つには、ふたつの方法がある。
名前 | CoreWebView2InitializationCompleted |
EnsureCoreWebView2Async |
---|---|---|
種類 | イベント | 非同期メソッド |
実行順位2 | 前(登録順) | 後 |
個人的な使い分け | 一度だけの処理、イベント登録 | 繰り返して使う処理 |
それぞれの使い方は次項にて。
イベントCoreWebView2InitializationCompleted
一つの方法としてCoreWebView2InitializationCompleted
イベントを使う。
名前の通り、CoreWebView2が初期化完了後に諸々を行うイベント。
試しに専用クラスのWebView2Controller
に変更を加えて、どうるなかを確認する。
まずは初期化完了を待たずにWebView2.CoreWebView2.Settings.AreDevToolsEnabled
を指定する。
false
を渡すことで開発者ツールを無効化3するが、もちろん例外が発生して動くことは無い。
// WebView2.CoreWebView2がnullでBADな例
public WebView2Controller()
{
+ this.webView2.CoreWebView2.Settings.AreDevToolsEnabled = false;
this.webView2.CoreWebView2InitializationCompleted += this.CoreWebView2Initialization;
}
次にイベントCoreWebView2InitializationCompleted
を使う。イベントとして登録したメソッドにさっきの追記コードを移植する。
private void CoreWebView2Initialization(object sender, CoreWebView2InitializationCompletedEventArgs e)
{
if (e.IsSuccess)
{
Debug.WriteLine("初期化成功");
+ this.webView2.CoreWebView2.Settings.AreDevToolsEnabled = false;
}
else
{
Debug.Fail("CoreWebView2の初期化に失敗しました。");
}
Debug.WriteLine("CoreWebView2InitializationCompleted");
}
今度はコンテキストメニューから開発者ツールが消えて成功3。
非同期メソッドEnsureCoreWebView2Async
まず先に専用クラスWebView2Controller
のNavigate
メソッドの事について。
WebView2Controller
のNavigate
メソッドはWebView2.CoreWebView2.Navigate
のラッパーで、ブラウザにURLを渡してそのアドレスに遷移してもらうものです。
もう先ほどのでお気づきかと思いますが、これもCoreWebView2
を介すので初期化完了を待たなければ成りません。
そこでもう一つの方法としてWebView2.EnsureCoreWebView2Async
を使っています。
CoreWebView2
の初期化が完了すると次のコードを実行します。
非同期なのでAsync/Awaitが使われています。
WebView2Controller
のNavigate
はいつどんなときに呼び出されても安全に動作するよう、このような方法が採られてます。
適当な応用集
応用自体は探せばまあまああるので適当。
警告
WebView2Controller
を改造して使うので、上記を読み飛ばすと分からなくなります。
設定で色々無効化する
xamlのプロパティでは変更できない部分。
private void CoreWebView2Initialization(object sender, CoreWebView2InitializationCompletedEventArgs e)
{
if (e.IsSuccess)
{
// 開発者ツールの無効化
this.webView2.CoreWebView2.Settings.AreDevToolsEnabled = false;
// コンテキストメニュー(右クリックメニュー)の無効化
this.webView2.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
// ダイアログ無効化
this.webView2.CoreWebView2.Settings.AreDefaultScriptDialogsEnabled = false;
// ステータスバー非表示
this.webView2.CoreWebView2.Settings.IsStatusBarEnabled = false;
// 内蔵されたエラーページを無効化
this.webView2.CoreWebView2.Settings.IsBuiltInErrorPageEnabled = false;
// ズームコントロールの無効化
this.webView2.CoreWebView2.Settings.IsZoomControlEnabled = false;
}
else
{
Debug.Fail("CoreWebView2の初期化に失敗しました。");
}
Debug.WriteLine("CoreWebView2InitializationCompleted");
}
留意点として、コンテキストメニューの無効化をするとキーによるページの「戻る」「進む」も無効化されます。
バージョンアップにつれて設定できる項目も増えているので、参考リンク先は偶には確認しておくことをすすめる。
参考
新しいウインドウを抑止する
JavaScriptによるwindow.open
や、HTMLの属性target="_blank"
、ユーザー操作による新しいウインドウを開かせないようにする。
private void CoreWebView2Initialization(object sender, CoreWebView2InitializationCompletedEventArgs e)
{
if (e.IsSuccess)
{
this.webView2.CoreWebView2.NewWindowRequested += this.NewWindowDeter;
}
else
{
Debug.Fail("CoreWebView2の初期化に失敗しました。");
}
Debug.WriteLine("CoreWebView2InitializationCompleted");
}
private void NewWindowDeter(object sender, CoreWebView2NewWindowRequestedEventArgs e)
{
// イベントを処理済みとして扱うなら新しいウィンドウは開きません。デフォルトはfalseです。
e.Handled = true;
}
これでどんな手法を採っても新しいウインドウは開かないように成る(Handled
は「キャンセル」や「停止」という意味では無い)。
というかソースの注釈やリファレンスになんか書いてあったね。
If set to true and no Microsoft.Web.WebView2.Core.CoreWebView2NewWindowRequestedEventArgs.NewWindow is set for window.open(), the opened WindowProxy is for a dummy window object and no window loads. The default value is false.
ちなみにこちらの記事では、ダミーの為にWebView2を生成して、それをe.NewWindow
に代入してstopで停止させる手法です。
しかしダミーインスタンスは残り続ける為、リソースの消費も大きく無駄です。
新しいウインドウは抑止したいが、ページは遷移させたいのであれば
private void NewWindowDeter(object sender, CoreWebView2NewWindowRequestedEventArgs e)
{
e.NewWindow ??= this.webView2.CoreWebView2;
}
e.NewWindow
に既存のCoreWebView2
を代入する処理に置き換える。あるいは
private void NewWindowDeter(object sender, CoreWebView2NewWindowRequestedEventArgs e)
{
e.Handled = true;// Handledプロパティにtrueを渡してイベントを処理済みとして扱う
this.Navigate(e.Uri);// WebView2Controller内のNavigateを使ってe.Uriプロパティからページ遷移する予定のURLを渡す
}
こっちのほうが感覚的に分かりやすいかも。
参考
AllowDrop = false
not working?
本来WebView2のAllowDrop
はデフォルトでfalse
なのですが、明確的に指定してもファイルドロップされると反応してローカルファイルや他のURLに遷移してしまいます。Drop
イベントも無反応で、キャンセルできず。
代替案として、「#新しいウインドウを抑止する」を参考にしてください。
動作的に新しいウィンドウを生成しようとしてくるので、その辺りで対応できます。
ページのタイトルをウインドウのタイトルに表示させたい
CoreWebView2の制約もあるので、少し回りくどいがCoreWebView2.DocumentTitleChanged
のラッパーを作る。
WebView2Controller
側:
private readonly Microsoft.Web.WebView2.Wpf.WebView2 webView2 = new();
+ public event EventHandler<string> DocumentTitleChanged;
// ~~中略~~
private void CoreWebView2Initialization(object sender, CoreWebView2InitializationCompletedEventArgs e)
{
if (e.IsSuccess)
{
+ this.webView2.CoreWebView2.DocumentTitleChanged += this.DocumentTitleChangedEvent;
}
else
{
Debug.Fail("CoreWebView2の初期化に失敗しました。");
}
Debug.WriteLine("CoreWebView2InitializationCompleted");
}
+ private void DocumentTitleChangedEvent(object sender, object e)
+ {
+ this.DocumentTitleChanged?.Invoke(this, this.webView2.CoreWebView2.DocumentTitle);
+ }
簡易的なイベントDocumentTitleChanged
を作る。これをMainWindow
側で登録・処理する。
面倒なので、独自の引数にせず、ドキュメントタイトルのstring
をダイレクトに渡す(名前で分かるよね)。
MainWindow
側:
// ~~中略~~
this.AddChild(this.dockPanel);
+ this.webView2Controller.DocumentTitleChanged += (object sender, string e) => this.Title = $"{e} - ぷらうざ";
ページのタイトルが書き換わる度にタイトルも変わっているはず。オーバーフローを避ける為に文字数制限付けた方が良いので、各自工夫して欲しい。
一行で書く為に、無名関数をラムダ式で書いてるが、色々問題あるので、よいこのみんなは、名前のある関数で書くと良いです。
参考
ユーザーデータフォルダの指定
要はキャッシュなどの保存先。
一応公式ページのコンセプトに「ユーザーデータフォルダ」の事について書かれてるが、無理矢理翻訳のせいか私の解読力が無いのかよく分からん。WebView2リファレンスの方が詳しいかもしれない。
詳しいリファレンスによるとユーザーデータフォルダの指定方法は二つ。
個人的にコードがまとまる方を選びたいので本項ではCoreWebView2CreationProperties
クラスを使う事とする。他はWebView2リファレンスを参照して欲しい。
private readonly Microsoft.Web.WebView2.Wpf.WebView2 webView2 = new();
+ private readonly Microsoft.Web.WebView2.Wpf.CoreWebView2CreationProperties coreWebView2CreationProperties = new();
// ~~中略~~
public WebView2Controller()
{
+ this.webView2.Initialized += this.WebView2Initialized;
this.webView2.CoreWebView2InitializationCompleted += this.CoreWebView2Initialization;
}
// ~~中略~~
+ private void WebView2Initialized(object sender, EventArgs e)
+ {
+ string userDataFolder = Environment.GetEnvironmentVariable("TEMP");
+ userDataFolder += @"\wv2";
+ if (Directory.Exists(userDataFolder))
+ {
+ try
+ {
+ Directory.CreateDirectory(userDataFolder);
+ }
+ catch (Exception)
+ {
+ this.webView2.Dispose();
+ throw;
+ }
+ }
+ // ユーザーデータフォルダを指定する
+ this.coreWebView2CreationProperties.UserDataFolder = userDataFolder;
+ // CoreWebView2CreationPropertiesをセットする
+ this.webView2.CreationProperties = this.coreWebView2CreationProperties;
+ }
CoreWebView2CreationProperties
インスタンスを生成し、それのUserDataFolder
プロパティにユーザーデータフォルダのパスを指定する。
あとはWebView2.CreationProperties
にCoreWebView2CreationProperties
をセットする。
例の場合だと、\Users\<ユーザー名>\AppData\Local\Temp
の中にwv2
フォルダが作成され、CoreWebView2の初期化後、その中に色々ぶち込まれる。
注意点として
-
CreationProperties
への追加は、CoreWebView2の初期化が始まる前(インスタンス生成直後など)なら挿入箇所はどの行でも良いです。 - 私が挿入位置を明確的にしたいが為に、関連の無いイベント
Initialized
を使っているだけです(FrameworkElementが初期化されたタイミングで発生します)。 -
CreateDirectory
しなくてもWebView2側がフォルダを作成しますが、パスチェックなどをこちらでやりたいのでこのようになってます。
選択されたテキストの抽出
マウスカーソルで選択したテキストを抽出する。
ドキュメントに介入するAPIはほとんど無く、基本JavaScriptを駆使することに成る。
private readonly Microsoft.Web.WebView2.Wpf.WebView2 webView2 = new();
private string SelectedTextScriptId;
// ~~中略~~
private void CoreWebView2Initialization(object sender, CoreWebView2InitializationCompletedEventArgs e)
{
if (e.IsSuccess)
{
this.webView2.CoreWebView2.WebMessageReceived += this.WebMessageProcessor;
this.SelectedTextInitialize();
}
else
{
Debug.Fail("CoreWebView2の初期化に失敗しました。");
}
Debug.WriteLine("CoreWebView2InitializationCompleted");
}
private void WebMessageProcessor(object sender, CoreWebView2WebMessageReceivedEventArgs e)
{
Debug.WriteLine(e.WebMessageAsJson);
}
private async void SelectedTextInitialize()
{
if (this.SelectedTextScriptId is null)
{
this.SelectedTextScriptId = await this.webView2.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(@"
new class {
constructor() {
window.document.addEventListener('mouseup', this.selectEvent);
}
selectEvent(event) {
if (event.isTrusted && event.button === 0) {
const selection = window.getSelection();
if (!selection.isCollapsed && selection.type.includes('Range')) {
console.log(`SelectedText: '${selection.toString()}'`);
window.chrome.webview.postMessage({
Type: 'SelectedText',
Text: selection.toString()
});
}
}
}
}");
}
}
CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync
を使ってテキストを抽出するJavaScriptを登録する。一度登録しておけばページ遷移しても消えることは無い。返り値のIDは例え使わなくても保存をする4。
CoreWebView2.WebMessageReceived
イベントを使ってパッシブに受け取る。受け取ったメッセージが勝手にJSON化されるのでデシリアライズなりしておけば良い。
試しに何かの文字列を選択するとデバッグメッセージに「{"Type":"SelectedText","Text":"【選択した文字列】"}
」と表示される。
以下は登録されたJavaScriptの部分を少し解説する。
new class {
constructor() {
window.document.addEventListener('mouseup', this.selectEvent);
}
selectEvent(event) {
if (event.isTrusted && event.button === 0) {
const selection = window.getSelection();
if (!selection.isCollapsed && selection.type.includes('Range')) {
console.log(`SelectedText: '${selection.toString()}'`);
window.chrome.webview.postMessage({
Type: 'SelectedText',
Text: selection.toString()
});
}
}
}
}
無名のclassが即座に実行されると、コンストラクタでmouseup
イベントを登録する。
もし、ユーザー操作によるマウスの左ボタンを上げたときにテキストが選択されてたなら選択範囲の文字列をwindow.chrome.webview.postMessage()
で送信します。
ここでなぜ、selectionchange
イベントが使われてないかと思う方もいるでしょう。
それは選択される度にメッセージを送信しようとするのでmouseup
イベントで工夫している。というよりそれ以外の最適解が見つからなかったのだ。
参考
- CoreWebView2.WebMessageReceived Event
- CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(String) Method
- 【JavaScript】EventTarget.addEventListener()
- 【JavaScript】window.getSelection
- 【JavaScript】Element: mouseup event
禁断?関連づけられたファイルをJavaScriptから呼び出す
むかし、HTA(HTML Application)という💩のようなアプリがありました。
new ActiveXObject("WScript.Shell")
にエクスキュート的なことをすることでいろんなアプリを立ち上げられたりできました。
今となってはセキュリティー面でよろしくはないが、仕事ツールの自動化としてはまだ使いどころはあるでしょう。普通じゃできない事をやれるのが.NET + WebView2のよいところ。
真っ先にセキュリティー面を考慮し、安全を最優先にしましょう。
HTAのような考え方がありますが、最新のWindowsでHTAやActiveXが無効化されている理由をよく考えてください。
コードは簡単に実行できないよう端折っていますので各自工夫して補完する。
この件に関して一切の問い合わせされても対応しません。自己責任で。
専用クラスを作ってCoreWebView2.AddHostObjectToScript
に投げる
制約はあるが、C#側で作ったクラスをページ内のJavaScriptで実行することができる。そのために専用クラスを作る。
// 実行を許可する拡張子
private static readonly string[] s_allowExtensions = new string[7] { "txt", "htm", "html", "doc", "docx", "xls", "xlsx" };
private static Process s_currentProc;
public void FileExecute(string? filename)
{
// nullと空白を許さない
if (String.IsNullOrEmpty(filename))
{
return;
}
// フルパスではない or 拡張子なしを許さない or ファイルが存在しないことを許さない
if (Path.IsPathFullyQualified(filename) == false || Path.HasExtension(filename) == false || File.Exists(filename) == false)
{
return;
}
string ext = Path.GetExtension(filename);
// filenameが許可される拡張子を持ち合わせているか?
foreach (string allow in s_allowExtensions)
{
if (ext.EndsWith("." + allow, StringComparison.OrdinalIgnoreCase))
{
try
{
if (s_currentProc != null && s_currentProc.HasExited == false)
{
break;// 連続実行の抑止により、前回のプロセスが起動中なら実行しない
}
s_currentProc = Process.Start("cmd.exe", "/K \"" + filename + "\"");// 実行
}
catch (Exception e)
{
Debug.WriteLine($"Crash!! ({e.Message})");
}
break;
}
}
}
作り終えたらCoreWebView2.AddHostObjectToScript("funcs", new ???())
に投げる。第一関数のfuncs
はJavaScript側で関数群を受け取るオブジェクト名になる。インスタンスの生成は必要ないが、全ての関数群はAsync/Awaitになる事に留意。第二関数は先ほど作った専用クラスのインスタンス生成してやるだけ。
ページ内のJavaScriptから実際に呼び出す
ローカルに適当なhtmlファイルを用意する。
JavaScriptからWebView2のホストオブジェクトを呼び出し、続けて上記でCoreWebView2.AddHostObjectToScript
に登録したオブジェクト名、専用クラスから呼び出すメソッドを指定する。完全修飾名で書くとchrome.webview.hostObjects.funcs.FileExecute
になる。
FileExecute
の引数は実行を許されている拡張子かつフルパスでなければならない(FileExecute("D:/適当なパス/内緒.txt")
このように)。
うまくいけば関連づけられたテキストエディタが相当ファイルを開いて起動してくれる。失敗してもスンとも反応しないので色々苦労することになるだろう。
このまま実装してはならない
実行できる拡張子を制限したり、フルパスでなければ起動しなかったり、連続実行を抑止するために… と、コードを見てもらえればいろいろな対策をとっていますがこれでも甘いでしょう。
なのでこのまま使ってはならない。
参考
最後
QiitaでWebView2に関する記事は少なく、なんか回りくどい感じがあったので自分で調べることにしました。
というより公式に載ってない情報がちらほらあるような…。
(2021年8月8日追記)
今までおまけ程度だった「適当な応用集」を気が向いたら追記していくスタイルでしたが区切りを付けたいのでいったん終了とします。読んで頂きありがとうございました。
(2022年9月28日追記)
1年ぶりに誤字とスクリプトの誤り修正、画像追加と、謎の項目も追加しました。