Electron + TypeScript で Web APIを呼び出すアプリを作る

# はじめに

<table width="100%"><tr><td>
(2022年11月13日追記） 郵便番号検索APIをrender.jsから呼び出していましたが、mainから呼び出すように変更しました。
</td></tr></table>

Electron の勉強のため簡単なアプリケーションを作ってみました。
こんな感じの画面になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1106967/fffd3aa9-160f-32d6-50c5-fcfad888ba31.png)

機能は次の通りです。

1. 郵便番号を入力して住所を検索する。
   住所の検索には Web API を使用しています。
2. 検索結果を画面に表示する。
   グリッド形式で表示してみました。
3. 住所の検索結果をファイルに保存する。
   保存するときにはダイアログを表示してファイル名を指定できるようにしています。

# 開発

## 住所を検索する Web API

郵便番号から住所を検索する API は次のサイトのものを使用しました。

http://zipcloud.ibsnet.co.jp/doc/api

curl コマンドで実行してみると、以下のような JSON 形式で住所情報が返ってくることがわかります。

```sh
$ curl -i "https://zipcloud.ibsnet.co.jp/api/search?zipcode=1000001"
HTTP/2 200
access-control-allow-origin: *
content-type: text/plain;charset=utf-8
x-cloud-trace-context: 2a2e857d7c90820992f424cfcb10a6ee
date: Fri, 12 Aug 2022 12:55:21 GMT
server: Google Frontend
content-length: 287

{
	"message": null,
	"results": [
		{
			"address1": "東京都",
			"address2": "千代田区",
			"address3": "千代田",
			"kana1": "ﾄｳｷｮｳﾄ",
			"kana2": "ﾁﾖﾀﾞｸ",
			"kana3": "ﾁﾖﾀﾞ",
			"prefcode": "13",
			"zipcode": "1000001"
		}
	],
	"status": 200
}
```

## プロジェクト作成

まずはプロジェクトを作成します。

```sh
$ mkdir webapi
$ cd webapi
$ npm init
$ npm install --save-dev typescript ts-loader
$ npm install --save electron
```

TypeScript のトランスパイルのための設定ファイルを作成します。

```sh
$ tsc init
```

次に package.json を修正します。scripts の部分で build と start を追加しています。tsc で TypeScript　をトランスパイル後に実行するようにしています。

```json
  "scripts": {
    "build": "tsc",
    "start": "npm run build && electron ./index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
```

## Web API を呼び出す

アプリケーションの雛形(index.ts)は次のサイトを参考にして作りました。SampleApp　というクラスにまとまっていて、すごく見やすいです。

https://qiita.com/professor/items/1861ff80e689a377899a

index.ts はほぼこのサイトのものと同じです。後ほどになりますが、データを保存するコードをここに追加します。

```typescript:index.ts
import { BrowserWindow, app, App } from "electron";

class SampleApp {
  private mainWindow: BrowserWindow | null = null;
  private app: App;
  private mainURL: string = `file://${__dirname}/index.html`;

  constructor(app: App) {
    this.app = app;
    this.app.on("window-all-closed", this.onWindowAllClosed.bind(this));
    this.app.on("ready", this.create.bind(this));
    this.app.on("activate", this.onActivated.bind(this));
  }

  private onWindowAllClosed() {
    this.app.quit();
  }

  private create() {
    this.mainWindow = new BrowserWindow({
      width: 800,
      height: 600,
    });

    this.mainWindow.loadURL(this.mainURL);

    this.mainWindow.on("closed", () => {
      this.mainWindow = null;
    });
  }

  private onReady() {
    this.create();
  }

  private onActivated() {
    if (this.mainWindow === null) {
      this.create();
    }
  }
}

const MyApp: SampleApp = new SampleApp(app);
```

index.html を作ります。
郵便番号を入れる入力エリアと検索ボタンがある簡単なものです。結果を表示する場所は `<p id="address"></p>` で定義しています。このバージョンではグリッド表示ではなく単純なテキスト表示です。(グリッドでの表示は後ほど行います。) 最後にレンダラーで使用する renderer.js を読み込んでいます。

```html:index.html
<!DOCTYPE html>
<html>
  <body>
    <h1>郵便番号検索</h1>
    <input type="text" id="zipcode" />
    <button type="button" class="btn btn-primary" id="search">Search</button>
    <br />
    <p id="address"></p>
  </body>
  <script src="./renderer.js"></script>
</html>
```

renderer.js を作ります。
`searchAddress` 関数は、指定された郵便番号を使用して住所を検索します。このとき `fetch()` を使用して Web API　を呼び出しています。関数の戻り値は検索結果の住所を文字列として連結したものです。また、検索ボタンがクリックされたときに発火するイベントリスナーを設定しています。検索ボタンが押されると、`searchAddress` よ呼び出して、結果を `id="address"` のエレメントに設定します。

```js:renderer.js
async function searchAddress(zipcode) {
  const response = await fetch(
    `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipcode}`
  );
  const json = await response.json();
  let addresses = "";
  for (let result of json.results) {
    addresses += result.address1 + result.address2 + result.address3 + " ";
  }
  return addresses;
}

const zipcode = document.getElementById("zipcode");
const address = document.getElementById("address");
const search = document.getElementById("search");

if (search !== null) {
  search.addEventListener("click", () => {
    if (zipcode !== null) {
      searchAddress(zipcode.value).then((addresses) => {
        console.log(addresses);
        if (address !== null) {
          address.innerText = addresses;
        }
      });
    }
  });
}
```

この状態で実行してみます。実行は次のコマンドで行うことができます。
```sh
$ npm start
```
Web API を呼び出して、無事に結果を表示することができました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1106967/79b64bc1-7d91-83ef-0b7d-29499402ff83.png)

## 検索結果をグリッド表示にする

検索結果をグリッド表示にします。グリッドにはGridjsを使用しました。

https://gridjs.io/

先ほど作成した index.html を修正します。
最初に gridjs.umd.js と mermaid.css を取り込みます。
```html
<link href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css" rel="stylesheet" />
<script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
```

前の結果表示箇所 `<p id="address"></p>` の部分を書き換えます。
Web API からメッセージを `id="message"` の部分に赤色で表示するようにしました。郵便番号の桁数が足りないなどエラーメッセージがここに赤色で出力されます。グリッドを表示する場所は `id="address_grid"` の部分になります。
```html
<p id="message" , style="color: #f00"></p>
<div id="address_grid"></div>
```

ここまでの修正を反映した index.html は次のようになります。
```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css"
      rel="stylesheet"
    />
    <script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
  </head>
  <body>
    <h1>Web API Sample</h1>
    <input type="text" id="zipcode" />
    <button type="button" class="btn btn-primary" id="search">Search</button>
    <br />
    <p id="message" , style="color: #f00"></p>
    <div id="address_grid"></div>
  </body>
  <script src="renderer.js" charset="UTF-8"></script>
</html>
```

続いて renderer.js を修正します。
グリッドは再検索時に上書きできるようにグローバル変数で定義しました。検索結果が0件の時は空のリストを指定して検索結果をクリアしています。また、グリッドには 6 件ごとにページネーションをする指定を入れています。

```js:renderer.js
async function searchAddress(zipcode) {
  let response = await fetch(
    `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipcode}`
  );
  return response.json();
}

var grid = null;
document.getElementById("search").addEventListener("click", () => {
  const zipcode = document.getElementById("zipcode").value;
  searchAddress(zipcode).then((response) => {
    const message = document.getElementById("message");
    message.innerHTML = response.message;
    let results = response.results;
    if (results === null) {
      results = [];
    }
    if (grid) {
      grid
        .updateConfig({
          data: results,
        })
        .forceRender();
    } else {
      grid = new gridjs.Grid({
        columns: [
          { id: "zipcode", name: "郵便番号" },
          { id: "address1", name: "都道府県" },
          { id: "address2", name: "市区町村" },
          { id: "address3", name: "町域" },
        ],
        data: results,
        pagination: {
          limit: 6,
        },
      }).render(document.getElementById("address_grid"));
    }
  });
});
```

プログラムを実行してみると、検索結果がグリッドで表示されるようになりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1106967/ea542ca8-b31f-a178-e5bd-3a0868eb92c1.png)

## 検索結果をファイルに保存する

ここまでは普通の Web アプリケーションがやっていることと同じです。ここからは Node API の機能を使って住所の検索結果をローカル PC に保存してみます。
Electron ではレンダラーから直接 Node API を呼び出すことができないので、 preload.js を定義してレンダラー側から呼び出せるようにします。(この例では preload.ts でファイルを作成しています。トランスパイル後に preload.js が作成され、実行時にはこの preload.js が使用されます。)

index.ts の BrowserWindow 作成時に preload の指定を追加します。これで preload された関数をレンダラーから呼び出すことができるようになります。

```typescript
    this.mainWindow = new BrowserWindow({
      width: 800,
      height: 600,
      webPreferences: {
        preload: path.join(__dirname, "preload.js"),
      },
    });
```

preload.ts は次のように作成します。レンダラーから `window.api.saveData()` を呼び出せば、ファイルの保存ができるようになります。

```typescript:preload.ts
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("api", {
  saveData: (data: object) => ipcRenderer.invoke("saveData", data),
});
```

最後に index.ts に `saveData` が呼び出された時のハンドラを定義します。ハンドラの設定は SampleApp　コンストラクタ内で行なっています。ここでは `saveData` ハンドラが呼び出されたときに`handleSaveData` メソッドが呼び出されるようにバインドしています。

```typescript
    ipcMain.handle("saveData", this.handleSaveData.bind(this));
```

`handleSaveData` は次のようになります。
```typescript
  private async handleSaveData(
    event: Electron.IpcMainInvokeEvent,
    data: object
  ) {
    try {
      const result = await dialog.showSaveDialog({
        properties: ["createDirectory"],
      });
      if (result && result.filePath) {
        await fs.writeFile(result.filePath, JSON.stringify(data, null, "  "));
      }
    } catch (err: any) {
      console.error(err.toString());
    }
  }
}
```
ファイルを保存するためのダイアログは Electron の dialog　モジュールにある showSaveDialog を使用しています。ダイアログ作成時に `createDirectory` を指定していますが、これはダイアログでフォルダの作成が行えるようにするものです。(このオプションは Mac　のみ有効です。) オプションを追加することで、ダイアログのタイトルを変えたりボタンの名前を変えたりすることもできます。

https://www.electronjs.org/ja/docs/latest/api/dialog#dialogshowsavedialogbrowserwindow-options

ファイルへの書き込みは Node API を使用しています。昔からある fs モジュールを使用しても良いのですが、ここでは Promise に対応した fs を見つけたのでそちらを使用してみます。まずは Promise 版の fs をインポートします。

```typescript
import { promises as fs } from "fs";
```

次にファイルへ書き込みを行います。 `data` はオブジェクトなので、`JSON.stringify()` を使って JSON 文字列に変換しています。また、JSONを見やすくするために第 3 引数にインデントのためのスペースを指定しています。

```typescript
await fs.writeFile(result.filePath, JSON.stringify(data, null, "  "));
```

プログラムを実行してみます。保存ボタンを押すとダイアログが表示されて、ファイルが保存できるようになりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1106967/022319a4-036b-36b0-2246-e9ca595c4e80.png)

保存されたファイルは次のようになります。

```json:address.json
[
  {
    "address1": "岡山県",
    "address2": "岡山市北区",
    "address3": "",
    "kana1": "ｵｶﾔﾏｹﾝ",
    "kana2": "ｵｶﾔﾏｼｷﾀｸ",
    "kana3": "",
    "prefcode": "33",
    "zipcode": "7000000"
  },
  {
    "address1": "岡山県",
    "address2": "岡山市中区",
    "address3": "",
    "kana1": "ｵｶﾔﾏｹﾝ",
    "kana2": "ｵｶﾔﾏｼﾅｶｸ",
    "kana3": "",
    "prefcode": "33",
    "zipcode": "7000000"
  },
(以下省略)
```

# パッケージの作成

プログラムの作成は前章で終わりですが、ここではプログラムを配布できるようにパッケージを作成してみます。パッケージの作成には electron-builder を使用しています。

https://www.electron.build/

まずは electron-builder のインストールです。

```sh
$ npm install --save-dev electron-builder
```

package.json に次の build　部分を追加します。配布物の出力先ディレクトリ、配布物に含めるファイルを指定しています。

```json
  "build": {
    "appId": "com.example.sample.webapi",
    "directories": {
      "output": "dist"
    },
    "files": [
      "index.html",
      "index.js",
      "package-lock.json",
      "package.json",
      "preload.js",
      "renderer.js"
    ]
  },
```

ビルドします。

```sh
$ npx electron-builder
```

オプションを何も指定しないと、ローカル PC に合わせたパッケージができるようです。私は Mac を使っているので、webapi-sample.app というファイルが作成されていました。

使用している PC　は Mac　ですが、 Windows 向けのインストーラーも作成することができるようです。次のコマンドを実行すると Windows　用のインストーラーが作成されます。

```sh
$ npx electron-builder --win --x64
```

# 最終版のソースコード

最終版のソースコードは次のようになりました。

```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css"
      rel="stylesheet"
    />
    <script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
  </head>
  <body>
    <h1>郵便番号検索</h1>
    <input type="text" id="zipcode" />
    <button type="button" class="btn btn-primary" id="search">検索</button>
    <br />
    <p id="message" , style="color: #f00"></p>
    <div id="address_grid"></div>
    <button type="button" class="btn" id="save" disabled>保存</button>
  </body>
  <script src="renderer.js" charset="UTF-8"></script>
</html>
```

```typescript:index.ts
import { BrowserWindow, app, App, ipcMain, dialog } from "electron";
import path = require("path");
import { promises as fs } from "fs";

const DEBUG_MODE = false;

class SampleApp {
  private mainWindow: BrowserWindow | null = null;
  private app: App;
  private mainURL: string = `file://${__dirname}/index.html`;

  constructor(app: App) {
    this.app = app;
    this.app.on("window-all-closed", this.onWindowAllClosed.bind(this));
    this.app.on("ready", this.create.bind(this));
    this.app.on("activate", this.onActivated.bind(this));
    ipcMain.handle("saveData", this.handleSaveData.bind(this));
  }

  private onWindowAllClosed() {
    this.app.quit();
  }

  private create() {
    let windowSize = { width: 800, height: 600 };
    if (DEBUG_MODE) {
      const { screen } = require("electron");
      const primaryDisplay = screen.getPrimaryDisplay();
      windowSize = primaryDisplay.workAreaSize;
    }
    this.mainWindow = new BrowserWindow({
      width: windowSize.width,
      height: windowSize.height,
      webPreferences: {
        preload: path.join(__dirname, "preload.js"),
      },
    });

    this.mainWindow.loadURL(this.mainURL);

    if (DEBUG_MODE) {
      this.mainWindow.webContents.openDevTools();
    }

    this.mainWindow.on("closed", () => {
      this.mainWindow = null;
    });
  }

  private onReady() {
    this.create();
  }

  private onActivated() {
    if (this.mainWindow === null) {
      this.create();
    }
  }

  private async handleSaveData(
    event: Electron.IpcMainInvokeEvent,
    data: object
  ) {
    try {
      const result = await dialog.showSaveDialog({
        properties: ["createDirectory"],
      });
      if (result && result.filePath) {
        await fs.writeFile(result.filePath, JSON.stringify(data, null, "  "));
      }
    } catch (err: any) {
      console.error(err.toString());
    }
  }
}

const MyApp: SampleApp = new SampleApp(app);
```

```typescript:preload.ts
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("api", {
  saveData: (data: object) => ipcRenderer.invoke("saveData", data),
});
```

```js:renderer.js
"use strict";

async function searchAddress(zipcode) {
  let response = await fetch(
    `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipcode}`
  );
  return response.json();
}

let grid = null;
let results;

document.getElementById("search").addEventListener("click", () => {
  const zipcode = document.getElementById("zipcode").value;
  searchAddress(zipcode).then((response) => {
    const message = document.getElementById("message");
    message.innerHTML = response.message;
    results = response.results;
    if (results === null) {
      results = [];
    }
    if (grid) {
      grid
        .updateConfig({
          data: results,
        })
        .forceRender();
    } else {
      grid = new gridjs.Grid({
        columns: [
          { id: "zipcode", name: "郵便番号" },
          { id: "address1", name: "都道府県" },
          { id: "address2", name: "市区町村" },
          { id: "address3", name: "町域" },
        ],
        data: results,
        pagination: {
          limit: 6,
        },
      }).render(document.getElementById("address_grid"));
    }
    document.getElementById("save").disabled = results.length === 0;
  });
});

document.getElementById("save").addEventListener("click", () => {
  console.log("clicked: save button");
  window.api.saveData(results);
});
```

```json:package.json
{
  "name": "webapi-sample",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "tsc",
    "start": "npm run build && electron ./index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "build": {
    "appId": "com.example.sample.webapi",
    "directories": {
      "output": "dist"
    },
    "files": [
      "index.html",
      "index.js",
      "package-lock.json",
      "package.json",
      "preload.js",
      "renderer.js"
    ]
  },
  "author": "",
  "license": "ISC"
}
```

# 最後に

夏休みの宿題、ではありませんが、 Electron　を使った簡単なアプリケーションを作成することができるようになりました。まだ TypeScript や JavaScript　に慣れてないので、まだまだこれからといった感じです。今回は画面アプリケーションをフレームワークを使わずに作っていますが、もっと楽に作るには、もっといろいろなことをやるには React　とか Vue.js　も使いながら開発を進めるのでしょうね...まだまだ先は長いです。

# 参考サイト

- Electron - クイック スタート
  https://www.electronjs.org/ja/docs/latest/tutorial/quick-start
- Electron でデスクトップアプリケーションを開発する方法
  https://www.twilio.com/blog/an-introduction-to-building-desktop-applications-with-electron-jp
- TypeScript と Electron
  https://qiita.com/professor/items/1861ff80e689a377899a
- 郵便番号検索 API
  http://zipcloud.ibsnet.co.jp/doc/api
- Grid.js
  https://gridjs.io/
- fs Promises API でawaitを使ってファイル読み書きする
  https://qiita.com/kulikala/items/135da20465c367348b52
- electron-builder
  https://www.electron.build/
- Electronアプリの配布パッケージを作る (electron-builder)
  https://maku.blog/p/2tcs8n2/

# (追記) 郵便番号検索APIをmainから呼び出すようにする

## render.jsの変更

修正前のソースでは、下記のように直接render.jsから呼び出しています。
```js:render.js(修正前)
async function searchAddress(zipcode) {
  let response = await fetch(
    `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipcode}`
  );
  return response.json();
}

document.getElementById("search").addEventListener("click", () => {
  const zipcode = document.getElementById("zipcode").value;
  searchAddress(zipcode).then((response) => {
    const message = document.getElementById("message");
    message.innerHTML = response.message;
    results = response.results;
```
これを下記のように変更しました。

```js:render.js(修正後)
document.getElementById("search").addEventListener("click", () => {
  const zipcode = document.getElementById("zipcode").value;
  window.api.searchAddress(zipcode).then((response) => {
    const message = document.getElementById("message");
    message.innerHTML = response.message;
    results = response.results;
```
## preload.ｔｓの変更

mainのメソッドを呼び出すためにsearchAddressを1行追加します。

```ts:preload.ts
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("api", {
  saveData: (data: object) => ipcRenderer.invoke("saveData", data),
  searchAddress: (data: object) => ipcRenderer.invoke("searchAddress", data),
});
```

## index.tsの変更

ソースを修正する前に、HTTPリクエストを呼び出すためのrequest-promiseをインストールします。

```shell
$ npm install request-promise --save
```

request-promiseを読み込んで
```ts
const rp = require("request-promise");
```
searchAddressのハンドラを追加して、
```ts
class SampleApp {
    constructor(app) {
        this.mainWindow = null;
        this.mainURL = `file://${__dirname}/index.html`;
        this.app = app;
        this.app.on("window-all-closed", this.onWindowAllClosed.bind(this));
        this.app.on("ready", this.create.bind(this));
        this.app.on("activate", this.onActivated.bind(this));
        electron_1.ipcMain.handle("saveData", this.handleSaveData.bind(this));
        electron_1.ipcMain.handle("searchAddress", this.handleSearchAddress.bind(this));
    }
```

ハンドラを実装します。
```ts
    handleSearchAddress(event, zipcode) {
        return __awaiter(this, void 0, void 0, function* () {
            const url = `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipcode}`;
            try {
                let response = yield rp({
                    url: url,
                    json: true,
                });
                console.log(response);
                return response;
            }
            catch (err) {
                console.error(err);
            }
        });
    }
```

ソースをGitHubに追加しました。

https://github.com/mahoutsukaino-deshi/electron_sample.git