Electronをcreate-react-app + TypeScriptでやるしfsも使う

create-react-appとElectronを併用したとき、
fsなどのnodeでしか動かないパッケージを使う方法がわかりにくかったので記事に残しておく。

リポジトリは[こちら](https://github.com/sankaku-deltalab/cra-ts-electron)

## 概要

### この記事でやること

- create-react-app (CRA) を使う
- TypeScriptでコードを書く
- Electronでreactのページを開く
- アプリ中でfsを使う（nodeIntegration: false のままで）
- アプリ中でデータを保存できるようにする
- デスクトップアプリ (.exe) にする

### やらないこと

- エディタやnpmなどの用意
- linter類の設定
- アプリの実装
- テストの作成

## 手順

1. CRAでプロジェクトを作る
2. Electronを使えるようにする
3. アプリ中でfs・保存を使えるようにする
4. Electron-Builderで実行ファイルを作れるようにする

### 1. CRAでプロジェクトを作る

このセクションではCRAでプロジェクトのひな型を作る。

このセクションが終わった後`npm start`を実行すれば、ブラウザでページが開く。

```bash
npx create-react-app cra-ts-electron --use-npm --template=typescript
cd cra-ts-electron
npm update
```

### 2. Electronを使えるようにする

このセクションではElectronでページが開くようにする。

このセクションが終わった後`npm start`を実行すれば、ブラウザでなくElectronでページが開く。

#### 必要なパッケージをインストールする

```bash
npm add electron electron-is-dev electron-reload electron-store typescript@latest
npm add -D concurrently cross-env npm-run-all rimraf wait-on
```

#### package.jsonを変更する

```json:package.json
{
  ...
  "homepage": "./",
  "main": "build/electron/main.js",
  "scripts": {
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "postinstall": "electron-builder install-app-deps",
    "wait-start": "wait-on http://localhost:3000",
    "start": "concurrently \"npm run start:start-server\" \"npm run start:watch-electron\" \"npm run start:use-electron\"",
    "start:start-server": "cross-env BROWSER=none react-scripts start",
    "start:watch-electron": "run-s wait-start start:watch-electron:watch",
    "start:watch-electron:watch": "tsc -p electron -w",
    "start:use-electron": "run-s wait-start start:use-electron:build start:use-electron:run",
    "start:use-electron:build": "tsc -p electron",
    "start:use-electron:run": "electron ."
  },
  ...
}
```

#### Electron用コードを追加する

以下のファイルを追加する。

- `electron/main.ts`
- `electron/tsconfig.json`

```typescript:electron/main.ts
import { app, BrowserWindow } from "electron";
import * as path from "path";
import * as isDev from "electron-is-dev";

function createWindow() {
  // Create the browser window.
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
    },
  });

  if (isDev) {
    win.loadURL("http://localhost:3000/index.html");
  } else {
    // 'build/index.html'
    win.loadURL(`file://${__dirname}/../index.html`);
  }

  // Hot Reloading
  if (isDev) {
    // 'node_modules/.bin/electronPath'
    require("electron-reload")(__dirname, {
      electron: path.join(
        __dirname,
        "..",
        "..",
        "node_modules",
        ".bin",
        "electron"
      ),
      forceHardReset: true,
      hardResetMethod: "exit",
    });
  }

  // Open the DevTools.
  if (isDev) {
    win.webContents.openDevTools();
  }
}

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(createWindow);

// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
  if (process.platform !== "darwin") {
    app.quit();
  }
});

app.on("activate", () => {
  // On macOS it's common to re-create a window in the app when the
  // dock icon is clicked and there are no other windows open.
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});

// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

```

```json:electron/tsconfig.json
{
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs",
    "lib": [
      "dom",
      "esnext"
    ],
    "sourceMap": true,
    "strict": true,
    "outDir": "../build",
    "rootDir": "../",
    "noEmitOnError": false,
    "typeRoots": [
      "node_modules/@types"
    ],
    "skipLibCheck": true
  }
}
```

### 3. アプリ中でfs・保存を使えるようにする

このセクションではアプリ中で（間接的に）fsを使えるようにする。
また`electron-store`を使って設定ファイルなどを保存できるようにする。

このセクションが終わった後`npm start`を実行すれば、Electronで開かれたページにカレントディレクトリにあるファイル・ディレクトリが表示される。
また`C:\Users\<user-name>\AppData\Roaming\cra-ts-electron\config.json`に以下の内容が保存されている。

```json
{
	"unicorn": "uni-uni"
}
```

#### Electronのファイルを編集する

```typescript:electron/main.ts
// ...
import { initIpcMain } from "./ipc-main-handler";

function createWindow() {
  // Create the browser window.
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, "preload.js"),
    },
  });
  
  // ...
}

// ...

// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

initIpcMain();

```

#### Electronのファイルを追加する

以下のファイルを追加する。

- `electron/ipc-main-handler.ts`
- `electron/preload.ts`
- `@types/global.d.ts`

```typescript:electron/ipc-main-handler.ts
import { ipcMain } from "electron";
import * as Store from "electron-store";
import * as fs from "fs";

export const initIpcMain = (): void => {
  const store = new Store();
  ipcMain.handle("read-dir", async () => fs.promises.readdir("./"));
  ipcMain.handle("save", (event, str: string) => {
    store.set("unicorn", str);
    console.log(`save: ${str}`);
  });
};

```

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

contextBridge.exposeInMainWorld("myAPI", {
  readDir: () => ipcRenderer.invoke("read-dir"),
  save: (str: string) => ipcRenderer.invoke("save", str),
});

```

#### 型定義ファイルを追加する

```typescript:@types/global.d.ts
declare global {
  interface Window {
    myAPI: Sandbox;
  }
}

export interface Sandbox {
  readDir: () => Promise<string[]>;
}

```

#### tsconfig.jsonを変更する

```json:tsconfig.json
{
  "compilerOptions": {
    ...
  }
  "files": [
    "@types/global.d.ts"
  ],
  "include": [
    "src"
  ]
}
```

#### Reactアプリを変更し、fsの使用・保存ができることを確認できるようにする

```typescript:src/App.tsx
import React, { useState, useEffect } from "react";
import logo from './logo.svg';
import './App.css';

const { myAPI } = window;

function App() {
  const [text, setText] = useState("not loaded");

  useEffect(() => {
    const f = async () => {
      setText("loading...");
      try {
        const dirs = await myAPI.readDir();
        myAPI.save("uni-uni");
        setText(`files are: ${dirs.join(", ")}`);
      } catch (e) {
        setText("loading was failed");
        alert(e);
      }
    };
    f();
  }, []);

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
        <p>
          {text}
        </p>
      </header>
    </div>
  );
}

export default App;

```


### 4. Electron-Builderで実行ファイルを作れるようにする

このセクションではElectron-Builderで実行ファイルを作れるようにする。

このセクションが終わった後`npm run build`を実行すれば、`dist/cra-ts-electron 0.1.0.exe`が作成される。

#### electron-builderをインストールする

```bash
npm add -D electron-builder
```

#### package.jsonのdependenciesをdevDependenciesに移す

package.jsonのdependenciesの内、
以下のパッケージ以外をdevDependenciesに移す。

- electron-is-dev
- electron-store
- react
- react-dom

#### package.jsonを更新する
```json:package.json
{
  ...
  "author": "Sankaku",
  "description": "Example of cra with electron",
  "build": {
    "extends": null,
    "files": [
      "build/**/*"
    ],
    "directories": {
      "buildResources": "assets"
    },
    "win": {
      "target": "portable"
    }
  },
  "scripts": {
    ...
    "build": "run-s build:clean build:react build:electron build:electron-builder",
    "build:clean": "rimraf build dist",
    "build:react": "react-scripts build",
    "build:electron": "tsc -p electron",
    "build:electron-builder": "electron-builder"
  },
  ...
}
```

#### .gitignoreを編集し、出力がリポジトリに残らないようにする

```gitignore:.gitignore
# ...

# electron output
/dist

```

## 参考

[create-react-appとelectron-builderでTypeScriptとHot Reloadに完全対応したElectronアプリ開発環境を作成する](https://qiita.com/yhirose/items/22b0621f0d36d983d8b0)  
[Electronのセキュリティについて大きく誤認していたこと
](https://qiita.com/sprout2000/items/2b65f7d02e825549804b)  
[Electronで設定ファイルを導入](https://qiita.com/mr_t_free/items/4633a1a9434e120c42d8)