From fea33347249c39b177cc4283b46fdacc0920b071 Mon Sep 17 00:00:00 2001 From: sabonerune <102559104+sabonerune@users.noreply.github.com> Date: Thu, 2 Jan 2025 19:18:24 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20WindowManager=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=20(#2455)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Hiroshiba Co-authored-by: Hiroshiba Kazuyuki --- .../electron/engineAndVvppController.ts | 12 +- src/backend/electron/main.ts | 300 +++++------------ src/backend/electron/manager/windowManager.ts | 315 ++++++++++++++++++ tests/e2e/electron/example.spec.ts | 3 +- 4 files changed, 398 insertions(+), 232 deletions(-) create mode 100644 src/backend/electron/manager/windowManager.ts diff --git a/src/backend/electron/engineAndVvppController.ts b/src/backend/electron/engineAndVvppController.ts index c30c4e1311..cc4bd95952 100644 --- a/src/backend/electron/engineAndVvppController.ts +++ b/src/backend/electron/engineAndVvppController.ts @@ -2,13 +2,14 @@ import path from "path"; import fs from "fs"; import { ReadableStream } from "node:stream/web"; import log from "electron-log/main"; -import { BrowserWindow, dialog } from "electron"; +import { dialog } from "electron"; import { getConfigManager } from "./electronConfig"; import { getEngineInfoManager } from "./manager/engineInfoManager"; import { getEngineProcessManager } from "./manager/engineProcessManager"; import { getRuntimeInfoManager } from "./manager/RuntimeInfoManager"; import { getVvppManager } from "./manager/vvppManager"; +import { getWindowManager } from "./manager/windowManager"; import { ProgressCallback } from "./type"; import { EngineId, @@ -72,14 +73,13 @@ export class EngineAndVvppController { vvppPath, reloadNeeded, reloadCallback, - win, }: { vvppPath: string; reloadNeeded: boolean; reloadCallback?: () => void; // 再読み込みが必要な場合のコールバック - win: BrowserWindow; // dialog表示に必要。 FIXME: dialog表示関数をDI可能にし、winを削除する }) { - const result = dialog.showMessageBoxSync(win, { + const windowManager = getWindowManager(); + const result = windowManager.showMessageBoxSync({ type: "warning", title: "エンジン追加の確認", message: `この操作はコンピュータに損害を与える可能性があります。エンジンの配布元が信頼できない場合は追加しないでください。`, @@ -94,8 +94,8 @@ export class EngineAndVvppController { await this.installVvppEngine(vvppPath); if (reloadNeeded) { - void dialog - .showMessageBox(win, { + void windowManager + .showMessageBox({ type: "info", title: "再読み込みが必要です", message: diff --git a/src/backend/electron/main.ts b/src/backend/electron/main.ts index abac4649e5..0bee5375cc 100644 --- a/src/backend/electron/main.ts +++ b/src/backend/electron/main.ts @@ -4,21 +4,11 @@ import path from "path"; import fs from "fs"; import { pathToFileURL } from "url"; -import { - app, - protocol, - BrowserWindow, - dialog, - Menu, - shell, - nativeTheme, - net, -} from "electron"; +import { app, dialog, Menu, nativeTheme, net, protocol, shell } from "electron"; import installExtension, { VUEJS_DEVTOOLS } from "electron-devtools-installer"; import log from "electron-log/main"; import dayjs from "dayjs"; -import windowStateKeeper from "electron-window-state"; import { hasSupportedGpu } from "./device"; import { getEngineInfoManager, @@ -29,6 +19,10 @@ import { initializeEngineProcessManager, } from "./manager/engineProcessManager"; import { initializeVvppManager, isVvppFile } from "./manager/vvppManager"; +import { + getWindowManager, + initializeWindowManager, +} from "./manager/windowManager"; import configMigration014 from "./configMigration014"; import { initializeRuntimeInfoManager } from "./manager/RuntimeInfoManager"; import { registerIpcMainHandle, ipcMainSendProxy, IpcMainHandle } from "./ipc"; @@ -44,7 +38,6 @@ import { EngineId, TextAsset, } from "@/type/preload"; -import { themes } from "@/domain/theme"; import { isMac } from "@/helpers/platform"; type SingleInstanceLockData = { @@ -110,8 +103,6 @@ if (errorForRemoveBeforeUserDataDir != undefined) { log.error(errorForRemoveBeforeUserDataDir); } -let win: BrowserWindow; - process.on("uncaughtException", (error) => { log.error(error); @@ -151,7 +142,28 @@ protocol.registerSchemesAsPrivileged([ { scheme: "app", privileges: { secure: true, standard: true, stream: true } }, ]); -const firstUrl = import.meta.env.VITE_DEV_SERVER_URL ?? "app://./index.html"; +// ソフトウェア起動時はプロトコルを app にする +void app.whenReady().then(() => { + protocol.handle("app", (request) => { + // 読み取り先のファイルがインストールディレクトリ内であることを確認する + // ref: https://www.electronjs.org/ja/docs/latest/api/protocol#protocolhandlescheme-handler + const { pathname } = new URL(request.url); + const pathToServe = path.resolve(path.join(__dirname, pathname)); + const relativePath = path.relative(__dirname, pathToServe); + const isUnsafe = + path.isAbsolute(relativePath) || + relativePath.startsWith("..") || + relativePath === ""; + if (isUnsafe) { + log.error(`Bad Request URL: ${request.url}`); + return new Response("bad", { + status: 400, + headers: { "content-type": "text/html" }, + }); + } + return net.fetch(pathToFileURL(pathToServe).toString()); + }); +}); // engine const vvppEngineDir = path.join(app.getPath("userData"), "vvpp-engines"); @@ -166,6 +178,7 @@ const onEngineProcessError = (engineInfo: EngineInfo, error: Error) => { // winが作られる前にエラーが発生した場合はwinへの通知を諦める // FIXME: winが作られた後にエンジンを起動させる + const win = windowManager.win; if (win != undefined) { ipcMainSendProxy.DETECTED_ENGINE_ERROR(win, { engineId }); } else { @@ -175,6 +188,16 @@ const onEngineProcessError = (engineInfo: EngineInfo, error: Error) => { dialog.showErrorBox("音声合成エンジンエラー", error.message); }; +const appState = { + willQuit: false, +}; + +initializeWindowManager({ + appStateGetter: () => appState, + isDevelopment, + isTest, + staticDir: __static, +}); initializeRuntimeInfoManager({ runtimeInfoPath: path.join(app.getPath("userData"), "runtime-info.json"), appVersion: app.getVersion(), @@ -187,6 +210,7 @@ initializeEngineProcessManager({ onEngineProcessError }); initializeVvppManager({ vvppEngineDir }); const configManager = getConfigManager(); +const windowManager = getWindowManager(); const engineInfoManager = getEngineInfoManager(); const engineProcessManager = getEngineProcessManager(); const engineAndVvppController = getEngineAndVvppController(); @@ -207,7 +231,7 @@ function openEngineDirectory(engineId: EngineId) { function checkMultiEngineEnabled(): boolean { const enabled = configManager.get("enableMultiEngine"); if (!enabled) { - dialog.showMessageBoxSync(win, { + windowManager.showMessageBoxSync({ type: "info", title: "マルチエンジン機能が無効です", message: `マルチエンジン機能が無効です。vvppファイルを使用するには設定からマルチエンジン機能を有効にしてください。`, @@ -218,148 +242,7 @@ function checkMultiEngineEnabled(): boolean { return enabled; } -const appState = { - willQuit: false, -}; let filePathOnMac: string | undefined = undefined; -// create window -async function createWindow() { - const mainWindowState = windowStateKeeper({ - defaultWidth: 1024, - defaultHeight: 630, - }); - - const currentTheme = configManager.get("currentTheme"); - const backgroundColor = themes.find((value) => value.name == currentTheme) - ?.colors.background; - - win = new BrowserWindow({ - x: mainWindowState.x, - y: mainWindowState.y, - width: mainWindowState.width, - height: mainWindowState.height, - frame: false, - titleBarStyle: "hidden", - trafficLightPosition: { x: 6, y: 4 }, - minWidth: 320, - show: false, - backgroundColor, - webPreferences: { - preload: path.join(__dirname, "preload.js"), - }, - icon: path.join(__static, "icon.png"), - }); - - let projectFilePath = ""; - if (isMac) { - if (filePathOnMac) { - if (filePathOnMac.endsWith(".vvproj")) { - projectFilePath = filePathOnMac; - } - filePathOnMac = undefined; - } - } else { - if (process.argv.length >= 2) { - const filePath = process.argv[1]; - if ( - fs.existsSync(filePath) && - fs.statSync(filePath).isFile() && - filePath.endsWith(".vvproj") - ) { - projectFilePath = filePath; - } - } - } - - // ソフトウェア起動時はプロトコルを app にする - if (import.meta.env.VITE_DEV_SERVER_URL == undefined) { - protocol.handle("app", (request) => { - // 読み取り先のファイルがインストールディレクトリ内であることを確認する - // ref: https://www.electronjs.org/ja/docs/latest/api/protocol#protocolhandlescheme-handler - const { pathname } = new URL(request.url); - const pathToServe = path.resolve(path.join(__dirname, pathname)); - const relativePath = path.relative(__dirname, pathToServe); - const isUnsafe = - path.isAbsolute(relativePath) || - relativePath.startsWith("..") || - relativePath === ""; - if (isUnsafe) { - log.error(`Bad Request URL: ${request.url}`); - return new Response("bad", { - status: 400, - headers: { "content-type": "text/html" }, - }); - } - return net.fetch(pathToFileURL(pathToServe).toString()); - }); - } - - await loadUrl({ projectFilePath }); - - if (isDevelopment && !isTest) win.webContents.openDevTools(); - - win.on("maximize", () => { - ipcMainSendProxy.DETECT_MAXIMIZED(win); - }); - win.on("unmaximize", () => { - ipcMainSendProxy.DETECT_UNMAXIMIZED(win); - }); - win.on("enter-full-screen", () => { - ipcMainSendProxy.DETECT_ENTER_FULLSCREEN(win); - }); - win.on("leave-full-screen", () => { - ipcMainSendProxy.DETECT_LEAVE_FULLSCREEN(win); - }); - win.on("always-on-top-changed", () => { - win.isAlwaysOnTop() - ? ipcMainSendProxy.DETECT_PINNED(win) - : ipcMainSendProxy.DETECT_UNPINNED(win); - }); - win.on("close", (event) => { - if (!appState.willQuit) { - event.preventDefault(); - ipcMainSendProxy.CHECK_EDITED_AND_NOT_SAVE(win, { - closeOrReload: "close", - }); - return; - } - }); - - win.on("resize", () => { - const windowSize = win.getSize(); - ipcMainSendProxy.DETECT_RESIZED(win, { - width: windowSize[0], - height: windowSize[1], - }); - }); - - mainWindowState.manage(win); -} - -/** - * 画面の読み込みを開始する。 - * @param obj.isMultiEngineOffMode マルチエンジンオフモードにするかどうか。無指定時はfalse扱いになる。 - * @param obj.projectFilePath 初期化時に読み込むプロジェクトファイル。無指定時は何も読み込まない。 - * @returns ロードの完了を待つPromise。 - */ -async function loadUrl(obj: { - isMultiEngineOffMode?: boolean; - projectFilePath?: string; -}) { - const url = new URL(firstUrl); - url.searchParams.append( - "isMultiEngineOffMode", - (obj?.isMultiEngineOffMode ?? false).toString(), - ); - url.searchParams.append("projectFilePath", obj?.projectFilePath ?? ""); - return win.loadURL(url.toString()); -} - -// 開始。その他の準備が完了した後に呼ばれる。 -async function start() { - await engineAndVvppController.launchEngines(); - await createWindow(); -} const menuTemplateForMac: Electron.MenuItemConstructorOptions[] = [ { @@ -416,7 +299,7 @@ const retryShowSaveDialogWhileSafeDir = async < */ const showWarningDialog = async () => { const productName = app.getName().toUpperCase(); - const warningResult = await dialog.showMessageBox(win, { + const warningResult = await windowManager.showMessageBox({ message: `指定された保存先は${productName}により自動的に削除される可能性があります。\n他の場所に保存することをおすすめします。`, type: "warning", buttons: ["保存場所を変更", "無視して保存"], @@ -474,7 +357,7 @@ registerIpcMainHandle({ */ SHOW_SAVE_DIRECTORY_DIALOG: async (_, { title }) => { const result = await retryShowSaveDialogWhileSafeDir(() => - dialog.showOpenDialog(win, { + windowManager.showOpenDialog({ title, properties: [ "openDirectory", @@ -490,7 +373,7 @@ registerIpcMainHandle({ }, SHOW_VVPP_OPEN_DIALOG: async (_, { title, defaultPath }) => { - const result = await dialog.showOpenDialog(win, { + const result = await windowManager.showOpenDialog({ title, defaultPath, filters: [ @@ -506,7 +389,7 @@ registerIpcMainHandle({ * 保存先として選ぶ場合は SHOW_SAVE_DIRECTORY_DIALOG を使うべき。 */ SHOW_OPEN_DIRECTORY_DIALOG: async (_, { title }) => { - const result = await dialog.showOpenDialog(win, { + const result = await windowManager.showOpenDialog({ title, properties: [ "openDirectory", @@ -522,7 +405,7 @@ registerIpcMainHandle({ SHOW_PROJECT_SAVE_DIALOG: async (_, { title, defaultPath }) => { const result = await retryShowSaveDialogWhileSafeDir(() => - dialog.showSaveDialog(win, { + windowManager.showSaveDialog({ title, defaultPath, filters: [{ name: "VOICEVOX Project file", extensions: ["vvproj"] }], @@ -536,7 +419,7 @@ registerIpcMainHandle({ }, SHOW_PROJECT_LOAD_DIALOG: async (_, { title }) => { - const result = await dialog.showOpenDialog(win, { + const result = await windowManager.showOpenDialog({ title, filters: [{ name: "VOICEVOX Project file", extensions: ["vvproj"] }], properties: ["openFile", "createDirectory", "treatPackageAsDirectory"], @@ -548,7 +431,7 @@ registerIpcMainHandle({ }, SHOW_WARNING_DIALOG: (_, { title, message }) => { - return dialog.showMessageBox(win, { + return windowManager.showMessageBox({ type: "warning", title, message, @@ -556,7 +439,7 @@ registerIpcMainHandle({ }, SHOW_ERROR_DIALOG: (_, { title, message }) => { - return dialog.showMessageBox(win, { + return windowManager.showMessageBox({ type: "error", title, message, @@ -564,7 +447,7 @@ registerIpcMainHandle({ }, SHOW_IMPORT_FILE_DIALOG: (_, { title, name, extensions }) => { - return dialog.showOpenDialogSync(win, { + return windowManager.showOpenDialogSync({ title, filters: [{ name: name ?? "Text", extensions: extensions ?? ["txt"] }], properties: ["openFile", "createDirectory", "treatPackageAsDirectory"], @@ -576,7 +459,7 @@ registerIpcMainHandle({ { title, defaultPath, extensionName, extensions }, ) => { const result = await retryShowSaveDialogWhileSafeDir(() => - dialog.showSaveDialog(win, { + windowManager.showSaveDialog({ title, defaultPath, filters: [{ name: extensionName, extensions: extensions }], @@ -591,48 +474,33 @@ registerIpcMainHandle({ }, IS_MAXIMIZED_WINDOW: () => { - return win.isMaximized(); + return windowManager.isMaximized(); }, CLOSE_WINDOW: () => { appState.willQuit = true; - win.destroy(); + windowManager.destroyWindow(); }, MINIMIZE_WINDOW: () => { - win.minimize(); + windowManager.minimize(); }, TOGGLE_MAXIMIZE_WINDOW: () => { - // 全画面表示中は、全画面表示解除のみを行い、最大化解除処理は実施しない - if (win.isFullScreen()) { - win.setFullScreen(false); - } else if (win.isMaximized()) { - win.unmaximize(); - } else { - win.maximize(); - } + windowManager.toggleMaximizeWindow(); }, TOGGLE_FULLSCREEN: () => { - if (win.isFullScreen()) { - win.setFullScreen(false); - } else { - win.setFullScreen(true); - } + windowManager.toggleFullScreen(); }, /** UIの拡大 */ ZOOM_IN: () => { - win.webContents.setZoomFactor( - Math.min(Math.max(win.webContents.getZoomFactor() + 0.1, 0.5), 3), - ); + windowManager.zoomIn(); }, /** UIの縮小 */ ZOOM_OUT: () => { - win.webContents.setZoomFactor( - Math.min(Math.max(win.webContents.getZoomFactor() - 0.1, 0.5), 3), - ); + windowManager.zoomOut(); }, /** UIの拡大率リセット */ ZOOM_RESET: () => { - win.webContents.setZoomFactor(1); + windowManager.zoomReset(); }, OPEN_LOG_DIRECTORY: () => { void shell.openPath(app.getPath("logs")); @@ -666,18 +534,14 @@ registerIpcMainHandle({ }, ON_VUEX_READY: () => { - win.show(); + windowManager.show(); }, CHECK_FILE_EXISTS: (_, { file }) => { return fs.existsSync(file); }, CHANGE_PIN_WINDOW: () => { - if (win.isAlwaysOnTop()) { - win.setAlwaysOnTop(false); - } else { - win.setAlwaysOnTop(true); - } + windowManager.togglePinWindow(); }, GET_DEFAULT_TOOLBAR_SETTING: () => { @@ -714,24 +578,7 @@ registerIpcMainHandle({ }, RELOAD_APP: async (_, { isMultiEngineOffMode }) => { - win.hide(); // FIXME: ダミーページ表示のほうが良い - - // 一旦適当なURLに飛ばしてページをアンロードする - await win.loadURL("about:blank"); - - log.info("Checking ENGINE status before reload app"); - const engineCleanupResult = engineAndVvppController.cleanupEngines(); - - // エンジンの停止とエンジン終了後処理の待機 - if (engineCleanupResult != "alreadyCompleted") { - await engineCleanupResult; - } - log.info("Post engine kill process done. Now reloading app"); - - await engineAndVvppController.launchEngines(); - - await loadUrl({ isMultiEngineOffMode: !!isMultiEngineOffMode }); - win.show(); + await windowManager.reload(isMultiEngineOffMode); }, WRITE_FILE: (_, { filePath, buffer }) => { @@ -792,7 +639,9 @@ app.on("window-all-closed", () => { app.on("before-quit", async (event) => { if (!appState.willQuit) { event.preventDefault(); - ipcMainSendProxy.CHECK_EDITED_AND_NOT_SAVE(win, { closeOrReload: "close" }); + ipcMainSendProxy.CHECK_EDITED_AND_NOT_SAVE(windowManager.getWindow(), { + closeOrReload: "close", + }); return; } @@ -841,7 +690,7 @@ app.once("will-finish-launching", () => { }); }); -app.on("ready", async () => { +void app.whenReady().then(async () => { await configManager.initialize().catch(async (e) => { log.error(e); @@ -936,7 +785,7 @@ app.on("ready", async () => { await engineAndVvppController.fetchInsallablePackageInfos(); for (const { engineName, packageInfo } of packageInfos) { // インストールするか確認 - const result = dialog.showMessageBoxSync(win, { + const result = dialog.showMessageBoxSync({ type: "info", title: "デフォルトエンジンのインストール", message: `${engineName} をインストールしますか?`, @@ -976,6 +825,7 @@ app.on("ready", async () => { } // 多重起動防止 + // TODO: readyを待たずにもっと早く実行すべき if ( !isDevelopment && !isTest && @@ -997,17 +847,23 @@ app.on("ready", async () => { await engineAndVvppController.installVvppEngineWithWarning({ vvppPath: filePath, reloadNeeded: false, - win, }); } } - void start(); + await engineAndVvppController.launchEngines(); + await windowManager.createWindow(filePath); }); // 他のプロセスが起動したとき、`requestSingleInstanceLock`経由で`rawData`が送信される。 app.on("second-instance", async (_event, _argv, _workDir, rawData) => { const data = rawData as SingleInstanceLockData; + const win = windowManager.win; + if (win == undefined) { + // TODO: 起動シーケンス中の場合はWindowが作られるまで待つ + log.warn("A 'second-instance' event was emitted but there is no window."); + return; + } if (!data.filePath) { log.info("No file path sent"); } else if (isVvppFile(data.filePath)) { @@ -1022,7 +878,6 @@ app.on("second-instance", async (_event, _argv, _workDir, rawData) => { closeOrReload: "reload", }); }, - win, }); } } else if (data.filePath.endsWith(".vvproj")) { @@ -1031,10 +886,7 @@ app.on("second-instance", async (_event, _argv, _workDir, rawData) => { filePath: data.filePath, }); } - if (win) { - if (win.isMinimized()) win.restore(); - win.focus(); - } + windowManager.restoreAndFocus(); }); if (isDevelopment) { diff --git a/src/backend/electron/manager/windowManager.ts b/src/backend/electron/manager/windowManager.ts new file mode 100644 index 0000000000..c0a49734af --- /dev/null +++ b/src/backend/electron/manager/windowManager.ts @@ -0,0 +1,315 @@ +import fs from "fs"; +import path from "path"; +import { + BrowserWindow, + dialog, + MessageBoxOptions, + MessageBoxSyncOptions, + OpenDialogOptions, + OpenDialogSyncOptions, + SaveDialogOptions, +} from "electron"; +import log from "electron-log/main"; +import windowStateKeeper from "electron-window-state"; +import { getConfigManager } from "../electronConfig"; +import { getEngineAndVvppController } from "../engineAndVvppController"; +import { ipcMainSendProxy } from "../ipc"; +import { isMac } from "@/helpers/platform"; +import { themes } from "@/domain/theme"; + +type WindowManagerOption = { + appStateGetter: () => { willQuit: boolean }; + staticDir: string; + isDevelopment: boolean; + isTest: boolean; +}; + +class WindowManager { + private _win: BrowserWindow | undefined; + private appStateGetter: () => { willQuit: boolean }; + private staticDir: string; + private isDevelopment: boolean; + private isTest: boolean; + + constructor(payload: WindowManagerOption) { + this.appStateGetter = payload.appStateGetter; + this.staticDir = payload.staticDir; + this.isDevelopment = payload.isDevelopment; + this.isTest = payload.isTest; + } + + /** + * BrowserWindowを取得する + */ + public get win() { + return this._win; + } + + /** + * BrowserWindowを取得するが存在しない場合は例外を投げる + */ + public getWindow() { + if (this._win == undefined) { + throw new Error("_win == undefined"); + } + return this._win; + } + + public async createWindow(filePathOnMac: string | undefined) { + if (this.win != undefined) { + throw new Error("Window has already been created"); + } + const mainWindowState = windowStateKeeper({ + defaultWidth: 1024, + defaultHeight: 630, + }); + + const configManager = getConfigManager(); + const currentTheme = configManager.get("currentTheme"); + const backgroundColor = themes.find((value) => value.name == currentTheme) + ?.colors.background; + + const win = new BrowserWindow({ + x: mainWindowState.x, + y: mainWindowState.y, + width: mainWindowState.width, + height: mainWindowState.height, + frame: false, + titleBarStyle: "hidden", + trafficLightPosition: { x: 6, y: 4 }, + minWidth: 320, + show: false, + backgroundColor, + webPreferences: { + preload: path.join(__dirname, "preload.js"), + }, + icon: path.join(this.staticDir, "icon.png"), + }); + + let projectFilePath = ""; + if (isMac) { + if (filePathOnMac) { + if (filePathOnMac.endsWith(".vvproj")) { + projectFilePath = filePathOnMac; + } + filePathOnMac = undefined; + } + } else { + if (process.argv.length >= 2) { + const filePath = process.argv[1]; + if ( + fs.existsSync(filePath) && + fs.statSync(filePath).isFile() && + filePath.endsWith(".vvproj") + ) { + projectFilePath = filePath; + } + } + } + + win.on("maximize", () => { + ipcMainSendProxy.DETECT_MAXIMIZED(win); + }); + win.on("unmaximize", () => { + ipcMainSendProxy.DETECT_UNMAXIMIZED(win); + }); + win.on("enter-full-screen", () => { + ipcMainSendProxy.DETECT_ENTER_FULLSCREEN(win); + }); + win.on("leave-full-screen", () => { + ipcMainSendProxy.DETECT_LEAVE_FULLSCREEN(win); + }); + win.on("always-on-top-changed", () => { + win.isAlwaysOnTop() + ? ipcMainSendProxy.DETECT_PINNED(win) + : ipcMainSendProxy.DETECT_UNPINNED(win); + }); + win.on("close", (event) => { + const appState = this.appStateGetter(); + if (!appState.willQuit) { + event.preventDefault(); + ipcMainSendProxy.CHECK_EDITED_AND_NOT_SAVE(win, { + closeOrReload: "close", + }); + return; + } + }); + win.on("closed", () => { + this._win = undefined; + }); + win.on("resize", () => { + const windowSize = win.getSize(); + ipcMainSendProxy.DETECT_RESIZED(win, { + width: windowSize[0], + height: windowSize[1], + }); + }); + mainWindowState.manage(win); + this._win = win; + + await this.load({ projectFilePath }); + + if (this.isDevelopment && !this.isTest) win.webContents.openDevTools(); + } + + /** + * 画面の読み込みを開始する。 + * @param obj.isMultiEngineOffMode マルチエンジンオフモードにするかどうか。無指定時はfalse扱いになる。 + * @param obj.projectFilePath 初期化時に読み込むプロジェクトファイル。無指定時は何も読み込まない。 + * @returns ロードの完了を待つPromise。 + */ + public async load(obj: { + isMultiEngineOffMode?: boolean; + projectFilePath?: string; + }) { + const win = this.getWindow(); + const firstUrl = + import.meta.env.VITE_DEV_SERVER_URL ?? "app://./index.html"; + const url = new URL(firstUrl); + url.searchParams.append( + "isMultiEngineOffMode", + (obj?.isMultiEngineOffMode ?? false).toString(), + ); + url.searchParams.append("projectFilePath", obj?.projectFilePath ?? ""); + await win.loadURL(url.toString()); + } + + public async reload(isMultiEngineOffMode: boolean | undefined) { + const win = this.getWindow(); + win.hide(); // FIXME: ダミーページ表示のほうが良い + + // 一旦適当なURLに飛ばしてページをアンロードする + await win.loadURL("about:blank"); + + log.info("Checking ENGINE status before reload app"); + const engineAndVvppController = getEngineAndVvppController(); + const engineCleanupResult = engineAndVvppController.cleanupEngines(); + + // エンジンの停止とエンジン終了後処理の待機 + if (engineCleanupResult != "alreadyCompleted") { + await engineCleanupResult; + } + log.info("Post engine kill process done. Now reloading app"); + + await engineAndVvppController.launchEngines(); + + await this.load({ + isMultiEngineOffMode: !!isMultiEngineOffMode, + }); + win.show(); + } + + public togglePinWindow() { + const win = this.getWindow(); + if (win.isAlwaysOnTop()) { + win.setAlwaysOnTop(false); + } else { + win.setAlwaysOnTop(true); + } + } + + public toggleMaximizeWindow() { + const win = this.getWindow(); + // 全画面表示中は、全画面表示解除のみを行い、最大化解除処理は実施しない + if (win.isFullScreen()) { + win.setFullScreen(false); + } else if (win.isMaximized()) { + win.unmaximize(); + } else { + win.maximize(); + } + } + + public toggleFullScreen() { + const win = this.getWindow(); + if (win.isFullScreen()) { + win.setFullScreen(false); + } else { + win.setFullScreen(true); + } + } + + public restoreAndFocus() { + const win = this.getWindow(); + if (win.isMinimized()) win.restore(); + win.focus(); + } + + public zoomIn() { + const win = this.getWindow(); + win.webContents.setZoomFactor( + Math.min(Math.max(win.webContents.getZoomFactor() + 0.1, 0.5), 3), + ); + } + + public zoomOut() { + const win = this.getWindow(); + win.webContents.setZoomFactor( + Math.min(Math.max(win.webContents.getZoomFactor() - 0.1, 0.5), 3), + ); + } + + public zoomReset() { + const win = this.getWindow(); + win.webContents.setZoomFactor(1); + } + + public destroyWindow() { + this.getWindow().destroy(); + } + + public show() { + this.getWindow().show(); + } + + public minimize() { + this.getWindow().minimize(); + } + + public isMaximized() { + return this.getWindow().isMaximized(); + } + + public showOpenDialogSync(options: OpenDialogSyncOptions) { + return this._win == undefined + ? dialog.showOpenDialogSync(options) + : dialog.showOpenDialogSync(this.getWindow(), options); + } + + public showOpenDialog(options: OpenDialogOptions) { + return this._win == undefined + ? dialog.showOpenDialog(options) + : dialog.showOpenDialog(this.getWindow(), options); + } + + public showSaveDialog(options: SaveDialogOptions) { + return this._win == undefined + ? dialog.showSaveDialog(options) + : dialog.showSaveDialog(this.getWindow(), options); + } + + public showMessageBoxSync(options: MessageBoxSyncOptions) { + return this._win == undefined + ? dialog.showMessageBoxSync(options) + : dialog.showMessageBoxSync(this.getWindow(), options); + } + + public showMessageBox(options: MessageBoxOptions) { + return this._win == undefined + ? dialog.showMessageBox(options) + : dialog.showMessageBox(this.getWindow(), options); + } +} + +let windowManager: WindowManager | undefined; + +export function initializeWindowManager(payload: WindowManagerOption) { + windowManager = new WindowManager(payload); +} + +export function getWindowManager() { + if (windowManager == undefined) { + throw new Error("WindowManager is not initialized"); + } + return windowManager; +} diff --git a/tests/e2e/electron/example.spec.ts b/tests/e2e/electron/example.spec.ts index d67508d9c0..1c0ee5dbe7 100644 --- a/tests/e2e/electron/example.spec.ts +++ b/tests/e2e/electron/example.spec.ts @@ -3,7 +3,7 @@ import os from "os"; import path from "path"; import { _electron as electron, test } from "@playwright/test"; import dotenv from "dotenv"; -import { BrowserWindow, MessageBoxSyncOptions } from "electron"; +import { MessageBoxSyncOptions } from "electron"; test.beforeAll(async () => { console.log("Waiting for main.js to be built..."); @@ -64,7 +64,6 @@ test.beforeEach(async () => { await app.evaluate((electron) => { // @ts-expect-error 2種のオーバーロードを無視する electron.dialog.showMessageBoxSync = ( - win: BrowserWindow, options: MessageBoxSyncOptions, ) => { // デフォルトエンジンのインストールの確認ダイアログ