UUUM攻殻機動隊

UUUMのエンジニアによる技術ブログです

1.0になったElectronを使ってみる

はじめまして、入社1ヶ月たったnakahashiです。攻殻機動隊は偉大なエンジニア揃いで、ちょっと緊張しています。

つい最近(2016年5月)、Electronが1.0になりました。

Atomエディタを始め、UUUM社で活用されまくっているSlackアプリなど、採用事例も増えてきています。

改めてElectronの概要をまとめつつ、ボイラープレートを作成して新しく追加されたSpectronを体験してみました。

Electronって何?

Electronは、クラスプラットフォームなデスクトップアプリケーションを開発できるフレームワークです。

実行環境はNode.jsとChromiumで出来ていて、開発者はECMAScript(JavaScript)やCSS、HTMLでアプリを作ります。

なので、本当に1つのコードが各プラットフォームで動きます。1つのコードから各プラットフォーム向けの言語やSDKに合わせてコードを出力するとかではありません。素晴らしい。

採用事例は?

海外製の主なプロダクトはここで見れます。誕生するきっかけとなったAtomエディタはもちろんのこと、MicrosoftのVisual Studio CodeやSlackが身近ですね。

国内では、Qiita向けのクライアントであるkobito for Windowsなどが知られています。

使うメリット

前述のとおり、1つのコードで、OS X、Windows、Linux向けのアプリケーションを開発可能なことが最大のメリットです。実行環境まで同じなので、プラットフォームによって細かい動作が変わるということは基本的にはありません。

(もちろんOSごとにUI自体の差異があるので、固有の作りこみが発生することもあります)

そして、Web開発のノウハウもパッケージもデスクトップアプリケーションに流用し放題です。手に馴染んだnpmやbowerのあれやこれやも使えますし、タスクランナーやAltJsだって使いたい! 使えます。

サーバとやり取りするような業務アプリでは、従来のようにWebアプリで作ろうとするとサポート対象のブラウザの分だけ実装や試験が複雑化しますが、Electronで作ればブラウザはChromium一択。幸せに慣れそうな感じがします。

基本構造

Electronアプリケーションは、メインプロセスとレンダラプロセスの2種類のプロセスで構成されます。

f:id:k_nakahashi:20160607120337p:plain

レンダラプロセスは、HTML/CSS/JavaScriptで作った画面をレンダリングするためのプロセスです。ほとんどWebアプリのView層そのものです。

メインプロセスは、アプリケーションのbootやウィンドウ起動、メニューのセットアップ等を主に行います。ElectronからOSのUIに対して行う固有の機能は、主にメインプロセスが担当しているという印象です。

メインプロセスとレンダラプロセスは、Electronが提供しているIPC APIで通信できます。

テストは?

テストももちろんJavaScript用のテスティングフレームワークを使い放題です。

Electronが1.0になったタイミングで、専用テストツールであるSpectronがリリースされました。Electron固有のインスタンスを手軽にテストできるようになっています。

ボイラーブレートを作ってみた

というわけで、以下のスタックでボイラープレートを作ってみました。

  • AltJs: ES2015
  • View: React
  • テスト: Spectron、mocha、chai

最近はタスクランナーなしでプロジェクト作るのが流行りらしいので、今回はgulp的なものは使っていません。

メインプロセス

以下がメインプロセスによるbootの実装です。ほとんど公式のelectron-quick-startと同じですが、ES2015にしている分、少しすっきりしました。

new BrowserWindow()でウィンドウを生成し、HTMLのパスを指定してloadURL()を呼んで画面を表示させてます。app.on()で各種イベントを受け取れるので、対応した処理を書けます。

import {app, BrowserWindow} from 'electron';

let mainWindow;

// ウィンドウを開く
function createWindow() {
  mainWindow = new BrowserWindow({
    width: 200, height: 100, title: 'boilerplate'
  });

  mainWindow.loadURL(`file://${__dirname}/../view/index.html`);

  mainWindow.on('closed', () => {
    mainWindow = null;
  });
}

// アプリ起動準備完了したので、ウィンドウ表示
app.on('ready', createWindow);

// 全てのウィンドウが閉じたので、アプリも終了
// OS Xの場合は除外
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

// ウィンドウがない状態でアクティブになったらウィンドウを開く
app.on('activate', () => {
  if (mainWindow === null) {
    createWindow();
  }
});

レンダラプロセス

レンダラプロセスのhtmlです。ReactにDOMを書かせるので、ほとんどjsのロードのみです。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>electron boilerplate</title>
  </head>
  <body>
    <div id="container"></div>
    <script src="../lib/index.js"></script>
  </body>
</html>

次はレンラダプロセスのjsの実装です。Reactでプラスボタンとマイナスボタン、現在値を描画させています。この辺りはElectronでもなんでもなく、普通のReactなWebアプリです。babelでES2015とJsxをまとめてコンパイルさせているので、このようなコードになります。

import React from 'react';
import ReactDOM from 'react-dom';

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = {count: 0};
  }

  handlePlus() {
    const count = this.state.count + 1;
    this.setState({count});
  }

  handleMinus() {
    const count = this.state.count - 1;
    this.setState({count});
  }

  render() {
    return (
      <div>
        <div>
          <button className="plus" onClick={::this.handlePlus}>+1</button>
          <button className="minus" onClick={::this.handleMinus}>-1</button>
        </div>
        <div>
          {this.state.count}
        </div>
      </div>
    );
  }
}

ReactDOM.render(<Counter />, document.getElementById('container'));

大作アプリが完成しました! f:id:k_nakahashi:20160607122323p:plain

テスト

Spectron使ったテストコードを書いてみました。そして少しハマりました…

基本的には公式が紹介しているSpectron+Mochaのパターン通りに作りました。

公式では、以下のようにアプリケーションのインスタンスを取得し、ウィンドウの数のチェックをしていました。

this.app = new Application({
  path: '/Applications/MyApp.app/Contents/MacOS/MyApp'
});

ですが、このMyAppはコードを実行形式にパッケージングした後にできるファイルで、開発中にいちいちこのファイルを作ってられません。開発中はelectron-prebuildというツールが提供するelectronというコマンドに、プロジェクトのある(package.jsonがある)ディレクトリのパスを指定することで、動作を確認できるからです。

electron-prebuildでテストしたい場合、Spectronでは以下のようにアプリケーションのインスタンスを取得すればいいようです。

this.app = new Application({
  path: path.resolve(__dirname, '../node_modules/.bin/electron'),
  args: [path.resolve(__dirname, '../')],
});

Applicationのコンストラクタに、electronコマンドがある場所のパスとプロジェクトのパスを渡せば、アプリケーションのインスタンスがゲットできます。

今回は以下のようにして、ウィンドウ数とタイトルをチェックするテストを書きました。

import {Application} from 'spectron';
import path from 'path';
import chai from 'chai';
chai.should();

describe ('app', function () {
  this.timeout(10000);

  beforeEach(() => {
    this.app = new Application({
      path: path.resolve(__dirname, '../node_modules/.bin/electron'),
      args: [path.resolve(__dirname, '../')],
    });
    return this.app.start();
  });

  afterEach(() => {
    if (this.app && this.app.isRunning()) {
      return this.app.stop();
    }
  });

  it('window number is 1.', () => {
    return this.app.client.getWindowCount().then((count) => {
      count.should.equal(1);
    });
  });

  it('title is "electron boilerplate".', () => {
    return this.app.browserWindow.getTitle().then((title) => {
      title.should.equal('electron boilerplate');
    });
  });
});

今回作ったボイラープレートはここにあります↓
https://github.com/nakahashi/electron-boilerplate

セキュリティ

技術スタックの性質上、セキュリティは特に気をつけません。

普通のブラウザで実行されるjsはサンドボックス内での動作ですが、Electron上のjsは、アプリを実行したユーザ権限の範囲内なら何でもできます。

以下の記事では、ElectronアプリにHDD内のファイルを全部消させるという実験をしています。面白そうだったので私もやってみました。(仮想マシンで)

ElectronアプリのXSSでrm -fr /を実行する - Qiita

こういう風にリンクを画面に追加させ、ユーザがクリックするとマシンが死ぬんですって!

<a href="javascript:
(() => {
    const {execSync} = require('child_process');
    execSync('sudo rm -fr --no-preserve-root /', {encoding: 'utf8'});
})();
">CLICK ME</a>

試してみました…

死にました。もう起動できませんw

f:id:k_nakahashi:20160607120420p:plain

Electronアプリで特に有効な防護策というのもないようなので、とにかく外部から注入されたコードには気をつけるしかありません。

まとめ

さくっとデスクトップアプリを作るには、今や筆頭候補かもしれません。

UUUMではエンジニアを絶賛募集中です。 ※毎月29日は超うまい肉が食べ放題です。

参考