🦕

Denoでデスクトップアプリを作る

2022/12/05に公開

で…できらぁ!!!

Deno Advent Calendar 5日目です。簡単なデスクトップペイントツールをDenoで作りました。

Denoは基本CUIツールやWebサービスを作るものと考えて差し支えないです。普通GUIはつくらないですね。その点はNode.jsだって同じなんですけど、とは言えみんなElectronアプリは使ってますよね。VSCode, Slackアプリ, Discordアプリ, Docker Desktopなどなど…。Electronアプリには個人的には思うところが山ほどありますがここでは省略します。まあとにかく、Denoでも画面を作りたい人はいます!ここに!

DenoでもElectronと同様にwebviewを使えるライブラリがあります。
https://github.com/webview/webview_deno

使い方

使い方は簡単で、このスクリプトを deno run --unstable -A main.ts で実行するだけです。

import { Webview } from "https://deno.land/x/webview/mod.ts";

const html = `
  <html>
  <body>
    <h1>Hello from deno v${Deno.version.deno}</h1>
  </body>
  </html>
`;

const webview = new Webview();

webview.navigate(`data:text/html,${encodeURIComponent(html)}`);
webview.run();

するとこうなります。

単純にwebviewを一個作ってそこにdataURLでHTMLを流し込んでいるだけですね。

webview.navigateに外部URLを指定して、Webアプリをデスクトップアプリ化するのもいいですね。まあWebサイトの乗っ取りなども考えると危険はあるのと、ネットワークがつながらないところで使えないとか、まあ気になる人は気になるかと思います。今回作るものは一応ローカル完結にしています。

パーミッションについて

Denoの利点であるセキュリティサンドボックスですが、webview_denoを実行するにはパーミッションが色々必要になります。

Windowsの場合はこんな感じです。

  • --allow-env
    • PLUGIN_URL, DENO_DIR, LOCALAPPDATA(おそらくWindowsの場合)
  • --allow-write
    • 実行時、ソースディレクトリにWebView2Loader.dllというDLLファイルが書き込まれます。また、終了時に削除されます。
  • --allow-read
    • WebView2Loader.dllに対するファイルアクセスが必要になります。
    • Windowsの場合\Users\username\AppData\Local\deno\plug\ へのアクセスを行います。これは denosaurs/plugというヘルパーライブラリがDLLをダウンロードしてくる場所です。
  • --allow-ffi
    • webview-denoのDLLへのffiアクセスに必要になります。
  • --unstable
    • Deno.dlopenというDLLを読み込む関数がまだUnstableです。

こんなに!?と思うかもしれませんが、Node.jsを始め多くの処理系ではこの辺は基本ノーガードです。Denoの場合ネットワークアクセスをブロックできていますので怪しいサーバにファイルや環境変数を送信される可能性は低減されています。

私はdeno run -Aをしがちですが、各自の危機感に応じてフラグを設定してください。フラグを含めた実行コマンドはtaskに書いてます(後述)。

Viteでビューを作る

DenoはViteが動いてVueでもReactでもNode.jsなしでプレビューとかビルドとかができてしまいます。すごいですね。Vite + Denoのサンプル集は下記にまとまっています。

https://github.com/bluwy/create-vite-extra

今回はペイントツールなので、今はノーフレームワークでもいいかな…。canvas使うだけなのでね。

普通にcanvasを全画面表示にします。

index.html

<html>
  <body>
    <canvas id="canv" width="600" height="600"></canvas>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

canvasにイベントハンドラを紐づけて線を書けるようにします。
getContextで desynchronized: trueしているのとか、canvas勢特有の技が色々ありますが、説明はしません。背中を見て覚えるのがcanvas職人の世界なのです。

main.js

import "./main.css";

const canv = document.querySelector("#canv");
const ctx = canv.getContext("2d", {
  desynchronized: true,
});
ctx.strokeStyle = "rgba(0, 0, 0, 0.5)";
ctx.lineWidth = 3;

let drag = null;

canv.addEventListener("pointerdown", (e) => {
  canv.setPointerCapture(e.pointerId);

  const x = e.offsetX;
  const y = e.offsetY;
  drag = { x, y };
});

canv.addEventListener("pointermove", (e) => {
  e.preventDefault();
  if (drag) {
    const x = e.offsetX;
    const y = e.offsetY;
    ctx.beginPath();
    ctx.moveTo(drag.x, drag.y);
    ctx.lineTo(x, y);
    ctx.stroke();
    drag = { x, y };
  }
});

canv.addEventListener("pointerup", (e) => {
  canv.releasePointerCapture(e.pointerId);
  drag = null;
});
canv.addEventListener("touchmove", (e) => {
  e.preventDefault();
});

canv.addEventListener("pointercancel", (e) => {
  drag = null;
});

スクロールバーが出ないようになんのかんのします。

main.css

body {
  background-color: #333;
  margin: 0;
}
canvas {
  background: white;
  touch-action: manipulation;
}

以下はwebviewを作るところです(Deno実行部)。

main.ts

import { SizeHint, Webview } from "https://deno.land/x/webview/mod.ts";

const html = Deno.readTextFileSync("dist/index.html");
const webview = new Webview(true, {
  width: 600,
  height: 600,
  hint: SizeHint.FIXED,
});

webview.title = "Paper";
webview.navigate(`data:text/html,${encodeURIComponent(html)}`);
webview.run();

ざっとこんなもんですね。

webview.navigateにdataURLを流し込む方式で面倒なのが、すべてのリソースをまとめた一枚のHTMLを作る必要があるというところです。これをやってくれる vite-plugin-singlefile というプラグインがあったのでこれを使います。

vite.config.mjs

import { defineConfig } from "npm:vite";
import { viteSingleFile } from "npm:vite-plugin-singlefile";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [viteSingleFile()],
});

フレームワークは使っていないものの、このあたりはVanillaJSでもViteを使う動機になりそうです。

保存機能を作る

さて問題の保存機能です。WebブラウザではFile System Access APIの利用を検討するところです。ただしこの利用感というのがややめんどくさくダイアログをいちいち開いてディレクトリを指定したりしなくてはいけません。私は1bitpaperの愛用者なので、同じように起動したらファイルの読み書きができてほしいです。

というわけでDeno側に保存させましょう。webview_denoのbindという機能を使います。

例:


import { Webview } from "../mod.ts";

const html = `
  <html>
  <body>
    <h1>Hello from deno v${Deno.version.deno}</h1>
    <button onclick="press('I was pressed!', 123, new Date()).then(log);">
      Press me!
    </button>
  </body>
  </html>
`;

const webview = new Webview();

webview.navigate(`data:text/html,${encodeURIComponent(html)}`);

let counter = 0;
webview.bind("press", (a, b, c) => {
  console.log(a, b, c);

  return { times: counter++ };
});

webview.bind("log", (...args) => console.log(...args));

webview.run();

これは公式サンプルコードですが、webview.bindでブラウザにpressという関数を追加することができます。ブラウザ側からは普通にそれを呼べばOKです。簡単ですね。

簡単なんですが、二点気をつけるところがあります。

  1. 引数で色々渡せそうですが、制約がありそうでした。Dateとかはサンプルで渡せていますが、試した限りUint8ArrayはNGでした。画像を保存する場合はbase64エンコードなどで文字列に直したものを渡す必要があります。

  2. bindにはasync functionは渡せません。普通にfunctionで書いて、Deno.writeFileSyncなどの同期系のAPIを使っています。V8のイベントループの制約とのことですが、ここは正直私はよく理解できていないので識者は下記を参照してください。
    https://github.com/webview/webview_deno/issues/131

で、保存機能ですがこういうふうにしました。

function url2buf(url: string) {
  const base64 = url.split(",")[1];
  const bytes = atob(base64);
  const buffer = new Uint8Array(bytes.length);
  for (let i = 0; i < bytes.length; i++) {
    buffer[i] = bytes.charCodeAt(i);
  }
  return buffer;
}

// webview.run has taken ahold of it only allowing sync callbacks
// https://github.com/webview/webview_deno/issues/131
webview.bind("save", (data: string) => {
  // pic_yyyy-mm-dd-hh-mm-ss.png
  const filename = "pic_" + new Date().toISOString().replace(/:/g, "-") +
    ".png";
  Deno.writeFileSync(filename, url2buf(data));
  return { ok: true, filename };
});

webview.bind("saveRecent", (data: string) => {
  Deno.writeFileSync("output.png", url2buf(data));
  return { ok: true, filename: "output.png" };
});

webview.bind("loadRecent", () => {
  const data = Deno.readFileSync("output.png");
  const base64 = btoa(String.fromCharCode(...data));
  return { data: `data:image/png;base64,${base64}` };
});

これでsaveにbase64化した画像を渡せば日付を付けてPNGで保存してくれます。UIからはエンターキーを押すことで保存してcanvasをクリアするようにしました。
フルのソースコードは下記に置いておきます。

https://github.com/hashrock/deno-paper-app

exe化

exe化してみましょう。
下記のdeno.jsonを作って、deno task compileを実行します。

{
  "tasks": {
    "start": "deno task build && deno run --unstable --allow-env --allow-read --allow-write --allow-ffi main.ts",
    "dev": "deno run -A --unstable --node-modules-dir npm:vite",
    "build": "deno run -A --unstable --node-modules-dir npm:vite build",
    "compile": "deno compile --unstable --allow-env --allow-read --allow-write --allow-ffi --output paper main.ts"
  }
}

できましたね!exeは70MBとちょっと大きくなってしまいましたが、これはOSやdenoのバージョンにより変わってくると思います(以前はもうちょっと小さかった気もします)。
Windowsではexeから直接起動するとターミナルが出てしまうのでそこはいずれ直したいですね。

ちょっとしくじったのは、起動にdist/index.htmlが必要になることです。deno compileでまとめてくれるのはimportで参照しているファイルまでなので、当然index.htmlは読み込まれません。結局リモートのWebサイトを読むようにしたほうがシンプルになるのかもしれません(セキュリティ上の懸念はありますが)。

webview_denoとElectronの違い

webview_denoとElectronの内部的に大きく違うところにも言及しておきます。

ElectronにはNode.jsとChromiumの特定バージョンが含まれています。バイナリサイズが巨大(Win x64版で約150mb)になるのはそれが理由です。Node.jsがメインプロセス、Chromiumはレンダープロセスとして分離していて、その二者がIPCによって連携しています。

webview_denowebviewというライブラリに依存しています。レンダラーとしては下記のOS側のAPIが利用されます。

Platform Technologies
Linux GTK 3, WebKitGTK
macOS Cocoa, WebKit
Windows Windows API, WebView2

この辺はOS側のWebViewを使っていたいにしえのPhoneGapと構図は同じ…というと、歴戦の受託プログラマはトラウマが蘇るかもしれませんが、Trident(IEのエンジン)やiOS, Androidの謎に古いシステム側のWebviewは(まともに更新されている環境では)もうありませんので落ち着いてください。Windows側もWin10以降ではWebView2が使われますので、ほぼほぼblink系の挙動を想定して良いと思います。

一番怖いのはmacOS Safariでのレンダリングですが、そのあたりはWeb開発者なら普通に普段動作確認するようなところなのでまあ大丈夫なんじゃないでしょうか。

Electronではバージョン固定したChromiumをバンドルしているので、レンダラの動作に関してはかなり安心できるところはあります。ただしWebView2を使うことで完全なサンドボックスが得られたり、バイナリサイズも比較的小さくなります。Deno本体が結構サイズがあり、WebView2もそれなりにメモリは食うので劇的な差とまでは言えませんが、Electronの常駐アプリがどんどん増えている昨今、少しでも省リソースな選択肢を持つのも良いかと思います。

その他よもやま

いくつか他にもDenoでデスクトップアプリを作るのに使えそうな有望なライブラリがあります。

littledivy/deno_sdl2

主にゲームなどに使われるシンプルな描画ライブラリSDLのバインディングです。SDLそのものの準備が少し大変ですが、UIなども自前描画する覚悟があれば何でも作れると思います。そしてあなたにはその覚悟があります、そうですね?

justjavac/deno_win32

Windowsに限ってよければWin32 APIを直接叩くことで画面を作れそうです。そしてその覚悟もまた、あなたに備わっています。
システムトレイ常駐型ソフトとかこれで作れないかなと思っています。

deno-windowing

まだ手元での動作を確認できていませんが、WebGLやVulkanなどを直接描画に使う目論見のプロジェクトのようです。おそらくゲーム向き。絶賛開発中です。

WSLの存在もあり最近のJSランタイムはWindows対応を捨てることが多くなっていますが、DenoにはちゃんとWindowsバイナリもありますし、compileでもWindows向けのexeが作れます。どんな環境でもGUIを作りたくなってしまう人にとっては外せないポイントだったりします。

これらのDeno向けネイティブライブラリを書いている人々は見た感じかなり若い方が多いですね。まとめてdenosaursというチームに所属しているみたいなので私は少額ですが寄付しています。この記事を見たあなたも寄付したくなってきたかもしれませんね…😋

astrodon

Deno向けに書かれたTauriラッパーのようですが、現在作者からメンテナンス中断のアナウンスがされています。

Gluon

Node.js向けに作られたwebviewライブラリのようですが、DenoとBunをサポートしているとのことです。

Deno本体にWebView対応を導入するProposal

https://github.com/denoland/deno/discussions/18042

本体からWebViewを直接使う提案がされています。現時点でのDeno開発はバイナリサイズや起動時間削減が重視されている(最近WebGPUが削除された理由)ので現時点では望み薄かと思いますが、今後の方針転換によっては望みが出てくるかと思います。

Discussion