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でしか動かない。
  • 一応、他のプラットフォームに流用が効くようにヘルプを書けなくもない。
  • 検索機能はついてる。


微妙かな!!


おわり。

Mac用ゆっくり系ボイス再生アプリMYukkuriVoiceでユーザー辞書機能に対応した

https://github.com/taku-o/myukkurivoice
MYukkuriVoiceは、Mac用のゆっくりボイスを再生したり、録音したりする機能のあるアプリです。

ユーザー辞書機能を作ろうとして、
同系統のアプリ、SofTalk棒読みちゃんでは、自力で文章を字句解析して置き換えていたようなので、
自分もまた自力で作ろうとしていたが、
AquesTalkのライブラリで、Mac環境用の辞書作成機能が提供されたので、想定より楽に作れてしまった。


動画作成用のアプリなので、宣伝は動画で行うべきである(主張)。
https://www.nicovideo.jp/watch/sm34217643
https://www.nicovideo.jp/watch/sm34217643

続・WAVファイル。JavaScriptでwavファイルを分解する

前回、wavファイルのフォーマットを調べたので、
wavファイルをパースできるようになった。JavaScriptで。
http://d.hatena.ne.jp/taku-o/20181120/1542726865

import * as fs from 'fs';
const cTable = require('console.table');

fs.readFile('sample.wav', 'binary', (err, content) => {
  if (err) {
    console.error(err); return;
  }

  let offset = 0;
  let d = null;
  let data:any = null;
  const tables = [];
  let buffer = Buffer.from(content, 'binary');

  // 1-4 Chunk ID "RIFF"
  d = buffer.readUIntBE(offset, 4);
  data = Buffer.from(d.toString(16), 'hex').toString();
  offset += 4;
  tables.push({ position: '1-4', header: 'Chunk ID "RIFF"', data: data, });

  // 5-8 Chunk Size
  d = buffer.readUIntLE(offset, 4);
  data = d;
  offset += 4;
  tables.push({ position: '5-8', header: 'Chunk Size', data: data, });

  // 9-12  Format "WAVE"
  d = buffer.readUIntBE(offset, 4);
  data = Buffer.from(d.toString(16), 'hex').toString();
  offset += 4;
  tables.push({ position: '9-12', header: 'Format "WAVE"', data: data, });

  // 13-16 Subchunk1 ID "fmt "
  d = buffer.readUIntBE(offset, 4);
  data = Buffer.from(d.toString(16), 'hex').toString();
  offset += 4;
  tables.push({ position: '13-16', header: 'Subchunk1 ID "fmt "', data: data, });

  // 17-20 Subchunk1 Size "16"
  d = buffer.readUIntLE(offset, 4);
  data = d;
  offset += 4;
  tables.push({ position: '17-20', header: 'Subchunk1 Size', data: data, });

  // 21-22 Audio Format "1"
  d = buffer.readUIntLE(offset, 2);
  data = d;
  offset += 2;
  tables.push({ position: '21-22', header: 'Audio Format "1" PCM', data: data, });

  // 23-24 Num Channels
  d = buffer.readUIntLE(offset, 2);
  data = d;
  offset += 2;
  tables.push({ position: '23-24', header: 'Num Channels', data: data, });

  // 25-28 Sample Rate
  d = buffer.readUIntLE(offset, 4);
  data = d;
  offset += 4;
  tables.push({ position: '25-28', header: 'Sample Rate', data: data, });

  // 29-32 Byte Rate
  d = buffer.readUIntLE(offset, 4);
  data = d;
  offset += 4;
  tables.push({ position: '29-32', header: 'Byte Rate', data: data, });

  // 33-34 Block Align
  d = buffer.readUIntLE(offset, 2);
  data = d;
  offset += 2;
  tables.push({ position: '33-34', header: 'Block Align', data: data, });

  // 35-36 Bits Per Sample
  d = buffer.readUIntLE(offset, 2);
  data = d;
  offset += 2;
  tables.push({ position: '35-36', header: 'Bits Per Sample', data: data, });

  // 37-40 Subchunk2 ID "data"
  d = buffer.readUIntBE(offset, 4);
  data = Buffer.from(d.toString(16), 'hex').toString();
  offset += 4;
  tables.push({ position: '37-40', header: 'Subchunk2 ID "data"', data: data, });

  // 41-44 Subchunk2 Size
  d = buffer.readUIntLE(offset, 4);
  data = d;
  offset += 4;
  tables.push({ position: '41-44', header: 'Subchunk2 Size', data: data, });

  // 45-   Subchunk2 data

  console.table(tables);
});
  • 結果
position  header                data  
--------  --------------------  ------
1-4       Chunk ID "RIFF"       RIFF  
5-8       Chunk Size            131108
9-12      Format "WAVE"         WAVE  
13-16     Subchunk1 ID "fmt "   fmt   
17-20     Subchunk1 Size        16    
21-22     Audio Format "1" PCM  1     
23-24     Num Channels          1     
25-28     Sample Rate           44100 
29-32     Byte Rate             88200 
33-34     Block Align           2     
35-36     Bits Per Sample       16    
37-40     Subchunk2 ID "data"   data  
41-44     Subchunk2 Size        131072

パースした感想

良い勉強の題材でありました。

WAVファイルのフォーマットの確認

アプリで出力したWAVファイルが正しくないのではないか、という問題に遭遇して、
その際、バイナリファイルといろいろな資料を読みながら、
WAVファイルがどのような仕様になっているか調べたので、その時の調査メモ。

用意したもの

バイナリエディタと電卓。
大きな数値の項目はリトルエンディアンで、文字の項目はASCII。
16進数を10進数で読む。
なので、用意する電卓は↑の変換が出来るものでないと読めないですのぅ。

バイナリファイルを読む

実際に作ったWAVファイルをバイナリエディタで読んでみつつ、
どの位置にどのような値が入っているか確認。

WAVEファイル その1
bytes 定義 入っていた値 解読 説明、または検算
1-4 Chunk ID 52 49 46 46 RIFF "RIFF"固定
5-8 Chunk Size FC 72 00 00 29436 ファイルサイズ - 8bytes
ファイルサイズからChunk ID Chunk Size除いた分か
29444 - 8 = 29436
9-12 Format 57 41 56 45 WAVE "WAVE"固定
13-16 Subchunk1 ID 66 6D 74 fmt "fmt"固定
17-20 Subchunk1 Size 20 10 00 00 16 "16"固定。Subchunk1の長さ(21-36)なので
21-22 Audio Format 01 00 1 "1"固定。(PCMなら)
23-24 Num Channels 01 00 1 チャネル数
25-28 Sample Rate 40 1F 00 00 8000 サンプリングレート
29-32 Byte Rate 80 3E 00 00 16000 ブロックサイズ
SampleRate * NumChannels * BitsPerSample/8
(8000 * 1 * 16) / 8 = 16000
33-34 Block Align 02 00 2 1サンプルあたりのビット数
NumChannels * BitsPerSample / 8
(1 * 16) / 8 = 2
35-36 Bits Per Sample 10 00 16 8 bits = 8, 16 bits = 16
37-40 Subchunk2 ID 64 61 74 61 data "data"固定
41-44 Subchunk2 Size D8 72 00 00 29400 このチャンクのデータのサイズ
ここからファイルの最後までが波形データだから
ファイルサイズ - ここまでのサイズ(44)になるはず
29444 - 44 = 29400
45- data ここからファイルの最後まで波形データが入っている
WAVEファイル その2
bytes 定義 入っていた値 解読 説明、または検算
1-4 Chunk ID 52 49 46 46 RIFF "RIFF"固定
5-8 Chunk Size 24 00 02 00 131108 ファイルサイズ - 8bytes
131116 - 8 = 131108
9-12 Format 57 41 56 45 WAVE "WAVE"固定
13-16 Subchunk1 ID 66 6D 74 fmt "fmt"固定
17-20 Subchunk1 Size 20 10 00 00 16 "16"固定。
21-22 Audio Format 01 00 1 "1"固定。(PCMなら)
23-24 Num Channels 01 00 1 チャネル数
25-28 Sample Rate 44 AC 00 00 44100 サンプリングレート
29-32 Byte Rate 88 58 01 00 88200 ブロックサイズ
SampleRate * NumChannels * BitsPerSample/8
(44100 * 1 * 16) / 8 = 88200
33-34 Block Align 02 00 2 1サンプルあたりのビット数
NumChannels * BitsPerSample / 8
(1 * 16) / 8 = 2
35-36 Bits Per Sample 10 00 16 8 bits = 8, 16 bits = 16
37-40 Subchunk2 ID 64 61 74 61 data "data"固定
41-44 Subchunk2 Size 00 00 02 00 131072 このチャンクのデータのサイズ
131116 - 44 = 131072
45- data ここから波形データ

WAVEのバイナリ読んだ感想

読みやすいフォーマット。
アプリからPCMのWAVEファイルを出力すると仮定した時、
ファイルサイズの箇所以外は割と固定値になる。

RIFFの仕様

WAVEで使われているRIFF (Resource Interchange File Format)の仕様も見ておく。
なるほどなー。

bytes 定義 説明、または検算
1-4 Chunk ID ChunkのID
5-8 Chunk Size Chunkのサイズ - IDとSizeフィールドを除いた値
9- data チャンクのデータ。データのサイズが偶数で無いなら埋める

もし、WAVEファイルにiXMLチャンクを差し込むなら

WAVEファイルにiXMLチャンクという仕様があって、
ずっと、どうやってデータを入れれば良いのかサンプルが見つからなくて困っていたんだけど、
RIFFの仕様をみたら、分かってきた。
同じように入れれば良いのだろう。(試してない)

bytes 定義 入れる値 解読 説明、または検算
1-4 Chunk ID 69 58 4D 4C iXML "iXML"固定
5-8 Chunk Size iXMLのデータサイズ - 8
9- data iXMLのデータ


chunkのdataに入れるデータは次のようなものになるだろう。
role nameの所以外は固定になりそう?

<?xml version="1.0" encoding="UTF-8"?>
<BWFXML>
    <IXML_VERSION>2.02</IXML_VERSION>
    <TRACK_LIST>
        <TRACK_COUNT>1</TRACK_COUNT>
        <TRACK>
            <CHANNEL_INDEX>1</CHANNEL_INDEX>
            <INTERLEAVE_INDEX>1</INTERLEAVE_INDEX>
            <NAME>role name</NAME>
        </TRACK>
    </TRACK_LIST>
</BWFXML>

↓実際に入れるデータ

&lt;?xml version="1.0" encoding="UTF-8"?&gt;&lt;BWFXML&gt;&lt;IXML_VERSION&gt;2.02&lt;/IXML_VERSION&gt;&lt;TRACK_LIST&gt;&lt;TRACK_COUNT&gt;1&lt;/TRACK_COUNT&gt;&lt;TRACK&gt;&lt;CHANNEL_INDEX&gt;1&lt;/CHANNEL_INDEX&gt;&lt;INTERLEAVE_INDEX&gt;1&lt;/INTERLEAVE_INDEX&gt;&lt;NAME&gt;role name&lt;/NAME&gt;&lt;/TRACK&gt;&lt;/TRACK_LIST&gt;&lt;/BWFXML&gt;


結論。ダメだった。BWFの仕様に合わせる必要があって、
他にも入れないとイケナイデータがある。
それと、Chunkの順番が決まっているようだ。

マルチなウィンドウのElectronアプリで、メニュー制御する話

Electronのアプリで、ウィンドウ毎に、メニューを切り替えたいとか、
有効無効を切り替えたいことってあると思うんです。

今回はフォーカスが当たっている時だけ、メニューを有効にする方法の話。

メニューアイテムのenabledを切り替える

メニューの有効無効を切り替えるには、
メニューのアイテムのenabled属性を切り替えれば良し。


メニューを作るときに、id属性を設定しておいて。

import {Menu} from 'electron';

const menuList = [
  {
    ... (略) ...
  },
  {
    label: '辞書',
    submenu: [
      {
        id: 'dict-close', // ←←← idを設定
        enabled: false,
        label: '辞書を閉じる (&#8984;W)',
        click() { if (myApp.dictWindow) { myApp.dictWindow.close(); } },
      },
      {
        id: 'dict-tutorial', // ←←← idを設定
        enabled: false,
        label: '辞書チュートリアル',
        click() { if (myApp.dictWindow) { myApp.dictWindow.webContents.send('menu', 'tutorial'); } },
      },
      {type: 'separator'},
      {
        id: 'dict-add', // ←←← idを設定
        enabled: false,
        label: '定義データ追加 (&#8984;N)',
        click() { if (myApp.dictWindow) { myApp.dictWindow.webContents.send('menu', 'add'); } },
      },
    ]
  },
  {
    ... (略) ...
  },
];
const menuTemplate = Menu.buildFromTemplate(menuList);
Menu.setApplicationMenu(menuTemplate);

そのidでメニューを取り出して、enabled属性を切り替える。

import {Menu} from 'electron';

const dictMenuItems = [
  'dict-close',
  'dict-tutorial',
  'dict-add',
  'dict-delete',
  'dict-save',
  'dict-cancel',
  'dict-export',
  'dict-reset',
];

const menu = Menu.getApplicationMenu();
for (let m of dictMenuItems) {
  const item = menu.getMenuItemById(m);
  item.enabled = true; // ←←← enabledを切り替え
}

フォーカスされたらメニューを有効に、フォーカスが外れたらメニューを無効に

BrowserWindowがフォーカスされている時だけメニューを有効にするには、
focus、blur、それとcloseのイベントを拾う。
closeも拾って置かないと、フォーカスされた状態でウィンドウ閉じられると、
ウィンドウ閉じてるのにメニューが有効なままになる。

this.dictWindow.on('close', () => {
  disableDictMenu(); // ←←← イベントを拾って、メニューのenabledを切り替える
});
this.dictWindow.on('focus', () => {
  enableDictMenu(); // ←←← イベントを拾って、メニューのenabledを切り替える
});
this.dictWindow.on('blur', () => {
  disableDictMenu(); // ←←← イベントを拾って、メニューのenabledを切り替える
});


ウィンドウまわりのコードも含めると。

... (略) ...

function showDictWindow(): void {
  const acceptFirstMouse = this.appCfg.acceptFirstMouse;
  this.dictWindow = new BrowserWindow({
    width: width,
    height: height,
    show: false, // show at did-finish-load event
  });
  this.dictWindow.loadURL(`file://${__dirname}/contents-dict.html`);

  // window event
  this.dictWindow.webContents.on('did-finish-load', () => {
    myApp.dictWindow.show(); myApp.dictWindow.focus();
  });
  this.dictWindow.on('close', () => {
    disableDictMenu();
  });
  this.dictWindow.on('focus', () => {
    enableDictMenu();
  });
  this.dictWindow.on('blur', () => {
    disableDictMenu();
  });
}

メニューのacceleratorが邪魔な話

メニューアイテムにacceleratorを設定していると、
そのショートカットキーは、全てのウィンドウで効いてしまう。

import {Menu} from 'electron';

const menuList = [
  {
    ... (略) ...
  },
  {
    label: '辞書',
    submenu: [
      {
        role: 'quit',
        accelerator: 'Command+Q', // ←←← acceleratorってこれ
      },
    ]
  },
  {
    ... (略) ...
  },
];
const menuTemplate = Menu.buildFromTemplate(menuList);
Menu.setApplicationMenu(menuTemplate);

これが邪魔なら、なんらかの対処をいれましょう。

acceleratorを無効にするか、
有効にしたい時以外はacceleratorを切る。

終わり

オワリダヨ

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前提の話してるけど、細かいことは気にしない!!!