LoginSignup
14

More than 1 year has passed since last update.

WPFでWinFormsやXAMLデザインに頼らず、WebView2を使いたい!

Last updated at Posted at 2021-06-27

私です。お母様とこれを観ている方々、お元気ですか。
ふと「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のコアなコードを書いていきます。

WebView2Controller.cs
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.xamlMainWindowウインドウを実装していく。

MainWindow.xaml
<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.csMainWindowを制御するコードを実装する。

MainWindow.xaml.cs
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要素を追加する事で、ウインドウサイズを自動で合わせてくる。
わざわざイベント作ってリサイズさせるコードを省くことはできる。

これで最低限の実装は完了したので、デバッグを走らせると意図したデバッグメッセージがコンソールに出力されている。

2022-09-27_001.png

そしてブラウザ画面、Googleが表示されるウインドウが立ち上がってくる。

2022-09-27_002.png

最初は動かないCoreWebView2を使えるようにする

CoreWebView2は名前の通り、WebView2のコアに相当するAPI。
しかしそのまま使うことはできない。

備考によるとEdgeブラウザーの起動は重く、時間を要する。
なのでWebView2インスタンス生成直後のWebView2.CoreWebView2nullを返すから、そのままでは使えない。

WebView2.CoreWebView2を介す処理は、初期化完了まで待つ。そうすることでnullに成らない。待ってあげないと「Microsoft.Web.WebView2.Wpf.WebView2.CoreWebView2.get が null を返しました」と怒られるからだ。

初期化完了を待つには、ふたつの方法がある。

名前 CoreWebView2InitializationCompleted EnsureCoreWebView2Async
種類 イベント 非同期メソッド
実行順位2 前(登録順)
個人的な使い分け 一度だけの処理、イベント登録 繰り返して使う処理

それぞれの使い方は次項にて。

イベントCoreWebView2InitializationCompleted

一つの方法としてCoreWebView2InitializationCompletedイベントを使う。
名前の通り、CoreWebView2が初期化完了後に諸々を行うイベント。

試しに専用クラスのWebView2Controllerに変更を加えて、どうるなかを確認する。

まずは初期化完了を待たずにWebView2.CoreWebView2.Settings.AreDevToolsEnabledを指定する。
falseを渡すことで開発者ツールを無効化3するが、もちろん例外が発生して動くことは無い。

WebView2Controller.cs
// WebView2.CoreWebView2がnullでBADな例
         public WebView2Controller()
         {
+            this.webView2.CoreWebView2.Settings.AreDevToolsEnabled = false;
             this.webView2.CoreWebView2InitializationCompleted += this.CoreWebView2Initialization;
         }

次にイベントCoreWebView2InitializationCompletedを使う。イベントとして登録したメソッドにさっきの追記コードを移植する。

WebView2Controller.cs
         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");
         }

2022-09-27_003.png

今度はコンテキストメニューから開発者ツールが消えて成功3

非同期メソッドEnsureCoreWebView2Async

まず先に専用クラスWebView2ControllerNavigateメソッドの事について。

WebView2ControllerNavigateメソッドはWebView2.CoreWebView2.Navigateのラッパーで、ブラウザにURLを渡してそのアドレスに遷移してもらうものです。
もう先ほどのでお気づきかと思いますが、これもCoreWebView2を介すので初期化完了を待たなければ成りません。

そこでもう一つの方法としてWebView2.EnsureCoreWebView2Asyncを使っています。

CoreWebView2の初期化が完了すると次のコードを実行します。
非同期なのでAsync/Awaitが使われています。

WebView2ControllerNavigateはいつどんなときに呼び出されても安全に動作するよう、このような方法が採られてます。

適当な応用集

応用自体は探せばまあまああるので適当。

警告
WebView2Controllerを改造して使うので、上記を読み飛ばすと分からなくなります

設定で色々無効化する

xamlのプロパティでは変更できない部分。

WebView2Controller.cs
        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"、ユーザー操作による新しいウインドウを開かせないようにする。

WebView2Controller.cs
        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で停止させる手法です。
しかしダミーインスタンスは残り続ける為、リソースの消費も大きく無駄です。

新しいウインドウは抑止したいが、ページは遷移させたいのであれば

WebView2Controller.cs
        private void NewWindowDeter(object sender, CoreWebView2NewWindowRequestedEventArgs e)
        {
            e.NewWindow ??= this.webView2.CoreWebView2;
        }

e.NewWindowに既存のCoreWebView2を代入する処理に置き換える。あるいは

WebView2Controller.cs
        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側:

WebView2Controller.cs
         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側:

MainWindow.xaml.cs
// ~~中略~~
             this.AddChild(this.dockPanel);

+            this.webView2Controller.DocumentTitleChanged += (object sender, string e) => this.Title = $"{e} - ぷらうざ";

ページのタイトルが書き換わる度にタイトルも変わっているはず。オーバーフローを避ける為に文字数制限付けた方が良いので、各自工夫して欲しい。

一行で書く為に、無名関数をラムダ式で書いてるが、色々問題あるので、よいこのみんなは、名前のある関数で書くと良いです。

参考

ユーザーデータフォルダの指定

要はキャッシュなどの保存先。

一応公式ページのコンセプトに「ユーザーデータフォルダ」の事について書かれてるが、無理矢理翻訳のせいか私の解読力が無いのかよく分からん。WebView2リファレンスの方が詳しいかもしれない。
詳しいリファレンスによるとユーザーデータフォルダの指定方法は二つ。
個人的にコードがまとまる方を選びたいので本項ではCoreWebView2CreationPropertiesクラスを使う事とする。他はWebView2リファレンスを参照して欲しい。

WebView2Controller.cs
         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.CreationPropertiesCoreWebView2CreationPropertiesをセットする。

例の場合だと、\Users\<ユーザー名>\AppData\Local\Tempの中にwv2フォルダが作成され、CoreWebView2の初期化後、その中に色々ぶち込まれる。

注意点として

  • CreationPropertiesへの追加は、CoreWebView2の初期化が始まる前(インスタンス生成直後など)なら挿入箇所はどの行でも良いです。
  • 私が挿入位置を明確的にしたいが為に、関連の無いイベントInitializedを使っているだけです(FrameworkElementが初期化されたタイミングで発生します)。
  • CreateDirectoryしなくてもWebView2側がフォルダを作成しますが、パスチェックなどをこちらでやりたいのでこのようになってます。

選択されたテキストの抽出

マウスカーソルで選択したテキストを抽出する。
ドキュメントに介入するAPIはほとんど無く、基本JavaScriptを駆使することに成る。

WebView2Controller.cs
         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イベントで工夫している。というよりそれ以外の最適解が見つからなかったのだ。

参考

禁断?関連づけられたファイルをJavaScriptから呼び出す

むかし、HTA(HTML Application)という💩のようなアプリがありました。
new ActiveXObject("WScript.Shell")にエクスキュート的なことをすることでいろんなアプリを立ち上げられたりできました。
今となってはセキュリティー面でよろしくはないが、仕事ツールの自動化としてはまだ使いどころはあるでしょう。普通じゃできない事をやれるのが.NET + WebView2のよいところ。

真っ先にセキュリティー面を考慮し、安全を最優先にしましょう。
HTAのような考え方がありますが、最新のWindowsでHTAやActiveXが無効化されている理由をよく考えてください

コードは簡単に実行できないよう端折っていますので各自工夫して補完する。
この件に関して一切の問い合わせされても対応しません。自己責任で。

専用クラスを作ってCoreWebView2.AddHostObjectToScriptに投げる

制約はあるが、C#側で作ったクラスをページ内のJavaScriptで実行することができる。そのために専用クラスを作る。

???.cs
    // 実行を許可する拡張子
    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年ぶりに誤字とスクリプトの誤り修正、画像追加と、謎の項目も追加しました。

  1. 個人的に*.xaml.csファイルに色々詰めるのが好きでは無い事と、まあまあのソース量になるので麺類にはしたくありません。

  2. リファレンス自体に「実行順位」とは書かれてない(利便上そうのように記載しているだけ)。リターン章にて、その旨が記載されているのでそちら参照して下さい。また留意点として欲しい。

  3. コンテキストメニュー及びキーボードショートカットからも開発者ツールの起動が無効化される。 2

  4. ビルドの際に最適化され、使われてない変数はどのみち削除される。

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
14