Narazaka::Blog

奈良阪という人のなにか

Blazor+Electron.NETでクロスプラットフォームGUIを作った時のメモ

Blazor Advent Calendar 2019 の記事です。

今年VRChat Advent Calendarにしか参加してなかったのでソフトウェアっぽいやつも書いてみたくなったやつ。

WinForms(Mono)は衰退しました

最近MacがCatalinaになって32bit対応を切りましたよね。これにより、MonoのWinFormsでだましだまし動かしていた拙作OSSGUIが死亡しました。

f:id:narazaka:20191224003049p:plain
Macはクソ

やや試行錯誤したんですがMonoがそもそもWinFormsにやる気がなく64bit対応は死んでいる臭いので、Windows Formsワンソースでのクロスプラットフォーム対応はあえなく死亡しました。

今最高に面妖なGUIフレームワーク

というわけでせっかくだから.NET 4.6だったのを.NET Core 3.1にしたうえでLinuxMacで動くC#GUIライブラリを探してみたんですが、AvaloniaもEto Formsもドキュメントスカスカでハマったし、Xamarin FormsはLinux/Mac対応公式でなくてVisual Studioでやるの面倒そう……。

WebエンジニアなのでこれはElectronでCLIを叩いた方がましかなと思ったところで出会ったのがElectron.NET。なんでもASP.NETサーバーをローカルで立ち上げ、ページをElectronでレンダリングして無理矢理GUIにするという面妖な技術とのこと。

さらにSPAできたらな……と思ったところで行き当たったのがBlazor。こちらもASP.NETサーバーがレンダリングした結果をフロントHTMLにシームレスに反映することでサーバーサイドC#でSPAができるという面妖な技術とのこと。(wasmにしてクライアント動作というオプションもあるらしいですが、プレビューとのことだったのでいいかげんハマりたくない気持ちから今回は回避しました。)

ブラウザフロントエンドのビューにC#コードを書いてラップし、それをWebsocketで無理矢理通信してサーバーレンダリングSPAする黒魔術Blazorと、ASP.NETサーバーを一般ユーザーPCで動かしてかつChromeバイナリも.NET Coreランタイムもドーンと配る富豪の極みのようなElectron.NETの夢の共演。

そういう魔術的体感大好きなので、最高の面妖開発体験が得られると期待して開発して、……結果的にちょくちょくハマりつつも案外すんなりGUIができました。

これのseedtable-eguiというプロジェクトがそれです。

github.com

seedtableライブラリ(CLIとしても動く)を参照して、そこのインターフェースを叩くGUIフロントエンド的なアプリです。 Windows Forms製のseedtable-guiから設定画面を省いた感じの機能体系ですね。

f:id:narazaka:20191224030348p:plain
ElectronとASP.NETで動いている面妖な産物
f:id:narazaka:20191224030600p:plain
Macでも動くよ

Blazor+Electron.NETでクロスプラットフォームGUIを作った時のメモ

というわけで、Blazor+Electron.NETのアプリを作れたんですが、技術の見た目がヤバいせいか少なくとも日本語資料では「dotnet newしてElectron.NETの最低限コード書いて立ち上げてみたよ!」という感じの記事しかありませんでした。

……いや、そもそもElectron.NET単体についてのやつも少ないですね……。みんな面妖技術は嫌いなのかも……。

なので実際にBlazor+Electron.NETで小規模なクロスプラットフォームWindows/Linux/MacGUIアプリケーションを組んだときにぶち当たった事についていくつか書きます。

なお導入自体は以下の記事が示している通りです。意外にもとっても簡単お手軽。

qiita.com

ちなみにASP.NETがどう動かされているのかは分からないんですが、とくにファイヤーウォールの許可などは必要ありませんでした。 地味な懸念がなく普通のアプリと同じように配布できるのはうれしいですね。

Electronのファイルダイアログなどを使いたい

Electron.NETとBlazorの組み合わせで最も使うと思われる箇所ですね。

JavaScriptとのインターフェースとしてIJSRuntimeという物が用意されており、これを使います。

docs.microsoft.com

C#からJSの関数を呼ぶ時には、まずrazorファイルでIJSRuntimeをDIで注入し、

@inject IJSRuntime JSRuntime;

InvokeAsync()を呼べば良い模様。

JSRuntime.InvokeAsync<OpenDialogResult>("showOpenDialog", option);

オプションについてはこのようにclassを定義しておくと良い感じにJSのオブジェクトと相互変換してくれます。

using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;

namespace seedtable_egui.Data.Electron {
    [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
    public class OpenDialogOption {
        public string Title { get; set; }
        public string DefaultPath { get; set; }
        public string ButtonLabel { get; set; }
        public IEnumerable<FileFilter> Filters { get; set; }
        public IEnumerable<string> Properties { get; set; }
    }

    [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
    public class OpenDialogResult {
        public bool Canceled { get; set; }
        public IEnumerable<string> FilePaths { get; set; }
    }
}

よく使う関数については拡張メソッドにシテオクと便利だと思います。

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.JSInterop;

namespace seedtable_egui.Data.Electron {
    public static class JSRuntimeExtensions {
        public static ValueTask<OpenDialogResult> ShowOpenDialog(this IJSRuntime js, OpenDialogOption option) {
            return js.InvokeAsync<OpenDialogResult>("showOpenDialog", option);
        }

        public static ValueTask<SaveDialogResult> ShowSaveDialog(this IJSRuntime js, SaveDialogOption option) {
            return js.InvokeAsync<SaveDialogResult>("showSaveDialog", option);
        }

        public static ValueTask ShowErrorBox(this IJSRuntime js, string content = null, string title = "エラー") {
            return js.InvokeVoidAsync("showErrorBox", title, content);
        }
    }
}

こうすれば

@code {
    async void OpenSeedPath() {
        var result = await JSRuntime.ShowOpenDialog(new OpenDialogOption {
            Title = "seedフォルダを開く",
            ButtonLabel = "開く",
            DefaultPath = SeedPath,
            Properties = new string[] { "openDirectory" },
        });
        if (!result.Canceled) {
            SeedPath = result.FilePaths.FirstOrDefault();
            StateHasChanged(); // JSRuntimeを使った際にステートが変わる場合はこれを叩かないとビューが更新されないです(ハマりどころ)
        }
    }
}

という感じでJS意識せず気軽に呼べます。

_Host.cshtmlに呼ばれるJS側関数を書いておきます。

    <script>
        const { BrowserWindow, dialog } = require('electron').remote;
        function showOpenDialog(options) { // 「開く」ダイアログを呼ぶ
            return dialog.showOpenDialog(win(), defaultNull(options));
        }
        function showErrorBox(title, content) { // エラーダイアログを呼ぶ
            return dialog.showErrorBox(title || undefined, content || undefined);
        }
        function win() { // ダイアログに親ウインドウが必要なのでJS側でハンドリングする
            return BrowserWindow.getFocusedWindow();
        }
        function defaultNull(obj) { // C#側から渡ってくるオプションは存在しないプロパティにnullが入っているが、electronのAPIはキー無し(undefined)を期待しているのでエラーを吐く場合があります。それの回避。
            const newObj = { ...obj };
            for (const key of Object.keys(newObj)) {
                if (newObj[key] === null) delete newObj[key];
            }
            return newObj;
        }
    </script>

かなりシームレスにElectronと連携できます。

Macでアプリケーションが終了しない問題

クロスプラットフォームGUIを作るとき頻発する「Macで閉じるボタン押してもアプリケーション終了しない問題」。

閉じるボタン押したのにアプリケーションが残っている、かつそのアプリのアイコン押しても何も反応がない……という悲しい状態になってしまいます。

本家Electron的にはメインプロセス側でこの問題に対処するのですが、Electron.NETにはどうも正規っぽい対処法がなかったです。

Applicationのcloseをとる系の対処が全滅したので、Electronのウインドウが閉じるJavaScript側のイベントを拾うことにしました。

上記IJSRuntimeとは逆向きにJSからC#を呼ぶ方法としては、DotNet.invokeMethodAsync("アセンブリ名", "静的メソッド名");と言う方法があります。

いきなりアセンブリ名が出てくるあたり無理矢理感のあるAPIですが、とりあえずDotNetという定数がJSに自動で注入されており、そこから[JSInvoke]属性のついたpublicな静的メソッドを呼べるとのこと。

_Host.cshtmlに

    <script>
        const { BrowserWindow, dialog } = require('electron').remote;
        function win() {
            return BrowserWindow.getFocusedWindow();
        }
        win().on('close', function(e) { // Electronのウインドウが閉じるとき
            DotNet.invokeMethodAsync("seedtable-egui", "OnClose"); // C#側のseedtable-eguiアセンブリ内の静的メソッドOnCloseを呼ぶ
        });
    </script>

Index.razorに

@code {
    [JSInvokable]
    public static void OnClose() {
        ElectronNET.API.Electron.App.Quit();
    }
}

と書いて事なきを得ました。(たぶんOnCloseはビューではなく別の普通の静的クラスに書くのが本来だと思いますが、JSInvoke属性が定義されている箇所を見つけるのが面倒だったので……)

razorデバッグできない問題

electronize startで単純に立ち上げるとVisual Studioデバッグできないっぽいです。(アタッチの設定書いていないので当たり前ですが……)

正直今回は小さいアプリ&既存の移植品だったためそんなに必要なく、そのままカンとprintfで済ませてしまいました。

ここ何か方法あるようなら誰か知見を書いてください。

「アプリケーションのフォルダ」がresources/binになるかも

ユーザーに直接見えるのはElectronを立ち上げるexeですが、Blazorが動いているのはASP.NET Coreのサーバー側です。

.NET Coreの1ファイルexeパッケージングなどに備えたPath.GetDirectoryName(System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName);等の手法では、サーバーDLLがあるresources/binになってしまう模様。

Application.StartupPathだとメインのexeパスになったり……するかな?(試していないです)

パッケージングの問題

electronize buildにまつわる問題

WindowsではMac OS X用ビルドができない

できないらしいです。これは回避不能っぽいのでMacを買うか、TravisMacビルドに投げましょう。

Linuxビルドだとサーバーしか吐かれない?

なぜかelectronize build /target linuxしてもresources配下のサーバー側しか吐かれず、electronのエントリポイントが吐かれないという現象に見舞われました。

electronizeから叩かれた時のelectron-builderだけなぜかそういう風に動いている模様だったので、

cd seedtable-egui
electronize build /target linux
cd obj/desktop/linux
npm install electron@7.1.2
npx electron-builder . --config=./bin/electron-builder.json --linux --x64 -c.electronVersion=7.1.2

と言う具合に、electronizeでサーバのpublishしてから、その中間フォルダで改めてelectron-builderを実行することでパッケージすることとしました。

原因はよく分からないのですが、応急処置は可能です。

パッケージングにめちゃくちゃ時間がかかる問題

デフォルトの設定だとWindowsインストーラー、LinuxがAppImage、Macdmgなどとなっているんですが、これがとにかく遅い。

圧縮あたりをJSでやってたりするのかもしれません。インストーラーなどが必須でないならば、ここは"dir"で出してzipコマンド等で固めるのが速くて良いです。

electron.manifest.jsonの"build"がそのままelectron-builderに渡されるオプションのJSONになるので書いてゆきましょう。

{
  "executable": "seedtable-egui",
  "splashscreen": {
    "imageFile": ""
  },
  "singleInstance": true,
  "build": {
    "appId": "net.narazaka.seedtable-egui.app",
    "productName": "seedtable-egui",
    "copyright": "Copyright © 2019 Narazaka",
    "buildVersion": "4.0.0-rc4",
    "compression": "maximum",
    "directories": {
      "output": "../../../bin/Desktop"
    },
    "extraResources": [
      {
        "from": "./bin",
        "to": "bin",
        "filter": ["**/*"]
      }
    ],
    "files": [
      {
        "from": "./ElectronHostHook/node_modules",
        "to": "ElectronHostHook/node_modules",
        "filter": ["**/*"]
      },
      "**/*"
    ],
    "win": {
      "target": "dir"
    },
    "linux": {
      "target": "dir"
    },
    "mac": {
      "target": "dir"
    }
  }
}

注意: electronize initなどのコマンドはプロジェクトのフォルダで実行すること

地味ですが、VisualStudioで作ったソリューションの中にElectron.NET環境を作る場合、electronizeコマンドはソリューションではなくプロジェクトのフォルダで実行してください。

でないとstartやらbuildやらがエラります。

まとめ

ちょくちょく問題はありましたが、正直ほとんどElectron.NETだけの問題で、特にBlazorは流石Microsoft謹製だけあってかなり隙のない作りで、正直予想外に安定感が感じられました。

Electron.NETはそういう意味でやや荒削りな所がありましたが、こちらもまあまあ回避可能です。

正直もっと手こずるかなと思っていたところ案外スムーズにいってしまったので、面妖技術に抵抗ない方には結構お勧めかもしれません。 見知らぬフレームワーク固有のXAMLタグいちいち使い方調べて書くより見知ったHTMLでバインディングできるのやっぱり良い。

github.com

ソースサンプルとしては、この中のseedtable-eguiプロジェクトが

  • サーバーサイドBlazor
  • Electron.NET
  • 他プロジェクト参照
  • Windows/Linux/MacのCIビルド

を含んだものなので、ざっくり参考になるかもしれません。

Blazor+Electron.NET、案外イケるよ!やっていこう!(雑

ついでに宣伝

github.com

seedtableは二次元データを格納したxlsxファイルとYAMLファイルの相互変換ツールです。 データはYAMLでgitフレンドリーにしつつ、その編集はExcelで快適にしたいというユースケースに使えます。

スマートフォンゲームの運用の辛みを救うために作ったツールで導入実績もあるので、興味があればどうぞ。

またBlazor+Electron.NETほどではないかもしれませんが、seedtableにはMotif*1の.NETバインディングWindows Forms仕立て)という面妖技術によるGUIもあります(漁港などに出没するらしいsazae657さんが作ったものです)。

github.com

Blazorのような心がわくわくする謎技術に興味がある方はこちらも触ってみても面白いかもしれません!(雑

*1:1989年から続くX Window System用のGUI規格およびウィジェットツールキット(GTKみたいなもの)