Web Audio APIでOfflineAudioContextを作るときの長さの指定、どうしよう

OfflineAudioContextのコンストラク

Web Audio APIのOfflineAudioContextを作るとき、
コンストラクタで長さを指定しますよね。length。

  • これが十分な長さより短いと作成した音声が短くなってしまい、
  • 長すぎると最後の方に音が出力されないゾーンが出来てしまう。
const offlineCtx = new OfflineAudioContext(numberOfChannels, length, sampleRate);


けれど、いつもいつも事前に適切な長さが分かることばかりではない。
特に、Web Audio APIで音声を加工するのであれば。
例えば、playbackRateを低くしたりすると、音声が長くなってしまいますよね。


(加工後の音声の秒数でも分かれば計算で出せるけど、
音声を加工するにはOfflineAudioContextを作る必要があって、
音声を加工する前にlengthの指定が必要なんですね)

どうしよう

これで良いのか分からないけど、
自分の対応はこうしてみた。

  • でっかく作って (短いと加工した音声が入らなくなるのだから当然だ)
  • 余分な部分を切り詰めよう

あ、これ、今更だけど、オフラインに音声ファイルを出力するケースを想定した考えかもしれない。

長いOfflineAudioContextを作る

これは簡単ですね。
lengthの数値を大きくすれば良い。

const offlineCtx = new OfflineAudioContext(numberOfChannels, length, sampleRate);

切り詰める

音声が入っている場所までの長さを調べる。

ああ、そうだ。このコードは最終的にWAVファイルの出力を想定しているので、
データのサイズが偶数である必要があって、長さを計測する際、少し調整が入っているよ。

function correctFrameCount(audioBuffer: AudioBuffer): number {
  let max = 0;
  for (let i = 0; i < audioBuffer.numberOfChannels; i++) {
    const buffer: Float32Array = audioBuffer.getChannelData(i);
    const count = correctBufferLength(buffer);
    if (max < count) {
      max = count;
    }
  }
  return max;
}
function correctBufferLength(buffer: Float32Array): number {
  let pos = 0;
  for (let i = buffer.length - 1; i >= 0; i--) {
    if (buffer[i] !== 0x00) {
      pos = i;
      break;
    }
  }
  if (pos % 2 != 0) {
    pos += 1;
  }
  return pos;
}

そして、測った長さにAudioBufferを切り詰める。

function buildCorrectAudioBuffer(audioBuffer: AudioBuffer): any {
  const frameCount = correctFrameCount(audioBuffer);
  const nAudioBuffer = new AudioBuffer({
      numberOfChannels: audioBuffer.numberOfChannels,
      length: frameCount,
      sampleRate: audioBuffer.sampleRate,
  });

  for (let i = 0; i < audioBuffer.numberOfChannels; i++) {
    const buffer = audioBuffer.getChannelData(i);
    const trimmed = buffer.slice(0, frameCount);
    nAudioBuffer.copyToChannel(trimmed, i, 0);
  }
  return nAudioBuffer;
}
const newAudioBuf = buildCorrectAudioBuffer(audioBuf);

おわり

おわり。

Electronでネイティブを実行できるnode-ffiを利用できるようになるまで

はじまりはじまり

node-ffiというライブラリがある。
これを使うと、ネイティブなライブラリ・フレームワーク
JavaScriptのコードから呼び出せる。
https://github.com/node-ffi/node-ffi


なのですが、Electronから使う場合、npmでnode-ffiを入れるだけでは、
動作しないことがほとんどなのであった。
↓のようなエラーが出たり(もちろんネイティブは呼び出せない)、そもそもElectronアプリが起動しなかったり。

was compiled against a different Node.js version using
NODE_MODULE_VERSION 64. This version of Node.js requires
NODE_MODULE_VERSION 57. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).
    at process.module.(anonymous function) [as dlopen] (ELECTRON_ASAR.js:172:20)
    at Object.Module._extensions..node (module.js:598:18)
    at Object.module.(anonymous function) [as .node] (ELECTRON_ASAR.js:186:18)
    at Module.load (module.js:503:32)
    at tryModuleLoad (module.js:466:12)
    at Function.Module._load (module.js:458:3)
    at Module.require (module.js:513:17)
    at require (internal/module.js:11:18)

ちなみに環境はMacですぞ。

どうするどうする?

こうすれば絶対大丈夫、というのは、まだよく分からない。
何度か同じ問題と戦っているけど、
一度成功するパターンを掴んだら、そこで問題の追跡止めちゃうからね。仕方ないね。


以下は、最新の成功パターンである。

バージョンを決める

いろいろな理由があって、入れるバージョンは、このバージョンになった。
Electron 3系は、Nodeは10.2。
Electron 3なので、Spectron 5.0。
electron-packagerは12.1.0以降でないとダメ。

  • Electron 3.1.1
  • Node 10.2.1
  • Spectron 5.0.0
  • electron-packager 12.1.0

node 10.2.1を入れる

nvmでnodeをインストールする。

nvm ls-remote
nvm install v10.2.1
nvm use v10.2.1

npmでelectronを入れる

electron-packagerもついでに入れる。

npm install --save-dev electron@3.1.1 spectron@5.0.0 electron-rebuild electron-packager@12.1.0

node-ffi関連のライブラリを入れる

ffiを使うには、natives、bindings、debug、ms、nan、ref、ref-structあたりが必要になる。
bindings、ms、debugは直接入れなくても平気ではないか説ある。
(でも、今試しにbindings、ms、debug抜いたら、ビルドエラーになっちゃった)

npm install --save natives bindings debug ffi ms nan ref ref-struct

TypeScriptを使っているなら、定義ファイルも入れておきましょう。

npm install --save-dev @types/ffi @types/ref @types/ref-struct

ffi関連ライブラリをビルドする

electron-rebuildコマンドでnodeのライブラリをビルドする。
環境構築が上手く行かないときは、ここのビルドが失敗することが多いと思う。

./node_modules/.bin/electron-rebuild

動作確認

これでElectronアプリが起動して、
かつ、変なエラーログが出ていなければ、成功です。
Let's enjoy node-ffi.

./node_modules/.bin/electron .

おわり

おわり。

続・Electronの検索ダイアログ

Electronのページ内検索ダイアログのライブラリを作り替えた。

electron-search-dialog
https://www.npmjs.com/package/electron-search-dialog

すごいシンプルになった

作り替えたら、renderer processのjavascriptから、
このコードを呼ぶと動くようになった。簡単。
テキトーに作ってたことがバレたなガハハ!

var SearchDialog = require('electron-search-dialog').default;
 
// create instance.
var mainWindow = require('electron').remote.getCurrentWindow();
var sd = new SearchDialog(mainWindow);
 
// open search dialog
sd.openDialog();

おわり

おわりだどん。

ElectronのBrowserWindowを閉じた時に、closeでなく非表示にして、次回の再表示を早くする

ElectronのBrowserWindowのインスタンスを画面表示のたびに作り直すのは重い。


ので、閉じたり開いたりを繰り返す、サブのウィンドウでは、

  • BrowserWindowを破棄して、再度作るのではなく
  • いったん隠して、必要になったらウィンドウを再表示する

ように変えると少し早くなる。

実装

実現するには、closeイベントでevent.preventDefault()しておいて、
BrowserWindowをhide()すれば良い。

subwindow.on('close', (event) => {
  event.preventDefault();
  subwindow.hide();
});


ウィンドウ表示部分のコードはこうなった。

var mainwindow = ...;
var subwindow;

function showSubWindow(): void {
  if (subwindow && !subwindow.isDestroyed()) {
    subwindow.show();
    subwindow.focus();
    return;
  }

  subwindow = new BrowserWindow({
    parent: mainwindow,
    show: false
  });
  subwindow.loadFile('./subwindow.html');

  subwindow.webContents.on('did-finish-load', () => {
    subwindow.show();
    subwindow.focus();
  });
  subwindow.on('close', (event) => {
    event.preventDefault();
    subwindow.hide();
  });

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

BrowserWindowの初期化処理が走らなくなるので

この実装だと、BrowserWindowの再表示時に初期化処理が走らない、
テキストフィールドへのautofocusとか動作しないので、

<!DOCTYPE html>
<html>
<head>
</head>
<body>
  <form>
    <label>検索する文字列:</label>
    <input id="search-text" class="form-control" type="search" autofocus>
  </form>
</body>
</html>

うまいこと誤魔化す必要があると思う。
こんな感じに。

window.onfocus = function(){
  document.getElementById('search-text').focus();
}

終わり

おわりじゃ!

Electron用の検索ダイアログ作った

つくった

Electronアプリのページ内を検索するダイアログ。
https://www.npmjs.com/package/electron-search-dialog

使うと、こんな感じに検索ウィンドウが表示されて、
Electronのページのテキストを検索できる。
Excelの検索ダイアログに似せてみた。


npmにpublishしたので、npm installで利用できる

npm install --save electron-search-dialog


localeの設定が違えば、検索ダイアログの言語も変わる。
アメリカ語と、日本語しかサポートしてないけどね。

使い方

  • ライブラリをインストールする
npm install --save electron-search-dialog
  • 検索するウィンドウを指定して、インスタンス生成。
  • openDialog()でウィンドウを表示。
var SearchDialog = require('electron-search-dialog').default;
 
// create instance.
var win = ... // searching page (BrowserWindow)
var sd = new SearchDialog(win);
 
// open search dialog
sd.openDialog();

実際には

実際には検索ダイアログは、画面の操作をトリガーにして開かれるでしょうから、
ipcRendererで起動処理を走らせるような使い方になるでしょう。
サンプルコードがこちらにあります。


  • main.js
var {app, BrowserWindow, ipcMain} = require('electron')
var SearchDialog = require('electron-search-dialog').default;
 
let mainWindow
 
function createWindow () {
  mainWindow = new BrowserWindow({width: 800, height: 600})
  mainWindow.loadFile('index.html')
  mainWindow.on('closed', function () {
    mainWindow = null
  })
 
  // -----------------------------
  // create SearchDialog instance,
  // and add open search dialog event
  // -----------------------------
  var _searchDialog = new SearchDialog(mainWindow);
  ipcMain.on('openSearchDialog', (event, message) => {
    _searchDialog.openDialog();
  });
}
app.on('ready', createWindow)
 
app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})
app.on('activate', function () {
  if (mainWindow === null) {
    createWindow()
  }
})
  • index.html
<!DOCTYPE html>
<html>
  <body>
    <h1>Hello World!</h1>
    We are using Node.js <script>document.write(process.versions.node)</script>,
    Chromium <script>document.write(process.versions.chrome)</script>,
    and Electron <script>document.write(process.versions.electron)</script>.
    <br>
    <button id="btn">open</button>
 
    <script>
      require('./renderer.js')
    </script> 
  </body>
</html>
  • renderer.js
// -----------------------------
// at search button clicked,
// send open search dialog event.
// -----------------------------
var ipcRenderer = require('electron').ipcRenderer;
 
var btn = document.getElementById('btn');
btn.addEventListener('click', () => {
  ipcRenderer.send('openSearchDialog', 'open');
});

いろいろ

BrowserWindowベースの検索ダイアログは重い

  • それはそうだ。
  • 一回開けば、そこからは表示・非表示の切り替えなので、遅くない。
  • 検索ダイアログのBrowserWindowインスタンスを、余裕のある時にあらかじめ初期化するような作りにしておけば、全然軽く出来そう。

クリアボタン

入力のクリアボタンなんか用意しないでも、
HTML5のsearchボタンを使えば、入力クリアボタンがついてくる。

<input type="search" value="">

なのですが、検索ダイアログの縦幅が小さすぎると、
なんか格好悪くなってしまったので、入力クリアボタン入れてしまった。

Electronでのlocaleの切り替え方法

Electronでlocaleを切り替えるには、こうする。
特にAPIとかないみたい。

var {app} = require('electron')
app.commandLine.appendSwitch('lang', 'en');

おわり

このライブラリを作った理由はだいたいElectronのfindInPageが使いづらいせい。
https://electronjs.org/docs/api/web-contents#contentsfindinpagetext-options

  • 検索ワードを入力したテキストフィールドが検索マッチしたり、
  • 次の検索候補に遷移しなかったり


同じように、Electronで画面の検索を行うライブラリには、
他に、chromeのwebviewを使って検索を実現する electron-in-page-search があります。
https://www.npmjs.com/package/electron-in-page-search


おわりだよ〜

Web Audio APIのOfflineAudioContextを使うたびに増えていく、スレッド数とメモリ使用量の調査

経緯

音声を操作するWeb Audio APIという、WebブラウザAPIがあります。
そのAPIの中の、OfflineAudioContextというオブジェクトを、MacのElectron製アプリで利用していたのですが、
アプリを動作させながらアクティビティモニタを確認していたら、何かがおかしい。おかしい。


何故か、
音声を再生するごとに、
アプリが使用しているスレッドの数が増えていったのです。




↓(何回か再生)

ということがあり、これは困ったぞ、と、いろいろ調べてみたのでありました。

Electron 1.8.8 で検証

Electron バージョン 1.8.8。最初に問題を発見した環境。少し古い。
Electronに組み込まれているChromiumのバージョンは

>process.versions.chrome
"59.0.3071.115"


最小の検証コード(一部)
ボタンを押したら音声を再生する。
https://github.com/taku-o/offlineaudiocontext-memoryleak/blob/master/electron188/renderer.js

var fs = require('fs');
var audioCtx = new window.AudioContext();

function toArrayBuffer(buf) {
    var _aBuffer = new ArrayBuffer(buf.length);
    var view = new Uint8Array(_aBuffer);
    for (let i = 0; i < buf.length; ++i) {
        view[i] = buf[i];
    }
    return _aBuffer;
}

var btn = document.getElementById('play');
btn.addEventListener('click', function(elm, ev) {
    console.log('click play');

    // read
    fs.readFile('./voice.wav', function(err, bufWav) {
        if (err) {
            console.log('failed to read wav. path %s', './voice.wav');
            return false;
        }
        console.log('read wav');

        // decode audio
        var aBuffer = toArrayBuffer(bufWav);
        audioCtx.decodeAudioData(aBuffer).then(function(decodedData) {
            console.log('decode wav');

            // process audio
            var offlineCtx = new OfflineAudioContext(decodedData.numberOfChannels, decodedData.length, decodedData.sampleRate);

            var inSourceNode = offlineCtx.createBufferSource();
            inSourceNode.buffer = decodedData;

            var gainNode = offlineCtx.createGain();
            gainNode.gain.value = 0.4;
            inSourceNode.connect(gainNode);
            gainNode.connect(offlineCtx.destination);

            inSourceNode.start(0);

            // offlineCtx rendering
            offlineCtx.startRendering().then(function(renderedBuffer) {
                console.log('process wav');

                // play
                var audioNode = audioCtx.createBufferSource();
                audioNode.buffer = renderedBuffer;
                audioNode.connect(audioCtx.destination);
                audioNode.start(0);
                console.log('play processed wav');
            });
        });
    });

}, false);
発生した現象

前述した通り、OfflineAudioContextで音声を再生する毎に、動作しているスレッド数が増えていく。
メモリの消費量も徐々に増えていく。




↓(何回か再生)


アクティビティモニタで確認してみると、
"offline audio renderer"という、いかにもOfflineAudioContextに関係ありそうなスレッドが
OfflineAudioContextが不要になった後でも残り続けているようです。
いろいろ試してみましたが、Javascript側からこの問題を解消することはできませんでした。

2550 Thread_13873430: offline audio renderer
  2550 thread_start  (in libsystem_pthread.dylib) + 13  [0x7fffb3b3e08d]
    2550 _pthread_start  (in libsystem_pthread.dylib) + 286  [0x7fffb3b3e887]
      2550 _pthread_body  (in libsystem_pthread.dylib) + 180  [0x7fffb3b3e93b]
        2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x2640e7  [0x10ed4e0e7]
          2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x290709  [0x10ed7a709]
            2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x26f183  [0x10ed59183]
              2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x25a15c  [0x10ed4415c]
                2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x25a6bf  [0x10ed446bf]
                  2550 CFRunLoopRunSpecific  (in CoreFoundation) + 420  [0x7fff9e185114]
                    2550 __CFRunLoopRun  (in CoreFoundation) + 1361  [0x7fff9e1858c1]
                      2550 __CFRunLoopServiceMachPort  (in CoreFoundation) + 212  [0x7fff9e186434]
                        2550 mach_msg  (in libsystem_kernel.dylib) + 55  [0x7fffb3a4b797]
                          2550 mach_msg_trap  (in libsystem_kernel.dylib) + 10  [0x7fffb3a4c34a]

Electron 2.1.0 で検証

Electronのバージョンを上げて試してみる。
Electronを2.1.0に上げてみました。

>process.versions.chrome
"61.0.3163.100"


Electron 1.8.8で試した時のように、
音声を再生する毎にスレッド数が増えて残り続けることはなくなりました。
やった!!





しかし、ごっつりメモリを消費するようにもなってしまいました。
ギエエエエエエエ


検証コード
https://github.com/taku-o/offlineaudiocontext-memoryleak/blob/master/electron210/renderer.js

Electron 2.1.0 に終了処理を追加

これには解決方法があります。
使用済みのOfflineAudioContext、AudioContextの終了処理をすれば良いのです。
disconnectとcloseを使えば良い。

(参考) Web Audio APIの闇
https://qiita.com/zprodev/items/7fcd8335d7e8e613a01f

var fs = require('fs');

function toArrayBuffer(buf) {
    var _aBuffer = new ArrayBuffer(buf.length);
    var view = new Uint8Array(_aBuffer);
    for (let i = 0; i < buf.length; ++i) {
        view[i] = buf[i];
    }
    return _aBuffer;
}

var btn = document.getElementById('play');
btn.addEventListener('click', function(elm, ev) {
    console.log('click play');

    // read
    fs.readFile('./voice.wav', function(err, bufWav) {
        if (err) {
            console.log('failed to read wav. path %s', './voice.wav');
            return false;
        }
        console.log('read wav');

        // decode audio
        var audioCtx = new window.AudioContext();
        var aBuffer = toArrayBuffer(bufWav);
        audioCtx.decodeAudioData(aBuffer).then(function(decodedData) {
            console.log('decode wav');

            // process audio
            var offlineCtx = new OfflineAudioContext(decodedData.numberOfChannels, decodedData.length, decodedData.sampleRate);

            var inSourceNode = offlineCtx.createBufferSource();
            inSourceNode.buffer = decodedData;

            var gainNode = offlineCtx.createGain();
            gainNode.gain.value = 0.4;
            inSourceNode.connect(gainNode);
            gainNode.connect(offlineCtx.destination);

            inSourceNode.start(0);

            // offlineCtx rendering
            offlineCtx.startRendering().then(function(renderedBuffer) {
                console.log('process wav');

                // play
                var audioNode = audioCtx.createBufferSource();
                audioNode.buffer = renderedBuffer;
                audioNode.connect(audioCtx.destination);
                audioNode.onended = function(evEnd) {
                  console.log('play processed wav ended');

                  inSourceNode.disconnect();
                  gainNode.disconnect();
                  audioNode.disconnect();
                  audioCtx.close();
                  global.gc();
                  console.log('close audio node');
                };
                audioNode.start(0);
                console.log('play processed wav');
            });
        });
    });

}, false);





メモリの消費量はだいぶ減りました。
音声再生しても、スレッド数も増えません。
しかし、まだまだ音声再生するごとに、メモリを消費してしまいます。


ChromiumのDevelop ToolのHeap Snapshotで、
音声再生前、音声再生後のスナップショットを取り、
どのようなゴミが残ってしまうのかと確認すると、

"Pending activities / 1 entries",
↓
"Pending activities / 2 entries",
↓
"Pending activities / 3 entries",


OfflineAudioContextで音声を再生する毎に、
"Pending activities"というゴミが増え続けていくことがわかりました。
こいつがメモリを掴んでいるのでしょう。多分。きっと。


検証コード
https://github.com/taku-o/offlineaudiocontext-memoryleak/blob/master/electron210close/renderer.js

Electron 3.1.1 で検証

さらにElectronのバージョンを上げる。3.1.1。
ちなみに最新のElectronのバージョンは4であり、まだバージョンを上げる余地がある。
4は新しすぎるので、個人的にはまだちょっと不安ありますけれど。

>process.versions.chrome
"66.0.3359.181"






良くなった。
Heap Snapshotで確認してみたが、
"Pending activities"の数が、音声再生後に増えるようなこともない。
勝ったな!!(なにが?)


まだ少しずつはメモリの消費が増えている。
他にも問題があるのかな?
issueリストの中に該当するものがあるかもしれない。
https://bugs.chromium.org/p/chromium/issues/list?can=2&q=component:Blink%3EWebAudio&sort=-modified&colspec=ID%20Pri%20M%20Stars%20ReleaseBlock%20Component%20Status%20Owner%20Summary%20OS%20Modified


まぁでも、これぐらいなら良いでしょう。
(そ、そうかな?)


検証コード
https://github.com/taku-o/offlineaudiocontext-memoryleak/blob/master/electron311/renderer.js

おわり

以上、今回、見つけた出来事でした。


Webなら、画面をリロードすれば、画面遷移すれば、ブラウザを閉じれば、解決する程度の問題ですが、
Electronの場合は、基本的にSingle Page Applicationで、しかも、アプリは起動され続けるので、
そこそこの問題になってしまうのです。


ひとまず、MacのElectronで、Web Audio APIのOfflineAudioContextを使うなら、バージョン3以降だな!

MacのElectronアプリに、MacのHelp Bookを組み込む

Macでヘルプで検索した時に起動するアレ。Help Book。

MacでElectronアプリを作るのであれば、
Help Bookにヘルプ機能を作り込めば、よりMacライクなアプリになるのではないか。
そう考えてトライしたのであった。

作業手順

1. Help Bookを作る
2. ElectronアプリにHelp Bookを組み込む
3. ElectronアプリからHelp Bookを立ち上げる

Help Bookを作ろう

まず、ヘルプを作る。手順は下のようになる。
面倒くさい。
ヘルプ作成用のアプリを使った方が良いかもしれない。Help Crafter(http://www.putercraft.com) とか

1. ディレクトリ構造を組み立てる
2. ヘルプをHTMLで書く
3. 索引を作る
4. ExactMatch.plistを用意する


Help Bookの資料は、このあたりにあります。
https://developer.apple.com/library/archive/documentation/Carbon/Conceptual/ProvidingUserAssitAppleHelp/authoring_help/authoring_help_book.html
http://macwiki.osdn.jp/wiki/index.php/ヘルプ作成のノウハウ


ただ、これだけだと資料が足りない。
次の本にもHelp Bookの説明が書いてあるらしい。
https://www.amazon.co.jp/Cocoa-Recipes-Mac-OS-X-ebook/dp/B003HOXLGG

Help Bookのディレクトリ構造を作る

AppleのHelp Bookの資料は↓のようになっている。
なのだけど、ディレクトリ名とか、ヘルプファイルの置き場所は、推奨から変えても動作する。

SampleApp.help/
    Contents/
        Info.plist                  -- 設定
        Resources/
            shrd/                   -- アイコンなど共通の素材入れる
            index.html              -- トップページ
            search.helpindex        -- 索引
            ja.lproj/
                ExactMatch.plist    -- このキーワードで検索したら、このanchorに遷移させる、という設定
                InfoPlist.strings
                pgs/
                    about.html      -- ヘルプの各ページ
                    voicecode.html
                gfx/                -- 画像
                scrpt/              -- JavaScriptなど
                sty/                -- スタイルシート
ヘルプを書く

ヘルプはHTMLで書く。
SafariのHelp Bookをみると、割と新しいめのHTMLの書き方でも良いようです。
jQueryとか使ってた。
ハイパーリンクも、Help Book特有の書き方しなくて良い。

/Applications/Safari.app/Contents/Resources/Safari.help


HTMLは閉じタグが必要。
XMLとしてパースできないと、あとの索引作成の処理でエラーになる。

<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="ROBOTS" content="ANCHORS" />
  <meta name="KEYWORDS" content="about" />
  <meta name="AppleIcon" content="shrd/icon.png" />
  <meta name="description" content="SampleAppについて" />
  <meta name="AppleTitle" content="SampleApp Help" />
  <title>SampleApp Help</title>
  <link rel="stylesheet" type="text/css" href="../assets/css/snap.css" />
  <link rel="stylesheet" type="text/css" href="../assets/css/nav.css" />
</head>
<body>
<div class="snap-drawers" id="sidebar">
  <div class="snap-drawer snap-drawer-left">
    <div>
      <h4>アプリに関わること</h4>
      <ul>
        <li><a href="apps-about.html">SampleAppについて</a></li>
        <li><a href="apps-voicecode.html">音声記号列</a></li>
        <li><a href="apps-trouble.html">トラブルシューティング</a></li>
        <li><a href="apps-update.html">アプリを更新するには</a></li>
        <li><a href="apps-uninstall.html">アンインストールするには</a></li>
        <li><a href="apps-backup.html">設定をバックアップするには</a></li>
        <li><a href="apps-license.html">AquesTalkのライセンスについて</a></li>
        <li><a href="apps-contact.html">連絡先</a></li>
      </ul>
    </div>
  </div>
  <div class="snap-drawer snap-drawer-right"></div>
</div>

<div id="content" class="snap-content">
  <div id="toolbar">
    <a href="#" id="open-left"></a>
    <h1>SampleApp Help</h1>

    <div class="docs">
      <a name="about"></a>
      SampleAppについて
    </div>

  </div>
</div>

<script src="../assets/js/snap.min.js"></script>
<script type="text/javascript" src="../assets/js/nav.js"></script>
</body>
</html>
索引を作る

hiutilコマンドを実行して索引ファイルを作る。拡張子は.helpindex
タイトルページと同じ場所に置く。

cd SampleApp.help/Contents/Resources/ja.lproj; hiutil -C -agv -f search.helpindex .
ExactMatch.plist

ExactMatch.plistで指定すると、あるキーで検索した時に指定したanchorが検索でヒットするようになる。

<?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>about</key>
        <string>about</string>
        <key>ABOUT_SAMPLEAPP</key>
        <string>about</string>
    </dict>
</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>
    </dict>
</plist>

ElectronアプリにHelp Bookを組み込もう

作ったHelp BookをクリックしてHelpViewerを起動しても、このままではヘルプは表示されない(うげー)。


システムにHelp Bookを登録する。
アプリのInfo.plistで指定しておくと、アプリ起動時に自動的に登録してくれる。

1. Info.plistでHelp Bookを指定する
2. アプリをビルドする
3. 作ったHelp Bookを、ElectronアプリのResourcesにコピーする
4. アプリを起動して、Help Bookを登録する
Info.plistでHelp Bookを指定する

このInfo.plistは、Help BookのInfo.plistではなくて、ElectronアプリのInfo.plist。


こんな感じのxmlファイルを用意して、

  • 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>CFBundleHelpBookFolder</key>
    <string>SampleApp.help</string>
    <key>CFBundleHelpBookName</key>
    <string>SampleApp Help</string>
  </dict>
</plist>
アプリをビルドする

作ったxmlファイルをextend-infoで指定して、Electronアプリをビルドする。
ElectronアプリのInfo.plistに書き加えられる。

electron-packager . SampleApp --platform=darwin --arch=x64 --electronVersion=2.0.8 --overwrite --asar.unpackDir=execmd --extend-info=extend.plist
作ったHelp Bookを、ElectronアプリのResourcesにコピーする

Help BookをElectronアプリのResources直下にコピーする。
Help Bookはアプリと一緒に配ることになる。

SampleApp.app/Contents/Resources/SampleApp.help
アプリを起動する

アプリを起動すると、Help Bookがシステムに登録されて、Help Bookをクリックした時に起動するようになる。
YATTAZE!!!

アプリからHelp Bookを起動する

さて、Help Bookを作ったのは良いが、
Electronアプリから起動するにはどうしたら良いだろう(えー)


AppleScriptを使うのが良いかな?
↓をマルパクリした。
http://piyocast.com/as/archives/2661


これでヘルプが起動した。

use AppleScript version "2.4"
use scripting additions
use framework "Foundation"
use framework "AppKit"

set anAppName to "SampleApp"
set aTargAnchor to "about"

set hRes to openHelpBook(anAppName, aTargAnchor) of me

on openHelpBook(anAppName, aTargAnchor)
    set locBookName to getHelpBook(anAppName) of me
    if locBookName = false then return false
    current application's NSHelpManager's sharedHelpManager()'s openHelpAnchor:aTargAnchor inBook:locBookName
end openHelpBook

on getHelpBook(anAppName)
    set aWorkspace to current application's NSWorkspace's sharedWorkspace()
    set appPath to aWorkspace's fullPathForApplication:anAppName
    if appPath is equal to missing value then return false

    set locBookName to (current application's NSBundle's bundleWithPath:appPath)'s objectForInfoDictionaryKey:"CFBundleHelpBookName"
    if locBookName is equal to missing value then return false
    return locBookName
end getHelpBook
var exec = require('child_process').exec
var epath = require('electron-path');

var unpackedPath = epath.getUnpackedPath();
var cmd = unpackedPath+ '/execmd/launch.scpt';

var el = document.getElementById('launch');
el.addEventListener('click', function(){
    exec('/usr/bin/osascript '+ cmd, {}, function(err, stdout, stderr) {
        //alert(err);
    });
});

完装した感想

さて、ElectronアプリにHelp Bookを組み込んだ感想ですが。

  • BrowserWindowでヘルプを表示するのに比べて、HelpViewerの起動が遅い。
  • 取り立てて、HelpViewer上のヘルプが読みやすいわけではない。
  • Help Bookにしたら、ヘルプを書きやすくなるようなこともない。
  • Macでしか動かない。
  • 一応、他のプラットフォームに流用が効くようにヘルプを書けなくもない。
  • 検索機能はついてる。


微妙かな!!


おわり。