Electronアプリで、アプリアイコンにドロップされたファイルを処理する

Finder上のアプリアイコンにファイルをドロップされた時、
Dock上のアプリアイコンにファイルをドロップされた時、
Electron製のアプリで、そのファイルを受け取って処理する機能の実装方法です。

(Macの話だったり、私製アプリのコードの断片が入ってたり、
Typescriptだったり、パラメータが雑だったりするけど、うまく読み替えて欲しい。)


アプリのデータの持ち方にもよりますけど、
・アプリが起動中にファイルをドロップする
・アプリが起動していない時にファイルをドロップする
で、処理方法を変える必要があると思う。

Info.plistでアプリがファイルを受け取れるように設定する

  • Mac環境の話
  • ファイルをアプリアイコンに落とした時に反応するように、アプリのInfo.plistを更新します。
  • 次のようなplistファイルを作って、アプリのInfo.plistに組み込みます。
  • この設定を入れないと、ファイルをドロップしても、アプリが反応しない。
  • extend.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>CFBundleDocumentTypes</key>
    <array>
      <dict>
        <key>CFBundleTypeName</key>
        <string>All Files</string>
        <key>LSHandlerRank</key>
        <string>Owner</string>
        <key>LSItemContentTypes</key>
        <array>
          <string>public.text</string>
          <string>public.data</string>
          <string>public.content</string>
        </array>
      </dict>
    </array>
  </dict>
</plist>
  • このextend.plistを、electron-packagerコマンド実行時に組み込んで、Info.plistにパラメータを追加します。
  • extend-infoで指定します。
electron-packager . YourAppName --platform=darwin --arch=x64 --extend-info=extend.plist



ファイルドロップのイベントを受け取る

  • "will-finish-launching"のタイミングで、"open-file"イベントをハンドリングする処理を登録します。(ここより前で拾うとマズイのかな?試さないけど)
  • main processのjavascript内に実装します。
  • preventDefault()した後に、ファイルをハンドリングする処理を入れます。
import {app} from 'electron';

// receive drop file to app icon event
let launchArgs = null;
app.on('will-finish-launching', () => {

  app.on('open-file', (event, filePath) => {
    event.preventDefault();

    if (myApp.mainWindow) {
      myApp.mainWindow.webContents.send('dropTextFile', filePath);
    } else {
      launchArgs = { filePath: filePath };
    }
  });

});

...(略)...


アプリアイコンにファイルをドロップする際、

  • アプリが起動済みである場合と、
  • アプリがまだ起動していない場合がありますよね。


アプリが起動済みで、BrowserWindowのインスタンスが既に作られているのであれば、
このようなファイルが渡されたよ、とイベントを飛ばせば事足ります。

if (myApp.mainWindow) {
  myApp.mainWindow.webContents.send('dropTextFile', filePath);
}

BrowserWindowインスタンス初期化時に、ファイルの情報を渡す

  • 起動していないアプリに、ファイルがドロップされた場合は、BrowserWindowのインスタンスはまだ作られていません。
  • ウィンドウのインスタンスが出来てから、イベントを投げて、ファイルの情報を渡してあげましょう。
  • (何らかの変数越しにパラメータを渡しても良いですけれど)
function showMainWindow(launchArgs): void {
  const myApp = this;
  let _launchArgs = launchArgs;

  mainWindow = new BrowserWindow({
    width: 800,
    height: 800,
    x: x,
    y: y,
    show: false, // show at did-finish-load event
  });
  mainWindow.loadURL(`file://${__dirname}/contents-main.html`);

  // main window event
  mainWindow.webContents.on('did-finish-load', () => {
    // receive drop file to app icon event
    if (_launchArgs && _launchArgs.filePath) {
      const filePath = _launchArgs.filePath; _launchArgs = null; // for window reload
      mainWindow.webContents.send('dropTextFile', filePath);
    }

    // show
    mainWindow.show();
    mainWindow.focus();
  });
  • "did-finish-load"のタイミングで、BrowserWindowインスタンスにイベントを投げています。
  • ドロップされたファイルの情報をクリアしておかないと、BrowserWindowを再読込した時に、また読み込まれてしまいますよ。
// main window event
mainWindow.webContents.on('did-finish-load', () => {
  // receive drop file to app icon event
  if (_launchArgs && _launchArgs.filePath) {
    const filePath = _launchArgs.filePath;
    _launchArgs = null;      // リロードされた時のために変数の中身を消しておく
    mainWindow.webContents.send('dropTextFile', filePath);
  }

  // show
  mainWindow.show();
  mainWindow.focus();
});

renderer processに渡されたファイルを読み込む

  • renderer processで渡されたファイルを読み込みます。
  • 起動済みのアプリにイベントを投げられた場合、起動前のアプリにイベントを投げられた場合で、どちらも同じインターフェイスで処理出来ると良いですね。
  • ここではipcRendererを使って処理しています。
// dropTextFile event
ipcRenderer.on('dropTextFile', (event, filePath: string) => {
  fs.readFile(filePath, 'utf-8', (err: Error, data: string) => {
    if (err) {
      MessageService.error('テキストファイルを読み込めませんでした。', err);
      return;
    }

    const win = remote.getCurrentWindow();
    win.focus();
    $scope.yinput.source = data;
    $timeout(() => { $scope.$apply(); });
  });
});
  • アプリアイコンにファイルを落とした時は、Electronアプリにはフォーカスが当たっていません。
  • このままでは、Electronアプリの画面のデータが更新されない、などの問題が起きます。
  • focus()を実行して、ウィンドウにフォーカスを当てましょう。
var remote = require('electron').remote;

const win = remote.getCurrentWindow();
win.focus();

makeSingleInstance

  • アプリが複数立ち上がっていると、うまく動かない場合があると思います。
  • (そういう記述を見た。いちいち検証しないよ。私のアプリではシングルインスタンスを有効にしないしね。)
  • makeSingleInstance()を実行して、アプリを複数起動できないようにして対処します。
app.makeSingleInstance((argv: string[], workingDirectory: string) => {});
  • 最新のバージョンでは、makeSingleInstance()は廃止され、代わりにrequestSingleInstanceLock()を使うことになりました。
  • https://electronjs.org/docs/api/app
const locked = app.requestSingleInstanceLock()

おわり

おわりだよ

Electronアプリは、まずelectron-localshortcut入れて、Command+R、Command+Wを潰すのがセオリーだと思う

おまえ、セオリー語れるほど、Electronやってないだろう。

globalShortcut

Electronにはショートカットキーを制御するglobalShortcutという機能があるんですけど、
これは公式ドキュメントにも書いてあるとおり、グローバルに効いてしまう。

アプリにフォーカスが当たって無くても、効いちゃうんですね。
これはこれで便利だけど、効き過ぎちゃう。

こんなCommand+Sを封じるアプリとか作ったら、あらゆるアプリのCommand+Sを封じちゃって大変ですね。
やってはいけませんよ。

'use strict';
const electron = require('electron');
const app = electron.app;
const globalShortcut = electron.globalShortcut;

app.on('ready', function() {
  globalShortcut.register('Command+S', function() {                                                    
    // アプリが立ち上がっている間、Command+S による保存を封じるぞ
    // 嫌がらせかな?
    console.log('Command+S is pressed')
  })
});

electron-localshortcut

ということで、electron-localshortcutです。
アプリにショートカットキーをつけたいならこちらです。

npm install --save electron-localshortcut

BrowserWindowインスタンスを生成したあと、
ウィンドウ毎にショートカットキーを登録する。
昔はウィンドウ指定できなかった。今はショートカットキーを有効にするウィンドウを指定する仕様になった。

var electron = require('electron');
var app = electron.app;
var localShortcut = require("electron-localshortcut");
var mainWindow;

...(中略)...

mainWindow = new BrowserWindow({
  width: 600,
  height: 600,
  acceptFirstMouse: true,
  show: false, // show at did-finish-load event
});
mainWindow.loadURL(`file://${__dirname}/window-main.html`);

// ショートカットキー
// Command+Qでアプリを閉じる
localShortcut.register(mainWindow, 'Command+Q', function() {
  app.quit();
});

// event
mainWindow.webContents.on('did-finish-load', function() {
  mainWindow.show();
  mainWindow.focus();
});

アプリらしく

で、今回のタイトルです。
Electronのアプリを作ると、
Command+R で画面のリロード、
Command+W でウィンドウを閉じる機能が動いてしまう。
ウェブブラウザに元々ついている機能です。


アプリにもよりますけど、自分はこれ、格好悪いと思う。
とてもブラウザブラウザ(?)してる。

なので、electron-localshortcut で、これのショートカットキーを上書きして、
機能にしないようにしておくべきなのではないか。そう思うのです。

localShortcut.register(mainWindow, 'Command+R', function() {
  // do nothing
});
localShortcut.register(mainWindow, 'Command+W', function() {
  // do nothing
});

おわり

おわりです。
なんかMac前提の話してるけど、細かいことは気にしない!!!

画像をElectron側にキャッシュしておく話

Electronを業務で使っている会社とかあるらしい。
で、自分も活用してみよう。Electronでどんなアプリを作るかって考えたら、

サーバーのデータを参照するElectronアプリも作れるけれど、
でも、それだったら、Webページでも良いわけで、
Electronを使うなら、やっぱりスタンドアローンなアプリにしたいじゃないですか。

しかし、業務で扱うデータって、残念ながら、だいたいサーバー上に置いてある。
そのデータに触れずに業務に役立つアプリを作ることは難しい。

それじゃ、こういう、ローカルとサーバ上、両方にデータがあるElectronアプリにするのが良いのかな、と。

そこで画像キャッシュだ

なぜ、画像かはわからないが、今回は画像だ。
毎回データをサーバーに取りに行かずに、(作るのが重い画像とかだ)
Electronのアプリ内部でキャッシュしておこう、という話。

実装

  • まず最初にimage-cacheを入れておいて、
npm install --save image-cache
  • cacheに画像を渡す処理。cacheから画像を取り出す処理を入れる。
# renderer.js
var imageCache = require('image-cache');

...(中略)...

// tweet.user_image は 画像URL
//
// not found image cache
if (! imageCache.isCachedSync(tweet.user_image)) {
  imageCache.setCache(tweet.user_image, function(error) {
    log.error(error);
  });
  tweet.user_image_link = tweet.user_image;

// found image cache
} else {
  const img = imageCache.getCacheSync(tweet.user_image);
  tweet.user_image_link = img.data;
}
  • angular.jsだけど
  • image-cacheから取り出したcacheは、BASE64な文字になっている。そのままそれを表示して良い。
# renderer.html
<li ng-repeat="item in streams track by item.idx">
  <img ng-src="{{item.user_image_link}}" width="32" height="32">
</li>

まとめ

Electronあまり関係ない話になってしまった。

Electronの画面にHTML落とすと、画面が切り替わっちゃうよね

Electronで立ち上げたBrowserWindowの画面に、
HTMLファイルを落とすと、
そのHTMLがウィンドウ内に表示されてしまう。


格好悪い!!!

これをふせぐには

ドロップした時に、画面が差し替わる問題を防ぐには、次のようなコードを入れておくと良い。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <script type="text/javascript">
    document.ondragover = document.ondrop = function(e) {
      e.preventDefault(); return false;
    };
  </script>
</head>

ドロップされたHTML上では、nodeとElectronの機能を使える

この挙動はアプリとして格好悪いが、
それだけはなく、
落としたHTMLではnodeとElectronの機能を利用できてしまう。
(↓のファイルをウィンドウにドロップする。)

# evil.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <script src="js/evil.js"></script>
</head>
<body>
evil
</body>
</html>
# js/evil.js
var fs = require('fs');
var filePath = '/Users/your.name/Desktop/evil.txt';

fs.writeFile(filePath, 'eviiiiiiilll', function(err) {
  if (err) {
    console.log(err);
    return;
  }
  console.log('done.');
});

アプリを操作できるような状況になってるわけだから、
特にセキュリティ的な問題にならないでしょうけどね。

Electron 3.0

結局、この挙動はやっぱり良くないよね、ってことで、
Electron 3.0では、デフォルトで無効にされたのであった。

おわりまる

Electronのアプリが落ちたり、画面がまっしろになっってしまった時の、問題の追い方

Electronアプリでのエラーの追い方。

アプリが起動もしないで落ちる時

アプリをクリックして起動したら、そのまま落ちる時。
最初に追うのは main processの初期化まわりで問題が起きていないか。
electron-logを入れて、ログ出力するのが良いと思う。


electron-logを入れる (https://github.com/megahertz/electron-log)

npm install --save electron-log

main processのjavascriptコードの上の方に、エラーを拾うコードを追加する。
便利だから、このコードは入れっぱなしでリリースしても良いと思う。

var log = require('electron-log');

process.on('uncaughtException', function(err) {
  log.error('electron:event:uncaughtException');
  log.error(err);
  log.error(err.stack);
  app.quit();
});

electron-logで出力されたエラーログを見に行く。
↓この場所に出力されます。
アンインストール方法という資料があるなら、このログファイルを消す手順も追加しておきましょう。

view $HOME/Library/Logs/YourAppName/log.log

画面がまっしろになる

アプリの画面がまっしろ。何も表示されない。

  • renderer process側で、読み込めていないリソースがあるか、
  • JavaScriptでエラーが起きているのではないか?

Developer Toolを開いて、エラーが起きていないか確認しましょう。

なんらかの操作をしたら、プチっとアプリが落ちる

なんらかの操作をトリガーとして、突然、アプリが落ちる。
↑で紹介した、electron-logのログに何か出てるのではないか?
例えば、このように。
このログは main process で読み込んでいるnpm moduleが見つからなかったエラー。

[2018-10-17 20:11:26:0174] [error] Error: Cannot find module 'about-window'
    at Module._resolveFilename (module.js:485:15)
    at Function.Module._resolveFilename (/Users/taku-o/Desktop/myukkurivoice/release/myukkurivoice/MYukkuriVoice-darwin-x64/MYukkuriVoice.app/Contents/Resources/electron.asar/common/reset-search-paths.js:35:12)
    at Function.Module._load (module.js:437:25)
    at Module.require (module.js:513:17)
    at require (internal/module.js:11:18)

自分はファンクションのパラメータ名と、
変数のスペル名を間違えていて、アプリが落ちる現象に遭遇したことがある。
エラーログが出なくて苦労した。

ダイナミックライブラリを読み込んで、
誤った操作した時も、もちろんアプリが無言で落ちた。
こちらは実装している人には原因がわかりやすい。

アプリにパッケージングしてない状態では動く。パッケージングすると動かない。

electron .

では正しく動作する。electron-packagerなどでアプリに固めると、動かなくなる現象。


どこで動かなくなっているかにもよるけれど、

  • 片方にしか入っていないファイルは無いか?
  • アプリケーションのapp.asarの中に、想定通りのファイルは入っているか?
# asarコマンドで、app.asarの中を覗ける
npm install -g asar
asar e YourAppName.app/Contents/Resources/app.asar dest
  • electron-packagerのコマンドオプションでasar.unpackDirを指定していて、アプリで実行した時にはファイルパスが間違っていたりしない?
  • 実行権限の必要なファイルを、app.asarの中に入れてしまっていないか
  • electron-packagerのコマンドオプションignoreで、実行に必要なファイルを指定してないか

electron-packagerのignoreオプションはRegExpのmatchです。
typescriptのファイルを除こうとして、".ts"と指定したら

contents

という文字列にマッチして、アプリが動かなくなったことがある。

まとめ

アプリが落ちたり、エラーが出たりはしないけど、うまく動かない、は自力で追って欲しい。
まとめたら、個人開発だけど、いろんなトラブルを拾ったなぁ。

electron-json-storage、electron-storeを使ったElectronアプリのテストの話

経緯とか

Electronでよく使われる(?)設定ファイル読込系ライブラリに

がある。

これらのライブラリは"userData"に設定ファイルを置くけれど、
設定ファイルの位置を変更する機能がないので、
単体テストを実行したら、
"userData"に置いていた私が丹精込めて育てた設定ファイルが上書きされちゃったことがある。

// Macならこの位置になる
$HOME/Library/Application Support/$app_name/

どのように対応する?

調べたら、例えば、このように対応したら良いらしい。
https://github.com/electron/spectron/issues/202

Spectronでapp.start()後に、アプリ側でuserDataを変更する。
これで私の設定ファイルは守護られたのだ…

// テストコード(抜粋)
// mainSpec.ts
import {Application} from 'spectron';
import * as assert from 'assert';
import * as temp from 'temp';
temp.track();

describe('window-main', function() {
  this.timeout(10000);

  beforeEach(function() {
    const fsprefix = `_ollfrow_${Date.now().toString(36)}`;
    const dirPath = temp.mkdirSync(fsprefix);
    this.app = new Application({
      path: 'ollfrow-darwin-x64/ollfrow.app/Contents/MacOS/ollfrow',
      env: {NODE_ENV: 'test', userData: dirPath},
    });
    return this.app.start();
  });

(略)
// electron.ts テストされるコード
// テスト時はuserDataを差し替える
if (process.env.NODE_ENV == 'test' && process.env.userData) {                                  
    app.setPath('userData', process.env.userData);
}

// 設定ファイルを使ったテスト

electron-json-storage 4.0

electron-json-storageの4.0では、setDefaultDataPath()が追加され、
設定ファイルの置き場所を変更できるようになった。

electron-storeの方は相変わらず"userData"を読み書きしに行くから、上のコードはそのまま利用中なのですけれど。

Electron製のアプリの見た目をMacふうのアプリにする electron-photonの話

最近、社内でLT(https://labs.gree.jp/blog/2018/10/17291/)があって、そこで話をしたので、その時の内容を書く。
これは、その一部の話。

photonkit

Electronアプリの見た目をMacアプリふうにする、photonkitというライブラリがかつてありました。


(このようになる)

なのですが、このphotonkit。最近、開発が止まっておりまして、
例えば、表示されないアイコンがあるとか、
Pull Requestが取り込まれないとか、そういう問題が出ていました。

electron-photon

photonkitはGitHubで管理されているのでフォークできるし、それほど問題でも無いのですが、
そのうち、photonkitの替わりとなるライブラリを作る人が現れます。
それが、このelectron-photonです。

photonkitから、electron-photonへの移行

この2つ、実現したいことは同じなのですが、実装は違っています。
残念ながらライブラリを差し替えたら、「はい、移行完了」とはなりません。

  • photonkit
<!DOCTYPE html>
<html>
  <head>
    <title>Photon</title>
    <link rel="stylesheet" href="photon.css">
    <script src="app.js" charset="utf-8"></script>
  </head>

  <body>
    <div class="window">
      <header class="toolbar toolbar-header">
        <h1 class="title">Photon</h1>
      </header>
      <div class="window-content">
        ...
      </div>
    </div>
  </body>
</html>
  • electron-photon
<ph-window class="vibrancy">
  <tool-bar type="header">
    <h1 class="title">Toolbar Header</h1>
  </tool-bar>

  <window-content>
    ...
  </window-content>

  <tool-bar type="footer">
    <h1 class="title">Toolbar Footer</h1>
  </tool-bar>
</ph-window>

(↑)テンプレートの書き方も違いますね。
試しに自分のアプリで書き換えてみましたが、
デザインはかなり崩れたものになってしまいました。

まとめ

ということで、新しいライブラリが出てきましたが、
新規に導入するならまだしも、
試した感じ、
既存のphotonkitアプリを書き換えるのは、少し面倒だったよ、というお話でした。