リマインダー開発Ⅳアプリをビルドし
OSへインストール
アプリとしてOSに認識させて、
OSの機能をフルに使いこなす

image: リマインダー開発Ⅳアプリをビルドし OSへインストール
Toplabo
Developing Reminder IV

この記事は『リマインダーを開発するⅢ』の続きになります。先にそちらをご覧ください。

Electronでアプリをビルドし、OSへインストールする。

 | 2025/10/03

アプリを
ビルド

Electronでアプリをビルドする
  • これまでの構成と今後

    前回までで、予約を保存し指定した時間前に通知がくるようなアプリを作成しました。
    ただし、Electronの環境下で動いているためElectronを終了、つまりPowerShellを終了させるとアプリも終了してしまいます。
    それをようやく今回、アプリをOSへインストールすることで、アプリがOS起動後も自動で起動し、常時バックグラウンドで予約を監視してくれる。そんなことが可能になります。つまりアプリを立ち上げていなくても、予告時刻が来たらサウンドで通知してくれたり、トースト(デスクトップ右下で横からスライドしてくる通知バナー)で知らせてくれるようになります。

    今回の目標

    OSへアプリとして認識させて、OSの機能をフルに使いこなす!になります。
    さらに前回までセキュリティ的にリスクのある方法進めてきましたが、今回ではそれを対策します。
    まとめると以下のようになります。

    ・データの保存読み込みをレンダプロセスから切り離し、メインプロセスで処理
     これまで保存と読み込みをレンダラプロセスで行っていたため、セキュリティリスクがありましたが、メインプロセスへ移行することでセキュリティリスクを無くします。
    このため両方の環境の橋渡しとして新たにpreload,jsファイルを追加。
    ・通知の処理も、メインプロセスへ移動する。
    通知処理も変更します。レンダプロセスでは、通知が発生のタイミングのみを監視し、通知処理自体はメインプロセスで行う。
    これはOSの通知処理に最適化するための保険になります。
    ・また、最大のネックになるデータの保存ディレクトリの共有化。
     アプリになるとデータの保存がアプリ専用のディレクトリである必要があります。そのためデフォルトのディレクトリに保存できるように修正します。
    アプリのデータ保存先は、Windowsの場合デフォルトでは、『C:\Users\digiv\AppData\Roaming\アプリ名』になります。
    これまでのプロジェクトディレクトリ内から変更します。
    ・メインプロセスのモジュール読み込み方法をrequireからimportへ変更
    メインプロセスでこれまでrequireでモジュールを読み込んでいましたが、これは古い仕様であるため、最新のimportで読み込む方法に変更します。

実装

必要なモジュールを追加
  • powerShellでビルドに必要なモジュールを追加

    electron-builder を導入
    さらにwindows、mac、linuxで共通して動作するモジュール、クロスプラットフォームにビルドできるモジュールもインストールします。

    npm install --save-dev electron-builder
    npm install --save-dev cross-env
各ファイルの修正
  • package.jsonへ追記

    package.json にコマンド"dist": "electron-builder"を追記。
    さらにビルド設定を追記※20行目あたり。
    特に、buildプロパティのappIdは非常に重要。
    ここできちんと明記することでOSが正しくアプリとして認識できるようになります。
    com.を先頭に記述するのは慣行のようです。

    {
    	"name": "shv-reminder",
    	"version": "1.1.0",
    	"description": "Simple Reminder",
    	"main": "main.js",
    	 "type": "module",
    	"scripts": {
    		"dev": "cross-env NODE_ENV=development electron .",
    		"start": "cross-env NODE_ENV=production electron .",
    		"dist": "electron-builder"
    	},
    	"keywords": [],
    	"author": "Studio Happyvalley",
    	"license": "ISC",
    	"devDependencies": {
    		"cross-env": "^10.1.0",
    		"electron": "^38.1.2",
    		"electron-builder": "^26.0.12"
    	},
    	"build": {
    		"appId": "com.shv.reminder",//ここのidは非常に重要
    		"productName": "SHV Reminder",
    		"win": {
    			"target": "nsis",
    			"icon": "icon.ico"
    		},
    		"nsis": {
    			"oneClick": false,
    			"perMachine": false,
    			"allowToChangeInstallationDirectory": true,
    			"createDesktopShortcut": true,
    			"createStartMenuShortcut": true,
    			"runAfterFinish": true
    		}
    	}
    }
    
    main.js

    今回requireからimportへの変更、保存ディレクトリの共有化、preload.jsの設定など修正箇所が多いのでほぼ全体修正となります。
    またタスクメニューも大幅に追加しました。簡易的に通知のテストができるようにテスト通知のコマンドもいれました。
    かなり長いですから、説明が必要と思われる箇所にはコメントを入れて有ります。

    import { app, BrowserWindow, ipcMain, Notification, Tray, Menu } from "electron";
    import path from "path";
    import { fileURLToPath } from "url";
    const __filename = fileURLToPath(import.meta.url);
    const __dirname = path.dirname(__filename);
    
    // アプリ用の保存先を「shv-reminder」に固定
    app.setAppUserModelId("com.shv.reminder");
    app.setPath("userData", path.join(app.getPath("appData"), "shv-reminder"));
    
    let tray = null;
    let mainWindow;
    
    function createWindow() {
      mainWindow = new BrowserWindow({
        width: 1000,
        height: 800,
        icon: path.join(__dirname, "icon.ico"),
        webPreferences: {
          preload: path.join(__dirname, "preload.js"),
          nodeIntegration: false,
          contextIsolation: true,
          sandbox: false
        }
      });
    
      // レンダラから保存先を問い合わせできるようにする
      ipcMain.handle("get-user-data-path", () => {
        return app.getPath("userData");
      });
    
      mainWindow.loadFile("index.html");
    
    	//closeイベントをフック(×を押したとき終了させない)
    	mainWindow.on("close", (event) => {
    		event.preventDefault();   // デフォルトの終了を止める
    		mainWindow.hide();        // ウィンドウを隠す(タスクトレイに残る)
    	});
    
      tray = new Tray(path.join(__dirname, "icon.ico"));
      const contextMenu = Menu.buildFromTemplate([
        { label: "開く", click: () => mainWindow.show() },
    	{ label: "隠す", click: () => mainWindow.hide() },
    	{ type: "separator" },
    		{
    		label: "設定",
    		submenu: [
    			{
    				label: "自動起動を有効化",
    				type: "checkbox",
    				checked: true,
    				click: (menuItem) => {
    					app.setLoginItemSettings({
    						openAtLogin: menuItem.checked,
    						path: process.execPath,
    					});
    				}
    			},
    			{
    				label: "通知テスト",
    				click: () => {
    					new Notification({ title: "テスト通知", body: "これはサンプル通知です。" }).show();
    				}
    			}
    		]
    	  },
    	  { type: "separator" },
    	  {
    		label: "終了",
    		click: () => {
    		  tray.destroy();
    		  app.quit();
    		}
    	  }
    
      ]);
      tray.setToolTip("SHV Reminder");
      tray.setContextMenu(contextMenu);
    }
    //通知イベント
    ipcMain.on("show-reminder", (event, { title, body, meta }) => {
    	const notification = new Notification({ title, body });
      notification.show();
    
      // 通知が来たらアイコンを赤丸付きに変える
      tray.setImage(path.join(__dirname, "icon-alert.ico"));
    
    });
    ipcMain.on("update-badge", (event, count) => {
      app.setBadgeCount(count);
    });
    
    //アプリを開いたら通常アイコンに戻す
    app.on("browser-window-focus", () => {
      if (tray) {
        tray.setImage(path.join(__dirname, "icon.ico"));
      }
    });														  
    
    app.whenReady().then(() => {
      createWindow();
    
      app.on("activate", () => {
        if (BrowserWindow.getAllWindows().length === 0) createWindow();
      });
    });				 
    
    app.on("window-all-closed", () => {
      if (process.platform !== "darwin") app.quit();
    });
    
    メインとレンダプロセスのブリッジpreload.js

    preload.jsをプロジェクト直下に配置します。
    これまでレンダ側で行っていたデータの保存と読み込みなどを行います。
    また、両環境のイベントのやり取りなども行います。

    mport { contextBridge, ipcRenderer } from "electron";
    import path from "path";
    import fs from "fs";
    import { fileURLToPath } from "url";
    const __filename = fileURLToPath(import.meta.url);
    const __dirname = path.dirname(__filename);
    
    let dataDir = null;
    
    ipcRenderer.invoke("get-user-data-path").then((userDataPath) => {
      dataDir = path.join(userDataPath, "data");
      if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
    });
    
    contextBridge.exposeInMainWorld("electronAPI", {
    	// JSON 読み込み
    	readJSON: (filename) => {
    		const pathinfo = {
    			"package.json": () => {
    				const __dirname = path.dirname(__filename);
    				return path.join(__dirname, `${filename}`);
    			},
    			default: () => {
    				return path.join(dataDir, `${filename}`);
    			}
    		};							 
    		try {
    			const fn = pathinfo[filename] ?? pathinfo.default;
    			const filePath = fn();
    			if (!fs.existsSync(filePath)) return {};
    			return JSON.parse(fs.readFileSync(filePath, "utf-8"));
    		} catch (err) {
    			return {};
    		}
    	},
    	updateBadge: (count) => ipcRenderer.send("update-badge", count),
    	// JSON 書き込み
    	writeJSON: (filename, data) => {
    		try {
    			const filePath = path.join(dataDir, filename);
    			fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
    		} catch (err) {
    		}
    	},
    	//通知を main に依頼
    	sendReminder: (title, body, meta) => {
    		ipcRenderer.send("show-reminder", { title, body, meta });
    	},
    
    	// 通知サポート有無
    	canNotify: () => ipcRenderer.invoke("can-notify"),
    
    	//タスクアイコンから予定追加処理
    	onOpenAddNote: (callback) => ipcRenderer.on("open-add-note", callback),
    	
    	//通知先の予定を選択
    	onFocusNote: (callback) =>
    	ipcRenderer.on("focus-note", (event, data) => {
    		callback(data);
    	}),
    });
    
    contextBridge.exposeInMainWorld("appEnv", {
      NODE_ENV: process.env.NODE_ENV
    });
    index.html

    index.htmlのjavascriptは半分くらいをメインプロセスへ移すので半分くらいになります。

    //旧
    const { ipcRenderer } = require("electron");
    const path = require("path");
    const fs = require("fs");
    
    		  
    let currentUserKey = 'cal-default';
      
    
    let dataDir = null;
    let dataFile = null;
    (async () => {
    	dataDir = await ipcRenderer.invoke("get-user-data-path");
    	if (!fs.existsSync(dataDir)) {
    		fs.mkdirSync(dataDir, { recursive: true });
    	}
    	dataFile = path.join(dataDir, `${currentUserKey}.json`);
    })();
    
    //新
    const state={
    	current: new Date(),
    	currentIndex: 0,
    	days: '',
    	date: '',
    	currentUserKey: 'cal-default'
    };
    
    通知関連の修正

    startReminder関数の最初の箇所に再チェック用に変数を追加し、処理が重複しないようにします。
    startReminderの起動タイミングは初期の開発ではhtmlの読み込み直後にしていましたが、予定を変更・修正した時にも再度startReminderを回す必要があるので、その時、処理が重複しないための対策です。

    let reminderTimer= null;
    let notified = {}; // 通知済みフラグ
    function startReminder() {
    	if (reminderTimer) {
    		clearInterval(reminderTimer); // すでに動いていたらリセット
    	}
    通知処理

    通知処理もメイン処理に任せるので修正します。

    //旧
    if (now >= alertTime && now < target) {
    	new Notification([`[${dateStr}/${note.time}: ${note.title}]\n${note.alertMinutes}分前のお知らせ`, 
    		`${note.title}\n${note.body}`
    	]);
    	// trayアイコン変更のリクエストをメインに送る
    	ipcRenderer.send("reminder-alert");
    
    //新
    if (now >= alertTime && now < target) {
    	console.log("▲通知発生", alertTime);
    	window.electronAPI.sendReminder(
    		`[${dateStr}/${note.time}: ${note.title}]\n${note.alertMinutes}前のお知らせ`,
    		`${note.body || ""}`,
    		{ dateStr, /*index: note.index,*/ title: note.title, time: note.time }
    	);
    読み込みと書きこみ

    データの読み込みと書きこみの箇所も下記の様に修正します。

    //旧
    // ディレクトリがなければ作る
    function ensureDir() {
      if (!fs.existsSync(dataDir)) {
    	fs.mkdirSync(dataDir, { recursive: true });
      }
    }
    
    function loadNotes() {
    	ensureDir();
    	if (!fs.existsSync(dataFile)) return {};
    	const raw = fs.readFileSync(dataFile, "utf-8");
    	return JSON.parse(raw || "{}");
    }
    
    function saveNotes(notes) {
    	fs.writeFileSync(dataFile, JSON.stringify(notes, null, 2), "utf-8");
    }
    
    //新
    
    function loadNotes() {
    	return window.electronAPI.readJSON(`${state.currentUserKey}.json`);
    }
    
    function saveNotes(notes) {
    	window.electronAPI.writeJSON(`${state.currentUserKey}.json`, notes);
    	(async () => {
    		await renderCalendar(); // 即反映
    		state.days= document.querySelectorAll(".day");
    		state.days[state.currentIndex].classList.add('currentday');
    		startReminder();
    	})();
    }
    
    startReminderの初期起動はレンダリングの後

    一番最後に下記を追記し、カレンダーをレンダリングし、通知処理を回します。

    renderCalendar();
    startReminder();

ビルド

いよいよアプリをビルド
  • 全ての準備が完了したのでビルド

    powerShellで下記を実行するとdistフォルダにWindowsインストーラー(.exe) が生成されます。

    npm run dist
    
    https://studio-happyvalley.com/wp/wp-content/uploads/fig4-1-1.png
    リマインダーをOSへインストールする

    インストーラをダブルクリックしリマインダーをインストールする。順当に次へをクリックしてください。
    特に何もせず、設定は不要です。

    https://studio-happyvalley.com/wp/wp-content/uploads/fig4-2-1.png
    インストールが正しく完了すると

    インストールが完了するとスタートメニューやデスクトップにアイコンが表示されます。
    また、アプリも起動します。

    https://studio-happyvalley.com/wp/wp-content/uploads/fig4-3-1.pnghttps://studio-happyvalley.com/wp/wp-content/uploads/fig4-4-1.png
    通知トーストのテスト

    タスクバーに表示されているアイコンを右クリックし、通知テストをクリック。
    正常にトーストがスライドするか確認します。

    https://studio-happyvalley.com/wp/wp-content/uploads/fig4-5-1.pnghttps://studio-happyvalley.com/wp/wp-content/uploads/fig4-6-1.png
OS側の設定
  • インストールすると通知の設定が可能に

    OSにインストールするとアプリと認識されるようになって色々な設定が可能になります。
    まず、設定したアイコンが表示されるようになります。

    https://studio-happyvalley.com/wp/wp-content/uploads/fig4-7-1.png通知設定画面の下段にアプリのアイコンが表示されるhttps://studio-happyvalley.com/wp/wp-content/uploads/fig4-8.pngバナーとアクションセンターに表示にチェック
    通知バナーが出ないときは設定を確認

    Windows 10 の通知設定
    アプリ通知が「バナー非表示・アクションセンターにのみ保存」になっている
    設定 → システム → 通知とアクション → 対象アプリの詳細設定 で

    ・「通知バナーを表示する」
    ・「通知をアクションセンターに表示する」

    両方がオンになってるか確認

    通知バナーがうるさいときはアプリを整理

    アクションセンターのアプリ選択画面で通知が欲しいアプリのみをセレクトすればスッキリします。

参考

実際に使用中のリマインダー
  • いろいろ機能を追加しています。

    ・予定の項目を追加。
    ・繰り返しの間隔を設定。
    ・繰り返しをストップ。
    ・予定を非表示にする。
    ・通知をクリックすると該当する予定を開く。
    ・OSの起動時に一緒に起動。
    などの機能を追加しています。

    下記に通知機能の作動状況を動画にしています。

参考
コード

リマインダー開発で作成したコード

『リマインダー開発Ⅳ.アプリをビルドし OSへインストール』関連のお薦め

このサイトで紹介しているコード、プログラムなどは個人の学習目的で作成されたものであり、いかなる保証も行いません。
利用はすべて自己責任でお願いします。
ただし、このページで紹介しているプログラムやビジュアルなどはご依頼いただければ実装を賜ります。
お問い合わせはこちら