Electronでダークモードの切り替えを試してみた

# 背景・目的
先日、Electronについて基本的な知識を整理しました。
今回は、Electronのプロセスモデル等について整理します。

https://qiita.com/zumax/items/2f016c4738bd02c8af05

# まとめ
下記に特徴を整理します。

|特徴|説明|
|:--|:--|
|プロセスモデル|hromiumのマルチプロセスアーキテクチャを継承している|
|マルチプロセス|メインとレンダラーの2種類がある|
|なぜマルチプロセスか|タブごとのオーバヘッドを減らすが、1つのウェブサイトがクラッシュしたりハングアップすると、ブラウザ全体に影響が及ぶ|
|メインプロセス|・ELectronアプリ1つにつき、1つのメインプロセスがある<BR>・アプリのエントリポイントとして機能する<BR>・メインプロセスはNode.js環境で動作する<BR>・モジュールをrequireしたり、Node.jsのすべてのAPIを利用したりできる|
|レンダラープロセス|・各 Electron アプリは、開いている BrowserWindow (及び各ウェブ埋め込み) ごとに個別のレンダラープロセスを生成する<BR>・レンダラーは、ウェブコンテンツのレンダリングを担う<BR>・レンダラープロセスで実行するコードはウェブ標準に従って動作しなければならない<BR>・レンダラーがrequrie、その他Node.jsのAPIに直接アクセスできない|

# 概要
## プロセスモデル
下記を基に整理します。

https://www.electronjs.org/ja/docs/latest/tutorial/process-model

> Electron は Chromium のマルチプロセスアーキテクチャを継承しており、フレームワークのアーキテクチャは最新のウェブブラウザに酷似しています。 This guide will expand on the concepts applied in the Tutorial.

- Chromiumのマルチプロセスアーキテクチャを継承している
- フレームワークのアーキテクチャは最新のWebブラウザに酷似している

### なぜシングルプロセスではないか?
> ウェブブラウザは非常に複雑なアプリケーションです。 ウェブコンテンツを表示するという主な機能のほかに、複数のウィンドウ (またはタブ) を管理したり、サードパーティの拡張機能を読み込んだりするなど、多くの副次的な役割を担っています。

> 以前のブラウザでは、これらの機能を単一のプロセスで実現していました。 このやり方は開いているタブごとのオーバーヘッドを減らしますが、1 つのウェブサイトがクラッシュしたりハングアップしたりすると、ブラウザ全体に影響が及びます。

- Webブラウザは非常に複雑なアプリケーション
    - ウェブコンテンツを表示するという機能
    - 複数のウィンドウ（タブ）を管理
    - サードパーティの拡張機能の読み込み
- 以前は、上記の機能を単一プロセスで実現していた
    - タブごとのオーバヘッドを減らすが、1つのウェブサイトがクラッシュしたりハングアップすると、ブラウザ全体に影響が及ぶ


### マルチプロセス
> この問題を解決するため、Chrome チームは各タブがそれぞれのプロセスで描画するようにすると決め、ウェブページ上のバグや悪意のあるコードがアプリ全体に与える影響を制限することにしました。 単一のブラウザプロセスはこれらのプロセスを制御し、アプリケーションのライフサイクル全体を制御します。 このモデルを視覚化したのが、Chrome 漫画本 の以下の図です。

- Chromeは、各タブがそれぞれのプロセスで描画し、ウェブページのバグ、悪意のあるコードがアプリ全体に与える影響を制限している

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206276/46d1effc-f4d3-49ce-9a5e-06530a3d25ed.png)

※ [出典：マルチプロセスモデル](https://www.electronjs.org/ja/docs/latest/tutorial/process-model#%E3%83%9E%E3%83%AB%E3%83%81%E3%83%97%E3%83%AD%E3%82%BB%E3%82%B9%E3%83%A2%E3%83%87%E3%83%AB)

> Electron アプリケーションも非常によく似た構造をしています。 あなたはアプリ開発者として、メイン と レンダラー の 2 種類のプロセスを制御することになります。 これらは、上述の Chrome 独自のブラウザプロセスとレンダラープロセスと似ています。

- Electronアプリも同様の構造をしている
- 開発者は、メインとレンダラーの2種類のプロセスを制御する
    - 上記のChrome独自のブラウザプロセスとレンダラープロセスと似ている

### メインプロセス 
> 各 Electron アプリにつき一つのメインプロセスがあります。これはアプリケーションのエントリポイントとして機能します。 メインプロセスは Node.js 環境で動作します。つまり、モジュールを require したり Node.js のすべての API を利用したりできます。

- ELectronアプリ1つにつき、1つのメインプロセスがある
- アプリのエントリポイントとして機能する
- メインプロセスはNode.js環境で動作する
- モジュールをrequireしたり、Node.jsのすべてのAPIを利用したりできる

#### ウインドウの管理
> BrowserWindow クラスの各インスタンスは、アプリケーションウインドウを作成し、その分かれたレンダラープロセス内でウェブページを読み込みます。 You can interact with this web content from the main process using the window's webContents object.

```js
const { BrowserWindow } = require('electron')

const win = new BrowserWindow({ width: 800, height: 1500 })
win.loadURL('https://github.com')

const contents = win.webContents
console.log(contents)
```


- BrowserWindowクラスの各インスタンスは、アプリウィンドウを作成
- 別れたレンダラープロセス内でWebページをよみこむ
- BrowserWindow モジュールは EventEmitter を継承している
    - 様々なユーザーイベント (例えば、ウインドウの最小化や最大化) ハンドラの追加可能
- BrowserWindow インスタンスが破棄されると、対応するレンダラープロセスも終了する


#### アプリケーションのライフサイクル
> The main process also controls your application's lifecycle through Electron's app module. このモジュールには、アプリケーションの動作をカスタマイズするためのイベントやメソッドが多数用意されています (例えば、プログラム側でアプリケーションを終了したり、アプリケーションの Dock を変更したり、アプリについてのパネルを表示したりできます)。

```js
// 非 macOS プラットフォームでウインドウが開かれていない時にアプリを終了する
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})
```

- アプリケーションの動作をカスタマイズするためのメソッドが用意されている
    - アプリの終了
    - アプリのパネルを表示

#### ネイティブAPI
> ウェブコンテンツ用の Chromium ラッパーだけでなく Electron の機能を拡張するため、メインプロセスではユーザーのオペレーティングシステムと対話するカスタム API も追加しています。 Electron は、メニュー、ダイアログ、tray アイコンなど、ネイティブなデスクトップ機能を制御する様々なモジュールを公開しています。

- ChromiumラッパーだけではなくElectronの機能を拡張するため、メインプロセスでは、ユーザのOSと対話するためのカスタムAPIも追加している
- ネイティブなデスクトップ機能を制御する様々なモジュールを公開している
    - メニュー
    - ダイアログ
    - Trayアイコン

### レンダラープロセス
> 各 Electron アプリは、開いている BrowserWindow (及び各ウェブ埋め込み) ごとに個別のレンダラープロセスを生成します。 その名の通り、レンダラーはウェブコンテンツの レンダリング を担います。 あらゆる意図と目的において、レンダラープロセスで実行するコードは (少なくとも Chromium がそうである限り) ウェブ標準に従って動作しなければなりません。

- 各 Electron アプリは、開いている BrowserWindow (及び各ウェブ埋め込み) ごとに個別のレンダラープロセスを生成する
- レンダラーは、ウェブコンテンツのレンダリングを担う

> そのため、あるブラウザウインドウ内のすべてのユーザーインターフェイスとアプリの機能は、ウェブの場合と同じツールとパラダイムで記述する必要があります。
> 全ウェブ仕様の説明はこのガイドの範疇の外ですが、最低限理解しておくべきことは以下の通りでしょう。

> - HTML ファイルがレンダラープロセスのエントリーポイントです
> - UI のスタイル付けは Cascading Style Sheets (CSS) で追加します
> - 実行する JavaScript コードは `<script>` 要素で追加できます

> さらにこれは、レンダラーが require やその他 Node.js の API に直接アクセスできないことも意味します。 NPM モジュールをレンダラーに直接組み込むには、ウェブの場合と同じバンドラーツールチェイン (例えば、webpack や parcel など) を使用する必要があります。


- レンダラープロセスで実行するコードはウェブ標準に従って動作しなければならない
- レンダラーがrequrie、その他Node.jsのAPIに直接アクセスできない


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

> プリロードスクリプトは、BrowserWindow コンストラクタの webPreferences オプションでメインプロセスからアタッチできます。

```js
const { BrowserWindow } = require('electron')
// ...
const win = new BrowserWindow({
  webPreferences: {
    preload: 'path/to/preload.js'
  }
})
// ...
```

- ウェブコンテンツの読み込み開始前にレンダラープロセス内で実行されるコード
- これらのスクリプトはレンダラーのコンテキスト内で実行される
- Node.jsのAPIにアクセスできるように多くの権限が与えられる
- プリロードスクリプトは、BrowserWindowコンストラクタのwebPreferencesオプションでメインプロセスからアタッチできる

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

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


```js
window.myAPI = {
  desktop: true
}
```

```js
console.log(window.myAPI)
// => undefined
```

> コンテキスト分離 (contextIsolation) とは、プリロードスクリプトをレンダラーのメインワールドから分離し、特権的 API がウェブコンテンツのコードへ漏れないようにすることです。

- コンテキスト分離
    - プリロードスクリプトをレンダラーのメインワールドから分離し、特権的APIがウェブコンテンツのコードへ漏れないようにする


- reload.js
```js
const { contextBridge } = require('electron')

contextBridge.exposeInMainWorld('myAPI', {
  desktop: true
})
```

- renderer.js
```js
console.log(window.myAPI)
// => { desktop: true }
```

> この機能は、主に以下に挙げる 2 つの目的において非常に便利です。

> リモート URL でホストされている既存ウェブアプリの Electron のラッパーを開発している場合、レンダラーの window グローバルにカスタムプロパティを追加することで、ウェブクライアント側でデスクトップ専用のロジックを利用できます。


- リモートURLでホストされている既存ウェブアプリのElectronラッパーを開発している場合
    - windowグローバルにカスタムプロパティを追加することで、ウェブクライアントが側でデスクトップ専用のロジックを利用できる


### ユーティリティプロセス
> ユーティリティプロセスは Node.js 環境で動作します。つまり、モジュールを require したり Node.js のすべての API を利用したりできます。 ユーティリティプロセスは例えば、信頼できないサービス、CPU 負荷の高いタスク、クラッシュしやすいコンポーネントなど、以前はメインプロセスや Node.js の child_process.fork API でスポーンしていたプロセスのホストに利用できます。 Node.js の child_process モジュールが生成するプロセスとユーティリティプロセスとの主な違いは、ユーティリティプロセスが MessagePort でレンダラープロセスと通信チャンネルを確立できることです。 

- ユーティリティプロセスは、Node.js環境で動作する
- 下記に利用できる　
    - 信頼できないサービス
    - CPU負荷が高いタスク、クラッシュしやすいコンポーネントなど

## コンテキストの分離
下記を基に整理します。

https://www.electronjs.org/ja/docs/latest/tutorial/context-isolation

### これは何か?
> コンテキスト分離は、あなたのプレロード スクリプトと Electron の内部ロジックの両方が、 webContents でロードしたウェブサイトに対して別のコンテキストで実行されることを保証する機能です。 これは、ウェブサイトが Electron の内部にアクセスできないようにするためのセキュリティを向上させ、プリロードスクリプトがアクセスできる強力な API を防ぐために重要です。

> つまり、プリロードスクリプトがアクセスできる window オブジェクトは、ウェブサイトがアクセスできるオブジェクトとは実際には 異なる オブジェクトであることを意味します。 例えば、コンテキストの分離が有効の場合、プリロードスクリプトで window.hello = 'wave' を設定してウェブサイトがこれにアクセスしようとしても、window.hello は undefined になります。

> コンテキストの分離は Electron 12 からデフォルトで有効になっており、すべてのアプリケーション で推奨されているセキュリティ設定です。

- プレロードスクリプトとElectronの内部ロジックの両方がwebContentsでロードしたウェブに対して別のコンテキストで実行されることを保証する
- ウェブサイトがElectronの内部にアクセスできないようにするためのセキュリティを向上させて、プリロードスクリプトがアクセスできる強力なAPIを防ぐ
- リロードスクリプトがアクセスできる window オブジェクトは、ウェブサイトがアクセスできるオブジェクトとは実際には 異なる オブジェクトである

# 実践
サンプルプログラムを実行してみます

## ダークモード
nativeTheme から派生したテーマカラーになる Electron アプリケーションの例を作る。
IPC チャンネルを利用したテーマの切り替えとリセットの制御も可能

1. ディレクトリを作成します
    ```bash
    % mkdir dark-mode
    % 
    ```

### main.js
```js
const { app, BrowserWindow, ipcMain, nativeTheme } = require('electron/main')
const path = require('node:path')

function createWindow () {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })

  win.loadFile('index.html')
}

ipcMain.handle('dark-mode:toggle', () => {
  if (nativeTheme.shouldUseDarkColors) {
    nativeTheme.themeSource = 'light'
  } else {
    nativeTheme.themeSource = 'dark'
  }
  return nativeTheme.shouldUseDarkColors
})

ipcMain.handle('dark-mode:system', () => {
  nativeTheme.themeSource = 'system'
})

app.whenReady().then(() => {
  createWindow()

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow()
    }
  })
})

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})
```

### renderer.js
```js
document.getElementById('toggle-dark-mode').addEventListener('click',async()=>{
  const isDarkMode = await window.darkMode.toggle()
  document.getElementById('theme-source').innerHTML = isDarkMode ? 'Dark' : 'Light'
})

document.getElementById('reset-to-system').addEventListener('click',async()=>{
  await window.darkMode.system()
  document.getElementById('theme-source').innerHTML = 'System'
})
```

### preload.js
```js
const { contextBridge, ipcRenderer } = require('electron/renderer')

contextBridge.exposeInMainWorld('darkMode', {
  toggle: () => ipcRenderer.invoke('dark-mode:toggle'),
  system: () => ipcRenderer.invoke('dark-mode:system')
})
```



### html
```html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Hello World!</title>
    <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';" />
    <link rel="stylesheet" type="text/css" href="./styles.css">
</head>
<body>
    <h1>Hello World!</h1>
    <p>Current theme source: <strong id="theme-source">System</strong></p>

    <button id="toggle-dark-mode">Toggle Dark Mode</button>
    <button id="reset-to-system">Reset to System Theme</button>

    <script src="renderer.js"></script>
</body>
</html>
```


### styles.css
```css
:root {
  color-scheme: light dark;
}

@media (prefers-color-scheme: dark) {
  body { background: #333; color: white; }
}

@media (prefers-color-scheme: light) {
  body { background: #ddd; color: black; }
}
```

### セットアップ
1. `npm init`を実行します
    ```bash
    % npm init
    This utility will walk you through creating a package.json file.
    It only covers the most common items, and tries to guess sensible defaults.
    
    See `npm help init` for definitive documentation on these fields
    and exactly what they do.
    
    Use `npm install <pkg>` afterwards to install a package and
    save it as a dependency in the package.json file.
    
    Press ^C at any time to quit.
    package name: (dark-mode) 
    version: (1.0.0) 
    description: 
    entry point: (main.js) 
    test command: 
    git repository: 
    keywords: 
    author: 
    license: (ISC) 
    About to write to /XXXX/XXXX/XXXX/XXXX/electron-example/dark-mode/package.json:
    
    {
      "name": "dark-mode",
      "version": "1.0.0",
      "main": "main.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "author": "",
      "license": "ISC",
      "description": ""
    }
    
    
    Is this OK? (yes) yes
    
    % 
    ```

1. `package.json`に、`npm run start`で実行できるように追記します
    ```json
      "scripts": {
        "start": "electron .",
        "test": "echo \"Error: no test specified\" && exit 1"
      },
    ```

### 実行
1. `npm start run`を実行します
    ```bash
    % npm start run
    
    > dark-mode@1.0.0 start
    > electron . run
    ```
1. 画面が起動されました
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206276/1faf4660-1222-4f6b-868b-41284f44ee6b.png)

1. 「Toggle Dark Mode」をクリックします。切り替わりました
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206276/96aff187-1c05-465b-8af2-8cbb19f56397.png)

1. 「Reset to System Theme」をクリックします。戻りました
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206276/fcaa70c4-5434-490f-b796-c0a70848efdd.png)


# 考察
今回は、アプリからダークモードの切り替えを行いました。
次回は、デバイス切り替えをためしてみたいとおもいまｓ

# 参考
https://qiita.com/zumax/items/2f016c4738bd02c8af05