⚛️

React+viteとelectronでデスクトップアプリっぽいものを作りたい①

に公開

経緯

  • その昔デスクトップアプリをWindowsFormで作っていた
  • C#系のWindowsFormやWPFでモダンなUIを表現できないか模索したが難しそう
  • Webアプリをデスクトップアプリ化してはどうか?

プロジェクト作成

  • ノードパッケージマネージャーよりviteでプロジェクト作成を行う
  • 今回は適当にプロジェクト名は「DesktopTest」とした
$ npm create vite@latest

> npx
> create-vite


◇  Project name:
│  DesktopTest

◇  Package name:
│  desktoptest

◇  Select a framework:
│  React

◇  Select a variant:
│  TypeScript + SWC

◇  Scaffolding project in C:\Users\yamamura\Documents\MyDevelop\DesktopTest...

└  Done. Now run:

  cd DesktopTest
  npm install
  npm run dev

npm notice
npm notice New major version of npm available! 10.8.1 -> 11.2.0
npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.2.0
npm notice To update run: npm install -g npm@11.2.0
npm notice
  • 続いて必要なパッケージをインストールします。
npm install

added 140 packages, and audited 141 packages in 25s

40 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
$ npm run dev

> desktoptest@0.0.0 dev
> vite


  VITE v6.2.1  ready in 346 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

実行画面

※参考

https://qiita.com/teradonburi/items/fcdd900adb069811bfda

electronで実行できるようにする

  • electronを開発環境にインストール
$ npm install electron --save-dev
  • Electron アプリは「メインプロセス」と「レンダラープロセス」に分かれます。ここでは、React+Vite で作成したフロントエンド(レンダラープロセス)を読み込むためのメインプロセスファイル(例:main.js)を作成します。
  • ./main.js
// main.js
import { app, BrowserWindow } from 'electron';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

function createWindow() {
    // ブラウザウィンドウの作成
    const mainWindow = new BrowserWindow({
        width: 800,
        height: 600,
        webPreferences: {
            // セキュリティ向上のため nodeIntegration は無効にする
            nodeIntegration: false,
            contextIsolation: true,
        },
    });

    // 開発環境の場合は Vite の開発サーバー、ビルド済みの場合はローカルファイルを読み込む
    if (process.env.NODE_ENV === 'development') {
        mainWindow.loadURL('http://localhost:3000');
    } else {
        mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'));
    }

}

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
    // macOS 以外は全ウィンドウが閉じたらアプリを終了する
    if (process.platform !== 'darwin') app.quit();
});

app.on('activate', () => {
    // macOS では Dock アイコンがクリックされた際、ウィンドウがなければ再作成する
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
  • package.jsonにビルドコマンド用にelectronとelectron:buildを追加する
  • ./package.json
"main": "main.js", // ←ここを追加
"scripts": {
  "dev": "vite",
  "build": "vite build",
  "electron": "set NODE_ENV=development && electron .", // ←ここを追加
  "electron:build": "npm run build && electron .", // ←ここを追加
}
  • ./vite.config.json
import react from '@vitejs/plugin-react-swc'
import { defineConfig } from 'vite'

export default defineConfig({
  base: './', // ←ここを追加
  plugins: [react()],
})
  • ビルド実行する
 $ npm run electron:build

> desktoptest@0.0.0 electron:build
> npm run build && electron .


> desktoptest@0.0.0 build
> tsc -b && vite build

vite v6.2.1 building for production...
32 modules transformed.
dist/index.html                   0.47 kB │ gzip:  0.30 kB
dist/assets/react-CHdo91hT.svg    4.13 kB │ gzip:  2.05 kB
dist/assets/index-D8b4DHJx.css    1.39 kB │ gzip:  0.71 kB
dist/assets/index-uZHFss2t.js   186.93 kB │ gzip: 59.03 kB
✓ built in 1.40s

windowありの画面

カスタムタイトルバーの実装

  • デフォルトのままでは嫌なのでカスタムタイトルバーを実装する

  • ./src/CustomTitleBar.tsx

import React from 'react';

interface CustomDragStyle extends React.CSSProperties {
    WebkitAppRegion?: 'drag' | 'no-drag';
}

export const CustomTitleBar: React.FC = () => {
    const handleMinimize = () => {
        window.electronAPI?.minimizeWindow();
    };
    const handleMaximize = () => {
        window.electronAPI?.maximizeWindow();
    };
    const handleClose = () => {
        window.electronAPI?.closeWindow();
    };
    return (
        <div
            style={{
                WebkitAppRegion: 'drag',
                position: 'fixed',
                top: 0,
                left: 0,
                right: 0,
                height: '35px',
                margin: 0,
                padding: 0,
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'space-between',
                backgroundColor: '#f0f0f0'
            } as CustomDragStyle}
        >
            <div
                style={{
                    WebkitAppRegion: 'no-drag',
                    marginLeft: '10px',
                    color: '#000',
                } as CustomDragStyle}
            >
                タイトル
            </div>
            <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
                <button
                    style={{
                        margin: '5px',
                        padding: '0',
                        WebkitAppRegion: 'no-drag',
                        width: '50px',
                        verticalAlign: 'middle'
                    } as CustomDragStyle}
                    onClick={handleMinimize}
                >-</button>
                <button
                    style={{
                        margin: '5px',
                        padding: '0',
                        WebkitAppRegion: 'no-drag',
                        width: '50px',
                        verticalAlign: 'middle'
                    } as CustomDragStyle}
                    onClick={handleMaximize}
                ></button>
                <button
                    style={{
                        margin: '5px',
                        padding: '0',
                        WebkitAppRegion: 'no-drag',
                        width: '50px',
                        verticalAlign: 'middle'
                    } as CustomDragStyle}
                    onClick={handleClose}
                >X</button>
            </div>
        </div>
    );
};
  • App.tsxで実装
  • ./src/App.tsx
import { useState } from 'react';
import './App.css';
import reactLogo from './assets/react.svg';
import { CustomTitleBar } from './CustomTitleBar';
import viteLogo from '/vite.svg';

function App() {
  const [count, setCount] = useState(0)

  return (
    <>
      <div>
        <CustomTitleBar />
        <a href="https://vite.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  )
}

export default App
  • セキュリティ対策として、レンダラープロセスで Node.js の機能に直接アクセスしないようにするため、preload.js を作成します。たとえば以下のように、レンダラープロセス側に限定した API を公開できます。
  • ./preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
    minimizeWindow: () => ipcRenderer.send('minimize-window'),
    closeWindow: () => ipcRenderer.send('close-window'),
    maximizeWindow: () => ipcRenderer.send('maximize-window')
});
  • ./src/global.d.ts
export { };

declare global {
    interface Window {
        electronAPI?: {
            minimizeWindow: () => void;
            closeWindow: () => void;
            maximizeWindow: () => void;
        };
    }
}
  • main.jsの更新
import { app, BrowserWindow, ipcMain } from 'electron';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

function createWindow() {
    // ブラウザウィンドウの作成
    const mainWindow = new BrowserWindow({
        width: 800,
        height: 600,
        webPreferences: {
            // セキュリティ向上のため nodeIntegration は無効にする
            nodeIntegration: false,
            contextIsolation: true,
            preload: path.join(__dirname, 'preload.js'),
        },
        frame: false,
        titleBarStyle: 'hidden',
    });

    // 開発環境の場合は Vite の開発サーバー、ビルド済みの場合はローカルファイルを読み込む
    if (process.env.NODE_ENV === 'development') {
        mainWindow.loadURL('http://localhost:3000');
    } else {
        mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'));
    }

}

ipcMain.on('minimize-window', (event) => {
    const win = BrowserWindow.fromWebContents(event.sender);
    win?.minimize();
});

ipcMain.on('close-window', (event) => {
    BrowserWindow.fromWebContents(event.sender)?.close();
});

ipcMain.on('maximize-window', (event) => {
    const win = BrowserWindow.fromWebContents(event.sender);
    if (win?.isMaximized()) {
        win?.unmaximize();
    } else {
        win?.maximize();
    }
});

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
    // macOS 以外は全ウィンドウが閉じたらアプリを終了する
    if (process.platform !== 'darwin') app.quit();
});

app.on('activate', () => {
    // macOS では Dock アイコンがクリックされた際、ウィンドウがなければ再作成する
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
});

カスタムタイトルバー

Discussion