electron-builder＋カスタムNSISスクリプトでインストーラーを良い感じにしよう


:::note
この記事は、 LITALICO Engineers Advent Calendar 2023 2つめ 12/1 公開の記事です
:::

こんにちは！ @mihotoyama です。

LITALICOでは『障害のない社会をつくる』というビジョンのもと、障害福祉・教育などの領域で、さまざまな事業を展開しています。

私たちのチームでは、学校向けの特別支援教育をサポートするサービス群 [LITALICO 教育ソフト](https://s-edu-soft.litalico.jp/) の開発をしています。

この中には弊社初となる  🖥  **デスクトップアプリケーション** 🖥 形態のプロダクトもあり、
ファーストリリースから日々挑戦の毎日となっていますが、

本記事ではそこで得た学びの一つ、
『electron-builder＋カスタムNSISスクリプトでインストーラーをいい感じにする方法』
をご紹介できればと思います 💡

## この記事について

- 💡 この記事でわかること
    - electron-builder×カスタムNSISスクリプトでインストーラーをカスタマイズするための前提知識と考え方
- ⛔️ この記事で書かないこと
    - electronおよびelectron-builderの基本的な開発方法には触れません
    - NSISの記法の詳細は取り上げません
- 👓 この記事の動作確認は、electron-builder v23系最新で行いました

## electron-builder・NSISについてさらっと説明

### electron-builder

electron-builderは、Electronのビルドツールの一つです。

Electronはnodejsベースのデスクトップアプリケーションフレームワークですから、
デスクトップアプリケーションとして動作するようにする箇所もJavascript（TypeScript）で実装できます。

これって、デスクトップアプリケーションを作るときにはとっても敷居が低いものです。

フロントエンド系の技術の経験があれば、C#をゴリゴリ書くなんてことをせずに、ささっと作れちゃうわけですから、うれしいですね。

とはいっても、これを一般的に配布されているようなデスクトップアプリケーションのような実行形式に変換するとなると、もう一段階やることがあります。

それが、アプリケーションのビルドです。

PCのOSやCPUのアーキテクチャによって内部的な制御の方法などはけっこう異なるので、
それに合わせてビルドしないとデスクトップアプリケーションは動作しません。
これを自力で行うと、考えることがとても多く、途端にハードルが上がってしまいます。

そこで登場するのが、ビルドツール！

electron-builderを始めとするビルドツールを使うと、
特定のOS/アーキテクチャ上で機能するようにパックして、
実行形式（にするためのインストーラー）への変換までをサポートしてくれるんです！
とってもありがたいものですね。


ちなみに、Electronのビルドツールはいくつか選択肢があります。

私たちのチームではプロダクト開発当初から [electron-builder]([https://www.electron.build](https://www.electron.build/donate)/) を使っていますが、
最近のElectron公式では [Electron Forge](https://www.electronforge.io/) を第一選択として推奨していますので、その点ご注意ください。

### NSIS

NSIS（Nullsoft Scriptable Install System）は、Windows OS用アプリケーションのインストーラーを記述するためのシステムです。

[NSIS Wiki](https://nsis.sourceforge.io/Main_Page) いわく、『可能な限りスモールでフレキシブルにデザインされたシステム』だそうです。

私は他のインストーラーを実装したことがないので比較はできませんが、
そう言われてみればそうかも？ 🤔 と思うくらいには、スクリプトの書き味はあっさりとしている印象です。

electron-builder は、Windows OS 向けにアプリビルドする際にインストーラー形式を選択することができます（参照：[electron-builder - configuration: Any Windows Target](https://www.electron.build/configuration/win#WindowsConfiguration-target)）が、
その中で NSIS はデフォルトの形式となっています。

ということで、下記のようなかんたんなカスタマイズなら、electron-builderの設定ファイルでオプションを指定するだけで自動的にできます。

　
 
- 現在のユーザーのみにインストールできるようにする
- すべてのユーザーに対してインストールできるようにする
- インストール先を選べるようにする
- インストーラーの画像を指定する
- 言語選択できるようにする
- 他にもいろいろ。
    - 参照：[electron-builder - configuration: NSIS](https://www.electron.build/configuration/nsis)

　

### electron-builderのカスタムNSISスクリプト

前節でオプション指定すればかんたんにカスタマイズできる！！という話をしたばかりですが、
実際に実装してみると、これだけでは不十分なときもあります。

　

- インストール先が選べるようになったがけど、その初期値も指定したい…
- 他のオプションとの兼ね合いでできなかったが、インストール対象をすべてのユーザーに対して/現在のユーザーのみ のいずれか一択にしたい…
- PCから取得した値とか計算した値とか、インストーラー上に表示したい…
- インストール時に、アプリフォルダじゃない場所にアプリのデータを置きたい…
- そうやって置いたデータも、アンインストール時にちゃんと削除したい…

　

そんなときもご安心ください。
electron-builderのカスタムNSISスクリプトを導入することで、いろんなカスタマイズが実現できます！

[electron-builder - configuration: NSIS＞ Custom NSIS Script](https://www.electron.build/configuration/nsis#custom-nsis-script)によると ：

1. ビルド対象となるフォルダに `build` ディレクトリを作成して、その中に `installer.nsh` ファイルを作成する
    1. electron-builder設定ファイルにも、 `include: "build/installer.nsh"` を指定する
2. そのファイルに、カスタマイズしたい macro を定義する 

の2ステップでできるんですって！！！！！！！！！

　
　

 
…というふうにとらえてやってみたんですが、
実際やってみると、ここに書いてある情報だけだと難しかったんですよね…

　


ライブラリのコードを解読し、
のべ100回は優に超えるビルド＆動作検証を繰り返して出した結論は、

electron-builderのカスタムNSISスクリプトを導入することで、いろんなカスタマイズが **（頑張れば）** 実現できます！

…でした 😅

## NSISの仕様でおさえておきたいこと

冒頭でもおことわりしたとおり、今回はNSISの記法等を詳細には記載しませんが、
この electron-builder × カスタムNSISスクリプト の道を進む者にはあまねく降りかかるであろう試練を予告する程度に、仕様を紹介できればと思います。

### Command

[NSISドキュメント](https://nsis.sourceforge.io/Docs/Chapter4.html#fileformat) より、NSISにbuilt-inで備わっているcommandには、 下記の2種類あります。

1. 通常の command
    - 先頭が大文字で定義されているもの
        - `MessageBox` や `CreateShortCut` などの [Instruction](https://nsis.sourceforge.io/Docs/Chapter4.html#instr)、`Section` や `Function` なんかもそう
    - インストーラー内での処理を指示する
2. コンパイル時に実行される command
    - `!` が接頭辞としてつくもの
        - `!define` や `!include`, `!macro` など。[NSISドキュメントのCompile-time commandページ](https://nsis.sourceforge.io/Docs/Chapter5.html#)にまとまっている
    - コンパイラへの指示をする

commandは他の言語だとbuilt-inの関数・メソッドみたいなはたらきをするのですが、
たとえば文字列処理など『あったらいいな』系の処理はあまり実装されていない印象です。
（あくまでインストーラーを機能させるものですからね。）

そういったものは次節で述べる Function や macro として自分で定義して使いましょう。

### Function, Section, macro

NISSスクリプト内でよく出てくる、コードのまとまり三銃士です。

Sectionにメインの処理の流れを書き、Function・macroにヘルパーとなる処理を書くような使い分けが一般的かと思います。

#### [Function]([https://nsis.sourceforge.io/Reference/](https://nsis.sourceforge.io/Reference/Section)Function)

どのプログラミング言語でもなじみ深い『Function』です。
NSISの場合はこんな感じです。

1. 繰り返し使いたい一連の処理を定義できる
2. 呼び出すときは、 `Call` コマンドを使う
3. 外から引数を与えることはできない
4. アンインストーラーから呼び出すFunctionの名称は、接頭辞 `un.` がつかなければならない
    - 反対に、インストーラーから呼び出すFunctionの名称には 、接頭辞 `un.` がついてはいけない
5. Functionは、macroの中に定義できる
6. 先頭に `.` がつくFunctionは callback function と呼ばれ、インストール/アンインストールの各段階で自動的に呼び出される
    1. 例：`.onInit` （インストーラー起動時）、 `.onInstall` （インストール処理開始時）

3.はちょっとショックかもしれません。

4.も、『インストーラーとアンインストーラーで同じ処理するFunctionを別々に定義しなきゃいけないの？？』と戸惑うかも。

でも、5.があるおかげでなんとかなります。

のちほどTipsの章で紹介します。

#### [Section](https://nsis.sourceforge.io/Reference/Section)

リファレンスの説明によると、『 ”インストーラーにより順番に実行される” コマンドのかたまり』です。

1. アンインストーラーで呼び出すSectionは、名称に接頭辞 `un.` がつかなければならない
2. Sectionは、macroの中に定義できない
    - ちなみに、 electron-builder でカスタムできるmacroには、2種類ある
        - Sectionの中に挿入されるもの
        - Sectionの外に挿入されるものがある
    - したがって、前者のカスタムmacro内にSection定義はできない

1.はFunctionと同じようなルールですね。

2.はelectron-builder の カスタムNSISにおいてはトラブルが発生しやすい箇所なので、Tipsでも取り上げます。

#### [macro](https://nsis.sourceforge.io/Reference/!macro)

『マクロ』ですから、処理のまとまりを使い回しやすくしたものですね。

1. `!macro` で定義し、挿入したい箇所に `!insertmacro` で呼び出して使う
2. `!define` で定義しておくと、 `${定義した名前}` で呼び出すことができる

Functionと使う目的は似通ってきますが、macroのほうがかなり柔軟な利用ができそうです。

こうなると Functionは使い勝手も悪いし使わなくていいのでは？という感じもしてきます。
これは実際に[**Macro vs Function**](https://nsis.sourceforge.io/Macro_vs_Function) というページが作成されるくらい頻出のトピックみたいですが、
このページでも出てくるように、 **Function と macro を組み合わせるとちょっと複雑な処理も使いまわしやすくできる** と考えておくとよさそうです！

　

## electron-builder×カスタムNSISスクリプトの基本

ここまでさらっとカスタムNSISスクリプトを作成するのに必要な前提知識を紹介しました。

これをもとに、electron-builderでカスタムNSISスクリプトを組み立てるヒントの入手方法を紹介します。

### なんとかするには、どうすれば良いのか

electron-builder のカスタムNSISスクリプトにちょっと込み入った処理を入れようとすると、
公式ドキュメントにはほとんど説明はなく、代わりに[『issueで聞いてもresolveされるとは思わないでね』](https://www.electron.build/configuration/nsis#:~:text=Keep%20in%20mind%20%E2%80%94%20if%20you%20customize%20NSIS%20script%2C%20you%20should%20always%20state%20about%20it%20in%20the%20issue%20reports.%20And%20don%E2%80%99t%20expect%20that%20your%20issue%20will%20be%20resolved) という趣旨の無慈悲な文言があることに気づくでしょう。

NSISの説明までelectron-builderのドキュメントに入れるときりがないですし、そこまでサポートしきれないですよね。

ということで自力で解決するために、リポジトリでコードを解読していきましょう。

幸い、[/packages/app-builder-lib/templates/nsis](https://github.com/electron-userland/electron-builder/tree/master/packages/app-builder-lib/templates/nsis) という見るからにNSISのテンプレートにしていそうなパスに、大量の `.nsh` ファイルと 1つの `.nsi` ファイルが置いてあります。

ここを見ればなんとかなるに違いありません。

## electron-builderにおけるNSIS関連コードの構成

いくつかファイルを見ていくと、気づくことがあります。

- 1つの `.nsi` ファイルに、他の `.nsh` ファイルを include して使っている
    - ⇒ `.nsi` はインストーラースクリプト、 `.nsh` はインストーラーのヘッダーファイル
    - ⇒ 全体の流れを把握するには、`.nsi` を読み、条件分岐によって includeされる `.nsh` の処理を追えば良い
- 全体を通して、メインの処理は [NSIS Modern User Interface （Modern UI）2]([https://nsis.sourceforge.io/Docs/Modern UI 2/Readme.html](https://nsis.sourceforge.io/Docs/Modern%20UI%202/Readme.html)) をベースとした構成になっている
    - ⇒Modern UI 2 のドキュメントを参考にすることで、electron-builderのドキュメントには記載がないが使用できるグローバル変数があることに気づくことができる
- `.nsi` / `.nsh` 内で、下記のようにカスタムmacroがincludeされている
    - たとえば [`preInit` の挿入箇所のコード](https://github.com/electron-userland/electron-builder/blob/512fb9ea0fd42158ee86d2989c3b8e1be0876ae7/packages/app-builder-lib/templates/nsis/installer.nsi#L48-L50) の場合

        ```nsh
        !ifmacrodef preInit
          !insertmacro preInit
        !endif
        ```
    
    - ⇒ カスタムmacroを定義すると、この箇所に挿入されることがわかる
    - ⇒ それぞれのカスタムmacroが実行されるタイミングや順番、前後の処理がわかる
- /include 配下に、メインの処理以外のお役立ち処理を記述したファイルが入っている
    - ⇒ここにある処理をカスタムNSISスクリプト内で使うことができる
    - ⇒分岐によって使いたい処理があるファイルが `!include` されていない場合は、カスタムスクリプト内で `!include` すれば使うことができる

…ここまでくれば、なんとかなりそうな気がしませんか？ 😄

　

## 実践！Tips集

それではいよいよ、これまでの内容を踏まえて、『こういうときはどうやるか』を紹介していきます。

### 関数（っぽいもの）を実装する

Functionの項目で述べた通り、NSISのFunctionは他の言語の関数のように引数を取ることができません。

代わりに、Function外でスタックとしてデータを入れておき、Function内でスタックからデータを取り出して処理するという方式を取ります。

下記のコード例は、[NSIS Wiki - Macro vs Function＞Hybrid](https://nsis.sourceforge.io/Macro_vs_Function#Hybrid) のコード例を簡略化したものです。

- `build/ShowMessageBox.nsh` というファイルを作成し、下記を記述する

```nsh
; ここに定義した名前を呼び出して使う
!define showMessageBox "!insertmacro showMessageBox"

; Functionをラップするmacro定義
!macro showMessageBox firstStr secondStr
  Push "${secondStr}" ; スタック: 一番上 | "${secondStr}" | 一番下
  Push "${firstStr}"  ; スタック: 一番上 | "${firstStr}" | "${secondStr}" | 一番下
  Call showMessageBox
!macroend

; Function本体（スタックの中のデータは、上のmacroで入れられたもの）
; スタック: 一番上 | "${firstStr}" | "${secondStr}" | 一番下
Function showMessageBox
  ; $0 , $1 はレジスターとして使える変数
  Exch $0                     ; スタック: 一番上 | $0 | "${firstStr}" | 一番下 、$0 = "${secondStr}"
  Exch 1                      ; スタック: 一番上 | "${firstStr}" | $0 | 一番下
  Exch $1                     ; スタック: 一番上 | $1 | $0 | 一番下 、$1 = "${firstStr}"
  MessageBox MB_OK "1番目の引数は $1 で、2番目の引数は $0 です"
  Pop $1                      ; スタック: 一番上 | $0 | 一番下
  Pop $0                      ; スタック: 空っぽ
FunctionEnd
```

このようにFunctionを呼び出す前にスタックにデータを入れておかないといけないので、
そのあたりをフォローするために、ラップ用のmacroでFunctionをくくってあげるのがベストプラクティスみたいです。

こうやって定義した関数っぽいmacroは、下記のように呼び出して使うことができます。

`build/installer.nsh`

```nsh
!macro customHeader
  !include "ShowMessageBox.nsh"
!macroend

!macro customInstall
	${showMessageBox} "1ほげほげ" "2ふがふが"
!macroend
```

上記の例でインストーラーを起動すると、インストールの途中で `1番目の引数は 1ほげほげ で、2番目の引数は 2ふがふが です` と文言があるメッセージボックスを表示します。

なお、[NSIS Wiki - Code Example](https://nsis.sourceforge.io/Category:Code_Examples) のページには、有志が記載したお役立ちコードがたくさん載っています。

やりたいことはここに記載されているかも？ぜひ見てみてください！

### ファイルを分ける

コードが増えてくると見通しが悪くなるので、使いまわしたいFunctionやmacroの定義は本体のファイルとは分けたいこともあると思います。

ファイル分割する際のポイントは下記です。

- ビルド対象のディレクトリに置いたカスタムNSISスクリプトの本体ファイル（`build/installer.nsh`）と同じ階層に配置する
- 本体ファイル内で、適切な箇所で `!include "ファイル名.nsh"`  を記述する
    - グローバル変数など、インストーラーやアンインストーラーのどの処理でも呼び出せる状態にしたいものなら、 `customHeader` 内で `!include` する
        - 1番最初に挿入されるので
    - アンインストーラーで使うなら、 `customUnInit` や `customUninstall` 内で `!include` する
        - アンインストーラー実行時に必ず実行される処理内に挿入されるので

### ビルド時にエラーが発生したら

electron-builder が実行するアプリビルドのうち、インストーラーのビルド段階での失敗のみ言及します。

#### エラー：`!macro: macro named "FUNC_HOGEHOGE" already exists!`

##### 概要

- すでにincludeされたファイルを再度includeした場合などに発生するエラー
- エラー例
    - ```sh
        Error output:
        !macro: macro named "FUNC_HOGEHOGE" already exists!
        !include: error in script: "/**/app/build/HogeFunc.nsh" on line 21
        Error in macro customHeader on macroline 3
        ```
    

##### 対処法

- カスタムスクリプト内で発生している場合は、以下の2つのいずれかが原因のことが多い
    - すでに同じ定義がelectron-builderやその内包するモジュールによって `!include` されているケース
        - ⇒重複している定義については、自分で作成した方の定義名を変更する
    - 複数のカスタムmacroを定義しており、それぞれで `!include` しようとしているケース
        - ⇒グローバル変数としてincludeフラグを定義しておき、includeフラグがfalseのときだけ `!include` するようにする
- 後者のコード例
   -  ```nsh
      !include "HogeFunc.nsh"
      ```
    
      となっている箇所を、以下のように `!ifndef フラグ名` で囲う
    
      ```nsh
      !ifndef HOGEFUNC_INCLUDED
        !define HOGEFUNC_INCLUDED
        !include "HogeFunc.nsh"
      !endif
      ```
    

#### エラー：`Error: command Function not valid in Function`

##### 概要

- Section内でFunctionは定義できないという旨のエラー
- エラー例
    - ```nsh
      Error output:
      Error: command Function not valid in Function
      !include: error in script: "/**/app/build/HogeFunc.nsh" on line 143
      Error in macro preInit on macroline 3
      ```
    

##### 対処法

- カスタムスクリプト内で発生している場合は、そのFunction定義を読み込むカスタムmacroの挿入先がSection内である可能性が高い
    - ⇒Section外に挿入されるカスタムmacroの一つ、`customHeader` 内でFunction定義（もしくは、 Function定義したファイルを `!include`する
    - ⇒もしくは、Functionではなくmacroとして定義する
        - Functionをmacroでラップするだけではダメ（間接的にSection内にFunctionを定義しているため）で、完全にmacroのみで書き直す必要があるので注意

#### エラー： `Error: resolving uninstall function "un.FuncHogeHoge" in uninstall section "" (0)`

##### 概要

- 文字どおり、NSISには下記の制約があるのに、それに違反しているというエラー
    - アンインストーラーで呼び出すFunctionは `un.` が接頭辞としてつかなければならない
    - インストーラーで呼び出すFunctionは  `un.` が接頭辞としてついてはいけない
- エラー例
    - ```nsh
      Error output:
      Error: resolving uninstall function "un.FuncHogeHoge" in uninstall section "" (0)
      Note: uninstall functions must begin with "un.", and install functions must not
      ```
    

##### 対処法

- 同じ処理をインストーラーでもアンインストーラーでも呼び出したい場合に出ることが多いと思われる
    - ⇒インストーラー用とアンインストーラー用でそれぞれ規定通りの名前でFunctionを定義する
    - ⇒コード重複を避けたい場合は、Function-likeなmacroを実装し、Functionでラップし、それをさらにmacroでラップするとよい
- コード例（重複を避けた場合）
    - [関数（っぽいもの）を実装する](#関数（っぽいもの）を実装する) の項目で実装したコードは、下記のように書き換えられる
    - ```nsh
      ; ここに定義した名前を呼び出して使う
      !define showMessageBox '!insertmacro "showMessageBox" ""'
      !define un.showMessageBox '!insertmacro "showMessageBox" "un."'
    
      ; Function-likeなmacroをラップするmacro定義
      !macro showMessageBox UN firstStr secondStr
        Push "${secondStr}" ; スタック: 一番上 | "${secondStr}" | 一番下
        Push "${firstStr}"  ; スタック: 一番上 | "${firstStr}" | "${secondStr}" | 一番下
        Call ${UN}showMessageBox
      !macroend
    
      ; Function-likeなmacro（スタックの中のデータは、上のmacroで入れられたもの）
      ; スタック: 一番上 | "${firstStr}" | "${secondStr}" | 一番下
      !macro FUNC_SHOWMESSAGEBOX
        ; $0 , $1 はレジスターとして使える変数
        Exch $0                     ; スタック: 一番上 | $0 | "${firstStr}" | 一番下 、$0 = "${secondStr}"
        Exch 1                      ; スタック: 一番上 | "${firstStr}" | $0 | 一番下
        Exch $1                     ; スタック: 一番上 | $1 | $0 | 一番下 、$1 = "${firstStr}"
        MessageBox MB_OK "1番目の引数は $1 で、2番目の引数は $0 です"
        Pop $1                      ; スタック: 一番上 | $0 | 一番下
        Pop $0                      ; スタック: 空っぽ
      !macroend
    
      ; インストーラーから呼び出されるFunction名を定義
      Function showMessageBox
        !insertmacro FUNC_SHOWMESSAGEBOX
      FunctionEnd
    
      ; アンインストーラーから呼び出されるFunction名を定義
      !ifdef BUILD_UNINSTALLER
        Function un.showMessageBox
          !insertmacro FUNC_SHOWMESSAGEBOX
        FunctionEnd
      !endif
      ```

　
 

...あれっ！
**これ、[Functionいらないのでは...？](#macro)**


　


## おわりに

こんな感じでやっていけば、electron-builderでNSISのインストーラーもバンバンカスタマイズできちゃいます。

…とはいえあんまり魔改造するようなら、NSISスクリプトを自力で全部書いちゃって、electron-builderの `script`オプションで置き換えちゃったほうがメンテナンスが楽かもしれませんね。

何事もほどほどが一番☝🏻❕

明日は @s12i さんの記事です！お楽しみに 👋