Webの技術でローカルアプリが作れるElectron

js_ts

Webの技術でローカルアプリが作れるElectron

NW.jsに続き、Electronも触ってみる。結論から言うと、こっちの方が情報量が多いという点で良い気がしている。機能的にはコンテキストの分離がNW.jsよりも カッチリ しているイメージ。

GettingStart

1. プロジェクトの用意

[]project-root
├ []src
│ ├ main.js
│ └ index.html
└ package.json

Electronはnwjsと違い、マニフェストファイルのようなものは存在しない。Electronでは、package.json(npm用のファイル)mainに指定されているjsファイルを立ち上げ、その中で ウィンドウを表示する命令を書く

main.jsの中身は下記のようになる。

const { app, BrowserWindow } = require('electron')

function createWindow () {
  // ブラウザウィンドウオブジェクトを生成
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true
    }
  })

  // htmlファイルを読込
  win.loadFile('index.html')

  // DevToolsを開く
  win.webContents.openDevTools()
}

// appの準備が出来たら、ウィンドウを生成する
app.whenReady().then(createWindow)

// Quit when all windows are closed.
app.on('window-all-closed', () => {
  // On macOS it is common for applications and their menu bar
  // to stay active until the user quits explicitly with Cmd + Q
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  // On macOS it's common to re-create a window in the app when the
  // dock icon is clicked and there are no other windows open.
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow()
  }
})

2.npmパッケージをインストール

$ npm install --save-dev electron

3.実行

$ electron .

これだけで、nwコマンドと同様に、開発中のデモが立ち上がる。

ビルドはまた今度。

メインプロセスとレンダープロセス

nwjsで、ブラウザコンテキストとノードコンテキストが別のスレッドで走っていたのと似たようなもので、electronにはメインプロセスレンダープロセスがある。

最初に立ち上がるのがメインプロセス。package.jsonmainを最初に実行するのがこのプロセスだ。このプロセスはNode.jsのコンテキストで走る。次に、メインプロセスからウィンドウが生成される。このウィンドウ上で走る(主にhtmlから<script>で読み込まれる)jsは、レンダープロセス上で走る。ここでのコンテキストはブラウザのコンテキストになる。

レンダープロセスはメインプロセスから生み出され、メインプロセスはレンダープロセスをいくつも生み出すことが出来る。

プロセス間の通信

となると、nwjsと同じく、プロセス間で通信をする手段が登場する。レンダープロセスがメインプロセスと通信をする手段のメインはipc(プロセス間通信)を使った通信で、electronにはそれ用のAPIが用意されている。

簡単なアプリケーションの場合、

  1. レンダープロセスがボタンのクリックなどのイベントを検知する
  2. イベントがローカルファイルの参照や操作を必要とする場合、ipcによりイベントをメインプロセスに伝える
  3. イベントをメインプロセスで処理し、結果をレンダープロセスに送る
  4. メインプロセスから受け取った結果を、レンダープロセスが画面の表示に反映する

という具合に処理を進めていく。

nwjsがブラウザコンテキスト上でrequireしてノードコンテキスト上で走るモジュールを直接操作できるかのような形で書けたのに対し、ipcによってデータを送りあっていることをより意識した形で書くのがelectron、electronがカッチリしていてnwjsが緩いと書いたのはこのため。

また、その他にも、remote.requireという仕組みがある。これは、nwjsにおいて、ブラウザコンテキスト上で読み込まれたnode.js用のモジュールが、ノードコンテキスト上で処理をして結果をブラウザコンテキスト上で参照できるのと同じような仕組みで、レンダープロセスはオブジェクトの参照を得るが、オブジェクトの中身はメインプロセス上で走る。これを多用する場合、nwjs同様コンテキストの仕分けがゆるくなる。

typescript との共存

typescriptで書きたい。

typescriptを導入する場合、いくつかキーとなるものが存在する。

この辺りだろうか。

コンテキストが違う問題

まず、レンダラープロセスとメインプロセスでコンテキストが違う問題。

これは、tsconfig.jsonの書き分けによってクリアしていく。tsconfig.jsonにはextendsという設定を継承するための仕組みがある。

tsconfig-base.json

{
  ここに共通となる設定
}

tsconfig-renderer.json

{
  "extends": "./tsconfig-base.json",
  "exclude": ["./src/main/"],
  その他設定
}

tsconfig-main.json

{
  "extends": "./tsconfig-base.json",
  "exclude": ["./src/renderer/"],
  その他設定
}

としておいて、コンパイル時に-pオプションでそれぞれの設定ファイルを指定。一回でできればよいが、出来なさそうなので、2回(レンダラープロセス用とメインプロセス用)コンパイルすることになる。

あとは、主にコンテキストの違いで書き方に違いが出るのはモジュール廻りなので、気をつけて書く。 ここで、紛らわしいのが、デフォルトの状態だとレンダラープロセスでもrequireが使えてしまうということだ。本来、requireCommonJS,つまりブラウザ環境上では使えないが、electronではブラウザ環境上でもNode.jsの機能が使えてしまうのでCommonJSのrequireがレンダラープロセス上を走るソース内に混ざっていても動く。

これだとそのうち混乱してしまうし、ブラウザコンテキスト上でNode.jsの機能が使えてしまうのは、XSS(クロスサイトスクリプティング)脆弱性が出た時に攻撃者がレンダラープロセスを経由して全てのNodeJSが提供する機能を利用出来てしまう危険があるので、禁止したほうが無難(後述する)

Node.jsのglobalにプロパティを追加する方法

何故コレを習得する必要があるのか。そこから説明する。

electronのBrowserWindowオブジェクトを生成する時、オプションにpreloadというものがある。ここには、ウィンドウが開く前に処理しておきたいものをまとめたjsファイルを指定する(必ずフルパスで指定すること)。指定されたjsファイルは、ウィンドウが開く前に実行される。

ここで実行されるものは、Node.jsの機能が全て使える状態で実行される。そして、preload中にglobal(Node.jsのグローバルオブジェクト)に追加されたプロパティは、ブラウザコンテキスト上のwindowからも参照できるようになる。

これで何が嬉しいのかと言うと、XSSを意識してレンダラープロセスでNode.jsの機能を使用することを禁止したのは良いが、そのままではelectronモジュールも使えなくなってしまう問題を解決できる。

レンダラープロセスでNode.jsを禁止するには、BrowserWindowオブジェクト生成時にnodeIntegrationfalseにすれば良い。これで、レンダラープロセスプロセス上でrequireなどのNode.jsの機能が禁止される。Node.jsの機能が禁止されるということは、ブラウザコンテキストからローカルファイルなどの深いところに手が届かなくなるので、すこし安心、というわけだ。

しかし、requireが使えないということは、electronモジュールも使えないということだ。electronモジュールが使えないということは、electronモジュールの中にあるipcRendererが使えなくなり、レンダラープロセスとメインプロセス間のコミュニケーション手段が無くなってしまう。

レンダラープロセスのみで完結するアプリであれば問題ないが、ファイルの操作を必要とするアプリケーションの場合これだと困るので、最低限必要なNode.jsモジュールだけは許可したい。この時に、preloadが使える。

preload時にはNode.jsの仕様が禁止されないことは既に書いた。また、preload時にglobalに追加したプロパティはその後、レンダラープロセスのwindowから参照できることも書いた。つまり、preload時にrequireで必要最低限のモジュールをロードし、globalに入れておけば、それだけはNode.jsが禁止されたレンダラープロセス上でも使用可能になるということだ。 (なので、XSSを意識して禁止したのであれば何を許可するのかは慎重に考えなくてはいけない。せっかく禁止したのにglobalに何でもかんでも入れていては、結局NodeJSの機能を禁止した意味が無くなってしまう。)

main_main.ts

  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: false,
      preload: path.join(__dirname, './preload.js'),
    }
  });

renderer_preload.ts

const electron = require('electron');

(process as NodeJS.EventEmitter).once('loaded', () => {
  console.log('preload');
  global.electron = electron;
  global.process = process;
});

こんな感じになる。preloadにはコンパイル後のjsファイルを指定することになるのに注意。

で、肝心のglobalだが、普通に入れると globalにelectronなんていうプロパティはありませんが? と言ってコンパイラに蹴られる。 これをなんとかしないといけない。

結論から言うと、型定義ファイルを書いて、プロジェクトに含める。型定義ファイルは下記のようになる。

/src/types/global.d.ts

declare namespace NodeJS {
  interface Global {
    electron: any;
  }
}

このファイルを、コンパイラがコンパイルする時に読み込むディレクトリに含めてあげれば、コンパイラがglobal(type : NodeJS.Gloabl)electron: anyがあるぞと認識してくれる。読み込みのディレクトリに含めるには、例えば上記の場合rootDir./src/になっていればその下にあるので自動で含まれるし、includeでディレクトリを指定しても含まれうと思う(試してないけど)。

ローカルにあるファイルを選択する方法

electronにおいて、ローカルにあるファイルを選択したいときにはdialogを使う。(下記サンプルはts) これは、ローカル環境にアクセスするため、メインコンテキスト上で行うべき操作である。

example.ts

import * as electron from 'electron';

electron.dialog.showOpenDialog(targetWindow)
  .then((result) => { callback(result); });

上記の例を使って説明をすると、dialogはファイルを選択する時に表示されるウィンドウで(“おきまり"のやつ)、これが表示されている間は親のウィンドウは操作を受け付けなくなるのが一般的(これをモーダルと呼ぶ)。targetWindowは、そのdialogを表示している間に操作を受け付けなくさせる親ウィンドウを指定する。また、result{canceled: boolean, filePaths: string[]}のような形のデータで、キャンセルされたか、選択されたファイルのパスはどれか(複数選択可の場合は複数候補が返る)、を取得することが出来る。

あとは、このファイルパスを使ってファイルを読み込めば良い。

electron + canvas2d で画像を扱う

例えばcanvas2dとelectronを使ってペイントアプリを作成しているような時、ローカルファイルの画像を読み込んでcanvasに画像を表示する機能が必要になるだろう。

いくつか方法がある。

1.レンダラプロセス側で読込

これが早い(実行速度的にも)。

  1. リクエストをレンダラプロセス側で検知し、メインプロセスにその旨をipcで送信
  2. メインプロセスはファイルオープンのイベントがトリガーされると、ダイアログを開き、ファイルパスを取得
  3. 取得したファイルパスをレンダラプロセスにipcで送信
  4. レンダラープロセスがファイルパスを元に画像データを取得、表示

レンダラープロセスが画像を表示する方法は下記のようになる。

window.electron.ipcRenderer.on('selectFile', (event, path) => {
  // Imageオブジェクトを生成
  const image = new Image();

  // 先に、画像の読み込みが完了した時の処理を仕込んでおく
  image.addEventListener('load', () => {
    ctx.drawImage(image, 0, 0);
  }, false);

  // srcにファイルパスを代入すると、画像の読込を開始、
  // 成功すれば、`load`で仕込んでおいたコールバックが実行され、canvasに表示される
  image.src = path;
});

2.メインプロセス側で読込

これは、レンダラープロセスが表示関係に徹すると考えた時に、「メインプロセスで画像を読み込んでデータだけ送って貰えば良い」と考えることもあると思う。そういう時のパターン。しかし、先に結論を言うと、ImageDataオブジェクトの生成に時間がかかりすぎるので(体感で分かる程に)おすすめできない

  1. リクエストをレンダラプロセス側で検知し、メインプロセスにその旨をipcで送信
  2. メインプロセスはファイルオープンのイベントがトリガーされると、ダイアログを開き、ファイルパスを取得
  3. 取得したファイルパスを使って、メインプロセスがファイルを読込
  4. 読み込んだ画像データをipcを使ってレンダラープロセスに送信
  5. レンダラープロセスが受け取ったデータを表示

このとき、注意しないといけないことがある。

それは、canvas2dで画像を表示するのに、ImageDataオブジェクトを生成しなくてはいけないということ。

const buffer = new Uint8ClampedArray(uint8ArrayData);
const imageData = new ImageData(buffer, w, h);

このオブジェクトを生成するに当たって、pngjpegなどの圧縮された画像データをパースして、width,height,そして生データを取得しなくてはいけない(つまり、ビットマップデータを取得)。

しかし、Node.jsには標準でこれを扱えるライブラリがない。そこで、外部ライブラリを使う必要がある。

Jimpなどの画像ライブラリによって圧縮された画像データからビットマップデータを抽出し、それらをレンダラープロセスに送信、レンダラープロセスは、そのデータを元にImageDataオブジェクトを生成して、canvasに表示する。

しかし、最初にも述べた通り、この方法はあまりにも遅いのでオススメしない。