Electron に Mac タッチバー API が実装された

個人的に気になっていた Electron のタッチバーサポートがついに master にマージされました.

実装は下記の PR で行われ,@MarshallOfSound さんの初期実装と @kevinsawicki さんのブラッシュアップで実装されました.

https://github.com/electron/electron/issues/8095

まだリリースされていないですが,待てなかったので master ブランチの実装を試してみました.

追記(2017/3/8): v1.6.3 beta としてリリースされました.

Electron の master ブランチをビルドする

ビルドの仕方はドキュメントにまとめられています. 再配布せず手元で試すだけであれば面倒そうな「macOS SDK」のセクションは無視してしまって大丈夫です.

# リポジトリを取得
$ git clone https://github.com/electron/electron && cd electron/
# ビルドに必要な依存関係パッケージの取得
$ ./script/bootstrap.py -v
# ビルド
$ ./script/build.py

ビルドにはそれなりに時間がかかります.out/R にリリースビルドの Electron.app が,out/Dデバッグビルドの Electron.app が生成されます.

Touchbar API

タッチバーはメインプロセスから制御することができます.レンダラプロセスからは IPC 経由でのアクセスになりそうです. macOS 10.12.1 以降のタッチバー搭載 MacBook Pro で利用できるようです

タッチバーの UI パーツにはいくつかの種類があり,各パーツごとの class と,それらをまとめる class が提供されています.

  • TouchBarButton : アイコンとテキストが置けるボタンです
  • TouchBarColorPicker : カラーピッカーです
  • TouchBarGroup : 複数の UI パーツを1つのグループとして配置できます
  • TouchBarLabel : テキストを表示できるラベルです
  • TouchBarPopover : アイコンとラベルとキャンセルボタンが表示できる,タッチバーに出るダイアログボックス的な UI です
  • TouchBarSlider : 音量などに使えるスライダーです
  • TouchBarSpacer : タッチバーのアイテムとアイテムの間にスペースを開けることができます.開けるスペースは small, large, flexible (スペースを取れるだけ取る) の中から選べます
  • TouchBar : TouchBar は上記 Touchbar の UI パーツを並べてタッチバーインスタンスを生成できます

タッチバーインスタンスBrowserWindowsetTouchbar() メソッドによってセットできます.セットされると即座にタッチバーが表示されます.また,setTouchbar メソッドに null を与えることでタッチバーを消すことができます.

サンプルアプリ

ドキュメントにのっているサンプルアプリを動かしてみます.ドキュメントにはコード片のみなので,動くコードを下記に置いてみました.

github.com

サンプルアプリではタッチバー上でスロットを回すことができます.

www.youtube.com

エントリポイントである main.js は下記の通りです.

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

// 必要なタッチバーの UI パーツをインポートする
const {TouchBarLabel, TouchBarButton, TouchBarSpacer} = TouchBar

let spinning = false

// スロットの各リールの文字列を表示するラベル3つ
const reel1 = new TouchBarLabel()
const reel2 = new TouchBarLabel()
const reel3 = new TouchBarLabel()

// スロットを回した結果を表示するラベル
const result = new TouchBarLabel()

// 'Spin' ボタン
const spin = new TouchBarButton({
  label: '🎰 Spin', // ボタンのテキスト.ここでは絵文字だけどアイコンも置けるはず…
  backgroundColor: '#7851A9',
  click: () => {
    // スロットが回っている間はクリックを無視する
    if (spinning) {
      return
    }

    // スロットを回す処理
    spinning = true
    result.label = ''

    let timeout = 10
    const spinLength = 4 * 1000 // 4 seconds
    const startTime = Date.now()

    // クリックされてから4秒間スロットを回す
    const spinReels = () => {
      // スロットのリールを更新する.止まった時のリールが結果になる
      updateReels()

      if ((Date.now() - startTime) >= spinLength) {
        finishSpin()
      } else {
        // 1.1倍ずつスロットを遅くしていく(慣性っぽさ)
        timeout *= 1.1
        setTimeout(spinReels, timeout)
      }
    }

    spinReels()
  }
})

// ランダムに絵柄を1つ選ぶ関数
const getRandomValue = () => {
  const values = ['🍒', '💎', '7️⃣', '🍊', '🔔', '⭐', '🍇', '🍀']
  return values[Math.floor(Math.random() * values.length)]
}

// 3つのリールを更新
const updateReels = () => {
  reel1.label = getRandomValue()
  reel2.label = getRandomValue()
  reel3.label = getRandomValue()
}

const finishSpin = () => {
  // Set を使って重複を省き,残った要素数で絵柄がいくつ合ったかをチェックする
  const uniqueValues = new Set([reel1.label, reel2.label, reel3.label]).size

  if (uniqueValues === 1) {
    // すべての絵柄が揃ったとき:大当たり!
    result.label = '💰 Jackpot!'
    result.textColor = '#FDFF00'
  } else if (uniqueValues === 2) {
    // 3つ中2つのリールが同じ絵柄だったとき:当たり
    result.label = '😍 Winner!'
    result.textColor = '#FDFF00'
  } else {
    // 絵柄がまったく揃わなかったとき:はずれ
    result.label = '🙁 Spin Again'
    result.textColor = null
  }

  spinning = false
}

// [ボタン]  [r1] [r2] [r3]  [result]
//
// のように配置.配列に入れたパーツが入れた順にタッチバーに表示される
const touchBar = new TouchBar([
  spin,
  new TouchBarSpacer({size: 'large'}),
  reel1,
  new TouchBarSpacer({size: 'small'}),
  reel2,
  new TouchBarSpacer({size: 'small'}),
  reel3,
  new TouchBarSpacer({size: 'large'}),
  result
])

let window

app.once('ready', () => {
  window = new BrowserWindow({
    width: 200,
    height: 200
  })
  window.loadURL(`file://${path.join(__dirname, '/index.html')}`)
  // ブラウザウィンドウにタッチバーをセットする.
  // このタイミングでタッチバーにスロットマシンが表示される
  window.setTouchBar(touchBar)
})

// 全てのウィンドウが閉じたときアプリを閉じる
app.on('window-all-closed', () => {
  app.quit()
})

まとめ

ついに Electron アプリでもタッチバーにアクセスできるようになりました. まだ入ったところなので多少バグなどがあるかもしれませんが,面白い機能なので是非自分がつくっているアプリでも活用を考えたいと思います. 何事も無ければ次リリースに含まれると思うので,今週後半か来週あたりには v1.6 系で使えるようになるのではと思います.

Electron アプリをつくる時に便利なパッケージ

この記事は Electron アドベントカレンダー2016 の13日目の記事です.

本記事では,僕が Electron アプリをつくる上で便利だったり,ほしかったのでつくったりしたパッケージを7つほど紹介します.

  1. electron-about-window
  2. electron-dl
  3. electron-in-page-search
  4. electron-window-state
  5. menubar
  6. node-auto-launch
  7. electron-mocha

electron-about-window

electron-about-window は 'このアプリについて' ウィンドウを簡単にクロスプラットフォームにつくるためのパッケージです.下記のように関数を1つインポートして呼び出すだけで「このアプリについて」ウィンドウを生成することができます.(example

import openAboutWindow from 'about-window';
openAboutWindow({ icon_path: 'path/to/icon.png' });

アイコンファイルへのパスはどうしても必要になってしまうため手で指定する必要がありますが,その他のバージョンやデスクリプションは package.json から引っ張ってきてくれるので特に指定する必要はありません.

例えば Shiba だと下記のようなウィンドウが生成されます.

f:id:rhysd:20161211191411p:plain

ウィンドウのライフサイクルなどはすべてライブラリ側でハンドルされるので,特にライブラリユーザ側が気にする必要はありません.

TypeScript も対応済みです.

electron-dl

Electron アプリ中で何かのファイルをローカルにダウンロードしたい時,やり方は2通りあります.

  1. Chrome のネイティブなダウンロード機能を Electron が提供する API で利用する
  2. ダイアログのみネイティブな API (openSaveDialog) を使って,ダウンロードや保存は Node.js の API で頑張る

できれば 1. でやりたいところですが,Electron のダウンロード関連の API は色々なところに API が分散していて使いにくいです.例えばダウンロードを開始する downloadURLWebContents に,ダウンロードの再開は Session に,ダウンロード後の Dock の挙動(macOS)は app.dock にあったりします.また,Chrome のダウンロードはダイアログを出すかどうかの制御や一時中断・再開,など一通りのことができるので複雑です.これは Electron が ChromiumC++ API を割とそのまま JavaScriptAPI として見せているためです.

electron-dl はこれらの使いづらい API を裏に隠し,簡単にファイルのダウンロードを行えるようにしてくれます.

import {download} from 'electron-dl';

const win = new BrowserWindow();

const opts = {
    // 保存先ディレクトリ.ダイアログを開く場合はこのディレクトリが初期値になる
    directory: '/path/to/some/direcotry',

    // 'save as' ダイアログを出すかどうか.出さない場合は上記の 'directory' プロパティ
    // で指定する.何も指定しなければデフォルトのダウンロードディレクトリ(~/Downloads など)
    // が使われる.
    saveAs: true
};

// https://example.com/some/file をダウンロードする(opts は省略可)
download(win, 'https://example.com/some/file', opts)
    .then(dl => {
        console.log('Download successfully completed', dl.getSavePath());
    })
    .catch(err => {
        console.error('Download failed', err.message);
    });

これだけでダウンロードが中断された場合の面倒などを含めたファイルのダウンロードをすべて行ってくれます.また,macOS のダウンロード中の挙動(Dock アイコンへのプログレスバーの表示や完了時の Dock アイコンのバウンス)もやってくれます.なおダウンロード完了後に return される値(上記コードの dl)は DownloadItem インスタンス です.

electron-in-page-search

Electron には Chrome のページ内検索を実行する API があり,WebContents インスタンス.findInPage() メソッドや found-in-page イベントを組み合わせて使えます.ですが,検索窓が別のネイティブなウィンドウで実装されていること前提になっていたり,BrowserWindow<webview> で挙動が違ったりと色々落とし穴があります.これはダウンロード周りと同様に Electron が ChromiumC++ API を割とそのまま JavaScriptAPI として見せているためです.この辺は以前メモしたりしました.

Electron で Chrome のページ内検索機能を使う

そこで,それらの落とし穴や検索の状態を気にせずページ内検索をアプリに組み込むためのモジュールとしてつくられたのが electron-in-page-search です.

これは exampleスクリーンショットです.ボタンを押すと検索窓が現れ,そこに検索ワードを入れて Enter キーや 次/前 検索ボタンで検索できます.検索窓のスタイルは CSS などで細かく制御できます.

import searchInPage from 'electron-in-page-search';
import {remote} from 'electron';

const inPageSearch = searchInPage(remote.getCurrentWebContents());

document.getElementById('some-button').addEventListener('click', () => {
    inPageSearch.openSearchWindow();
});

前日の joe-re さんの記事にもあったように,Electron のネイティブな API のテストは色々面倒ですが,electron-in-page-search はすでに各プラットフォーム(OS X, Linux, Windows)でテスト済みなので安心できます.

TypeScript も対応済みです.

electron-window-state

アプリがウィンドウを生成する時に,毎回同じサイズ・位置で生成するのではなく以前ユーザが作成したウィンドウの位置を覚えておいてほしい事があります.ブラウザウィンドウのサイズはレンダラプロセス作成前に知る必要があるので,アプリディレクトリに JSON ファイルなどで保存しておく必要があります.

Electron アプリのウィンドウサイズ&ポジションを復元する

electron-window-state はその辺りをやってくれるパッケージです.

const windowStateKeeper = require('electron-window-state');
let win;

app.on('ready', () => {
  // Load the previous state with fallback to defaults
  const state = windowStateKeeper({
    defaultWidth: 1000,
    defaultHeight: 800
  });

  win = new BrowserWindow({
    x: state.x,
    y: state.y,
    width: state.width,
    height: state.height
  });

  state.manage(win);
});

ウィンドウの状態を保存・復帰したり,初期値をハンドルしてくれるので,気にする必要がなくなります.

menubar

menubar はメニューバーに常駐して,メニューアイテムがクリックされた時にミニウィンドウを表示するようなアプリをつくるための BrowserWindow の wrapper です.

menubar の様子

メニューアイテムの表示やウィンドウ位置の制御,表示のトグルなどを管理してくれるので,十数行でメニューウィンドウを扱うアプリが作れます.

import * as menubar from 'menubar';

const mb = menubar({
    index: '/path/to/index.html',
    icon: '/path/to/icon.png'
});

mb.on('ready', () => {
    // app.on('ready') 相当.アプリが立ち上がった後の処理
    mb.showWindow();
});
mb.on('after-create-window', () => {
    // ウィンドウが作成された後の処理
});

OS X, Linux, Windows に対応していますが,LinuxUbuntu などのメジャーなディストリビューション向けです.また,Windows でタスクバーの位置を左にするなどの設定をしている場合は対応していないようです.

node-auto-launch

上記のメニューバーに常駐するアプリなどを実装すると,OS 起動時にアプリも一緒に起動してほしいと思います.起動時に自動でアプリをスタートするにはそれぞれの OS ごとに設定する(OS X なら Launch Agent, Windows ならレジストリ登録など)必要がありますが,node-auto-launch ではこれらの処理を wrap して,クロスプラットフォームに OS 起動時のアプリ自動実行を行ってくれます.

const AutoLaunch = require('auto-launch');

const launcher = new AutoLaunch({
    name: 'Your App Name',
    path: '/path/to/YourApp',
});

launcher.isEnabled().then(enabled => {
    if (enabled) {
        // すでに自動起動アプリとして登録済み
        return;
    }
    launcher.enable();
});

GitHub の通知を流してくれるメニューバーアプリ gitify などが利用しています.

electron-mocha

Electron アプリの単体テストを書く時,テスト対象が Electron のモジュールに依存していると Node.js では単体テストが実行できません.

そこで,Electron 上で mocha単体テストを実行できるのが electron-mocha です.メインプロセス側でもレンダラプロセス側でもテストを実行できます.

Electron アプリだけでなく,DOM API に依存した単体テストなんかもレンダラプロセス側では実行できるので,DOM API に依存したテストを行いたい時一般にも使えます.

describe('', function () {
    before(function () {
        this.elem = document.querySelector('.target');
    });

    it('', function () {
        this.elem.click();
        assert.equal(this.elem.innerText, 'Clicked');
    });
});

のようなテストが特に jsdom などを使わずに普通に動きます.

まとめ

Electron 1.0 が出てからだいぶ時間が経ち,それなりにエコシステムも育ってきました.今回は私が実際に導入してみて便利だったものを紹介しましたが,awesome-electron にはさらにたくさんのツールやライブラリが掲載されています.

Electron で Chrome のページ内検索機能を使う

比較的最近,Electron に Chrome のページ内検索を JavaScript から行える API が入りました.最近そのことを知ったので,今日昨日あたりでその機能を使って Shiba に Markdown プレビュー内の検索機能を実装しました.案外手こずってしまったのでまとめてみます.

f:id:rhysd:20160321232118g:plain

github.com

実装コード

今回 Shiba 向けに実装したコードは この diff が全てです.僕の説明よりも実際に動いているコードを見て理解したいという方はそちらを見てください.

ページ内検索 API 概要

WebContents オブジェクトに findInPage()stopFindInPage() の2つのメソッドが生えています.また,WebContents.on() で飛んでくるイベントの中に found-in-page というイベントが追加されています.使う API はコレで全てです.なお,<webview> にもほぼ同じ API が生えています.ですので,<webview> の中の検索をしたい場合はそれらを代わりに使います.今回は BrowserWindow 内の検索を実装しました.

実際に動くコードは上で示した diff を見ていただくとして,概要はこのようになっています.

const previous_text = '';
const webcontents = browser_window.webContents;
webcontents.on('found-in-page', (event, result) => {
    if (result.activeMatchOrdinal) {
        // マッチした箇所を覚えておく
        this.active = activeMatchOrdinal;
    }

    if (result.finalUpdate) {
        // M個のマッチ中 N 番目がアクティブな時,N/M という文字列をつくる
        this.result_string = `${this.active}/${result.matches}`;
    }
});

function search(text) {
    if (previous_text === text) {
        // 前回の検索時とテキストが変わっていないので次のマッチを検索
        webcontents.findInPage(text, {findNext: true});
    } else {
        // 検索開始
        previous_text = text;
        webcontents.findInPage(text);
    }
}

const input = document.querySelector('input');
input.addEventListener('keydown', event => {
    if (event.code === 'Enter') {
        search(input.value);
    }
})

const stop_button = document.querySelector('button');
stop_button.addEventListener('click', () => {
    // マッチした部分のハイライトを消して検索終了
    webcontents.stopFindInPage('clearSelection');
});

found-in-page イベントは1回の検索で2度飛んできます.

  • アクティブな(オレンジにハイライトされる)検索結果が見つかった時点で1回発火
    • この時 activeMatchOrdinalresult に入ってくる
  • ページ全体の検索が完了した時点で1回発火
    • この時マッチの総数を表す matchesresult に入ってくる

これによって JavaScript 側で検索結果の詳細を取れます.ちょっと stateful で使いにくいですが,使えなくはないです.マッチした箇所も result.selectionArea で取れます.

次に例えば <input> 要素にテキストを入力させて Enter キーで検索開始するとします.新規に検索する場合も前回の検索を継続する場合も findInPage() メソッドを使います.これもちょっと stateful です.でも前回の検索テキストを持っておけば上記のように Chrome っぽい挙動で検索が行えます.

最後に検索を終了したい場合は stopFindInPage() メソッドを使います.引数に渡す文字列で検索結果のハイライトを消すかどうかを決められます.

これで終われれば「面倒なページ内検索が Chrome の力を借りて一瞬で実装できる!」で終わったのですが,残念ながらそうはなりませんでした.

ページ内検索 API のハマりどころ

Chrome ではページ内検索はブラウザの UI として C++ で実装されています.ですが,Electron は Chromium のガワを取り払ったブラウザのため検索ウィンドウはありません.そこで JavaScript から触れるようにしようというのが上記 API でした. ですが,ここには1つ実は大きな問題があります.

上記のように,「検索窓」UI を自前で提供してやる必要があります.そこで,<input> タグ(Shiba の場合は Polymer を使っているのでデザインを揃えるために <paper-input>)を使って UI を HTML で書いていきます.いざ完成し,テキストを入れ Enter を押すと異変に気づきます.

最初の検索はうまくいき,最初のマッチがハイライトされますが,次の Enter を入力してもアクティブなマッチが次のマッチに移動しません.ここで僕は remote モジュール越しにレンダラプロセスから API を呼んでいたこともあり,違う箇所を疑いだしてハマってしまいました.

実際の挙動はこうでした.

  1. 最初の検索をする.
  2. ページ内検索はテキストエリアを含めた全ての文字列を検索するので,検索窓の中の <input> のテキストも含めて検索する
  3. 次の検索をしようとして検索窓にカーソルがある状態で Enter を押す.
  4. 検索窓内の <input> のテキストエリアの文字列が更新される(Enter 入力なので見かけ上は変更はない.が,内部的には更新される)
  5. ページ内のコンテンツが更新されると Chrome 組み込みの検索機能はページの最初から検索をやり直す
  6. 結果としてマッチは最初の場所から動かない(毎回 Enter 入力で検索がリセットされてしまう)

Electron はほぼ全ての UI を HTML/CSS でページ内に描画するため,入力フォームも HTML/CSS で書くのが自然です.ですが,ページ内検索機能は上記のように機能しなくなります.これを解決するにはフォームをページ外に持っていくしかありません(もっと良い方法あればぜひ教えて下さい).

さっと思いつく方法は次のどちらかです.

  • 新しい小さい検索窓用の BrowserWindow をつくり,そっちに検索ワードを入力させる
  • <webview> タグで検索窓をラップし埋め込む

どちらが良いかはアプリケーションによると思います.今回 Shiba では後者を採用しました.ウィンドウ右上の検索窓内の <paper-input><webview> でラップされた別のプロセスで動いています.よってページ内検索機能への影響がありません.

欠点としては <webview> を導入するとフォーカスの処理がやや煩雑になります.(ページ内検索をするとフォーカスが <input> から外れてしまうので <webview> の中の <input> にフォーカスを戻す必要がある)

ハマりどころ対応版の実装は JavaScript 部分では下記のようになりました.

本体側

const previous_text = '';
const webview = document.querySelector('webview');
webview.src = 'file:///path/to/search-box.html';
webview.on('ipc-message', event => {
    if (event.channel === 'search:start') {
        const text = event.args[0];
        if (previous_text === text) {
            remote.getCurrentWebContents().findInPage(text, {findNext: true});
        } else {
            remote.getCurrentWebContents().findInPage(text);
        }
    }
});

<webview> で読み込まれた HTML 側

const ipc = require('electron').ipcRenderer;
const input = document.querySelector('input');
input.addEventListener('keydown', event => {
    if (event.code === 'Enter') {
        // send() じゃないので注意!
        ipc.sendToHost('search:start', input.value);
    }
});

まとめ

冒頭にも書きましたが,思っていたより手こずってしまいました.しかし,一度分かってしまえば1時間足らずぐらいでページ内検索を実装してしまえそうです.お手軽感はあります.

飽くまで Chrome のページ内検索 APIJavaScript に露出させているだけです.なので,検索マッチのハイライトを変えられなかったりなど細かい制御は効きません.最初から React.js などの SPA 向けライブラリを使っている場合はどっちが良いか,アプリ次第になるかなと思います.