【electron】electron+typescript+reactでデスクトップアプリを作ってみる

初書：2021/07/14→2021/11/25
mac : 11.4
npm: 8.1.0
node: 16.13.0
electron: 15.3.0

# 前書き

久しぶりにelectronを使うので、初期の設定から最低限の起動までをメモしておく。
最近Typescriptをよく使うので、せっかくならTypescriptと、あとReactも使えるみたいなのでまとめて使ってみる

# 参考サイト

今回こちらの方をかなり参考にさせてもらった。

[Electron + TypeScript + React の環境構築 (Summer 2021)](https://zenn.dev/sprout2000/articles/5d7b350c2e85bc)


# 使うモジュール一覧

ElectronはNode.jsで動くので、npmを使う。
そしてその時に色々と使うので、簡単に仕様などをメモしておく

## Electron

> JavaScript, HTML, CSS でクロスプラットフォームなデスクトップアプリ開発

が出来るオープンソフトウェアフレームワーク。
今回のベースとなるので詳しい内容は既にご存知でしょう(丸投げ)。

[Electron | JavaScript, HTML, CSSによるクロスプラットフォームなデスクトップアプリ開発](https://www.electronjs.org/)

## Typescript

Javascriptの型を厳密にした言語。コンパイルでjavascriptになるのでベースはjs。
適切に使うと型の恩恵を受けれる他、入力補完も出来るようになるので便利。

## React

ユーザインタフェース構築のためのJavaScriptライブラリ。今回はレンダラープロセス側で使う。
Reactにはクラスベースと関数ベースがあり、最近は関数ベースが主流になりつつあるが、
個人的にはクラスベースの方がやっていることが可視化しやすい感じがしてクラスベースを使っている。
下のコードもクラスベースなので、関数ベースで書きたい人は適宜書き換えてください。

## webpack

モジュールバンドラー。今回は複数のファイルをまとめて１つのファイルにしたり、コンパイル周りをしたりするために使う。

## npm-run-all

npmで複数のコマンドを連続で実行するためのもの。普段は`&&`や`|`を使っていたが、windowsでは使えない？らしい。
何かその辺をうまくやってくれるモジュール。

## rimraf

`rm -rf`をするもの。これもwindows関連でうまくやってくれるやつ。
今回はコンパイル先で不具合を起こさないように毎回消すために使う。

## cross-env

実行時に任意の環境変数を設定できる。

# インストール

まずはディレクトリを作成し、その中で`npm init -y`（-yはデフォルト値で決めてくれる）

## electronのインストール

```zsh
% npm install --save-dev electron
```

## typescriptのインストール

```zsh
% npm install --save-dev typescript ts-node @types/node
```
ts-nodeはtypescriptのまま実行できるライブラリ

## Reactのインストール

```zsh
% npm install -save react react-dom
% npm install --save-dev @types/react @types/react-dom
```

## webpackのインストール

```zsh
% npm install --save-dev webpack webpack-cli
```
webpack-cliはwebpackコマンドを実行できるようにするライブラリ

## webpackでバンドルする時に必要なものをインストール

```zsh
% npm install --save-dev ts-loader css-loader mini-css-extract-plugin html-webpack-plugin  @types/mini-css-extract-plugin
```
ts、html、cssをそれぞれバンドルする時に必要

## バンドルから実行に必要なものをインストール

```zsh
% npm install --save-dev rimraf cross-env npm-run-all
```

以上。ちなみにこの上にeslintを入れる予定だったのだが、想像以上に衝突が起こってしまい[^1]、例外を作っていくのが大変だったので導入を断念した。

[^1]: まずElectronをsave-devで入れている時点で警告が出る。

# 各種設定

無事にインストールが終われば各種設定を行う。gitとフォーマッタの設定は各自で。

## Typescript

```zsh
% npx tsc --init
```

これでtsconfig.jsonが出来たら、中身を少し弄る。

```json-doc
"target": "es2020",
"lib": ["DOM", "ES2020"],
"jsx": "react-jsx",
"sourceMap": true,
"outDir": "./dist",
```

libに関しては必要なのかいまいち分かってない。targetと同じでdomだけなので不要な気もしている。

## Webpack

コードは参考サイトからほとんどお借りしている。

```ts:webpack.config.ts
import path from 'path';

/** エディタで補完を効かせるために型定義をインポート */
import { Configuration, DefinePlugin } from 'webpack';

import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';

const isDev = process.env.NODE_ENV === 'development';

/** 共通設定 */
const base: Configuration = {
  mode: isDev ? 'development' : 'production',
  // メインプロセスで __dirname でパスを取得できるようにする
  node: {
    __dirname: false,
    __filename: false,
  },
  resolve: {
    extensions: ['.js', '.ts', '.jsx', '.tsx', '.json'],
  },
  output: {
    // バンドルファイルの出力先（ここではプロジェクト直下の 'dist' ディレクトリ）
    path: path.resolve(__dirname, 'dist'),
    // webpack@5.x + electron では必須の設定
    publicPath: './',
    filename: '[name].js',
    // 画像などのアセットは 'images' フォルダへ配置する
    assetModuleFilename: 'images/[name][ext]',
  },
  module: {
    rules: [
      {
        /**
         * 拡張子 '.ts' または '.tsx' （正規表現）のファイルを 'ts-loader' で処理
         * node_modules ディレクトリは除外する
         */
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: 'ts-loader',
      },
      {
        /** 拡張子 '.css' （正規表現）のファイル */
        test: /\.css$/,
        /** use 配列に指定したローダーは *最後尾から* 順に適用される */
        use: [
          /* セキュリティ対策のため（後述）style-loader は使用しない */
          MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: { sourceMap: isDev },
          },
        ],
      },
      {
        /** 画像やフォントなどのアセット類 */
        test: /\.(bmp|ico|gif|jpe?g|png|svg|ttf|eot|woff?2?)$/,
        /** アセット類も同様に asset/inline は使用しない */
        /** なお、webpack@5.x では file-loader or url-loader は不要になった */
        type: 'asset/resource',
      },
    ],
  },
  /**
   * developmentモードではソースマップを付ける
   *
   * レンダラープロセスでは development モード時に
   * ソースマップがないと electron のデベロッパーコンソールに
   * 'Uncaught EvalError' が表示されてしまうことに注意
   */
  devtool: isDev ? 'inline-source-map' : false,
  plugins : [
    new DefinePlugin({
      'process.env.VERSION_ENV': `"${require('./package.json').version}"`,
    })
  ]
};

// メインプロセス用の設定
const main: Configuration = {
  // 共通設定の読み込み
  ...base,
  target: 'electron-main',
  entry: {
    main: './src/main.ts',
  },
};

// プリロード・スクリプト用の設定
const preload: Configuration = {
  ...base,
  target: 'electron-preload',
  entry: {
    preload: './src/preload.ts',
  },
};

// レンダラープロセス用の設定
const renderer: Configuration = {
  ...base,
  // セキュリティ対策として 'electron-renderer' ターゲットは使用しない
  target: 'web',
  entry: {
    renderer: './src/renderer.tsx',
  },
  plugins: [
    /**
     * バンドルしたJSファイルを <script></script> タグとして差し込んだ
     * HTMLファイルを出力するプラグイン
     */
    new HtmlWebpackPlugin({
      template: './src/index.html',
      minify: !isDev,
      inject: 'body',
      filename: 'index.html',
      scriptLoading: 'blocking',
    }),
    new MiniCssExtractPlugin(),
  ],
};

/**
 * メイン，プリロード，レンダラーそれぞれの設定を
 * 配列に入れてエクスポート
 */
export default [main, preload, renderer];
```

コンパイルの中にcssをパックするファイルがあるが、
tsxの中でcssをimportして使っているので不要なのかもしれない。
この辺りはwebpackやcss-importをもう少し理解して換える必要があると思われ。

## package.json

実行コマンドを追加しておく。
またメインプロセスを`dist/main.js`に変更しておく

```json:package.json
  "scripts": {
    "start": "run-s clean build serve",
    "clean": "rimraf dist",
    "build": "cross-env NODE_ENV=\"development\" webpack --progress",
    "serve": "electron ."
  },
  "main": "dist/main.js",
```

`clean`はdistディレクトリとその中身を削除するコマンド
`build`はwebpackを使用してコンパイルするコマンド
`serve`はElectronを起動するコマンド
で`start`がそれらを順番にまとめて実行するコマンド

ちなみに(余談)
buildコマンドにて、cross-envで`NODE_ENV="development"`を指定して、build後のコードを見てみると、
`process.env.NODE_ENV === "development"`がtrueに置き換わっているのが分かる。
これは、cross-envで指定した`NODE_ENV`の値を反映して置き換わっているのではなく、
webpackの仕様によって書き変わっている。[^10]
他に`process.env`で指定した環境情報を置き換える場合は、
`base.plugins`で使用している`DefinePlugin`というライブラリで同様に置き換えることができる。
このコードでは`VERSION_ENV`を`package.json`のversionになるよう上書きしている。

[^10]: ではcross-envは何をしているの？というと、webpack.config.tsのenvの中身を置き換えている。

## 各種ファイル作成

とりあえず実行に必要なファイルを作成する。

- `src`ディレクトリを作成
- `src/main.ts`の作成(メインプロセス)
- `src/index.html`の作成(レンダラープロセスのhtml部分)
- `src/preload.ts`の作成(プレロード)
- `src/renderer.tsx`の作成(レンダラープロセス)

ファイルの中身はとりあえず空でいい。

# とりあえず実行してみる

```zsh
% npm run start
```

いくつかのコマンドが実行されたのち、`electron .`が実行されれば成功。
なお画面表示部分を作成していないので、ウィンドウは特に出てこない。

# 最低限のコードを書く

流石に何も表示されないのは面白くないので、よくあるHello worldを表示してみる。

## main.ts

呼び出しの大元となるmain.ts。ここでレンダラープロセスを生成する。

```ts:src/main.ts
import path from 'path';
import { app, BrowserWindow } from 'electron';

/**
 * BrowserWindowインスタンスを作成する関数
 */
const createWindow = () => {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js'),
    },
  });

  // 開発時にはデベロッパーツールを開く
  if (process.env.NODE_ENV === 'development') {
    mainWindow.webContents.openDevTools({ mode: 'detach' });
  }

  // レンダラープロセスをロード
  mainWindow.loadFile('dist/index.html');
};

/**
 * アプリを起動する準備が完了したら BrowserWindow インスタンスを作成し、
 * レンダラープロセス（index.htmlとそこから呼ばれるスクリプト）を
 * ロードする
 */
app.whenReady().then(async () => {
  // BrowserWindow インスタンスを作成
  createWindow();
});

// すべてのウィンドウが閉じられたらアプリを終了する
app.once('window-all-closed', () => app.quit());
```

簡単にコード解説

- `createWindow`はアロー関数で、`new BrowserWindow`で新しいウィンドウを生成している。
`webPreferences`で指定している各種設定内容は後に少し出てくる。
- `mainWindow.webContents.openDevTools`ではchromeのF12でよくみるあれを初めから表示している。
ちなみに本番では表示しないようになっているが、メニューバーに「toggle Developer tools」がある限り、普通に開くことができるので
非表示にしたい場合は注意。

- `app.whenReady()`で準備ができれば先程の関数を呼び出し、ウィンドウを生成する。

- `app.once('window-all-closed', () => void);`は、全てのウィンドウが閉じた時にAppを終了するイベント。
windowsではこれがなくても特に問題がないが、macの場合はウィンドウを閉じただけではAppが終了しない。(メールやメモなど、デフォルトのAppで見かける)
そのため、ウィンドウが閉じると同時に終了させたい場合はこの一行が必要になる。

## index.html

```html:src/index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <!-- CSP の設定 -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self';" />

    <title>Electron Title</title>
  </head>
  <body>
    <!-- react コンポーネントのマウントポイント -->
    <div id="root"></div>
  </body>
</html>
```

簡単にコード解説

metaタグで`Content-Security-Policy`を設定している。
これにより、埋め込みスクリプトや外部サーバーのスクリプト等を実行できなくなる。
これは安全に使うために入れているので、極力入れておいた方がいいが、外部ファイルの読み込み等は難しくなる。

`body`内には`<div id="root"></div>`だけが存在している。
この中にReactを用いたhtmlを構成していくので、この中は今は空でいい。
また、webpackにより、このしたの行にrenderer.jsが自動的に書き加えられる。

## preload.ts

今現在はメインとレンダラーで通信を行わないので空ファイル

## renderer.tsx

```tsx:src/renderer.tsx
import React from "react";
import ReactDOM from "react-dom";

class RootDiv extends React.Component<{}, {}> {
  constructor(props: {}) {
    super(props);
  }
  render = () => {
    return <>Hello World</>;
  };
}

ReactDOM.render(<RootDiv />, document.getElementById("root"));
```

簡単にコード解説

Reactを使用しているので、React自体の詳細な説明は他のページを当たって欲しい。
今回はただ単に`Hello World`を表示できればいいので、`render`では要素名なしの`<>Hello World</>`を返している。

また、`React.Component`内の関数は、`constructor`を除いて基本的にアロー関数を使用する。
理由はthisをbindすることを忘れる/bindが面倒だから。


# 再び実行してみる

もう一度実行する。

```zsh
% npm run start
```

今回はウィンドウが表示され、「Hello World」が表示されれば成功。

# メインプロセスと通信する

これで一応最低限Electronを作成出来たが、デスクトップアプリケーションなので、
基本的にメインプロセスと通信する必要が出てくる。
なので、今回はファイル名を入力して、存在するかチェックするとても簡単なアプリケーションを作成してみる。

## 方針決定

Electronなのでnodeを使えるように思えるが、`main.ts`の`new BrowserWindow`の際に、
`nodeIntegration`をfalseに設定していると、レンダラープロセス側からnodeにアクセスできなくなる。
これをtrueにしてしまえばレンダラープロセスだけで完結するが、例えば`iframe`や、`a`タグを踏んだりするなどして
外部のサイトを開くことが出来るようになると、そのサイトに悪意のコードがあればnodeを実行できるようになり、危険になる。
そのため、基本的には`nodeIntegration`はfalseで扱う。

ではどのようにメインプロセスと通信するかというと、先ほど空白にした`preload.ts`を経由して通信を行う。

> プリロードスクリプトは、ウェブコンテンツの読み込み開始前にレンダラープロセス内で実行されるコードです。 これらのスクリプトはレンダラーのコンテキスト内で実行されますが、Node.js の API にアクセスできるようにより多くの権限が与えられています。

> (中略)

> プリロードスクリプトは、グローバルな Window インターフェイスをレンダラーと共有し Node.js の API にアクセスすることができます。そのため、window グローバルに任意の API を公開してウェブコンテンツが利用できるようにすることで、レンダラーを強化する役割を果たしています。

> プリロードスクリプトはアタッチされているレンダラーと window グローバルを共有しますが、contextIsolation のデフォルト値によりプリロードスクリプトの変数は window に直接アタッチできません。

[プロセスモデル | Electron](https://www.electronjs.org/ja/docs/latest/tutorial/process-model)より

つまり、`contextBridge`を利用してレンダラープロセス側のグローバル変数に追加でき、かつnodeを実行できるレンダラープロセス側のソースコードになる。[^2]
これを利用して、メインプロセスと繋ぐ橋を生成し、通信を行う。

[^2]: ということはcontextBridgeを利用せずプレロードでnodeを呼び出せばいいのでは？誰もやらないということは多分何かあるのだと思うが…。

## preload.ts

```ts:preload.ts
import { contextBridge, ipcRenderer } from "electron";

export const preloadObject = {
  existFile: async (filePath: string) : Promise<boolean> => {
    const result = await ipcRenderer.invoke('existFile', filePath);
    return result;
  },
};

contextBridge.exposeInMainWorld('api', preloadObject);
```

メインプロセスには`contextBridge.exposeInMainWorld`を利用することで使用できる。
これの第一引数で渡すstringが`window`に追加され、第二引数がその中に入る。
今回であれば第一引数が`api`で、第二引数が`{existFile: Function}`なので、
`window.api.existFiles(...arg)`でレンダラープロセスで呼び出すことができる。

また`existFile`の中では`ipcRenderer.invoke`を利用することでメインプロセス側に通信を試みる。
第一引数がイベント名で第二引数以降が渡す変数となる。
メインプロセス側の受け取りは後で記述する。

## global.d.ts

唐突に出てきた`d.ts`。別に単に`ts`でもいいけど。
preloadからrendererに公開されたapiは、typescriptでは認識してくれないため型の推論をしてくれない。
そのため、その中間となる型定義ファイルを用意することで、typescriptの恩恵を受けようというもの。
ちなみに、preloadとmain間の通信で型推論する方法はないと思う。あれば教えてください。

ということで、`src`の中に`@types`ディレクトリを生成し、その中に`global.d.ts`を生成する。
ちなみにこの`global`という名前は何でもいい。

```ts:src/@types/global.d.ts
import { preloadObject } from '../preload';

declare global {
  interface Window {
    api: typeof preloadObject;
  }
}
```

これで、`window.api.`と入力すると`existFile`が入力予測で表示される。

## renderer.ts

先ほど使ったglobal.d.tsを使って型推論をしてもらう。
もちろんここからコピペするだけならanyでも何でもいいので使う意味はないが、この先は自身で作成すると思うので。

```tsx:src/renderer.ts
import React from "react";
import ReactDOM from "react-dom";

interface States {
  filename: string;
}

class RootDiv extends React.Component<{}, States> {
  constructor(props: {}) {
    super(props);
    this.state = {
      filename: "",
    };
  }

  handleOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    this.setState({
      filename: event.target.value,
    });
  };

  handleOnClick = async () => {
    const { filename } = this.state;
    const result = await window.api.existFile(filename);
    alert(`${filename}は${result ? "存在しています" : "存在していません"}`);
  };

  render = () => {
    return (
      <>
        <input type="text" onChange={this.handleOnChange} />
        <button onClick={this.handleOnClick}>検索</button>
      </>
    );
  };
}

ReactDOM.render(<RootDiv />, document.getElementById("root"));
```

簡単にコード解説

`RootDiv`クラスの中身を大幅に書き換えた。
今回は結果を`alert`で表示するため、renderは入力boxとボタンのみ設置されている。

また、`handleOnChange`と`handleOnClick`はそれぞれtextの中身を書き換えた時と、ボタンをクリックした際に呼び出される。
これにより、`state`の`filename`で入力情報を管理し、検索を押された際は先程のAPIを利用してファイルが存在するかどうかをチェックしている。


## main.ts

```ts:main.ts
import fs from "fs";
import { app, BrowserWindow, ipcMain } from "electron";

// 先ほどのウィンドウ生成コードがここに入る

ipcMain.handle("existFile", (event: Electron.IpcMainInvokeEvent, filename: string) => {
    return fs.existsSync(filename);
  }
);
```

レンダラープロセス側からのイベントを受け取るには、`ipcMain.handle`を使用し、第一引数は`ipcRenderer.invoke`の第一引数と一致している必要がある。
`ipcMain.handle`の第二引数は関数が入り、この関数の第一引数は`Electron.IpcMainInvokeEvent`が入る。第二引数以降はinvokeの第二引数以降。
今回は、nodeの標準にある`fs`の`existsSync`を使用して存在確認をしている。

# 再度実行してみる

もう一度実行する。

```zsh
% npm run start
```

今回はウィンドウに入力boxと検索ボタンが表示される。
試しに`./src/main.ts`と入力し、「./src/main.tsは存在しています」と表示されれば成功。
また、`./main.ts`と入力し、「./main.tsは存在していません」と表示されれば成功。

ちなみに相対パス指定で実行しているのは、nodeが提供する`fs.existsSync`に依存しているため、`npm run start`を叩く位置によってパスは変化する。

# 終わりに

とりあえずこれで最低限動作するようなところまで出来るようになるはず。
一部まだ見直さないといけなさそうなところもあるので、気が向いたら更新するかも。