Electron & React で ContextBridge を使って安全に DB 操作する
セキュリティを考える
一見すると、この課題には難しい点はないように思われます。 Renderer プロセスから ipcRenderer を読み込み、Main プロセスの ipcMain との間でプロセス間通信を実行すれば、Main プロセスを介して Renderer プロセスと DB との間でデータの受け渡しが可能です。 ところが、2020年現在、こうした設計はセキュリティ上の理由から推奨されていません。XSS で Node.js Modules にアクセスされて SSH キーを不正取得されたりしても困るといった動機は理解できますが、不便でもあります。 そこで Renderer プロセスから安全に Node.js Modules を利用するため、ほかのスクリプト実行前にあらかじめ preload で読み込むことを行います。 この preload において contextBridge を用いて関数を API 化し Renderer プロセスから利用可能にすることが最近のセキュリティ重視の設計です。 ここで問題となるのは contextBridge によって作成された API (オブジェクト) には型定義が存在しないため TypeScript のコンパイル時にエラーの原因となります。$ npx tsc Property 'api' does not exist on type 'Window & typeof globalThis'.TypeScript は React を安全に使うために必要です。 ELECTRON が用いる HTML/CSS/JS にはデスクトップアプリに適した UI フレームワークが存在しないため React がそれを補います。つまり React もあったほうがいいものです。 それらのリソースを管理するのは Webpack です。 こうした一つ一つの要素技術に設定ファイルがありますので、組み合わせると面倒なことになります。
$ cat /etc/os-release NAME="Ubuntu" VERSION="18.04.5 LTS (Bionic Beaver)" ID=ubuntu ID_LIKE=debian PRETTY_NAME="Ubuntu 18.04.5 LTS" VERSION_ID="18.04" HOME_URL="https://www.ubuntu.com/" SUPPORT_URL="https://help.ubuntu.com/" BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" VERSION_CODENAME=bionic UBUNTU_CODENAME=bionic $ node --version v12.16.3 $ npm --version 6.14.8 $ yarn version yarn version v1.22.5 $ cat package.json |grep dependencies -A 10 "dependencies": { "eslint-loader": "^4.0.2", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "^5.2.0", "react-scripts": "^3.4.1", "sqlite3": "^5.0.0", "typeorm": "^0.2.26", "typescript-fsa": "^3.0.0", "typescript-fsa-reducers": "^1.2.2" }, $ cat package.json |grep devDependencies -A 21 "devDependencies": { "@types/react": "^16.9.49", "@types/react-dom": "^16.9.8", "@types/react-router": "^5.1.8", "@types/sqlite3": "^3.1.6", "@typescript-eslint/eslint-plugin": "^4.1.0", "@typescript-eslint/parser": "^4.1.0", "electron": "^10.1.1", "electron-rebuild": "^2.0.3", "eslint": "^7.8.1", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^6.11.0", "eslint-plugin-prettier": "^3.1.4", "eslint-plugin-react": "^7.20.6", "html-webpack-plugin": "^4.4.1", "prettier": "^2.1.1", "react-devtools": "^4.8.2", "ts-loader": "^8.0.3", "typescript": "^4.0.2", "webpack": "^4.44.1", "webpack-cli": "^3.3.12" } TypeORM をインストールはしていますが後述の理由から使用しません。また機能要件に対して必要性を感じないので Redux も使用しません。
Main プロセスにおいて preload を読み込む
ELECTRON の main プロセスの内容を記述したファイルを main.ts とすると、その内容は次のようになるはずです。import { app, BrowserWindow } from 'electron';
import path from 'path';
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
allowRunningInsecureContent: false,
contextIsolation: true,
enableRemoteModule: false,
nodeIntegration: false,
nodeIntegrationInWorker: false,
preload: path.join(app.getAppPath(), 'preload.js'), // これを追記
},
});
win.loadFile(path.join(app.getAppPath(), 'index.html'));
win.on('closed', () => {
win = null;
});
};
ここでドキュメントに従い、BrowserWindow の中に preload を設定します。
ファイルパスは環境に応じて適宜変更してください。
Preload において関数を API 化する
Main プロセスから preload を読み込むよう設定したあとは、main プロセスと renderer プロセスとの間で通信を行う準備を行います。 ここで contextBridge を用いて関数を API 化することが肝要です。import { ipcRenderer, contextBridge } from 'electron';
contextBridge.exposeInMainWorld(
'api', {
electronIpcInvoke: (channel: string, ...arg: any) => {
ipcRenderer.invoke(channel, arg);
},
}
);
先述のように contextBridge によって作成された API (オブジェクト) には型定義が存在しないため TypeScript のコンパイル時にエラーの原因となります。
API の型定義を行う
そこで正統な対処法として、型定義ファイルを作成して tsconfig に定義ファイルを読み込ませることがよく行われます。$ mkdir -p ./src/\@types $ vim ./src/@types/global.d.ts個人的に確認はしていませんが、原則的にはこれで問題なく動作すると思われます。 この代わりに、私は API に対して interface を作成し、実行時に import することを行いました。
export default interface Api{
electronIpcInvoke:(channel: string, ...arg: any) => Promise ;
}
declare global {
interface Window {
api: Api;
}
}
これで main プロセスと renderer プロセスとの間でデータの受け渡しを行えるようになります。
Webpack の設定ファイルを書き換える
型定義を与えてコンパイル(トランスパイル)できるようになっても、肝心の preload.js ファイル自体が実行ファイルとして読み込まれなければ意味がありません。 そのために次は webpack.config.js の設定を書き換えて preload を読み込ませます。
module: {
entry: {
index: './src/index.tsx',
main: './src/main.ts',
preload:'./src/preload.ts'
},
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].js',
},
}
具体的には entry と output の項目に preload を書き加えるだけで大丈夫です。
import { app, BrowserWindow, ipcMain, IpcMainEvent } from 'electron'; import path from 'path'; import * as sqlite3 from 'sqlite3'; const createWindow = () => { const win = new BrowserWindow({ width: 800, height: 600, webPreferences: { allowRunningInsecureContent: false, contextIsolation: true, enableRemoteModule: false, nodeIntegration: false, nodeIntegrationInWorker: false, preload: path.join(app.getAppPath(), 'preload.js'), }, }); win.loadFile(path.join(app.getAppPath(), 'index.html')); win.on('closed', () => { win = null; }); }; // DB操作を追記 ipcMain.handle('connectTest', async () =>{ console.log('-- connection starts --'); const db = new sqlite3.Database('database.sqlite3'); db.serialize(function() { db.run("CREATE TABLE lorem (info TEXT)"); let stmt = db.prepare("INSERT INTO lorem VALUES (?)"); for (let i = 0; i < 10; i++) { stmt.run("Ipsum " + i); } stmt.finalize(); db.each("SELECT rowid AS id, info FROM lorem", function(err, row) { console.log(row.id + ": " + row.info); }); }); db.close(); console.log('-- It worked --'); }); これを IPC 通信を用いて renderer プロセスから呼び出してやれば良いわけです。 ここに来てついに React の出番となります。 preload にて定義した API のelectronIpcInvoke を呼び出します。
import React from 'react';
import ReactDOM from 'react-dom';
import './config/api.interface';
function btnClick(e):void {
window.api.electronIpcInvoke('connectTest','');
}
const container = document.getElementById('contents');
ReactDOM.render(
Connect
, container);
これで Connect ボタンをクリックすると IPC 通信で main プロセスの connectTest を呼び出して、データベースに接続・操作を行います。
この状態でビルド、実行してボタンをクリックした際に以下のように表示されれば成功です。
$ npm start > electron ./dist/main.js -- connection starts -- -- It worked -- 1: Ipsum 0 2: Ipsum 1 3: Ipsum 2 4: Ipsum 3 5: Ipsum 4 6: Ipsum 5 7: Ipsum 6 8: Ipsum 7 9: Ipsum 8 10: Ipsum 9
TypeORM を使えない
以下は余談です。 TypeORM (version 0.2.26) にて ormconfig.json を作成せずに createConnection に直接的に引数を渡そうとするとエラーになります。 より具体的には以下のようなことをやっています。
import {createConnection} from "typeorm";
import options from './config/config/ormconfig';
const connection = createConnection(options);
import { ConnectionOptions, DatabaseType } from "typeorm";
import { Customers } from "../db/entity/customers.schema";
const sqliteDB : DatabaseType = 'sqlite';
export const options: ConnectionOptions = {
type: sqliteDB,
database: 'database.sqlite',
synchronize: true,
logging: false,
entities: [Customers,],
};
明らかに現状では開発中のライブラリですし、どうしても必要なものではありませんので私は使用することを諦めました。
ちなみに下記のようなエラーはコンストラクタを作成して各カラムの変数を初期化すると消えます。
TS2564: Property 'name' has no initializer and is not definitely assigned in the constructor.
エンティティを定義されているファイルに constructor を追加してやれば問題解決です。
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
@Entity()
export class Customers {
@PrimaryGeneratedColumn()
id?: string;
@Column()
name:string;
@Column()
zip:string;
@Column()
address:string;
@Column()
countrycode:number;
@Column()
phone:number;
constructor(name:string, zip:string, address:string, countrycode:number, phone:number){
this.name=name;
this.zip=zip;
this.address=address;
this.countrycode=countrycode;
this.phone=phone;
}
}
また次のようなエラーは tsconfig.json の設定により表示を消すことができます。
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
お好きなエディタで設定ファイルを開いて該当箇所のコメントアウトを削除してください。
$ vim tsconfig.json
/* Experimental Options */
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
こうしたマイナーなエラーは簡単に対処できますが、データベースに接続できないことにはどうしようもありません。
改善されることを待つか、使用することを諦めるほかにありません。私はもう諦めてしまいました。
コメント
まだコメントはありません。最初のコメントをどうぞ。
コメントを残す