Electronで作る常駐アプリ

業務ツールを作成する際、試しにElectronで作ってみました。 諸々の理由で結局採用しなかったんですが、せっかくなのでやったことの記録をしておきたいと思います。

実際、公式のドキュメントがわかりやすいのでそれで事足りちゃいます。
この辺までは触ったな・・という自分用のメモですのでしっかり学びたい方は公式のクイック スタート | Electronがおすすめですのよ。

Electronとは

ElectronはGitHubが開発したソフトウェアフレームワークです。

www.electronjs.org

トップページでは

JavaScript, HTML, CSSクロスプラットフォームなデスクトップアプリ開発

さらに、はじめに | Electronでは

Electron は Chromium と Node.js をバイナリに組み込むことで、単一の JavaScript コードベースを維持しつつ、ネイテイブ開発経験無しでも WindowsmacOSLinux で動作するクロスプラットフォームアプリを作成できます。

とあります。

ブラウザなら各プラットフォームにポーティングされてるし、その上で動くJavascriptでコード書けばマルチプラットフォームできるじゃんという発想ですね。 考えた人すごいなぁと素直に感動した覚えがあります。

今回作るアプリ

業務ツールで必要だった要素は以下の通りでした。

  • 常駐アプリでトレイ(通知領域)に格納
  • PCの挙動(サスペンドリジューム・シャットダウン)で処理を実施
  • それ以外に定期的な処理を実施(ポーリング)

今回は

  • 起動したらトレイにアイコン表示
  • PCの挙動(サスペンドリジューム・シャットダウン)をログに記録
  • 一定期間毎に周囲のWiFiアクセスポイント数を検索して通知

みたいな役に立たないアプリケーションを作ってみます。

プロジェクト作成

クイック スタートを参考にプロジェクトを作成します。

mkdir electron-tray-sample && cd electron-tray-sample
yarn init

entry pointだけ main.js にしておきました。ついでに空っぽのファイルを用意しておきましょう。

touch main.js

続いてdevDependencies に Electronパッケージをインストールします。
devDependenciesはアプリケーションじゃなくて開発に必要なパッケージで、---devオプションをつけてインストールできます。

yarn add --dev electron

最後にstartで実行できるようにpackage.jsonを書き換えます。

{
  "name": "electron-tray-sample",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "electron": "^19.0.4"
  },
  "scripts": {
    "start": "electron ."
  }
}

この状態で実行してみましょう。

yarn start

ElectronのアイコンがDockに出てきました。成功です。

トレイ

トレイへアイコンを追加してみます。
イコン画像を準備しなきゃなので適当に作ります。tray.pngを16x16、tray@2x.pngを32x32で作成しました。

app.whenReady()でアプリケーションの初期化完了時のPromiseを取得してトレイを生成します。
トレイのモジュールはTrayです。わかりやすい!

const { app, Tray, Menu, nativeImage } = require('electron')

app.whenReady().then(() => {
  const img = nativeImage.createFromPath(__dirname + "/assets/tray.png")
  let tray = new Tray(img)
  tray.setToolTip('Tray app')
  tray.setContextMenu(Menu.buildFromTemplate([
    { label: 'Quit', role: 'quit' }
  ]))
})

実行してみるとトレイに先程のアイコンが出ました。

右クリックでメニューも出ます。

macOSだとDockにアイコンが表示されてしまうので、常駐アプリっぽくないです。
app.dock.hide()で消せますので消しちゃいましょう。

if (process.platform === 'darwin') {
  app.dock.hide()
}

電源状態とログ

電源状態のモニタをするためpowerMonitorというモジュールが用意されています。
さまざまな電源がらみのイベントが用意されていますが、suspendresumeshutdownを受けてコンソールへメッセージを出力してみます。

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

app.whenReady().then(() => {
  powerMonitor.on('suspend', () =>{
    console.log('suspend')
  })
  powerMonitor.on('resume', () =>{
    console.log('resume')
  })
  powerMonitor.on('shutdown', () =>{
    console.log('shutdown')
  })
})

実行後スリープ→復帰させてみた様子です。う、うん・・としか言いようのない画像ですね。

シャットダウンはコンソールだと見逃すのでログに残しましょう。
electron-logというモジュールを利用します。

yarn add electron-log
const { app, powerMonitor } = require('electron')
const log = require('electron-log');

app.whenReady().then(() => {
  log.info('begin')
  powerMonitor.on('suspend', () =>{
    log.info('suspend')
  })
  powerMonitor.on('resume', () =>{
    log.info('resume')
  })
  powerMonitor.on('shutdown', () =>{
    log.info('shutdown')
  })
})

ログの出力先はこんなルールです。

  • macOS: ~/Library/Logs/{app name}/{process type}.log
  • Windows: %USERPROFILE%\AppData\Roaming{app name}\logs{process type}.log
  • Linux: ~/.config/{app name}/logs/{process type}.log

メインプロセスから出力しているので、macOSで動かしたらここに書き込まれています。

~/Library/Logs/electron-tray-sample/main.log

WiFiスキャナ

node-wifi-scannerというモジュールをインストールします。

yarn add node-wifi-scanner

Readmeの例を参考に、スキャンできたSSIDの数を表示してみます。
よく考えてみるとSSIDじゃなくてMACアドレスの方が良かったですかね?

const scanner = require('node-wifi-scanner');

scanner.scan((err, networks) => {
  if (err) {
    console.error(err);
    return;
  }
  const ssids = networks.map(n => n['ssid'])
  if (ssids.length > 0) {
    console.log(`Found ${ssids.length} access point`)
  }
});

この処理を定期的に呼び出し、結果をコンソールじゃなくて通知をするように変更します。

const { app, Notification, powerMonitor } = require('electron')
const scanner = require('node-wifi-scanner');

const findap = () => {
  scanner.scan((err, networks) => {
    if (err) {
      console.error(err)
      return
    }
    const ssids = networks.map(n => n['ssid'])
    if (ssids.length > 0) {
      new Notification({
        title: 'WiFi AP',
        body: `Found ${ssids.length} access point`,
        silent: true,
      }).show()
    }
  })
}

app.whenReady().then(() => {
  setInterval(() => findap(), 30 * 1000)
})

ちゃんと通知が来ました。それにしても意味がない通知ですね。

Package

なんとなく完成した気がするので配布用パッケージを作ります。

クイックスタートに倣って Electron Forge を利用したいと思います。

yarn add --dev @electron-forge/cli
npx electron-forge import

これで yarn run makeでパッケージが出来上がりますが、アイコンがElectronのままなのでせっかくだから変更しましょう。

各プラットフォーム用にアイコン作るのは面倒ですが、electron-icon-builderを利用すると簡単にできます。

www.npmjs.com

1024x1024のpng画像から作成したアイコンファイルをassetsディレクトリに配置しました。

アイコンの指定はpackagerConfigiconに指定します。

  "config": {
    "forge": {
      "packagerConfig": {
        "icon": "assets/icon"
      },
      "makers": [
           :
           :

これでyarn run makeすると、outディレクトリにパッケージが作成されます。
ちゃんとアプリケーションアイコンも指定した通りになっていますね。

作成したアプリをmacOSとWindows10で動作させた様子です。
どちらもちゃんと動いてますね。

終わりに

バイナリサイズが大きいなどいくつか不利な点はありますが、簡単にマルチなプラットフォームのアプリケーションができてしまうのは便利だな〜と感じます。

ちなみに採用しなかった理由ですが、Windows環境でShutdownのイベントが発火しなかったからです。
何度もシャットダウンしてデバッグしちゃいましたが、ちゃんとAPIのドキュメントにWindows対応してないこと書いてありますね。

教訓;ドキュメントはちゃんと読もう

プロジェクトはGitHubにあげてますのでよければどうぞ

github.com