Electronでタスクトレイ常駐のアプリを作る

大企業やその子会社などに入ってしまうと、IT周りは世間の二周半遅れのシステムを使わされるだけでなく、「あれは駄目」「これはするな」という時代遅れの制限が山程あります。結果的にシャドーITを推進して別のセキュリティホールを空けることになるのですが、そこには目が行かないようです。

こういった規制は、あるポイントにだけしか着目しておらず、その規制の結果、送信ミスを招くといったことがあるのもしばしば。そこでフリーソフトが使えないのであれば、作るしかないので、以下のアプリを作ることにしました。今回は、Windows上で開発を行っています。

※今回の一番の肝はxlsxの読み書きとSQLite3のインストールです.

今回作成するアプリケーションの概要

要件事項

  1. タスクトレイに常駐するタイプのアプリケーションを作る
  2. メールアドレス一覧は個人単位でメンテするのではなく、ファイルサーバ上Excelファイルに対して行う
  3. ファイルはファイルサーバ上のXLSXをロードしてリスト化。dialogにてファイルの選択を実装する。
  4. タスクトレイのアイコンを叩くと、小さなウィンドウが表示され、宛先一覧が出てくる
  5. 4.の項目をクリックすると、Outlookが起動して、宛先にはまとめてメアドが挿入される
  6. 送信先一覧を確認できるようにする。
  7. 2.のデータはアプリが持ってるSQLiteのデータベースに格納しそこから取り出す。
  8. 指定時間の間隔で、xlsxファイルから最新データを自動で取得するオートリロード機能をつける。

といった具合。タスクトレイに常駐といっても、メインメニューコンテキストメニューなどは使用せず、バルーンタイプのアプリが表示される形にしたい(設定関係はコンテキストメニューで対応)。今回これをElectronで作ってみることにしました。

今回使用するモジュール等

また、SQLite3はモジュールのビルドが必要であるため、node-gyp が必要です。詳細は、後述とElectronでMySQLへ接続するの「WindowsでKeytarを使う」も参照して、使えるように準備しておいてください。

今回使用するファイル等

今回作成したテストアプリケーション

今回作成したアプリをelectron-packagerにてパッケージにしたものをアップロードしました(Windows10 64bit版)。7-zipで圧縮しています。解凍すると中に「rocketman.exe」があります。これを起動すると

  1. タスクトレイに常駐します。db.sqlite3が無い場合には自動で作成され、テーブルも自動で作成されます。
  2. Ctrl + Mキーいつでもリストを呼び出せるようになります。
  3. レジストリのスタートアップにrocketman.exeが自動登録され、次回起動時には自動的に起動します。
  4. 終了するとCtrl+Mのショートカットキーを解除します。
  5. アップデートで指定のxlsxからメアドリストを取得し、db.sqlite3に格納します。

図:設定画面とトレイのコンテキストメニューの様子

事前準備

追加モジュールのインストール

まずは普通にモジュールをインストールします。一部のモジュールはリビルドが必要なケースがあるので、注意が必要です。プロジェクトフォルダを作り、npm init -ypackage.jsonを作った後に、以下のコマンドでインストールします。

npm i xlsx
npm i auto-launch --save
npm i jquery
npm i electron-store
npm i promise
npm i node-cron --save
npm i about-window

auto-launchモジュールの注意点

このモジュールは、WindowsやmacOS起動時に自動的にアプリを起動するように「レジストリ」や「ログイン項目」に登録してくれる素敵なモジュールです。特に今回のような「トレイ常駐型」アプリの場合、手動でいちいち起動するというのも億劫なので、もってこいなのですが、開発中だけ注意点があります。

開発中にこのモジュールを使ってコードを書き、electron .でデバッグテストをすると、electron本体のexeが自動起動に登録されてしまい、毎回起動時に空のelectronが起動してしまいます。ですので、electron-packagerなどで最終リリース直前にコードを追加して、使うようにすると良いでしょう。

なお、Windowsの場合、レジストリは以下の場所にexe名で登録されます。アプリを手動削除しても、このレジストリのエントリは残りますが、特に問題は起きないので気になるようでしたら、インストーラなどを使って、アンインストール時に該当のエントリーを削除するようにすれば良いです。

//自動起動のレジストリ登録先
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run

図:こんな感じに自動起動が登録される

SQLite3モジュールのインストール

今回はWindows10上で開発を行っていますが、以前のMySQL接続アプリの時と同様に、SQLiteのインストールは面倒な壁があります。以前は、Keytarモジュールのインストールでリビルドする為の環境を構築しました。その後、electron-rebuildを行えば使えましたが、今回のSQLiteはその手順では、Electronでは使えるようになりません。参考になったサイトは以下のサイトです。

また、keytarの事例ですが、electron5.0.0とkeytar4.6.0でのネイティブモジュールビルド環境の整備をまとめましたので、合わせてご覧ください。

electron@5.0.0でkeytar@4.6.0をWindowsで使う2020年版

sqlite3モジュールの際には、以下の問題があります。

  1. keytarモジュールの場合、msvsは2017でnode-gypのビルドを通過する事ができましたが、SQLite3ではmsvsは2015でないと通過ができない。
  2. また、npmで配信されているモジュールではなく、ソースからビルドしなければ動かない。
  3. sqlite3が対応できているのはelectron 3.x系。また、その場合、sqlite3は4.0.2ではビルド通過を確認。
  4. ただし、electron 5.x系以降は、sqlite@5.0.0が正式にElectronサポートをしたので、npm i sqlite@5.0.0にてインストールが可能(ただし、msvsは2015の指定は必要)

ちなみに、前回のMySQL接続アプリ作成時の環境で、コマンドを実行してインストールしようとしたところ、以下のようなエラーが発生した。

C:\Users\googl\Documents\rocketman>npm install sqlite3@4.0.2 --build-from-source --save --runtime=electron --target=3.0.0 --dist-url=https://atom.io/download/electron

> sqlite3@4.0.2 install C:\Users\googl\Documents\rocketman\node_modules\sqlite3
> node-pre-gyp install --fallback-to-build

node-pre-gyp WARN Using request for node-pre-gyp https download
このソリューション内のプロジェクトを 1 度に 1 つずつビルドします。並行ビルドを有効にするには、"/m" スイッチを追加してください。
C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\Common7\IDE\VC\VCTargets\Microsoft.Cpp.Platform.targets(67,5): error MSB8020: v140 (プラットフォーム ツールセット = 'v140') のビル
ド ツールが見つかりません。v140 ビルド ツールを使用してビルドするには、v140 ビルド ツールをインストールしてください。または、[プロジェクト] メニューを選択するかソリューションを 右クリックし [ソリューションの再ターゲット] を選択して、現在の Visual Studio Tools にアップグレードすることもできます。 [C:\Use
rs\googl\Documents\rocketman\node_modules\sqlite3\build\deps\action_before_build.vcxproj]

このv140というのは、Visual Studio 2015のビルドツールのバージョンで、node-gypのMSVSVersion.pyの228行目付近に書かれているものになります。この値を141に書き換えればビルドできるという情報もありますが、今回は素直に以下のコマンドで、msvs2015を追加インストールする事にしました(Powershellを管理者権限で起動して実行する必要があります)。node-gyppython, electron-rebuildは正しく動作してる環境を前提にしています。

npm i --vs2015 -g windows-build-tools

インストール完了まで相当の時間が掛かるので、お茶でも飲みながら待ちましょう。以下のような表示になったら完了です。

Now configuring the Visual Studio Build Tools..

All done!

+ windows-build-tools@5.1.0
added 2 packages from 2 contributors, removed 2 packages and updated 12 packages in 1472.176s

続けて、Powershell上で対象のプロジェクトフォルダまで移動し、以下のコマンドを打ってsqlite3をインストールしてみます。targetの3.0.0はelectronのバージョンです。

npm install sqlite3@4.0.2 --build-from-source --save --runtime=electron --target=3.0.0 --dist-url=https://atom.io/download/electron

無事にビルドが完了すると以下のような表示になり、package.jsonに記述が追加されます。これで、sqlite3がelectronで使えるようになりました。

+ sqlite3@4.0.2
added 61 packages from 46 contributors and audited 272 packages in 42.471s
found 0 vulnerabilities

SQLite3ファイルの準備

SQLite3のDBファイルを用意する必要がありますが、今回のような簡易的なアプリであれば、事前に用意せず、Node.jsのプログラム内でファイルを作り、テーブルを作ってしまったほうがてっとり速いです。Windows用にはGUIでSQLiteの管理の出来るアプリケーションがあり、作成したSQLiteのDBファイルの中身を弄る事が可能です。

今回はmaillistテーブルを用意し、ID(Auto Increment)、LISTNAME, USERNAME, ADDRESSの4つのフィールドのみなので、ソースコード内でSQLにて作成しています。一応、前項にて作成した空のファイルをダウンロードできるようにしてあります。他にもグループ名用のgrplistテーブル、NGリスト用のnglistテーブルを用意しています。

図:SQLiteファイルの中身を見てみた

マスターになるExcelファイルの準備について

node.jsのxlsxモジュールは、直接読み書きをする場合、正直言って遅いです。よって、このxlsxファイルはマスターとしてファイルサーバに配置をしておき、その中のデータはSQLite3のファイルの中に格納しておくほうがスピード面では圧倒的に有利です。よって、今回のアプリからは、リストのアップデート時だけxlsxモジュールにて読み書きを実装しています。

今回マスターとなるExcelファイルは非常に単純なもので、シート名(リスト名)とその中にユーザ名とメアド、タイプ(to cc, bccを指定)の列が用意されてるだけのです。リストを増やしたい場合には、既存のシートと同じシートを用意し、シート名を設定します。シート名がリストの名前になるので、注意してください。

※このリストをElectronアプリから設定より指定しておく必要があります。

図:リストを用意して配置しておきましょう

バージョン情報ウィンドウ

今回から、electron-about-windowモジュールを追加し、バージョン情報を出すようにしてみました。詳細はindex.jsのコードを見ていただければわかりますが、こうした小さな心遣いもこの手のアプリケーションにあると無いとでは随分違いますね。このモジュールはpackage.jsonの値も取ってきてくれるので、オプション指定を加えなくてもある程度は、この画面が出来てしまいます。

但し注意点があり、これもまたBrowserWindowなので、このまま閉じるとアプリまで終わってしまいますので、window-all-closedイベントを追加し、何もしない動作を設定しておきました。設定などで「自動起動するかどうか」のチェックボックスに応じて設定などが望ましいかもしれません。

図:バージョンナンバー表記してみた

グローバルショートカットの設定について

今回のアプリケーションはいわば、メルアドランチャーなので、いちいちタスクバーからマウス操作でメニュー出して操作するというのは、面倒です。ということで、electron標準で装備されてるglobalshortcutを利用して、いつでもどこでもCtrl+Mキー(自由に割り当ては可能)でメニューを呼び出し、またメニューウィンドウは、AlwaysOnTopにて最前列表示にしています。

また、クリック後は勝手に消えてくれたほうが良いので、HTML側にフォーカスを失ったらwindow.closeする設定も入れてあります(同時に、ESCを押してもcloseするようにしてあります)。

一方で、グローバルショートカットは、設定したショートカットを起動中はずっとキープしたままになってしまうので、よく使うようなショートカットに割り当てないように注意が必要な点と、アプリケーション終了時には、この設定を解除するようにコードを設定しておいてあげる必要性があります。

NGリストの機能について

今回のアプリでは、取得したメアドグループリストに於いて、表示したくないものはNGリストに入れることが出来るようになっています。そのため、別にNGリストテーブルを用意しており、CREATE VIEWにてクエリを作っています。これはmaillistとnglistのテーブル間で一致するものを除外した「不一致クエリ」で、NGリストテーブルにデータを追加すると、必然的にリストから消えます。

このVIEW(クエリ)は、mailmasterとして作っており、メインメニューから参照されるのはテーブルではなく、このVIEWになります。

図:VIEWはdb.sqlite3作成時に作られます。

多重起動の防止

アプリケーション自体の多重起動防止および、BrowserWindowの多重起動防止も必要になってくるかもしれません。これらは以下のようなコードをindex.jsに加える事で、多重起動防止になります。

アプリの多重起動防止

アプリの多重起動防止は簡単です。以下のコードをindex.jsに追加するだけで実現可能です。

//二重起動の防止
const doubleboot = app.requestSingleInstanceLock();
if(!doubleboot){
  app.quit();
}

ウィンドウの多重起動防止

こちらのコードは、同じウィンドウが多重で開かないようにするためのもので、例えばセッティングのウィンドウなどが多重で起動するのは好ましくないので、開いてるかチェックし、開いていたらフォーカスをオンにするだけといったシンプルなものです。

//ウィンドウの多重起動防止
if (setWindow && !setWindow.isDestroyed()) {
    setWindow.show();
    setWindow.focus();
    return;
}

ソースコード

メインプロセス側(index.js)

今回のプログラムのバックエンド部分を担当する心臓部です。設定の保存や呼び出し、xlsxファイルからのデータ取得、SQLite3ファイルへのデータの読み書き、オートリロード用のCronJob機能、トレイ表示などを担当。今回はアプリ起動時にはトレイに格納されるだけで、メインウィンドウはありません。

今回のプログラムのバックエンド部分を担当する心臓部です。設定の保存や呼び出し、xlsxファイルからのデータ取得、SQLite3ファイルへのデータの読み書き、オートリロード用のCronJob機能、トレイ表示などを担当。今回はアプリ起動時にはトレイに格納されるだけで、メインウィンドウはありません。

'use strict';

//標準モジュールの宣言
const electron = require('electron');
const { app, BrowserWindow, Tray, Menu, globalShortcut } = require('electron');
const path = require('path');
const join = require('path').join;
const { dialog } = require('electron');
const fs = require('fs');

//Node.js側とHTML側で通信をするモジュール
const ipcMain = require('electron').ipcMain;

//追加モジュールの宣言
const xlsx = require('xlsx');
const Utils = xlsx.utils;
const Store = require('electron-store');
const store = new Store();
const sqlite3 = require('sqlite3').verbose();
var dbfile = __dirname + '/db.sqlite3';  //sqlite3のファイル名設定
var Promise = require('promise');
var CronJob = require('node-cron');
const openAboutWindow = require('about-window').default;
var AutoLaunch = require('auto-launch');
var jobs;

//自動起動設定を初期化
var rocketman = new AutoLaunch({
  name:'ロケットマン',
  path:app.getPath('exe'),
});

rocketman.isEnabled()
  .then(function(isEnabled){
    if(isEnabled){
      return;
    }
    //デバッグ時にはここはコメントアウトしておこう
    rocketman.enable();
  })
  .catch(function(err){
    //エラー捕捉時の動作
  });


//オートリロード設定があれば、cronjobをセットする
var autoreload = store.get("reload");
if(autoreload == "" || autoreload == undefined){
  //リロード設定がないので、cron設定は行わない
}else{
  //crontimeを設定する(毎分)
  if(autoreload == 1){
    //1分おきに実行だけ
    var cronTime = "* * * * *";
  }else{
    //それ以外の59分まで
    var cronTime = "*/" + Number(autoreload) + " * * * *";
  }

  //引数の分を元に、分単位トリガーを設置する
  jobs = CronJob.schedule(cronTime, () => {
    updatelist(0);
  });

  console.log(autoreload + "min CronJob Setting Up");
}

//二重起動の防止
const doubleboot = app.requestSingleInstanceLock();
if(!doubleboot){
  app.quit();
}

//sqliteファイルの生成
if(fs.existsSync(dbfile)){
  //ファイルは存在するので、dbとして接続する
  var db = new sqlite3.Database(dbfile);
}else{
  //ファイルが存在しないので、dbファイルを作成して接続する
  var db = new sqlite3.Database(dbfile);

  //テーブルを作成する(グループリスト用)
  db.run('CREATE TABLE grplist (ID INTEGER PRIMARY KEY, LISTNAME STRING);');
  //テーブルを作成する(メアドリスト用)
  db.run('CREATE TABLE maillist (ID INTEGER PRIMARY KEY, LISTNAME STRING, USERNAME STRING, ADDRESS STRING, TYPE STRING);');
  //NGリスト用テーブルを作る
  db.run('CREATE TABLE nglist (ID INTEGER PRIMARY KEY, LISTNAME STRING);');
  //NGリストとの不一致クエリをつくる
  db.run('CREATE VIEW mailmaster AS SELECT maillist.* FROM maillist LEFT OUTER JOIN nglist ON maillist.LISTNAME = nglist.LISTNAME WHERE nglist.LISTNAME IS NULL;');

}

//変数の初期化
let listWindow = null;
let setWindow = null;
let mainWindow = null;
let ngWindow = null;
let tray = null;

//cronJob設定を行う
function setCronJob(timeval){
  if(jobs == "" || jobs == undefined){
    //既存のジョブ設定がないので、何もしない
  }else{
    //既存のジョブ設定を削除する
    jobs.destroy();
  }

  //crontimeを設定する(毎分)
  if(timeval == 1){
    //1分おきに実行だけ
    var cronTime = "* * * * *";
  }else{
    //それ以外の59分まで
    var cronTime = "*/" + Number(timeval) + " * * * *";
  }

  //引数の分を元に、分単位トリガーを設置する
  jobs = CronJob.schedule(cronTime, () => {
    updatelist(0);
  });

  console.log(timeval + "min CronJob Setting Up");

}

//終了時処理
process.on('exit', function(){
  //終了時メッセージ
  console.log('Exiting Application....');

  //DBをクローズする
  db.close();

  //グローバルレジスターのショートカットを解除
  globalShortcut.unregister('Ctrl+m');

  //全てのグローバルレジスターショートカットを解除
  //globalShortcut.unregisterAll()

  //終了処理
  setWindow = null;
});

//Ctrl+Cで強制終了時の処理
process.on('SIGINT',function(){
  process.exit(0);
});

//app.onセクション
app.on('ready',() => {
  //トレイアイコンを設定
  tray = new Tray(__dirname + '/img/trayicon.ico')

  //トレイのコンテキストメニューを設定
  const contextMenu = Menu.buildFromTemplate([
    {label:'設定', click(menuItem){
      setwindowopen();  //setting.htmlを開く
    }},
    {label:'リスト表示',click(menuItem){
      listwindowopen(); //list.htmlを開く
    }},
    {label:'NGリスト表示',click(menuItem){
      ngwindowopen(); //nglist.htmlを開く
    }},
    {label:'アップデート',click(menuItem){
      //マスターファイルに接続してSQLite3内のデータを更新する
      updatelist(1);
    }},
    {type:'separator'},
    {label:'バージョン',click(menuItem){
      openAboutWindow({
        icon_path: join(__dirname,'/img/trayicon.ico'),
        copyright: 'Copyright (c) 2019 officeforest',
        package_json_dir: __dirname,
        homepage: 'https://officeforest.org/',
        product_name: '簡易宛先セット ロケットマン',
        win_title: 'バージョン情報',
        win_options:{
          parent:null,
          modal:true
        },
      });
      app.on('window-all-closed', () => {
        //なにもしない
        //これをいれておかないと、バージョン情報を閉じると、アプリも終わっちゃう
      });
    }},
    {label:'閉じる',click(menuItem){
      app.quit();
    }}
  ]);

  //ツールチップの設定
  tray.setToolTip("簡単宛先セット");

  //右クリック時にコンテキストメニュー表示をセットする
  tray.on('right-click',() =>{
    //メニューを表示
    tray.popUpContextMenu(contextMenu);
  });

  //シングルクリック時にリストを表示
  tray.on('click',() =>{
    sendList();
  });

  // グローバルショートカットキーを設定(Ctrl+mキーでメニューを表示する)
  globalShortcut.register('Ctrl+m', function() {                                                    
    //シングルクリック時と同じコマンドを割り当てる
    sendList();
  })
});

//セッティングウィンドウを表示する
function setwindowopen(){
  //セッティングウィンドウ
  setWindow = new BrowserWindow({
    'width': 550,
  	'height': 380,
  	'autoHideMenuBar':true,
  	//nodeIntegrationを有効にしないとrenderProcessでrequireを使えない。v5.0.0ではデフォルトで廃止
  	webPreferences: {
        nodeIntegration: true
    },
    'resizable':false,
    'fullscreenable':false,
    'fullscreen':false,
    'modal':true,
    'minimizable':false,
    'maximizable':false,
    'icon':__dirname + "/img/setting.ico",
    'title':'アプリの設定'
  });

  setWindow.loadURL('file://' + __dirname + '/setting.html');

  setWindow.on('closed', function() {
    //キャッシュを捨てる
    electron.session.defaultSession.clearCache(() => {})
    setWindow = null;
  });

  // 全てのウィンドウが閉じたときの処理
  app.on('window-all-closed', () => {
    //なにもしない
    //これをいれておかないと、全部のウィンドウが閉じられるとアプリまで閉じてしまう、。
  });
}

//メールアドレスリスト表示
function listwindowopen(){
  electron.session.defaultSession.clearCache(() => {})
  //セッティングウィンドウ
  listWindow = new BrowserWindow({
    'width': 700,
  	'height': 630,
  	'autoHideMenuBar':true,
  	//nodeIntegrationを有効にしないとrenderProcessでrequireを使えない。v5.0.0ではデフォルトで廃止
  	webPreferences: {
        nodeIntegration: true
    },
    'resizable':false,
    'fullscreenable':false,
    'fullscreen':false,
    'modal':true,
    'minimizable':false,
    'maximizable':false,
    'icon':__dirname + "/img/setting.ico",
    'title':'アドレスリスト'
  });

  listWindow.loadURL('file://' + __dirname + '/list.html');

  listWindow.on('closed', function() {
    //キャッシュを捨てる
    electron.session.defaultSession.clearCache(() => {})
    listWindow = null;
  });

  // 全てのウィンドウが閉じたときの処理
  app.on('window-all-closed', () => {
    //なにもしない
    //これをいれておかないと、全部のウィンドウが閉じられるとアプリまで閉じてしまう、。
  });
}

//NG解除用リストの表示
function ngwindowopen(){
  electron.session.defaultSession.clearCache(() => {})
  //セッティングウィンドウ
  ngWindow = new BrowserWindow({
    'width': 500,
  	'height': 500,
  	'autoHideMenuBar':true,
  	//nodeIntegrationを有効にしないとrenderProcessでrequireを使えない。v5.0.0ではデフォルトで廃止
  	webPreferences: {
        nodeIntegration: true
    },
    'resizable':false,
    'fullscreenable':false,
    'fullscreen':false,
    'modal':true,
    'minimizable':false,
    'maximizable':false,
    'icon':__dirname + "/img/cross.png",
    'title':'NGリスト'
  });

  ngWindow.loadURL('file://' + __dirname + '/nglist.html');

  ngWindow.on('closed', function() {
    //キャッシュを捨てる
    electron.session.defaultSession.clearCache(() => {})
    ngWindow = null;
  });

  // 全てのウィンドウが閉じたときの処理
  app.on('window-all-closed', () => {
    //なにもしない
    //これをいれておかないと、全部のウィンドウが閉じられるとアプリまで閉じてしまう、。
  });
}

//リストを表示する関数
function sendList(){
  electron.session.defaultSession.clearCache(() => {})
  //メインメニューウィンドウ
  mainWindow = new BrowserWindow({
    'width': 320,
  	'height': 450,
  	'frame':false, //フレームレスにする
    'fullscreenable':false,
    'resizable':false,
    'alwaysOnTop':true,
    'transparent':true,
    'icon':__dirname + "/img/trayicon.ico",
    'title':'ロケットマン'
  });

  mainWindow.loadURL('file://' + __dirname + '/main.html');

  //表示するポジションを設定
  var position = getWindowPosition();
  mainWindow.setPosition(position.x, position.y, false);

  mainWindow.on('closed', function() {
    //キャッシュを捨てる
    electron.session.defaultSession.clearCache(() => {})
    mainWindow = null;
  });

  // 全てのウィンドウが閉じたときの処理
  app.on('window-all-closed', () => {
    //なにもしない
    //これをいれておかないと、全部のウィンドウが閉じられるとアプリまで閉じてしまう、。
  });
}

//trayの位置を基準にポジションを決定する関数
const getWindowPosition = () => {
    const windowBounds = mainWindow.getBounds();
    const trayBounds = tray.getBounds();

    // Center window horizontally below the tray icon
    const x = Math.round(trayBounds.x + (trayBounds.width / 2) - (windowBounds.width / 2))
    console.log(x);
    // Position window 4 pixels vertically below the tray icon
    const y = Math.round(trayBounds.y + trayBounds.height - 500)
    console.log(y);
    return {x: x, y: y}
}

//ウィンドウをコントロールする
ipcMain.on('closeset', function( event, args ){

  //コマンド名によって処理を開始
  switch(args){
    case "setting":
      //セッティングウィンドウを非表示にする
      setWindow.close();
      break;
    case "open":
      //セッティングウィンドウを表示する
      setwindowopen();
      break;
    default:
      break;
  }
});

ipcMain.on('async', function( event, args, args2){
  //コマンド名によって処理を開始
  switch(args){
    case "init":
      //レンダラー側に送りつける設定を集める
      var array = [];
      array.push(store.get("xlsxid"));
      array.push(store.get("reload"));
      array.push(store.get("xlsxid2"));

      //レンダラー側に値を送信
      event.sender.send('init', array);
      break;
    case "keeplist":
      //sqliteのmaillistデータを取得して返す
      getlist();
      break;
    case "getsqlist":
      //sqliteのmaillistデータをgrp毎にデータを取得して返す
      getgrpsqlite();
      break;
    case "innglist":
      //受け取ったタイトルをnglistにinsertする
      insnglist(args2);
      break;
    case "getnglist":
      //nglistの全データを取得して返す
      getnglist();
      break;
    case "delnglist":
      delnglist(args2);
      break;
  }
});

//設定値関係をコントロールする
ipcMain.on('setstore', function( event, args ){
  //配列データを受け取る
  var array = args;

  //他の情報はelectron-storeで保存する
  store.set("xlsxid",array[0]);
  store.set("reload",array[1]);
  store.set("xlsxid2",array[2]);

  //セッティングウィンドウを非表示にする
  setWindow.close();

  //reloadの値が1~59分の指定があれば、cronを設定
  if(array[1]>=1 && array[1]<=59){
    setCronJob(array[1]);
  }

  //メッセージを表示
  //メッセージオプション
  var options ={
    type:'info',
    title:"設定の保存",
    button:['OK'],
    message:'セット完了',
    detail:'アプリに設定が保存されました。'
  }

  //表示する
  dialog.showMessageBox(null,options);
});

//ファイル選択ダイアログ
ipcMain.on('filedialog', function( event, args ){
  //メッセージを表示
  //メッセージオプション
  var options ={
    properties:['openFile'],
    title:"ファイルの選択",
    filters:[
      {name:'宛名リスト',extensions:['xlsx']}
    ]
  }

  //表示する
  dialog.showOpenDialog(setWindow,options,(items)=>{
    //レンダラー側に値を送信
    if(args == 0){
      //パブリックファイルとして返す
      event.sender.send('filepath', items);
    }else{
      //プライベートファイルとして返す
      event.sender.send('filepath2', items);
    }
  });
});

//マスターファイルに接続して、データをHTML側へJSON形式で返す
function getlist(){
  //maillistテーブルを空にして、データをインサートする
  db.serialize(function(){
    var selectman = new Promise(function(resolve, reject){
      //DELETE文を発行する
      db.all('SELECT * FROM mailmaster',function(err,rows){
        if(err){
          //メッセージを表示
          //メッセージオプション
          var options ={
            type:'info',
            title:"エラーメッセージ",
            button:['OK'],
            message:'データ取得エラー',
            detail:err.message
          }

          //表示する
          dialog.showMessageBox(null,options);
          return;
        }else{
          resolve(rows);
        }
      });
    });

    //データをJSON形式に加工してHTML側へ返却する
    //dlengthとrecordの2つを配列で送る
    selectman.then(function (ret){
      //データ件数を取得
      var dlength = ret.length;

      //HTML側へ送り返す
      listWindow.webContents.send('listget', [dlength,ret]);
    });
  });
}

//nglistのデータを取得して返す
function getnglist(){
  //maillistテーブルを空にして、データをインサートする
  db.serialize(function(){
    var selectman = new Promise(function(resolve, reject){
      //DELETE文を発行する
      db.all('SELECT * FROM nglist',function(err,rows){
        if(err){
          //メッセージを表示
          //メッセージオプション
          var options ={
            type:'info',
            title:"エラーメッセージ",
            button:['OK'],
            message:'データ取得エラー',
            detail:err.message
          }

          //表示する
          dialog.showMessageBox(null,options);
          return;
        }else{
          resolve(rows);
        }
      });
    });

    //データをJSON形式に加工してHTML側へ返却する
    //dlengthとrecordの2つを配列で送る
    selectman.then(function (ret){
      //データ件数を取得
      var dlength = ret.length;

      //HTML側へ送り返す
      ngWindow.webContents.send('nglistget', [dlength,ret]);
    });
  });
}

//nglistから対象のタイトルを解除する
function delnglist(title){
  var exists = "";
  //nglistから対象のタイトルを削除する
  db.serialize(function(){
    var delngman = new Promise(function(resolve, reject){
      //DELETE文を発行する
      db.run("DELETE FROM nglist WHERE LISTNAME = '" + title + "'",function(err,res){
        if(err){
          //エラーフラグを立てる
          exists = false;
          //メッセージを表示
          //メッセージオプション
          var options ={
            type:'info',
            title:"エラーメッセージ",
            button:['OK'],
            message:'NG解除エラー',
            detail:err.message
          }

          //表示する
          if(flg == 1){
            dialog.showMessageBox(null,options);
            return;
          }
        }else{
          //エラーフラグはTrueにする
          exists = true;
          resolve(exists);
        }
      });
    });

    //delete後に配列データをinsert
    delngman.then(function (exists){
      if(exists){
        //レンダラプロセス側にリロード命令を送る
        ngWindow.webContents.send('ngreload', "OK");
      }
    });
  });
}

//マスターファイルに接続して、データをSQLite3へ流し込む
//flg == 1の時は色々メッセージを表示する
function updatelist(flg){
  //xlsxファイルのパスを取得する
  var path = store.get("xlsxid");

  //excelファイルのパスがからの場合エラー表示で終了する
  if(path == "" || path == undefined){
    //メッセージを表示
    //メッセージオプション
    var options ={
      type:'info',
      title:"エラーメッセージ",
      button:['OK'],
      message:'取り込みエラー',
      detail:'Excelのファイルが指定されていないみたいですよ。'
    }

    //表示する
    if(flg == 1){
      dialog.showMessageBox(null,options);
    }
    return;
  }

  //エラートラップ
  try{
    //Excelファイルをロードする
    let workbook = xlsx.readFile(path);

    //sheet一覧を返す
    let sheetNames = workbook.SheetNames;
    var slength = sheetNames.length;
    console.log(slength);

    //データ格納用の配列を用意
    var array = [];
    var grparray = [];

    //シートリストループを回してデータを取得する
    Promise.all(sheetNames.map(async element => {
      //リストタイトルを取得する
      var listtitle = sheetNames[i];

      //リスト名を配列に加える
      if(listtitle == "" || listtitle == undefined){
      }else{
        grparray.push(listtitle);
      }

      //シートを読み込みする
      var ss = workbook.Sheets[listtitle];

      //セルの有効レンジを取得する(ただし空白は存在すると看做される)
      var range = ss["!ref"];

      //範囲情報を数値情報へ変換する
      var dRange = Utils.decode_range(range);

      //セル情報を配列にpushする
      for (let rowIndex = dRange.s.r; rowIndex <= dRange.e.r; rowIndex++) {
        //1行目はスルーする(タイトル行の為)
        if(rowIndex == 0){
          continue;
        }

        //一時配列を用意
        var tempArray = [];

        //リストタイトルをpushする
        tempArray.push(listtitle);

        for (let colIndex = dRange.s.c; colIndex <= dRange.e.c; colIndex++) {
          //アドレス名を取得する
          const address = Utils.encode_cell({ r: rowIndex, c:colIndex });
          const cell = ss[address];

          //一時配列にpushする
          tempArray.push(cell.v);
        }

        //書き込み用配列にpushする
        array.push(tempArray);
      }
    }));
  }catch(err){
    //メッセージオプション
    var options ={
      type:'info',
      title:"エラーメッセージ",
      button:['OK'],
      message:'取り込みエラー',
      detail:"エクセルファイルが見つかりませんでした。\n" + err.message
    }

    //表示する
    if(flg == 1){
      dialog.showMessageBox(null,options);
    }
    return;
  }

  //maillistテーブルを空にして、データをインサートする
  var exists = "";
  db.serialize(function(){
    var deleteman = new Promise(function(resolve, reject){
      //DELETE文を発行する
      db.run('DELETE FROM maillist',function(err,res){
        if(err){
          //エラーフラグを立てる
          exists = false;
          //メッセージを表示
          //メッセージオプション
          var options ={
            type:'info',
            title:"エラーメッセージ",
            button:['OK'],
            message:'データインポートエラー',
            detail:err.message
          }

          //表示する
          if(flg == 1){
            dialog.showMessageBox(null,options);
            return;
          }
        }else{
          //エラーフラグはTrueにする
          exists = true;
        }
      });

      //delete文を発行する
      db.run('DELETE FROM grplist',function(err,res){
        if(err){
          //エラーフラグを立てる
          exists = false;
          //メッセージを表示
          //メッセージオプション
          var options ={
            type:'info',
            title:"エラーメッセージ",
            button:['OK'],
            message:'データインポートエラー',
            detail:err.message
          }

          //表示する
          if(flg == 1){
            dialog.showMessageBox(null,options);
            return;
          }
        }else{
          //エラーフラグはTrueにする
          exists = true;
          resolve(exists);
        }
      });
    });

    //delete後に配列データをinsert
    deleteman.then(function (exists){
      if(exists){
        //配列のデータ数を取得
        var length = array.length;
        var glength = grparray.length;


        //INSERT文のベース
        for(var i = 0;i<length;i++){
          db.run('insert into maillist (LISTNAME, USERNAME, ADDRESS, TYPE) values ($L, $U, $A, $T)',
            {
              $L: array[i][0],
              $U: array[i][1],
              $A: array[i][2],
              $T: array[i][3]
            }
          );
        }

        //grparrayをgrplistテーブルに入れる
        for(var i = 0;i<glength;i++){
          db.run('insert into grplist (LISTNAME) values ($L)',
            {
              $L: grparray[i],
            }
          );
        }

        //メッセージを表示
        //メッセージオプション
        var options ={
          type:'info',
          title:"完了メッセージ",
          button:['OK'],
          message:'データ取り込み完了',
          detail:'マスターよりメアドリスト取り込み完了しました。'
        }

        //表示する
        if(flg == 1){
          dialog.showMessageBox(null,options);
        }
      }
    });
  });

}

//SQLiteのメールデータをgrplist毎にselectして返す
function getgrpsqlite(){
  var exists = "";
  db.serialize(function(){
    var selectman = new Promise(function(resolve, reject){
      //SELECT文を発行する
      db.all('SELECT * FROM grplist',function(err,rows){
        if(err){
          //エラーフラグを立てる
          exists = false;
          //メッセージを表示
          //メッセージオプション
          var options ={
            type:'info',
            title:"エラーメッセージ",
            button:['OK'],
            message:'データ取得エラー',
            detail:err.message
          }

          //表示する
          dialog.showMessageBox(null,options);
          return;
        }else{
          //エラーフラグはTrueにする
          exists = true;
          resolve(rows);
        }
      });
    });

    //grplistを元にフィルタしてデータを取得させる
    selectman.then(function (rows){
      //リストを回して、select文を連続発行
      var makeman = new Promise(function(resolve, reject){
        Promise.all(rows.map(async element => {
          return await grpselect(element.LISTNAME);
        }));
        resolve("OK");
      })

      makeman.then(function(){
        getprvlist();
      });
    });
  });
}

//private.xlsxの中身を取得して返す
function getprvlist(){
  //private.xlsxのパスを調べる
  var path = store.get("xlsxid2");

  //pathが空ならば何もせずに終了
  if(path == undefined || path == ""){
    //空なので何もしない
  }else{
    //xlsxモジュールで値を取得する
    //Excelファイルをロードする
    let workbook = xlsx.readFile(path);

    //sheet一覧を返す
    let sheetNames = workbook.SheetNames;
    var slength = sheetNames.length;

    //各シートのデータを配列で整備する
    var array = [];
    var grparray = [];
    for(var i = 0;i<slength;i++){
      //リストタイトルを取得する
      var listtitle = sheetNames[i];

      //grparrayにlisttitleをpush
      grparray.push(listtitle);

      //シートを読み込みする
      var ss = workbook.Sheets[listtitle];

      //セルの有効レンジを取得する(ただし空白は存在すると看做される)
      var range = ss["!ref"];

      //範囲情報を数値情報へ変換する
      var dRange = Utils.decode_range(range);

      //セル情報を配列にpushする
      for (let rowIndex = dRange.s.r; rowIndex <= dRange.e.r; rowIndex++) {
        //1行目はスルーする(タイトル行の為)
        if(rowIndex == 0){
          continue;
        }

        //一時配列を用意
        var tempArray = [];

        //リストタイトルをpushする
        tempArray.push(listtitle);

        for (let colIndex = dRange.s.c; colIndex <= dRange.e.c; colIndex++) {
          //アドレス名を取得する
          const address = Utils.encode_cell({ r: rowIndex, c:colIndex });
          const cell = ss[address];

          //一時配列にpushする
          tempArray.push(cell.v);
        }

        //書き込み用配列にpushする
        array.push(tempArray);
      }
    }

    console.log(grparray);

    //2つの配列データをレンダラプロセス側へ送る
    mainWindow.webContents.send('prvlistget', grparray, array);
  }
}

//グループ名でselectして値をレンダラプロセス側へ返す
function grpselect(grpname){
  var exists = "";
  db.serialize(function(){
    var selectman = new Promise(function(resolve, reject){
      //SELECT文を発行する
      db.all("SELECT * FROM mailmaster WHERE LISTNAME = '" + grpname + "'",function(err,rows){
        if(err){
          //エラーフラグを立てる
          exists = false;
          //メッセージを表示
          //メッセージオプション
          var options ={
            type:'info',
            title:"エラーメッセージ",
            button:['OK'],
            message:'データ取得エラー',
            detail:err.message
          }

          //表示する
          dialog.showMessageBox(null,options);
          return;
        }else{
          //エラーフラグはTrueにする
          exists = true;
          resolve(rows);
        }
      });
    });

    //取得データをレンダラプロセス側へ返却する
    selectman.then(function (rows){
      //データ件数を取得
      var dlength = rows.length;

      //HTML側へ送り返す
      mainWindow.webContents.send('sqlistget', [dlength,rows]);
    });
  });
}

//nglistにタイトルをぶっこむルーチン
function insnglist(title){
  var exists = "";
  db.serialize(function(){
    var insman = new Promise(function(resolve, reject){
      db.run('insert into nglist (LISTNAME) values ($L)',
        {
          $L: title
        }
      );
    });
  });
}
  • アプリ起動時に、db.sqlite3がない場合には、自動で作成し、create tableにてテーブルの作成も行っています。
  • アプリ起動時に、オートリロードに設定値がある場合には、CronJobにて指定の分毎にxlsxからsqliteへデータを同期させるスケジュールを設定しています。
  • process.onにて終了時イベントの補足をし、db.close()するようにしています。
  • app.onのreadyイベントにて、tray化とアイコンの指定、コンテキストメニューの設定をしています。シングルクリックでメインメニュー表示(まだ未実装)、右クリックでコンテキストメニューが表示され、設定やリスト表示、アプリ終了などが出来るようにしています。
  • setwindowopenで設定画面、listwindowopenでメアドリスト表示用の画面を呼び出しています。
  • dialogモジュールはメインプロセス側からダイアログファイル選択画面を表示可能です。
  • getlist関数にて、db.allを使ってsqliteのmaillistテーブルに取得済みメアドをJSON形式でレンダラプロセス側に返しています。
  • updatelist関数にて、xlsxへ接続し、「シート名一覧を取得」「そのシートの有効セル範囲の取得」「1行目を除いたデータを配列で取得しています。
  • updatelist関数で受け取ったデータは、db.runにてinsert intoで1行ずつインサートしています。インサート前にはDELETEで一旦全データ削除も実行しています。
  • updatelist関数の引数が1の時はメッセージ表示、0の時はメッセージ非表示にしています。前者は手動アプデ時に利用し、後者はオートリロード時に利用するためこのようにしています。
  • オートリロードの設定が空の場合には、CronJobは作成されず、自動でデータの取得はされないようになっています。
  • メインのリスト表示はsendList関数です。Ctrl+Mキーでいつでも呼び出せるようにglobalshortcutを設定し、呼び出しています。
  • また、アプリ終了時に設定されたグローバルショートカットを解除するコードも追加しています(process.onのexitにて)
  • sendList関数でmain.htmlを呼び出しています。フレームレスで常に最前面に表示の状態で表示します。
  • getWindowPosition関数にて、ディスプレイ画面の右下の丁度よい位置にメインリストメニューを表示するよう座標を返しています。
  • 起動したら、次回以降自動で起動するようにauto-launchのコードが入っています。デバッグ時にはコメントアウトしましょう。
  • シートの順番どおりに取得できるように、updatelistおよびgetgrpsqliteについては、Promise.allで処理を入れてみました。

insert into、delete fromと、select fromではdbのメソッドが異なるので、注意が必要です。また、select文はselect * from tablenameで取得可能ですが、deleteなどはdelete * from tablenameではエラーになります。SQL文の方言に注意!!

レンダラプロセス側

セッティング画面(setting.html)

データの塊が入ってるxlsxファイルの指定や、自動的にxlsxからデータをリロードしてSQLite3のファイルに入れる時間間隔を指定する設定画面です。このxlsx設定がないとデータを取得できません。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <script>
      var $ = jQuery = require("jquery")
    </script>

    <script type="text/javascript" src="js/jquery-ui.min.js"></script>
    <script src="js/jquery.touch-punch.min.js"></script>
    <link rel="stylesheet" href="css/jquery-ui.css">
    <link rel="stylesheet" href="css/setting.css">

    <script src="index.js"></script>
    <script>

      $(function() {
          $( "input[type=submit], a, button" )
            .button()
            .click(function() {
            });
      });

      // IPC通信を行う
      var ipcRenderer = require( 'electron' ).ipcRenderer;
      window.onload = function () {
        //受信レンダラーの準備
        testAsync();
      };

      //メインプロセス側からの非同期に通信を受信待機する(1回だけ実行)
      function testAsync() {
        //各種雑多なメッセージを受け取る
        ipcRenderer.on('msg', function(event,arg) {
          alert(arg);
        });

        //保存済みデータを受け取る
        ipcRenderer.on('init',function(event,arg){
          //受け取ったデータをHTMLのボックスに反映する
          var array = arg;

          //配列データを各項目に入れてあげる
          document.getElementById("xlsxid").value = array[0];
          document.getElementById("reload").value = array[1];
          document.getElementById("xlsxid2").value = array[2];
        });

        //ファイルのパスを取得する
        ipcRenderer.on('filepath',function(event,arg){
          //受け取ったデータをHTMLのボックスに反映する
          var array = arg;

          //ファイルのパスを入れてあげる
          document.getElementById("xlsxid").value = array[0];
        });

        //ファイルのパスを取得する(プライベート)
        ipcRenderer.on('filepath2',function(event,arg){
          //受け取ったデータをHTMLのボックスに反映する
          var array = arg;

          //ファイルのパスを入れてあげる
          document.getElementById("xlsxid2").value = array[0];
        });

        //設定値をelectron-storeから取得する
        ipcRenderer.send('async', "init");
      }

      //キャンセル時に設定ウィンドウを閉じる
      function notsetting(){
        ipcRenderer.send('closeset', "setting");
      }

      //セッティング項目を保存する
      function savesetting(){
        //入力値のvalidation
        var validata = "";
        var array = [];

        //ファイルのパス
        validata = document.getElementById("xlsxid").value;
        if(validata == ""){
          alert("XLSXファイルが指定されていませんよ");
          document.getElementById("xlsxid").focus();
          return;
        }else{
          array.push(validata);
        }

        //オートリロードタイム
        var regex = new RegExp(/^[0-9]+$/);
        validata = document.getElementById("reload").value;

        //数値かどうかチェック
        if(regex.test(validata)){
          //1~59の値かどうかをチェック
          if(validata >= 1 && validata <= 59){
            array.push(validata);
          }else{
            //エラーを返す
            alert("1~59の範囲の値を入力してください。");
            return;
          }
        }else{
          //空白かどうかをチェック
          if(validata == "" || validata == undefined){
            //空白はよしとする
            array.push("");
          }else{
            //エラー表示
            alert("数値と空文字以外の値が入っていますよ。");
            return;
          }
        }

        //プライベート用リスト
        validata = document.getElementById("xlsxid2").value;
        if(validata == ""){
          array.push("");
        }else{
          array.push(validata);
        }

        //メインプロセスに処理を送る
        ipcRenderer.send('setstore', array);
      }

      //ファイル選択ダイアログを表示(0=public, 1=private)
      function filedialog(flag){
        //メインプロセスに処理を送る
        ipcRenderer.send('filedialog',flag);
      }

    </script>
  </head>
  <body>
    <!-- セッティング項目を表示 -->
    <form class="contact_form" action="#" method="post" name="contact_form">
        <ul>
            <li>
                 <h2>宛先ファイル設定</h2>
                 <span class="required_notification">*印は、必須入力項目です</span>
            </li>
            <li>
                <label for="uid">XLSXファイル:</label>
                <input type="uid" name="uid" placeholder=""  style="width:150px" id="xlsxid" required />
            </li>
            <li>
                <label for="uid">プライベートリスト:</label>
                <input type="uid" name="uid2" placeholder=""  style="width:150px" id="xlsxid2"/>
            </li>
            <li>
              <label for="name">オートリロード:</label>
              <input type="number" name="cron" placeholder="59"  style="width:50px" id="reload"/>
              <span class="form_hint">リストをリロードする時間(1分~59分を指定)</span>
            </li>
        </ul>
    </form>

    <p>
      <center>
        <button onClick='savesetting()' id="saveman" class="action" title='設定を保存する'>設定保存</button>
        <button onClick='notsetting()' id="cancelman" class="action" title='キャンセル'>キャンセル</button>
        <button onClick='filedialog(0)' id="fileman" class="action" title="xlsxファイルの指定">XLSXの指定</button>
        <button onClick='filedialog(1)' id="fileman2" class="action" title="プライベートファイルの指定">Privateの指定</button>
      </center>
    </p>

  </body>
</html>
  • setting.html起動時に、メインプロセスからelectron-storeで保存済みデータを取得して、反映しています。
  • ファイルの選択に於いては、メインプロセス側からファイル選択ダイアログを呼び出し、結果をテキストボックスに入力しています。
  • オートリロードは1分〜59分での指定に制限しています。
  • データ保存時には、オートリロードの値は空もしくは1〜59までの数値(正規表現でvalidation)だけを受け入れるようにしています。

図:xlsxファイルの指定とリロード時間の指定

メアドリスト表示画面(list.html)

取得したデータは、db.sqlite3ファイルのmaillistテーブルに格納されます。このデータから、現在取得済みのメールアドレスリストを一覧表示する機能の部分がlist.htmlになります。起動時にSQLite3にアクセスして全データを取得、jQuery DataTablesによって、綺麗に整形したデータを表示します。また、検索機能が素敵なので、リストのリアルタイムフィルターが気に入っています。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <script>
      var $ = jQuery = require("jquery")
    </script>

    <script type="text/javascript" src="js/jquery-ui.min.js"></script>
    <script src="js/jquery.touch-punch.min.js"></script>
    <script src="js/datatables.js"></script>
    <link rel="stylesheet" href="css/jquery-ui.css">
    <link rel="stylesheet" href="css/datatables.css">

    <script src="index.js"></script>
    <script>
      // IPC通信を行う
      var ipcRenderer = require( 'electron' ).ipcRenderer;
      window.onload = function () {
        //受信レンダラーの準備
        testAsync();

        //現在のsqliteに格納済みデータを表示
        ipcRenderer.send('async', "keeplist");
      };

      //メインプロセス側からの非同期に通信を受信待機させる(1回だけ)
      function testAsync() {
        //各種雑多なメッセージを受け取る
        ipcRenderer.on('message', function(event,arg) {
          alert(arg);
          return;
        });

        //メアドリストを取得する
        ipcRenderer.on('listget',function(event,arg){
          //JSONデータを取得する
          var json = arg;

          //データを分解
          var dlength = json[0];
          var record = json[1];

          //データ生成ルーチンへ渡す
          onList(dlength,record);
          return;
        })
      }

      //メアドリストをdatatablesに反映
      function onList(dlength,record){
        //データをパースする
        var json = record;
        var length = dlength;

        //反映先のエレメントを取得
        var sampleNode=document.getElementById("userlist");
        var html = "";

        //htmlにテーブルの初期タグ部分を格納する。
        html  = "<table id=" + "table_id" + " class='display' cellspacing='0' width='100%'>"
              + "<thead><tr>"
              + "<th>リスト名</th>"
              + "<th>ユーザ名</th>"
              + "<th>メールアドレス</th>"
              + "<th>タイプ</th>"
              + "</tr></thead>"
              + "<tfoot><tr>"
              + "<th>リスト名</th>"
              + "<th>ユーザ名</th>"
              + "<th>メールアドレス</th>"
              + "<th>タイプ</th>"
              + "</tr></tfoot>"
              + "<tbody>";

        //ループを回してシート状況を反映する
        for(var i = 0;i<length;i++){
          //テーブルタグを生成して格納する
          html  += "<tr>"
                + "<td>" + String(json[i].LISTNAME) + "</td>"
                + "<td>" + String(json[i].USERNAME) + "</td>"
                + "<td>" + String(json[i].ADDRESS) + "</td>"
                + "<td>" + String(json[i].TYPE) + "</td>"
                + "</tr>";
        }

        //htmlにテーブルの終了タグを追加
        html += "</tbody></table>";

        //生成したHTMLを置き換え
        sampleNode.innerHTML = html;

        $(document).ready(function() {
          $('#table_id').DataTable({
            "processing": true,
            "lengthChange":false,
            "order": [[ 0, "desc" ]],
            "language": {
              "emptyTable" : "データが登録されていません。",
              "info" : "_TOTAL_ 件中 _START_ 件から _END_ 件までを表示",
              "infoEmpty" : "",
              "infoFiltered" : "(_MAX_ 件からの絞り込み表示)",
              "infoPostFix" : "",
              "thousands" : ",",
              "lengthMenu" : "1ページあたりの表示件数: _MENU_",
              "loadingRecords" : "ロード中",
              "processing" : "処理中...",
              "search" : "検索",
              "zeroRecords" : "該当するデータが見つかりませんでした。",
              "paginate" : {
                "first" : "先頭",
                "previous" : "前へ",
                "next" : "次へ",
                "last" : "末尾"
              }
            }
          });
        });
      }
    </script>

    <title>アドレスリスト</title>
  </head>

  <body>
    <!-- アドレスリストを表示 -->
    <div id="userlist" style="font-size: 14px;">
      <center>
        <img border="0" src="img/ProgressSpinner.gif">
        <b><div style="color:red; font-size:10pt;">整形中...しばらくそのままお待ち下さい!!</div></b>
      </center>
    </div>
  </body>
</html>
  • list.html起動時にipcRenderer.send('async', "keeplist");にて、sqliteのデータを取得しに行っています。
  • データはIPC通信にて、メインプロセス側から「データ件数」と「データの塊(JSON形式)」で取得しています。
  • その後、onList関数にて整形して、jQuery DataTablesで整形して反映しています。

図:取得済みリストの一覧表示

メインメニュー(main.html)

このアプリの一番の機能である「メアドリストメインメニュー」を担当するファイルです。sendListから呼び出されます。コンテキストメニューの「リスト表示」およびCtrl+Mのグローバルショートカットから呼び出せます。また、escキーを押すことで、自動でウィンドウをクローズするようにもしてあります。また、ウィンドウが非アクティブになっても自動でクローズするようにしてあります。

また、NGリストへの登録および、プライベートリストの表示にも追加で対応させました。プライベートリストは赤いパネルで表示され、通常のmaster.xlsxからのデータは青いパネルで表示するように区分けしてあります。プライベートリストはNGリストに未対応なので、xlsxから直接データを削除してもらえればOKです。

今回のBoxデザインはこちらからお借りしました。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <script>
      var $ = jQuery = require("jquery")
    </script>

    <script type="text/javascript" src="js/jquery-ui.min.js"></script>
    <script src="js/jquery.touch-punch.min.js"></script>
    <link rel="stylesheet" href="css/jquery-ui.css">

    <style>
      body {
          font-family: Arial, Helvetica, sans-serif;
          text-shadow: 1px 1px lightslategray;
          color:white;
          background-color: #2b2b2b;
          /*background:rgba(0,0,0,0);*/
          -wetkit-user-select:none; /* 文字のコピペ防止 */
      }

      /*スクロールバー非表示*/
      body::-webkit-scrollbar {
        display: none;
      }

      /*BOXデザイン*/
      .box10 {
          padding: 0.5em 1em;
          margin: 1em 0;
          color: #00BCD4;
          background: #e4fcff;/*背景色*/
          border-top: solid 6px #1dc1d6;
          box-shadow: 0 3px 4px rgba(0, 0, 0, 0.32);/*影*/
          position: relative;
          z-index: 1; /* 必要であればリンク要素の重なりのベース順序指定 */
      }
      .box10 p {
          margin: 0;
          padding: 0;
      }
      .box10 a.test {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        text-indent:-999px;
        z-index: 2; /* 必要であればリンク要素の重なりのベース順序指定 */
      }
      .box2 img.tomato {
        position: absolute;
        z-index: 3;
      }
      .box10:hover {
          filter:alpha(opacity=70);/* IE 6,7*/
          -ms-filter: "alpha(opacity=70)";/* IE 8,9 */
          -moz-opacity:0.7;/* FF , Netscape */
          -khtml-opacity: 0.7;/* Safari 1.x */
          opacity:0.7;
          zoom:1;/*IE*/
      }

      /*BOXデザイン*/
      .box11 {
          padding: 0.5em 1em;
          margin: 1em 0;
          color: #ad01a4;
          background: #fdd6ff;/*背景色*/
          border-top: solid 6px #db29e8;
          box-shadow: 0 3px 4px rgba(0, 0, 0, 0.32);/*影*/
          position: relative;
          z-index: 1; /* 必要であればリンク要素の重なりのベース順序指定 */
      }
      .box11 p {
          margin: 0;
          padding: 0;
      }
      .box11 a.test {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        text-indent:-999px;
        z-index: 2; /* 必要であればリンク要素の重なりのベース順序指定 */
      }
      .box11:hover {
          filter:alpha(opacity=70);/* IE 6,7*/
          -ms-filter: "alpha(opacity=70)";/* IE 8,9 */
          -moz-opacity:0.7;/* FF , Netscape */
          -khtml-opacity: 0.7;/* Safari 1.x */
          opacity:0.7;
          zoom:1;/*IE*/
      }

      .box {
        float: left;
        width: 90%;
      }

      .box2 {
        float: left;
        width: 10%;
        text-align: center;
      }

      .boxContainer {
        overflow: hidden;
        width:100%;
      }

      /* clearfix */
      .boxContainer:before,
      .boxContainer:after {
        content: "";
        display: table;
      }

      .boxContainer:after {
        clear: both;
      }

    </style>


    <script src="index.js"></script>
    <script>
      //box10生成用カウンタ
      var cnt = 0;

      // IPC通信を行う
      var ipcRenderer = require( 'electron' ).ipcRenderer;

      //escキーでウィンドウを閉じてしまうようにする
      document.addEventListener('keydown', event => {
        if (event.key === 'Escape' || event.keyCode === 27) {
            window.close();
        }
      });

      //起動時に自動的に実行
      testAsync();

      //メインプロセス側からの非同期に通信を受信待機させる(1回だけ)
      function testAsync() {
        //現在のsqliteに格納済みデータを表示
        ipcRenderer.send('async', "getsqlist");

        //各種雑多なメッセージを受け取る
        ipcRenderer.on('msgmain', function(event,arg) {
          alert(arg);
          return;
        });

        //メアドリストを取得する
        ipcRenderer.on('sqlistget',function(event,arg){

          //レコード数とレコードをを取得する
          var length = arg[0];
          var record = arg[1];

          //mailto組み立て
          var mailto = "mailto:";
          var to = "";
          var cc = "";
          var bcc = "";
          var title = record[0].LISTNAME;
          var tempflg = 1;

          for(var i = 0;i<length;i++){
            //TYPE判定
            var type = record[i].TYPE;

            switch(type){
              case "to":
                to = to + record[i].ADDRESS + ";";
                break;
              case "cc":
                cc = cc + record[i].ADDRESS + ";";
                break;
              case "bcc":
                bcc = bcc + record[i].ADDRESS + ";";
                break;
            }
          }

          //mailto文字列を組み立てる
          if(to.length == undefined || to.length == 0){
            //宛先がないので、なにもしない
          }else{
            mailto = mailto + to;
            tempflg = tempflg + Number(1);
          }

          if(cc.length == undefined || cc.length == 0){
            //cc宛先がないので、なにもしない
          }else{
            switch(tempflg){
              case 1:
                mailto = mailto + "cc=" + cc;
                break;
              case 2:
                mailto = mailto + "?cc=" + cc;
                break;
            }
            tempflg = tempflg + Number(1);
          }

          if(bcc.length == undefined || bcc.length == 0){
            //bcc宛先がないので何もしない
          }else{
            switch(tempflg){
              case 1:
                mailto = mailto + "bcc=" + bcc;
                break;
              case 2:
                mailto = mailto + "?bcc=" + bcc;
                break;
              case 3:
                mailto = mailto + "&bcc=" + bcc;
                break;
            }
          }

          //box10のIDを生成
          var boxid = "boxid" + cnt;
          cnt = cnt + Number(1);

          //HTMLを組み立て
          var html = "";
          var linktitle = "&quot;" + title + "&quot;" + "," + "&quot;" + boxid + "&quot;";

          html = "<div class='box10' id='" + boxid +"'>"
               + "<div class='boxContainer'><div class='box'>"
               + "<p>" + title + "</p>" + "<a href='" + mailto + "' class='test'></a>"
               + "</div>"
               + "<div class='box2'>"
               + "<a href='#' onClick='potato(" + linktitle + ");'><img src='img/cross.png' class='tomato'></a>"
               + "</div>"
               + "</div></div>"

          //既存のエレメントに動的追加
          var element = $("#group").html();
          var values = element + html;

          //HTML反映
          $("#group").html(values);
          return;
        });
      }

      //プライベートリストの取得と反映
      ipcRenderer.on('prvlistget',function(event,arg,arg2){
        //配列を取得する
        var grparray = arg;
        var listarray = arg2;
        var glength = grparray.length;
        var length = listarray.length;

        //リストを生成する
        for(var i = 0;i<glength;i++){
          //mailto組み立て
          var mailto = "mailto:";
          var to = "";
          var cc = "";
          var bcc = "";
          var title = grparray[i];
          var tempflg = 1;

          //リストファイルを解析
          for(var j = 0;j<length;j++){
            //リスト名が一致するものだけを構築する
            if(title == listarray[j][0]){
              //TYPE判定
              var type = listarray[j][3];

              switch(type){
                case "to":
                  to = to + listarray[j][2] + ";";
                  break;
                case "cc":
                  cc = cc + listarray[j][2] + ";";
                  break;
                case "bcc":
                  bcc = bcc + listarray[j][2] + ";";
                  break;
              }
            }
          }

          //mailto文字列を組み立てる
          if(to.length == undefined || to.length == 0){
            //宛先がないので、なにもしない
          }else{
            mailto = mailto + to;
            tempflg = tempflg + Number(1);
          }

          if(cc.length == undefined || cc.length == 0){
            //cc宛先がないので、なにもしない
          }else{
            switch(tempflg){
              case 1:
                mailto = mailto + "cc=" + cc;
                break;
              case 2:
                mailto = mailto + "?cc=" + cc;
                break;
            }
            tempflg = tempflg + Number(1);
          }

          if(bcc.length == undefined || bcc.length == 0){
            //bcc宛先がないので何もしない
          }else{
            switch(tempflg){
              case 1:
                mailto = mailto + "bcc=" + bcc;
                break;
              case 2:
                mailto = mailto + "?bcc=" + bcc;
                break;
              case 3:
                mailto = mailto + "&bcc=" + bcc;
                break;
            }
          }

          //box11のIDを生成
          var boxid = "boxid2" + cnt;
          cnt = cnt + Number(1);

          //HTMLを組み立て
          var html = "";
          var linktitle = "&quot;" + title + "&quot;" + "," + "&quot;" + boxid + "&quot;";

          html = "<div class='box11' id='" + boxid +"'>"
               + "<div class='boxContainer'><div class='box'>"
               + "<p>" + title + "</p>" + "<a href='" + mailto + "' class='test'></a>"
               + "</div>"
               + "<div class='box2'>"
               + "</div>"
               + "</div></div>"

          //既存のエレメントに動的追加
          var element = $("#group").html();
          var values = element + html;

          //HTML反映
          $("#group").html(values);
        }
      });

      //非アクティブになった場合にクローズする
      function checkactive(){
        if(document.hasFocus()){
          //アクティブなので何もしない
        }else{
          //非アクティブなので閉じる
          window.close();
        }
      }

      //バツ印クリック時イベント
      function potato(title,id){
        //問い合わせボックス
        var res = confirm(title + "をNGリストに追加しますか?");

        //条件判定
        if(res == true){
          //対象のエレメントを非表示にする
          document.getElementById(id).style.display = "none";

          //メインプロセスのnglistに該当のタイトルをぶっこむ
          ipcRenderer.send('async', "innglist", title);
        }else{
          //キャンセル時は何もしない
        }
        return false;
      }

    </script>
  </head>

  <body onLoad="setInterval('checkactive()',250)">
    <div id="group">
    </div>
  </body>
</html>
  • DIV BOX全体にリンクを反映しているので、クリックするだけでメーラーが起動し、アドレスを自動で入れてくれます。
  • メアドの区切りはセミコロン(;)を利用しています。Outlookがデフォルトだとカンマ区切りがNGな為(設定変えれば済む話ではあるんですが・・・)
  • スクロールバーがダサいので、CSSにてスクロールバーは非表示にしています。ただし、リストが多い場合には非表示でもホイールでスクロールは可能です。
  • メインプロセス側からはシート単位でデータがレンダラプロセス側に送られてくるので、div id=groupの中身を取得して加えながら、innerHTMLで戻してあげてます。瞬時に行われるので、気になることはないと思いますが。
  • Ctrl+Mもしくはアイコンクリックで表示。Escキーもしくは非アクティブで閉じるように設定してあります。
  • プライベートリストは赤いパネル、通常のリストは青いパネルでNGアイコン付になります。

図:こんな感じに表示されます。

NGリスト(nglist.html)

現在、NG登録されているリストを表示します。また、この画面よりNG登録解除も出来るようにしてあります。基本レイアウトはlist.htmlと殆ど同じ形式です。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <script>
      var $ = jQuery = require("jquery")
    </script>

    <script type="text/javascript" src="js/jquery-ui.min.js"></script>
    <script src="js/jquery.touch-punch.min.js"></script>
    <script src="js/datatables.js"></script>
    <link rel="stylesheet" href="css/jquery-ui.css">
    <link rel="stylesheet" href="css/datatables.css">

    <script src="index.js"></script>
    <script>

      // IPC通信を行う
      var ipcRenderer = require( 'electron' ).ipcRenderer;
      window.onload = function () {
        //受信レンダラーの準備
        testAsync();

        //nglistのデータを取得する
        ipcRenderer.send('async', "getnglist");

      };

      //メインプロセス側からの非同期に通信を受信待機させる(1回だけ)
      function testAsync() {
        //メアドリストを取得する
        ipcRenderer.on('nglistget',function(event,arg){
          //JSONデータを取得する
          var json = arg;

          //データを分解
          var dlength = json[0];
          var record = json[1];

          //データ生成ルーチンへ渡す
          onList(dlength,record);
          return;
        });

        //nglistをリロードする
        ipcRenderer.on('ngreload',function(event,arg){
          //DataTablesをデストロイ
          $('#table_id').DataTable().destroy();

          //データを再取得する
          ipcRenderer.send('async', "getnglist");
        });
      }

      //メアドリストをdatatablesに反映
      function onList(dlength,record){
        //データをパースする
        var json = record;
        var length = dlength;

        //反映先のエレメントを取得
        var sampleNode=document.getElementById("userlist");
        var html = "";

        //htmlにテーブルの初期タグ部分を格納する。
        html  = "<table id=" + "table_id" + " class='display' cellspacing='0' width='100%'>"
              + "<thead><tr>"
              + "<th>リスト名</th>"
              + "<th>NG解除</th>"
              + "</tr></thead>"
              + "<tfoot><tr>"
              + "<th>リスト名</th>"
              + "<th>NG解除</th>"
              + "</tr></tfoot>"
              + "<tbody>";

        //ループを回してシート状況を反映する
        for(var i = 0;i<length;i++){
          //NG解除するタイトルの指定
          var linktitle = "&quot;" + String(json[i].LISTNAME) + "&quot;";

          //テーブルタグを生成して格納する
          html  += "<tr>"
                + "<td>" + String(json[i].LISTNAME) + "</td>"
                + "<td>" + "<a href='#' onClick='potato(" + linktitle + ");'><b>NGの解除</b></a>" + "</td>"
                + "</tr>";
        }

        //htmlにテーブルの終了タグを追加
        html += "</tbody></table>";

        //生成したHTMLを置き換え
        sampleNode.innerHTML = html;

        $(document).ready(function() {
          $('#table_id').DataTable({
            "processing": true,
            "lengthChange":false,
            "order": [[ 0, "desc" ]],
            "pageLength":5,
            "language": {
              "emptyTable" : "データが登録されていません。",
              "info" : "_TOTAL_ 件中 _START_ 件から _END_ 件までを表示",
              "infoEmpty" : "",
              "infoFiltered" : "(_MAX_ 件からの絞り込み表示)",
              "infoPostFix" : "",
              "thousands" : ",",
              "lengthMenu" : "1ページあたりの表示件数: _MENU_",
              "loadingRecords" : "ロード中",
              "processing" : "処理中...",
              "search" : "検索",
              "zeroRecords" : "該当するデータが見つかりませんでした。",
              "paginate" : {
                "first" : "先頭",
                "previous" : "前へ",
                "next" : "次へ",
                "last" : "末尾"
              }
            }
          });
        });
      }

      //NG解除用コマンド
      function potato(title){
        //問い合わせボックス
        var res = confirm(title + "をNGから解除しますか?");

        //条件判定
        if(res == true){
          //メインプロセスのnglistに該当のタイトルをぶっこむ
          ipcRenderer.send('async', "delnglist", title);
        }else{
          //キャンセル時は何もしない
        }
        return false;
      }
    </script>

    <title>NGタイトルリスト</title>
  </head>

  <body>
    <!-- アドレスリストを表示 -->
    <div id="userlist" style="font-size: 14px;">
      <center>
        <img border="0" src="img/ProgressSpinner.gif">
        <b><div style="color:red; font-size:10pt;">整形中...しばらくそのままお待ち下さい!!</div></b>
      </center>
    </div>
  </body>
</html>

図:NGリストの様子。NGの解除をクリックで解除可能

スプラッシュスクリーンを付ける

今回のアプリケーションは、タスクトレイ常駐型のアプリケーションであるが故に、起動してもタスクトレイに常駐してるだけで、見た目なにもウィンドウは表示されません。そのため、起動しているのかしていないのかがわかりにくいので、スプラッシュスクリーンを付けてみたいと思います。

app.on('ready',() => {}の中にウィンドウを開くコード書き、setTimeoutで自動的に閉じるだけの簡単な方法です。

メインプロセス側コード

app.on('ready',() => {
  //スプラッシュスクリーンを生成
  splash = new BrowserWindow({
        width: 640,
        height: 480,
        frame: false,   //ブラウザをフレームレスで表示
        transparent: true,  //ブラウザの背景を透過
        alwaysOnTop: true
  });

  splash.webContents.once('did-finish-load', function(){
          setTimeout(function(){
            //5秒後にクローズする
            splash.close();
          }, 5000);
      });

  //表示するHTMLファイルを指定
  splash.loadURL('file://' + __dirname + '/splash.html');

  splash.on('closed', function() {
    //キャッシュを捨てる
    electron.session.defaultSession.clearCache(() => {})
    splash = null;
  });

  app.on('window-all-closed', () => {
    //なにもしない
    //これをいれておかないと、全部のウィンドウが閉じられるとアプリまで閉じてしまう、。
  });
});
  • 今回のウィンドウはロゴタイトル用なので、フレームレス&背景透過のオプションを指定しておきます。
  • ロードした後、5秒後に自動的にクローズするように、setTimeoutを利用しています。
  • 実際の画像サイズとウィンドウのサイズに注意。画像サイズが大きいとおかしな表示になります。

レンダラ側コード(splash.html)

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <script>
      var $ = jQuery = require("jquery")
    </script>

    <script type="text/javascript" src="js/jquery-ui.min.js"></script>
    <script src="js/jquery.touch-punch.min.js"></script>
    <link rel="stylesheet" href="css/jquery-ui.css">
    <link rel="stylesheet" href="css/setting.css">

    <script src="index.js"></script>
    <title>起動中!!</title>
  </head>
  <body>
    <img src="img/title.png" alt="タイトルロゴ" width="630" height="339" border="0" />
  </body>
</html>

画像を1枚ロードするだけ。しかも、メインプロセスでフレームレス・背景透明化しているので、利用する画像が透過PNGであれば、綺麗に背景が透けるクールなロゴが表示されるようになります。

図:起動時にアプリのロゴを出すのがスプラッシュスクリーン

実行と結果の様子

electronでタスクトレイ常駐アプリをつくってみた

動画:こんな感じのグループリストが出てくる

関連リンク

Electronでタスクトレイ常駐のアプリを作る” に対して2件のコメントがあります。

  1. さわだ より:

    参考になりました。ありがとうございます。
    ちなみにapp.isPackagedを見れば開発中の起動かパッケージされたものなのかを区別できるようで、これによりauto-launchで登録を行うかどうかを分けることができました。

    1. akanemaru2017 より:

      さわださん

      app.isPackagedで判定できたんですね。これはURL Schemeの設定でも便利でした。
      ありがとうございます

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)