Electron製アプリが Mac App Storeへの申請で non-public APIの利用で rejectされる問題

Electron製アプリがrejectされている

Electronプロジェクトの、このGitHub issueで気づいたのですが、

どうもここ最近、具体的には、10月末から
Electron製のアプリが、Mac AppStoreでrejectされています。
issueのタイトルは5.0.10となっていますが、
別に5.0.10でなくてもrejectされています。


このあたりのAPIがprivate APIで、
これらをElectronが使用しているから、らしい。

CAContext
CALayerHost
NSAccessibilityRemoteUIElement
NSNextStepFrame
NSThemeFrame
NSURLFileTypeMappings


これらのAPIはいつから使われている。

古くから使われている。
Electron 3だと、次の3つが入っているようだ?

CAContext
CALayerHost
NSURLFileTypeMappings


そう何を隠そう、私もreject喰らったのである。
他の人がどうしているのか探したらわかったのである。


どうする?

どうしよう?
少し困る現象でありますね。

PhantomJSの代わりとしてGoogle Chromeをヘッドレスで使う。のではなくChromiumを使う。

PhantomJSの開発が止まってしまった

PhantomJSブラウザの開発が止まってしまいました。
npmでさくっと導入できるヘッドレスなブラウザで、
テストの時に便利だったのですけれどね。

Google Chromeをヘッドレスモードで使う?

そこで、代わりに、Google Chrome
ヘッドレスモードで使おうっていう考えがあります。


ただー。
PhantomJSはnpm installでインストールできた。
Google Chromeはインストールが面倒くさい。


Google Chromeのインストールは、Macなら次のURLからインストールする


Ubuntuなら、こんなインストール手順になりますかね。

wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
sudo dpkg -i google-chrome-stable_current_amd64.deb
sudo apt-get update
sudo apt-get -f install


そこでChromium

npmでChromiumをインストールする

npm install --save-dev @taku-o/chromium


とりあえず、CHROMIUM_VERSIONでバージョン指定できるようにしておいた。
が、どうやってバージョン指定できるようにしようかなぁ、ってのは
迷いどころである。

CHROMIUM_VERSION=76 npm install --save-dev @taku-o/chromium


これはWebdriverIOのテストコードなのですけれど、
おおよそChromeを使うように設定を組んでおいてから、
実行時に、Chromeの代わりにChromiumのパスを渡す感じになりますな。

# wdio.conf.js抜粋
var chromium = require('@taku-o/chromium');

exports.config = {
    ...
    capabilities: [{
        browserName: 'chrome',
        'goog:chromeOptions': {
            args: ['--headless', '--disable-gpu'],
            binary: chromium.path,  // ←←←←←←←←←← ここに注目
        },
    }],
    ...


サンプルコードと手順

mkdir webdriverio_chromium-test-samples
cd webdriverio_chromium-test-samples
npm init
mkdir -p ./test/specs
npm install --save-dev @wdio/cli @wdio/cli @wdio/local-runner @wdio/mocha-framework @wdio/spec-reporter @wdio/sync wdio-chromedriver-service chromedriver
npm install --save-dev @taku-o/chromium

# WebdriverIOのテストコード作成
vim test/specs/basic.js

# テストの設定作成
vim wdio.conf.js

# テスト実行
./node_modules/.bin/wdio wdio.conf.js
  • 実行結果
Execution of 1 spec files started at 2019-09-01T11:32:10.850Z

2019-09-01T11:32:10.886Z INFO @wdio/cli:Launcher: Run onPrepare hook
Starting ChromeDriver 76.0.3809.126 (d80a294506b4c9d18015e755cee48f953ddc3f2f-refs/branch-heads/3809@{#1024}) on port 4444
Only local connections are allowed.
Please protect ports used by ChromeDriver and related test frameworks to prevent access by malicious code.
2019-09-01T11:32:11.651Z INFO @wdio/local-runner: Start worker 0-0 with arg: wdio.conf.js
[0-0] RUNNING in chrome - /test/specs/basic.js
[0-0] 2019-09-01T11:32:12.089Z INFO @wdio/local-runner: Run worker command: run
[0-0] 2019-09-01T11:32:12.145Z INFO webdriver: [POST] http://127.0.0.1:4444/session
[0-0] 2019-09-01T11:32:12.146Z INFO webdriver: DATA { capabilities:
   { alwaysMatch: { browserName: 'chrome', 'goog:chromeOptions': [Object] },
     firstMatch: [ {} ] },
  desiredCapabilities:
   { browserName: 'chrome',
     'goog:chromeOptions':
      { args: [Array],
        binary:
         '/Users/taku-o/Desktop/webdriverio_chromium-test-samples/node_modules/@taku-o/chromium/lib/chromium/chrome-mac/Chromium.app/Contents/MacOS/Chromium' } } }
[0-0] 2019-09-01T11:32:12.879Z INFO webdriver: COMMAND navigateTo("https://webdriver.io/")
[0-0] 2019-09-01T11:32:12.880Z INFO webdriver: [POST] http://127.0.0.1:4444/session/a296b1e791776ba38f9de341c0985c1e/url
[0-0] 2019-09-01T11:32:12.881Z INFO webdriver: DATA { url: 'https://webdriver.io/' }
[0-0] 2019-09-01T11:32:14.932Z INFO webdriver: COMMAND getTitle()
[0-0] 2019-09-01T11:32:14.933Z INFO webdriver: [GET] http://127.0.0.1:4444/session/a296b1e791776ba38f9de341c0985c1e/title
[0-0] 2019-09-01T11:32:14.938Z INFO webdriver: RESULT WebdriverIO · Next-gen WebDriver test framework for Node.js
[0-0] 2019-09-01T11:32:14.940Z INFO webdriver: COMMAND deleteSession()
[0-0] 2019-09-01T11:32:14.941Z INFO webdriver: [DELETE] http://127.0.0.1:4444/session/a296b1e791776ba38f9de341c0985c1e
[0-0] PASSED in chrome - /test/specs/basic.js
2019-09-01T11:32:15.105Z INFO @wdio/cli:Launcher: Run onComplete hook

 "spec" Reporter:
------------------------------------------------------------------
[chrome  mac os x #0-0] Spec: /Users/taku-o/Desktop/webdriverio_chromium-test-samples/test/specs/basic.js
[chrome  mac os x #0-0] Running: chrome on mac os x
[chrome  mac os x #0-0]
[chrome  mac os x #0-0] webdriver.io page
[chrome  mac os x #0-0]    ✓ should have the right title
[chrome  mac os x #0-0]
[chrome  mac os x #0-0] 1 passing (2.1s)

Spec Files:      1 passed, 1 total (100% completed) in 00:00:04 


おわり

一気にやっちゃったけど、

いや、こっち使うべきかな

github.com

ボタンを長押ししたら、ショートカットキーのヒントを表示する

⌘キーを長押しした時、ショートカットのヒントアイコンを表示する

SlackのMacアプリが
⌘キーを長押しすると「⌘キー+数字キー」のショートカットの
ヒントを表示するなんてことをやってまして、

→ (ふわっと出てくる)


これは良いUIだと思ったのです。


要件

  • ⌘キーを長押ししたらアイコンを表示する
  • キーを離したらアイコンを消す


実装

こんな感じかな?

  • CSS変数で表示を切替。
  • ボタンを押してから、1秒後にアイコンを表示する。
  • ボタンを放したら、アイコンを消す。もしくはアイコン表示のアニメーション(?)を止める。
  • フォーカスが外れた時もアイコンを消す処理を入れておいた方が良さそう
    • これで長押しを表現。
    • 今はタイマーで表示制御とか、要らないんですね。


<html>
<head>
    <style>
        button {
            width: 200px;
            height: 50px;
        }

        :root {
            --shortcut-hint-delay: 1s;
            --shortcut-hint-opacity: 0;
        }
        .shortcut-hint-container {
            position: relative;
        }
        .shortcut-hint {
            position: absolute;
            width: 10px;
            left: 3px;
            top: calc(100% - 12px);
            z-index: 1;
            vertical-align: middle;
            text-align: center;
            line-height: 10px;
            font-size: 9px;
            color: white;
            background-color: black;
            border-radius: 2px;
            transition-delay: var(--shortcut-hint-delay);
            opacity: var(--shortcut-hint-opacity);
        }
    </style>

    <script>
        var hintDisplayed = false;

        window.addEventListener('keydown', function(e) {
          if (e.metaKey) {
            hintDisplayed = true;
            document.documentElement.style.setProperty('--shortcut-hint-delay', '1s');
            document.documentElement.style.setProperty('--shortcut-hint-opacity', '1');
          }
        });
        window.addEventListener('keyup', function(e) {
          if (hintDisplayed) {
            hintDisplayed = false;
            document.documentElement.style.setProperty('--shortcut-hint-delay', '0s');
            document.documentElement.style.setProperty('--shortcut-hint-opacity', '0');
          }
        });
        window.addEventListener('blur', (e) => {
          if (hintDisplayed) {
            hintDisplayed = false;
            document.documentElement.style.setProperty('--shortcut-hint-delay', '0s');
            document.documentElement.style.setProperty('--shortcut-hint-opacity', '0');
          }
        });
    </script>
</head>
<body>
    <div class="shortcut-hint-container">
        <button>保存ボタン</button>
        <label class="shortcut-hint">S</label>
    </div>
</body>
</html>



おわり

おわりだん。

AutoMator Actionの「Finder項目にフィルタを適用」はキャッシュを持っている(みたい)

遭遇した問題

MacAutoMatorという作業自動化アプリがあるのだが、

そのAutoMatorで使用できる、
「Finder項目にフィルタを適用」というアクションは、


(おそらく)実行結果のキャッシュを持っていて、
何度も何度も繰り返し処理を実行するような場合、
最新のフィルタリング結果が次のアクションに渡されない。

過去の実行結果が渡ってしまう。


よって、例えば、フォルダーアクションなどで、このフィルタリングを使うと、
・フォルダーにファイルを追加しても検知されない、
・その追加したファイルに対する処理がスルーされる、
などの問題がしばしば発生する。
発生した。


(これがうまくいかない)


対応方法

この実行結果のキャッシュの問題は、
「Finder項目にフィルタを適用」を「シェルスクリプトを実行」に置き換えて、
シェルスクリプトでフィルタリングしてしまえば解決する。

# 拡張子wavのファイルに絞って、次のアクションに回すシェルスクリプト
while read line; do
    if expr "$line" : ".*.wav$" > /dev/null
    then
        echo "$line" >&1
    fi
done


(こんな感じに)


おわり

案外、この問題には苦戦した。

Electronアプリの起動時間短縮に挑む

アプリが起動するまでの時間が短いと気持ちいい

アプリの起動は最初の一回しか発生しないのに、
その後はアプリは起動しっぱなしなのに、
この最初の起動までの時間が短いと、アプリの使用感が非常に良くなるような気がする。 気がしない?


この記事はElectronアプリの起動速度を速くするために
いろいろやったことと、その結果の記録です。
環境はMacのElectronです。


なお計測はしていたが、記録はない。
各仕組みの導入の前後で結構コードが変わってしまって、
計測の仕方も変わってしまって、
あとバイナリは残っているから調査可能だけど、面倒くさいからであります。


初回起動時の記録は捨てとこう

ところでElectronのアプリって、
ビルド後初回の起動より、2回目・3回目の方が早く起動しますね。
計測して起動時パフォーマンスチューニングするなら、初回の記録は捨てときましょ。


やったことリスト

  • require文の書き換え・遅延ローディング
  • ファイルの統合・分割・minify
  • コールバック・Promise
  • 設定ファイルを同期・非同期で読み込む
  • 設定ファイルの読み込みのタイミングをずらす
  • Electronのバージョンを変更する
  • アプリ起動時の見た目をごまかす


require文の書き換え・遅延ローディング

require文を書き換えて、ライブラリを遅延ローディングするように変更する。
すごく効く。効いた。

var log = require('electron-log');
log.error('error found.');

↓例えば、こう書き換える

var _log, log = () => { _log = _log || require('electron-log'); return _log; };
log().error('error found.');


ファイルの分割・統合・minify

Electronアプリで使用しているファイルを、

  • Webpackなどで1つにまとめたり、
  • 逆に細かく分割してファイル1つあたりを軽量にしたり、
  • ファイルをminifyしてファイルのサイズを小さくしたり。

asarに固めたElectronアプリでは効かない

全然全然本当にまったく効果なかった。
まったく効果がないので、余計な手間がビルド時に発生する分、マイナスまである。

何度となく諦めきれずに試したが、そのたびにまったく効果がないと知ることとなった。
でも、私はきっとまた試すよ。


コールバック・Promise

コールバックとPromiseは、コールバックの方が早い。
世間の流れと逆方向でありますね。

でも、仕方ない。
アプリの起動時には、ほんの僅かでも速度が欲しいのであります。
効く

// Promise
hoge().then((data1) => {
  foo(data1).then(data2) => {
    // 何かやる
  });
});
// コールバック
hoge((data1) => {
  foo(data1, (data2) => {
    // 何かやる
  });
});


設定ファイルを同期・非同期で読み込む

設定ファイルを読み込む処理があるとして、

  • 非同期で設定ファイルを読み込むより、
  • 同期で設定ファイルを読み込む方がかなり早い


早いが、もちろんデメリットあり。
アプリの処理によっては、処理がブロックされて逆に遅くなるでしょう。

更に更に、
例えば、設定ファイルがJSONであるなら、
こんな感じにrequire文を使って読み込むと、
同期での設定ファイル読み込みより更に早かったのですけど、
これはセキュリティ的な問題があるでしょうな。

var dataJson = null;
try {
  // data.jsonの読み込み
  dataJson = require(`${app.getPath('userData')}/data.json`);
} catch (e) {
  dataJson = [];
}
// 読み込み終わったらキャッシュ消しておく
delete require.cache[`${app.getPath('userData')}/data.json`];


設定ファイルの読み込みのタイミングをずらす

私はAngularを使用しているのですけど、
Renderer Processで画面を描画中に設定ファイルを読み込もうとすると、
同期であろうと非同期であろうと、ものすごく遅くなる。

その描画中の範囲を外して設定ファイルを読み込むようにすると
めちゃくちゃ早くなる。
とても良く効く


自分の環境では、同じ処理が実行タイミングの違いで、これくらい効いた。

1秒 → 0.002秒


Electronのバージョンを変更する

少し効く
Electronのバージョンを3から4にする。
何度も計測した結果、ちょっぴり早くなっていると思う。
ちょっぴり。

でも、Electronのバージョンは、あまり選択可能な要素ではないですね。

私はまだElectron4で安定動作させられなかったので、
Electron3に戻しちゃいましたけど、
いろいろ問題解決したら、もちろんElectronのバージョンを上げておきたい。

もうElectronの5が出てる?気のせいでしょ。


アプリ起動時の見た目をごまかす

設定ファイルからデータを読み込んで、 読み込み終わった順にそのデータでビューを更新すると、
画面がちらちら更新されて、体感、アプリの起動が遅く感じる。

一括でパッと表示した方が早く感じる。操作できるまでの時間変わらなくても。

設定ファイルの読み込み順と、読み込みタイミングの考慮は要るけれど、
手間暇かけると、アプリの起動時間の体感は良くなる。


おわり

おわりだよー。

gulp3のrun-sequence + エラー処理なコードを、gulp4で置き換える

いろいろ

gulp3にタスクを連続で実行するrun-sequenceというライブラリがありました。

gulpのバージョン4以降では、このrun-sequenceの代わりに、gulp.seriesを使うことになったのだが、 gulp.seriesには、run-sequenceにあった、発生したエラーを受け取るcallbackが用意されていませんでした。

try-catch文を使っても、gulp.series内で発生したエラーを拾うことはできない。
では、gulp3 → gulp4と書き換える際、run-sequenceをどのように書き換えるべきか。


gulp3 + run-sequenceのコード

run-sequenceでは、こんな感じで発生したエラーを拾えていた。

const gulp = require('gulp');
const runSequence = require('run-sequence');

gulp.task('package', (cb) => {
  runSequence('tsc-debug', '_rm-package', '_package-debug', (err) => {
    if (err) {
      gulp.start('_notifyError');
    }
    cb(err);
  });
});


gulp4 + gulp.seriesのコード

エラーを拾うコールバックがgulp.seriesに用意されていないので、
すんなりgulp4に書き換えられない。 エラー処理が抜けてしまう。

const gulp = require('gulp');

gulp.task('package',
  gulp.series('tsc-debug', '_rm-package', '_package-debug'));


gulp.seriesのコードを追ってみる

gulpの各タスクの処理はtaskWrapperで包まれる。
// node_modules/undertaker/lib/set-task.js
function taskWrapper() {
  return fn.apply(this, arguments);
}
gulp.seriesを作成時、createExtensionsが呼ばれる。
// node_modules/undertaker/lib/series.js
function series() {
  var create = this._settle ? bach.settleSeries : bach.series;
  var args = normalizeArgs(this._registry, arguments);
  var extensions = createExtensions(this); // ←←←←←←←←←←←←←←←←←←←
  var fn = create(args, extensions);
createExtensionsで、エラー処理のハンドリングが実装されている
  • エラー発生時にerrorイベントが発行される
// node_modules/undertaker/lib/helpers/createExtensions.js
function createExtensions(ee) {
  return {
    error: function(error, storage) {
      if (Array.isArray(error)) {
        error = error[0];
      }
      storage.release();
      ee.emit('error', { // ←←←←←←←←←←←←←←←←←←←
        uid: storage.uid,
        name: storage.name,
        branch: storage.branch,
        error: error,
        duration: process.hrtime(storage.startHr),
        time: Date.now(),
      });
    },
最終的にgulpでエラーを拾える
  • つまり、gulp.series内で発生したエラーは、次のようなコードで拾える。
    • gulp.on('error', エラー処理)
const gulp = require('gulp');

gulp.on('error', (err) => { // ←←←←←←←←←←←←←←←←←←←
  console.log('--- catch error');
});


gulp.seriesで発生したエラーを拾うコードの例(全体)

const gulp = require('gulp');

gulp.task('hello', (cb) => {
  console.log('hello');
  return cb();
});

gulp.task('world', (cb) => {
  console.log('world');
  return cb();
});

gulp.task('gulp', (cb) => {
  console.log('gulp');
  throw new Error('error from task "gulp"');
});

gulp.on('error', (err) => {
  console.log('--- catch error');
});

gulp.task('run', gulp.series('hello', 'world', 'gulp'));


npmのモジュールにしてみた

  • gulp3 run-sequenceの処理を、なるべくそのままgulp4.seriesに置き換えれるようにするnpmモジュール
npm install --save-dev gulp-task-error-handler


gulp3 run-sequenceのコードを、gulp4 gulp.seriesで置き換える例
  • gulp3 run-sequence code
    • 例えば、このようなコードがあったとして、
const gulp = require('gulp');
const runSequence = require('run-sequence');

gulp.task('package', (cb) => {
  runSequence('tsc-debug', '_rm-package', '_package-debug', (err) => {
    if (err) {
      gulp.start('_notifyError');
    }
    cb(err);
  });
});
  • replace with gulp-task-error-handler sample.
    • このモジュールを使うと、このように置き換えられる。
const gulp = require('gulp');
const handleError = require('gulp-task-error-handler');

gulp.task('package',
  handleError(gulp.series('tsc-debug', '_rm-package', '_package-debug'), (err) => {
    gulp.task('_notifyError')();
  })
);


おわり

おわりなのだ。

MacのElectronで"safeDialogs"の機能が動作するようになった

要約

動作しないと自分の中で評判だった、
Electronの無限アラート防止機能"safeDialogs"の問題が修正。
Electronバージョン5から動作するようになった。


BrowserWindowのsafeDialogsオプション

ElectronのBrowserWindowに、"safeDialogs"というオプションがあります。
このオプションは、例えば、
アラートダイアログが無限に繰り返し表示されるような処理が見つかった時、
そのループを停止してくれる機能です。

safeDialogs Boolean (optional)
- Whether to enable browser style consecutive dialog protection. Default is false.

https://electronjs.org/docs/api/browser-window


動作サンプル


こんな感じの無限アラートを、
Electronのrendererプロセスに仕込んで、

while (true) alert('!')


BrowserWindowのパラメータで、"safeDialogs"を指定しておくと、

function createWindow () {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      safeDialogs: true, // ←←←←←←←←←←←←
      safeDialogsMessage: 'stop dialog',
      nodeIntegration: true
    }
  })


無限アラートを検知した時に、
ループを停止するチェックボックスが表示されます。

ただし、Macでは長らく動作していなかった。

この機能、オプション導入直後から、
MacのElectronでは長らく動作していませんでした。
誰も試してなかったのかな。

無限アラートがあっても、スルーされてました。
https://github.com/electron/electron/issues/17543


これで安心

たとえ、兵庫県に住んでいてもね!!