diff --git a/build/electronBuilderConfig.ts b/build/electronBuilderConfig.ts index d7a9e2bfd9..121b489b2e 100644 --- a/build/electronBuilderConfig.ts +++ b/build/electronBuilderConfig.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { readdirSync, existsSync, rmSync } from "node:fs"; -import { config } from "dotenv"; +import dotenv from "dotenv"; import { Configuration as ElectronBuilderConfiguration } from "electron-builder"; import { z } from "zod"; import afterAllArtifactBuild from "./afterAllArtifactBuild"; @@ -9,7 +9,7 @@ import artifactBuildCompleted from "./artifactBuildCompleted"; const rootDir = path.join(import.meta.dirname, ".."); const dotenvPath = path.join(rootDir, ".env.production"); -config({ path: dotenvPath }); +dotenv.config({ path: dotenvPath, quiet: true }); const VOICEVOX_ENGINE_DIR = process.env.VOICEVOX_ENGINE_DIR ?? "../voicevox_engine/dist/run/"; diff --git a/playwright.config.ts b/playwright.config.ts index fb9b5722a5..68c48b6977 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,7 @@ import type { PlaywrightTestConfig, Project } from "@playwright/test"; import dotenv from "dotenv"; -dotenv.config({ path: ".env.test", override: true }); +dotenv.config({ path: ".env.test", override: true, quiet: true }); let project: Project; let webServers: PlaywrightTestConfig["webServer"]; diff --git a/src/backend/browser/sandbox.ts b/src/backend/browser/sandbox.ts index 0562861881..e22ae8cb11 100644 --- a/src/backend/browser/sandbox.ts +++ b/src/backend/browser/sandbox.ts @@ -112,6 +112,9 @@ export const api: Sandbox = { closeWindow() { throw new Error(`Not supported on Browser version: closeWindow`); }, + launchWelcomeWindow() { + throw new Error(`Not supported on Browser version: launchWelcomeWindow`); + }, minimizeWindow() { throw new Error(`Not supported on Browser version: minimizeWindow`); }, diff --git a/src/backend/electron/appStateController.ts b/src/backend/electron/appStateController.ts index f0d53c6cb1..276985cd79 100644 --- a/src/backend/electron/appStateController.ts +++ b/src/backend/electron/appStateController.ts @@ -1,8 +1,8 @@ import { app } from "electron"; -import { ipcMainSendProxy } from "./ipc"; -import { getWindowManager } from "./manager/windowManager"; +import { getMainWindowManager } from "./manager/windowManager/main"; import { getEngineAndVvppController } from "./engineAndVvppController"; import { getConfigManager } from "./electronConfig"; +import { getWelcomeWindowManager } from "./manager/windowManager/welcome"; import { ExhaustiveError } from "@/type/utility"; import { createLogger } from "@/helpers/log"; import { Mutex } from "@/helpers/mutex"; @@ -20,11 +20,84 @@ export class AppStateController { * - unconfirmed:ユーザーが終了をリクエストした状態 * - dirty:クリーンアップ前の状態 * - done:クリーンアップ処理が完了し、アプリが終了する準備が整った状態 + * - switch: ウィンドウ切替のために一時的に終了処理を中断している状態 */ - private quitState: "unconfirmed" | "dirty" | "done" = "unconfirmed"; + private quitState: "unconfirmed" | "dirty" | "done" | "switch" = + "unconfirmed"; + /** 現在アクティブなウィンドウ */ + private activeWindow: "main" | "welcome" | null = null; private lock = new Mutex(); + async startup() { + const engineAndVvppController = getEngineAndVvppController(); + const packageStatuses = + engineAndVvppController.fetchEnginePackageLocalInfos(); + + if (packageStatuses.length === 0) { + log.info("No downloadable engine packages found. Launching main window."); + await this.launchMainWindow(); + return; + } + + const defaultEngineExists = packageStatuses.some((status) => { + return status.installed.status !== "notInstalled"; + }); + if (defaultEngineExists) { + log.info("Default engine found. Launching main window."); + await this.launchMainWindow(); + } else { + log.info("No default engine found. Launching welcome window."); + await this.launchWelcomeWindow(); + } + } + + async switchToMainWindow() { + log.info("Switching to main window"); + this.quitState = "switch"; + + const welcomeWindowManager = getWelcomeWindowManager(); + if (welcomeWindowManager.isInitialized()) { + log.info("Destroying welcome window"); + welcomeWindowManager.destroyWindow(); + } + + await this.launchMainWindow(); + this.quitState = "unconfirmed"; + } + async switchToWelcomeWindow() { + log.info("Switching to welcome window"); + this.quitState = "switch"; + + const mainWindowManager = getMainWindowManager(); + if (mainWindowManager.isInitialized()) { + log.info("Destroying main window and cleaning up engines"); + const engineAndVvppController = getEngineAndVvppController(); + mainWindowManager.destroyWindow(); + await engineAndVvppController.cleanupEngines(); + } + + await this.launchWelcomeWindow(); + this.quitState = "unconfirmed"; + } + + async launchWelcomeWindow() { + this.activeWindow = "welcome"; + + const welcomeWindowManager = getWelcomeWindowManager(); + await welcomeWindowManager.createWindow(); + } + + async launchMainWindow() { + const engineAndVvppController = getEngineAndVvppController(); + const mainWindowManager = getMainWindowManager(); + + this.activeWindow = "main"; + + await engineAndVvppController.launchEngines(); + await mainWindowManager.createWindow(); + } + onQuitRequest(DI: { preventQuit: () => void }): void { log.info(`onQuitRequest called. Current quitState: ${this.quitState}`); @@ -42,7 +115,12 @@ export class AppStateController { DI.preventQuit(); void (async () => { await using _lock = await this.lock.acquire(); - this.checkUnsavedEdit(); + if (this.activeWindow === "main") { + this.checkUnsavedEdit(); + } else { + log.info("Main window is not active. Proceeding to shutdown."); + this.shutdown(); + } })(); break; } @@ -57,6 +135,10 @@ export class AppStateController { case "done": log.info("Quit process already done. Proceeding to quit."); break; + case "switch": + log.info("Quit process is in switch state. Preventing quit request."); + DI.preventQuit(); + break; default: throw new ExhaustiveError(this.quitState); } @@ -64,18 +146,17 @@ export class AppStateController { private checkUnsavedEdit() { log.info("Checking for unsaved edits before quitting"); - const windowManager = getWindowManager(); + const mainWindowManager = getMainWindowManager(); try { - // TODO: ipcの送信以外で失敗した場合はシャットダウンしないようにする - ipcMainSendProxy.CHECK_EDITED_AND_NOT_SAVE(windowManager.getWindow(), { - closeOrReload: "close", + mainWindowManager.ipc.CHECK_EDITED_AND_NOT_SAVE({ + nextAction: "close", }); } catch (error) { log.error( "Error while sending CHECK_EDITED_AND_NOT_SAVE IPC message:", error, ); - void windowManager + void mainWindowManager .showMessageBox({ type: "error", title: "保存の確認に失敗しました", @@ -98,8 +179,11 @@ export class AppStateController { /** 編集状態に関わらず終了する */ shutdown() { + const mainWindowManager = getMainWindowManager(); this.quitState = "dirty"; - getWindowManager().destroyWindow(); + if (mainWindowManager.isInitialized()) { + mainWindowManager.destroyWindow(); + } this.initiateQuit(); } diff --git a/src/backend/electron/engineAndVvppController.ts b/src/backend/electron/engineAndVvppController.ts index 1b19348059..592f228299 100644 --- a/src/backend/electron/engineAndVvppController.ts +++ b/src/backend/electron/engineAndVvppController.ts @@ -1,38 +1,49 @@ import { dialog } from "electron"; -import semver from "semver"; 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 { getMainWindowManager } from "./manager/windowManager/main"; import { MultiDownloader } from "./multiDownloader"; import { EngineId, EngineInfo, engineSettingSchema } from "@/type/preload"; import { PackageInfo, fetchLatestDefaultEngineInfo, - getSuitablePackageInfo, } from "@/domain/defaultEngine/latetDefaultEngine"; +import type { RuntimeTarget } from "@/domain/defaultEngine/latetDefaultEngine"; import { loadEnvEngineInfos } from "@/domain/defaultEngine/envEngineInfo"; import { UnreachableError } from "@/type/utility"; import { ProgressCallback } from "@/helpers/progressHelper"; import { createLogger } from "@/helpers/log"; import { DisplayableError, errorToMessage } from "@/helpers/errorHelper"; +import { isLinux, isMac, isWindows } from "@/helpers/platform"; const log = createLogger("EngineAndVvppController"); -/** エンジンパッケージの状態 */ -export type EnginePackageStatus = { - package: { - engineName: string; - engineId: EngineId; - packageInfo: PackageInfo; - latestVersion: string; - }; +export type EnginePackageBase = { + engineName: string; + engineId: EngineId; +}; + +/** ローカルのインストール状況(パッケージ) */ +export type EnginePackageLocalInfo = { + package: EnginePackageBase; installed: | { status: "notInstalled" } - | { status: "outdated" | "latest"; installedVersion: string }; + | { status: "installed"; installedVersion: string }; +}; + +/** オンラインで取得した最新情報(パッケージ) */ +export type RuntimeTargetPackageInfo = { + target: RuntimeTarget; + packageInfo: PackageInfo; +}; + +export type EnginePackageRemoteInfo = { + package: EnginePackageBase; + availableRuntimeTargets: RuntimeTargetPackageInfo[]; }; /** @@ -111,7 +122,7 @@ export class EngineAndVvppController { reloadNeeded: boolean; reloadCallback?: () => void; // 再読み込みが必要な場合のコールバック }) { - const windowManager = getWindowManager(); + const windowManager = getMainWindowManager(); const result = windowManager.showMessageBoxSync({ type: "warning", title: "エンジン追加の確認", @@ -186,17 +197,53 @@ export class EngineAndVvppController { } /** - * 最新のエンジンパッケージの情報や、そのエンジンのインストール状況を取得する。 + * ダウンロード可能なデフォルトエンジン情報を取得する。 + * online fetchは行わない。 */ - async fetchEnginePackageStatuses(): Promise { - const statuses: EnginePackageStatus[] = []; + private getDownloadableEnvEngineInfos() { + return loadEnvEngineInfos().filter( + (engineInfo) => engineInfo.type === "downloadVvpp", + ); + } - for (const envEngineInfo of loadEnvEngineInfos()) { - if (envEngineInfo.type != "downloadVvpp") { - continue; - } + private fetchInstalledEngineStatus( + engineId: EngineId, + ): EnginePackageLocalInfo["installed"] { + const isInstalled = this.engineInfoManager.hasEngineInfo(engineId); + if (!isInstalled) { + return { status: "notInstalled" }; + } + + const installedEngineInfo = + this.engineInfoManager.fetchEngineInfo(engineId); + return { + status: "installed", + installedVersion: installedEngineInfo.version, + }; + } + + /** + * デフォルトエンジンのインストール状況を取得する(オフライン)。 + */ + fetchEnginePackageLocalInfos(): EnginePackageLocalInfo[] { + return this.getDownloadableEnvEngineInfos().map((envEngineInfo) => ({ + package: { + engineName: envEngineInfo.name, + engineId: envEngineInfo.uuid, + }, + installed: this.fetchInstalledEngineStatus(envEngineInfo.uuid), + })); + } + + /** + * 最新のエンジンパッケージの情報や、そのエンジンのインストール状況を取得する(オンライン)。 + */ + async fetchLatestEnginePackageRemoteInfos(): Promise< + EnginePackageRemoteInfo[] + > { + const statuses: EnginePackageRemoteInfo[] = []; - // 最新情報を取得 + for (const envEngineInfo of this.getDownloadableEnvEngineInfos()) { const latestUrl = envEngineInfo.latestUrl; if (latestUrl == undefined) throw new Error("latestUrl is undefined"); @@ -206,38 +253,33 @@ export class EngineAndVvppController { continue; } - // 実行環境に合うパッケージを取得 - const packageInfo = getSuitablePackageInfo(latestInfo); - log.info(`Latest default engine version: ${packageInfo.version}`); + const availableRuntimeTargets: RuntimeTargetPackageInfo[] = + Object.entries(latestInfo.packages) + .map(([target, packageInfo]) => ({ + target: target, + packageInfo, + })) + .filter((runtimeTargetInfo) => + EngineAndVvppController.isSupportedTarget(runtimeTargetInfo.target), + ) + .toSorted( + (a, b) => + a.packageInfo.displayInfo.order - b.packageInfo.displayInfo.order, + ); - // インストール状況を取得 - let installedStatus: EnginePackageStatus["installed"]; - const isInstalled = this.engineInfoManager.hasEngineInfo( - envEngineInfo.uuid, - ); - if (!isInstalled) { - installedStatus = { status: "notInstalled" }; - } else { - const installedEngineInfo = this.engineInfoManager.fetchEngineInfo( - envEngineInfo.uuid, + if (availableRuntimeTargets.length === 0) { + log.error( + `No supported runtime targets were found for ${envEngineInfo.name}`, ); - const installedVersion = installedEngineInfo.version; - installedStatus = { - status: semver.lt(installedVersion, packageInfo.version) - ? "outdated" - : "latest", - installedVersion, - }; + continue; } statuses.push({ package: { engineName: envEngineInfo.name, engineId: envEngineInfo.uuid, - packageInfo, - latestVersion: packageInfo.version, }, - installed: installedStatus, + availableRuntimeTargets, }); } @@ -373,6 +415,42 @@ export class EngineAndVvppController { return this.vvppManager.handleMarkedEngineDirs(); }); } + + /** このRuntime Targetはこのプラットフォームで動くか */ + static isSupportedTarget(target: string): boolean { + let isSupported = true; + const os = target.split("-")[0]; + switch (os) { + case "windows": + isSupported &&= isWindows; + break; + case "macos": + isSupported &&= isMac; + break; + case "linux": + isSupported &&= isLinux; + break; + default: + isSupported = false; + } + + const arch = target.split("-")[1]; + switch (arch) { + case "x64": + isSupported &&= process.arch === "x64"; + break; + case "arm64": + isSupported &&= process.arch === "arm64"; + break; + case "x86": + isSupported &&= process.arch === "ia32"; + break; + default: + isSupported = false; + } + + return isSupported; + } } let manager: EngineAndVvppController | undefined; diff --git a/src/backend/electron/ipc.ts b/src/backend/electron/ipc.ts index bd66fda522..e9b21f9faf 100644 --- a/src/backend/electron/ipc.ts +++ b/src/backend/electron/ipc.ts @@ -1,33 +1,37 @@ import { BrowserWindow, ipcMain, IpcMainInvokeEvent } from "electron"; import { wrapToTransferableResult } from "./transferableResultHelper"; -import { IpcIHData, IpcSOData } from "./ipcType"; +import { BaseIpcData } from "./ipcType"; import { createLogger } from "@/helpers/log"; +import { objectEntries } from "@/helpers/typedEntries"; +import { ensureNotNullish } from "@/helpers/errorHelper"; const log = createLogger("ipc"); -export type IpcMainHandle = { - [K in keyof IpcIHData]: ( +export type IpcMainHandle = { + [K in keyof Ipc]: ( event: IpcMainInvokeEvent, - ...args: IpcIHData[K]["args"] - ) => Promise | IpcIHData[K]["return"]; + ...args: Ipc[K]["args"] + ) => Promise | Ipc[K]["return"]; }; -type IpcMainSend = { - [K in keyof IpcSOData]: ( - win: BrowserWindow, - ...args: IpcSOData[K]["args"] - ) => void; +export type IpcSendProxy = { + [K in keyof Ipc]: (...args: Ipc[K]["args"]) => void; }; -// FIXME: asを使わないようオーバーロードにした。オーバーロードも使わない書き方にしたい。 -export function registerIpcMainHandle( - listeners: T, -): void; -export function registerIpcMainHandle(listeners: { - [key: string]: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown; -}) { - Object.entries(listeners).forEach(([channel, listener]) => { +const ipcHandlers = new Map< + string, + ((event: IpcMainInvokeEvent, ...args: unknown[]) => unknown)[] +>(); +const delegated = Symbol("delegated"); +export function registerIpcMainHandle( + win: BrowserWindow, + listeners: IpcMainHandle, +): void { + objectEntries(listeners).forEach(([channel, listener]) => { const errorHandledListener: typeof listener = (event, ...args) => { + if (win.isDestroyed() || event.sender.id !== win.webContents.id) { + return delegated; + } try { validateIpcSender(event); } catch (e) { @@ -37,19 +41,45 @@ export function registerIpcMainHandle(listeners: { return wrapToTransferableResult(() => listener(event, ...args)); }; - ipcMain.handle(channel, errorHandledListener); + if (ipcHandlers.has(channel as string)) { + ensureNotNullish(ipcHandlers.get(channel as string)).push( + errorHandledListener, + ); + } else { + ipcHandlers.set(channel as string, [errorHandledListener]); + ipcMain.handle(channel as string, async (event, ...args: unknown[]) => { + const handlers = ipcHandlers.get(channel as string); + if (!handlers) { + throw new Error( + `No handlers registered for channel: ${String(channel)}`, + ); + } + for (const handler of handlers) { + const result = await handler(event, ...args); + if (result !== delegated) { + return result; + } + } + throw new Error( + `No valid handler found for channel: ${String(channel)}`, + ); + }); + } }); } -export const ipcMainSendProxy = new Proxy( - {}, - { - get: - (_, channel: string) => - (win: BrowserWindow, ...args: unknown[]) => - win.webContents.send(channel, ...args), - }, -) as IpcMainSend; +export const createIpcSendProxy = ( + win: BrowserWindow, +) => + new Proxy( + {}, + { + get: + (_, channel: string) => + (...args: unknown[]) => + win.webContents.send(channel, ...args), + }, + ) as IpcSendProxy; /** IPCメッセージの送信元を確認する */ const validateIpcSender = (event: IpcMainInvokeEvent) => { diff --git a/src/backend/electron/ipcMainHandle.ts b/src/backend/electron/ipcMainHandle.ts index c1dd85246d..ca1858126e 100644 --- a/src/backend/electron/ipcMainHandle.ts +++ b/src/backend/electron/ipcMainHandle.ts @@ -8,8 +8,9 @@ import { writeFileSafely } from "./fileHelper"; import { IpcMainHandle } from "./ipc"; import { getEngineInfoManager } from "./manager/engineInfoManager"; import { getEngineProcessManager } from "./manager/engineProcessManager"; -import { getWindowManager } from "./manager/windowManager"; +import { getMainWindowManager } from "./manager/windowManager/main"; import { getAppStateController } from "./appStateController"; +import { IpcIHData } from "./ipcType"; import { AssetTextFileNames } from "@/type/staticResources"; import { failure, success } from "@/type/result"; import { @@ -55,7 +56,7 @@ async function retryShowSaveDialogWhileSafeDir< * 警告ダイアログを表示し、ユーザーが再試行を選択したかどうかを返す */ const showWarningDialog = async () => { - const windowManager = getWindowManager(); + const windowManager = getMainWindowManager(); const productName = app.getName().toUpperCase(); const warningResult = await windowManager.showMessageBox({ message: `指定された保存先は${productName}により自動的に削除される可能性があります。\n他の場所に保存することをおすすめします。`, @@ -90,14 +91,13 @@ export function getIpcMainHandle(params: { staticDirPath: string; appDirPath: string; initialFilePathGetter: () => string | undefined; -}): IpcMainHandle { +}): IpcMainHandle { const { staticDirPath, appDirPath, initialFilePathGetter } = params; const configManager = getConfigManager(); const engineAndVvppController = getEngineAndVvppController(); const engineInfoManager = getEngineInfoManager(); const engineProcessManager = getEngineProcessManager(); - const windowManager = getWindowManager(); return { GET_TEXT_ASSET: async (_, textType) => { const fileName = path.join(staticDirPath, AssetTextFileNames[textType]); @@ -123,6 +123,7 @@ export function getIpcMainHandle(params: { * 保存先になるディレクトリを選ぶダイアログを表示する。 */ SHOW_SAVE_DIRECTORY_DIALOG: async (_, { title }) => { + const windowManager = getMainWindowManager(); const result = await retryShowSaveDialogWhileSafeDir( () => windowManager.showOpenDialog({ @@ -146,6 +147,7 @@ export function getIpcMainHandle(params: { * 保存先として選ぶ場合は SHOW_SAVE_DIRECTORY_DIALOG を使うべき。 */ SHOW_OPEN_DIRECTORY_DIALOG: async (_, { title }) => { + const windowManager = getMainWindowManager(); const result = await windowManager.showOpenDialog({ title, properties: [ @@ -161,6 +163,7 @@ export function getIpcMainHandle(params: { }, SHOW_WARNING_DIALOG: (_, { title, message }) => { + const windowManager = getMainWindowManager(); return windowManager.showMessageBox({ type: "warning", title, @@ -169,6 +172,7 @@ export function getIpcMainHandle(params: { }, SHOW_ERROR_DIALOG: (_, { title, message }) => { + const windowManager = getMainWindowManager(); return windowManager.showMessageBox({ type: "error", title, @@ -177,6 +181,7 @@ export function getIpcMainHandle(params: { }, SHOW_OPEN_FILE_DIALOG: (_, { title, name, extensions, defaultPath }) => { + const windowManager = getMainWindowManager(); return windowManager.showOpenDialogSync({ title, defaultPath, @@ -189,6 +194,7 @@ export function getIpcMainHandle(params: { _, { title, defaultPath, name, extensions }, ) => { + const windowManager = getMainWindowManager(); const result = await retryShowSaveDialogWhileSafeDir( () => windowManager.showSaveDialog({ @@ -210,6 +216,7 @@ export function getIpcMainHandle(params: { }, IS_MAXIMIZED_WINDOW: () => { + const windowManager = getMainWindowManager(); return windowManager.isMaximized(); }, @@ -218,30 +225,41 @@ export function getIpcMainHandle(params: { appStateController.shutdown(); }, + SWITCH_TO_WELCOME_WINDOW: async () => { + const appStateController = getAppStateController(); + await appStateController.switchToWelcomeWindow(); + }, + MINIMIZE_WINDOW: () => { + const windowManager = getMainWindowManager(); windowManager.minimize(); }, TOGGLE_MAXIMIZE_WINDOW: () => { + const windowManager = getMainWindowManager(); windowManager.toggleMaximizeWindow(); }, TOGGLE_FULLSCREEN: () => { + const windowManager = getMainWindowManager(); windowManager.toggleFullScreen(); }, /** UIの拡大 */ ZOOM_IN: () => { + const windowManager = getMainWindowManager(); windowManager.zoomIn(); }, /** UIの縮小 */ ZOOM_OUT: () => { + const windowManager = getMainWindowManager(); windowManager.zoomOut(); }, /** UIの拡大率リセット */ ZOOM_RESET: () => { + const windowManager = getMainWindowManager(); windowManager.zoomReset(); }, @@ -277,6 +295,7 @@ export function getIpcMainHandle(params: { }, ON_VUEX_READY: () => { + const windowManager = getMainWindowManager(); windowManager.show(); }, @@ -285,6 +304,7 @@ export function getIpcMainHandle(params: { }, CHANGE_PIN_WINDOW: () => { + const windowManager = getMainWindowManager(); windowManager.togglePinWindow(); }, @@ -328,6 +348,7 @@ export function getIpcMainHandle(params: { }, RELOAD_APP: async (_, { isMultiEngineOffMode }) => { + const windowManager = getMainWindowManager(); await windowManager.reload(isMultiEngineOffMode); }, diff --git a/src/backend/electron/ipcType.ts b/src/backend/electron/ipcType.ts index 6228f1b9a8..862b3abc19 100644 --- a/src/backend/electron/ipcType.ts +++ b/src/backend/electron/ipcType.ts @@ -34,12 +34,12 @@ export type IpcIHData = { SHOW_SAVE_DIRECTORY_DIALOG: { args: [obj: { title: string }]; - return?: string; + return: string | undefined; }; SHOW_OPEN_DIRECTORY_DIALOG: { args: [obj: { title: string }]; - return?: string; + return: string | undefined; }; SHOW_OPEN_FILE_DIALOG: { @@ -51,7 +51,7 @@ export type IpcIHData = { defaultPath?: string; }, ]; - return?: string; + return: string | undefined; }; SHOW_WARNING_DIALOG: { @@ -83,7 +83,7 @@ export type IpcIHData = { extensions: string[]; }, ]; - return?: string; + return: string | undefined; }; IS_AVAILABLE_GPU_MODE: { @@ -101,6 +101,11 @@ export type IpcIHData = { return: void; }; + SWITCH_TO_WELCOME_WINDOW: { + args: []; + return: void; + }; + MINIMIZE_WINDOW: { args: []; return: void; @@ -227,6 +232,8 @@ export type IpcIHData = { }; }; +export type BaseIpcData = Record; + /** * send, on */ @@ -274,7 +281,7 @@ export type IpcSOData = { CHECK_EDITED_AND_NOT_SAVE: { args: [ obj: { - closeOrReload: "close" | "reload"; + nextAction: "close" | "reload" | "switchToWelcome"; isMultiEngineOffMode?: boolean; }, ]; diff --git a/src/backend/electron/main.ts b/src/backend/electron/main.ts index 7c1ed0adf4..93213ed344 100644 --- a/src/backend/electron/main.ts +++ b/src/backend/electron/main.ts @@ -13,28 +13,26 @@ import { initializeEngineInfoManager } from "./manager/engineInfoManager"; import { initializeEngineProcessManager } from "./manager/engineProcessManager"; import { initializeVvppManager, isVvppFile } from "./manager/vvppManager"; import { - getWindowManager, - initializeWindowManager, -} from "./manager/windowManager"; + getMainWindowManager, + initializeMainWindowManager, +} from "./manager/windowManager/main"; import configMigration014 from "./configMigration014"; import { initializeRuntimeInfoManager } from "./manager/RuntimeInfoManager"; -import { registerIpcMainHandle, ipcMainSendProxy, IpcMainHandle } from "./ipc"; import { getConfigManager } from "./electronConfig"; import { getEngineAndVvppController } from "./engineAndVvppController"; import { getIpcMainHandle } from "./ipcMainHandle"; +import { getWelcomeIpcMainHandle } from "./welcomeIpcMainHandle"; import { getAppStateController } from "./appStateController"; +import { initializeWelcomeWindowManager } from "./manager/windowManager/welcome"; import { assertNonNullable } from "@/type/utility"; import { EngineInfo } from "@/type/preload"; -import { isMac, isProduction } from "@/helpers/platform"; +import { isDevelopment, isMac, isProduction, isTest } from "@/helpers/platform"; import { createLogger } from "@/helpers/log"; type SingleInstanceLockData = { filePath: string | undefined; }; -const isDevelopment = import.meta.env.DEV; -const isTest = import.meta.env.MODE === "test"; - if (isDevelopment) { app.commandLine.appendSwitch("remote-debugging-port", "9222"); } @@ -200,9 +198,9 @@ const onEngineProcessError = (engineInfo: EngineInfo, error: Error) => { // winが作られる前にエラーが発生した場合はwinへの通知を諦める // FIXME: winが作られた後にエンジンを起動させる - const win = windowManager.win; + const win = mainWindowManager.win; if (win != undefined) { - ipcMainSendProxy.DETECTED_ENGINE_ERROR(win, { engineId }); + mainWindowManager.ipc.DETECTED_ENGINE_ERROR({ engineId }); } else { log.error(`onEngineProcessError: win is undefined`); } @@ -210,11 +208,6 @@ const onEngineProcessError = (engineInfo: EngineInfo, error: Error) => { dialog.showErrorBox("音声合成エンジンエラー", error.message); }; -initializeWindowManager({ - isDevelopment, - isTest, - staticDir: staticDir, -}); initializeRuntimeInfoManager({ runtimeInfoPath: path.join(app.getPath("userData"), "runtime-info.json"), appVersion: app.getVersion(), @@ -226,8 +219,26 @@ initializeEngineInfoManager({ initializeEngineProcessManager({ onEngineProcessError }); initializeVvppManager({ vvppEngineDir, tmpDir: app.getPath("temp") }); +initializeMainWindowManager({ + isDevelopment, + isTest, + staticDir: staticDir, + ipcMainHandle: getIpcMainHandle({ + staticDirPath: staticDir, + appDirPath, + initialFilePathGetter: () => initialFilePath, + }), +}); +initializeWelcomeWindowManager({ + isDevelopment, + isTest, + staticDir: staticDir, + ipcMainHandle: getWelcomeIpcMainHandle(), +}); + const configManager = getConfigManager(); -const windowManager = getWindowManager(); +const appStateController = getAppStateController(); +const mainWindowManager = getMainWindowManager(); const engineAndVvppController = getEngineAndVvppController(); /** @@ -237,7 +248,7 @@ const engineAndVvppController = getEngineAndVvppController(); function checkMultiEngineEnabled(): boolean { const enabled = configManager.get("enableMultiEngine"); if (!enabled) { - windowManager.showMessageBoxSync({ + mainWindowManager.showMessageBoxSync({ type: "info", title: "マルチエンジン機能が無効です", message: `マルチエンジン機能が無効です。vvppファイルを使用するには設定からマルチエンジン機能を有効にしてください。`, @@ -293,15 +304,6 @@ if (isMac) { } } -// プロセス間通信 -registerIpcMainHandle( - getIpcMainHandle({ - staticDirPath: staticDir, - appDirPath, - initialFilePathGetter: () => initialFilePath, - }), -); - // app callback app.on("web-contents-created", (_e, contents) => { // リンククリック時はブラウザを開く @@ -327,7 +329,7 @@ app.on("web-contents-created", (_e, contents) => { // Called before window closing app.on("before-quit", async (event) => { - void getAppStateController().onQuitRequest({ + appStateController.onQuitRequest({ preventQuit: () => event.preventDefault(), }); }); @@ -428,67 +430,6 @@ void app.whenReady().then(async () => { log.error("Vue Devtools failed to install:", e); } } - - // VVPPがデフォルトエンジンに指定されていたらインストール・アップデートする - // NOTE: この機能は工事中。参照: https://github.com/VOICEVOX/voicevox/issues/1194 - const packageStatuses = - await engineAndVvppController.fetchEnginePackageStatuses(); - - for (const status of packageStatuses) { - // 最新版がインストール済みの場合はスキップ - if (status.installed.status == "latest") { - continue; - } - - let dialogOptions: { - title: string; - message: string; - okButtonLabel: string; - }; - if (status.installed.status == "notInstalled") { - dialogOptions = { - title: "デフォルトエンジンのインストール", - message: `${status.package.engineName} をインストールしますか?`, - okButtonLabel: "インストールする", - }; - } else { - dialogOptions = { - title: "デフォルトエンジンのアップデート", - message: `${status.package.engineName} の新しいバージョン(${status.package.latestVersion})にアップデートしますか?`, - okButtonLabel: "アップデートする", - }; - } - - // インストールするか確認 - const result = dialog.showMessageBoxSync({ - type: "info", - title: dialogOptions.title, - message: dialogOptions.message, - buttons: [dialogOptions.okButtonLabel, "キャンセル"], - cancelId: 1, - }); - if (result == 1) { - continue; - } - - // ダウンロードしてインストールする - let lastLogTime = 0; // とりあえずログを0.1秒に1回だけ出力する - await engineAndVvppController.downloadAndInstallVvppEngine( - app.getPath("downloads"), - status.package.packageInfo, - { - onProgress: ({ type, progress }) => { - if (Date.now() - lastLogTime > 100) { - log.info( - `VVPP default engine progress: ${type}: ${Math.floor(progress)}%`, - ); - lastLogTime = Date.now(); - } - }, - }, - ); - } - // 多重起動防止 // TODO: readyを待たずにもっと早く実行すべき if ( @@ -519,14 +460,13 @@ void app.whenReady().then(async () => { } } - await engineAndVvppController.launchEngines(); - await windowManager.createWindow(); + await appStateController.startup(); }); // 他のプロセスが起動したとき、`requestSingleInstanceLock`経由で`rawData`が送信される。 app.on("second-instance", async (_event, _argv, _workDir, rawData) => { const data = rawData as SingleInstanceLockData; - const win = windowManager.win; + const win = mainWindowManager.win; if (win == undefined) { // TODO: 起動シーケンス中の場合はWindowが作られるまで待つ log.warn("A 'second-instance' event was emitted but there is no window."); @@ -543,19 +483,19 @@ app.on("second-instance", async (_event, _argv, _workDir, rawData) => { asDefaultVvppEngine: false, reloadNeeded: true, reloadCallback: () => { - ipcMainSendProxy.CHECK_EDITED_AND_NOT_SAVE(win, { - closeOrReload: "reload", + mainWindowManager.ipc.CHECK_EDITED_AND_NOT_SAVE({ + nextAction: "reload", }); }, }); } } else if (data.filePath.endsWith(".vvproj")) { log.info("Second instance launched with vvproj file"); - ipcMainSendProxy.LOAD_PROJECT_FILE(win, { + mainWindowManager.ipc.LOAD_PROJECT_FILE({ filePath: data.filePath, }); } - windowManager.restoreAndFocus(); + mainWindowManager.restoreAndFocus(); }); if (isDevelopment) { diff --git a/src/backend/electron/manager/windowManager.ts b/src/backend/electron/manager/windowManager/main.ts similarity index 81% rename from src/backend/electron/manager/windowManager.ts rename to src/backend/electron/manager/windowManager/main.ts index 8d82ae3b0c..72b727a443 100644 --- a/src/backend/electron/manager/windowManager.ts +++ b/src/backend/electron/manager/windowManager/main.ts @@ -9,31 +9,42 @@ import { SaveDialogOptions, } from "electron"; import windowStateKeeper from "electron-window-state"; -import { getConfigManager } from "../electronConfig"; -import { getEngineAndVvppController } from "../engineAndVvppController"; -import { ipcMainSendProxy } from "../ipc"; -import { getAppStateController } from "../appStateController"; +import { getConfigManager } from "../../electronConfig"; +import { getEngineAndVvppController } from "../../engineAndVvppController"; +import { + createIpcSendProxy, + IpcMainHandle, + IpcSendProxy, + registerIpcMainHandle, +} from "../../ipc"; +import { IpcIHData, IpcSOData } from "../../ipcType"; +import { getAppStateController } from "../../appStateController"; import { themes } from "@/domain/theme"; import { createLogger } from "@/helpers/log"; -const log = createLogger("WindowManager"); +const log = createLogger("MainWindowManager"); type WindowManagerOption = { staticDir: string; isDevelopment: boolean; isTest: boolean; + + ipcMainHandle: IpcMainHandle; }; -class WindowManager { +class MainWindowManager { private _win: BrowserWindow | undefined; + private _ipc: IpcSendProxy | undefined; private staticDir: string; private isDevelopment: boolean; private isTest: boolean; + private ipcHandle: IpcMainHandle; constructor(payload: WindowManagerOption) { this.staticDir = payload.staticDir; this.isDevelopment = payload.isDevelopment; this.isTest = payload.isTest; + this.ipcHandle = payload.ipcMainHandle; } /** @@ -53,6 +64,13 @@ class WindowManager { return this._win; } + public get ipc() { + if (this._ipc == undefined) { + throw new Error("_ipc == undefined"); + } + return this._ipc; + } + public async createWindow() { if (this.win != undefined) { throw new Error("Window has already been created"); @@ -79,28 +97,33 @@ class WindowManager { show: false, backgroundColor, webPreferences: { - preload: path.join(import.meta.dirname, "preload.mjs"), + preload: path.join(import.meta.dirname, "preload.cjs"), }, icon: path.join(this.staticDir, "icon.png"), }); + this._win = win; + const ipc = createIpcSendProxy(win); + registerIpcMainHandle(win, this.ipcHandle); + this._ipc = ipc; + win.on("maximize", () => { - ipcMainSendProxy.DETECT_MAXIMIZED(win); + ipc.DETECT_MAXIMIZED(); }); win.on("unmaximize", () => { - ipcMainSendProxy.DETECT_UNMAXIMIZED(win); + ipc.DETECT_UNMAXIMIZED(); }); win.on("enter-full-screen", () => { - ipcMainSendProxy.DETECT_ENTER_FULLSCREEN(win); + ipc.DETECT_ENTER_FULLSCREEN(); }); win.on("leave-full-screen", () => { - ipcMainSendProxy.DETECT_LEAVE_FULLSCREEN(win); + ipc.DETECT_LEAVE_FULLSCREEN(); }); win.on("always-on-top-changed", () => { if (win.isAlwaysOnTop()) { - ipcMainSendProxy.DETECT_PINNED(win); + ipc.DETECT_PINNED(); } else { - ipcMainSendProxy.DETECT_UNPINNED(win); + ipc.DETECT_UNPINNED(); } }); win.on("close", (event) => { @@ -111,16 +134,16 @@ class WindowManager { }); win.on("closed", () => { this._win = undefined; + this._ipc = undefined; }); win.on("resize", () => { const windowSize = win.getSize(); - ipcMainSendProxy.DETECT_RESIZED(win, { + ipc.DETECT_RESIZED({ width: windowSize[0], height: windowSize[1], }); }); mainWindowState.manage(win); - this._win = win; await this.load({}); @@ -235,6 +258,10 @@ class WindowManager { return this.getWindow().isMaximized(); } + public isInitialized() { + return this._win != undefined; + } + public showOpenDialogSync(options: OpenDialogSyncOptions) { return this._win == undefined ? dialog.showOpenDialogSync(options) @@ -266,13 +293,13 @@ class WindowManager { } } -let windowManager: WindowManager | undefined; +let windowManager: MainWindowManager | undefined; -export function initializeWindowManager(payload: WindowManagerOption) { - windowManager = new WindowManager(payload); +export function initializeMainWindowManager(payload: WindowManagerOption) { + windowManager = new MainWindowManager(payload); } -export function getWindowManager() { +export function getMainWindowManager() { if (windowManager == undefined) { throw new Error("WindowManager is not initialized"); } diff --git a/src/backend/electron/manager/windowManager/welcome.ts b/src/backend/electron/manager/windowManager/welcome.ts new file mode 100644 index 0000000000..cb35831783 --- /dev/null +++ b/src/backend/electron/manager/windowManager/welcome.ts @@ -0,0 +1,264 @@ +import path from "node:path"; +import { + BrowserWindow, + dialog, + MessageBoxOptions, + MessageBoxSyncOptions, + OpenDialogOptions, + OpenDialogSyncOptions, + SaveDialogOptions, +} from "electron"; +import { getConfigManager } from "../../electronConfig"; +import { getAppStateController } from "../../appStateController"; +import { + createIpcSendProxy, + IpcMainHandle, + IpcSendProxy, + registerIpcMainHandle, +} from "../../ipc"; +import { themes } from "@/domain/theme"; +import { WelcomeIpcIHData, WelcomeIpcSOData } from "@/welcome/backend/ipcType"; + +type WindowManagerOption = { + staticDir: string; + isDevelopment: boolean; + isTest: boolean; + ipcMainHandle: IpcMainHandle; +}; + +class WelcomeWindowManager { + private _win: BrowserWindow | undefined; + private _ipc: IpcSendProxy | undefined; + private staticDir: string; + private isDevelopment: boolean; + private isTest: boolean; + private ipcMainHandle: IpcMainHandle; + + constructor(payload: WindowManagerOption) { + this.staticDir = payload.staticDir; + this.isDevelopment = payload.isDevelopment; + this.isTest = payload.isTest; + this.ipcMainHandle = payload.ipcMainHandle; + } + + /** + * BrowserWindowを取得する + */ + public get win() { + return this._win; + } + + public isInitialized() { + return this._win != undefined; + } + + /** + * BrowserWindowのIPC送信用プロキシを取得する + */ + public get ipc() { + if (this._ipc == undefined) { + throw new Error("_ipc == undefined"); + } + return this._ipc; + } + + /** + * BrowserWindowを取得するが存在しない場合は例外を投げる + */ + public getWindow() { + if (this._win == undefined) { + throw new Error("_win == undefined"); + } + return this._win; + } + + public async createWindow() { + if (this.win != undefined) { + throw new Error("Window has already been created"); + } + const configManager = getConfigManager(); + const currentTheme = configManager.get("currentTheme"); + const backgroundColor = themes.find((value) => value.name == currentTheme) + ?.colors.background; + + const win = new BrowserWindow({ + minWidth: 320, + backgroundColor, + webPreferences: { + preload: path.join(import.meta.dirname, "welcomePreload.cjs"), + }, + icon: path.join(this.staticDir, "icon.png"), + titleBarStyle: "hidden", + trafficLightPosition: { x: 6, y: 4 }, + frame: false, + }); + const ipc = createIpcSendProxy(win); + this._ipc = ipc; + registerIpcMainHandle(win, this.ipcMainHandle); + + win.on("maximize", () => { + ipc.DETECT_MAXIMIZED(); + }); + win.on("unmaximize", () => { + ipc.DETECT_UNMAXIMIZED(); + }); + win.on("enter-full-screen", () => { + ipc.DETECT_ENTER_FULLSCREEN(); + }); + win.on("leave-full-screen", () => { + ipc.DETECT_LEAVE_FULLSCREEN(); + }); + win.on("close", (event) => { + const appStateController = getAppStateController(); + void appStateController.onQuitRequest({ + preventQuit: () => event.preventDefault(), + }); + }); + win.on("closed", () => { + this._win = undefined; + this._ipc = undefined; + }); + this._win = win; + + await this.load(); + + if (this.isDevelopment && !this.isTest) win.webContents.openDevTools(); + } + + public async load() { + const win = this.getWindow(); + let firstUrl: URL; + if (import.meta.env.VITE_DEV_SERVER_URL != undefined) { + firstUrl = new URL(import.meta.env.VITE_DEV_SERVER_URL); + firstUrl.pathname = "/welcome/index.html"; + } else { + firstUrl = new URL(`app://./welcome/index.html`); + } + await win.loadURL(firstUrl.toString()); + } + + public async reload() { + const win = this.getWindow(); + win.hide(); // FIXME: ダミーページ表示のほうが良い + + // 一旦適当なURLに飛ばしてページをアンロードする + await win.loadURL("about:blank"); + + await this.load(); + 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: WelcomeWindowManager | undefined; + +export function initializeWelcomeWindowManager(payload: WindowManagerOption) { + windowManager = new WelcomeWindowManager(payload); +} + +export function getWelcomeWindowManager() { + if (windowManager == undefined) { + throw new Error("WindowManager is not initialized"); + } + return windowManager; +} diff --git a/src/backend/electron/renderer/menuBarData.ts b/src/backend/electron/renderer/menuBarData.ts index 5d0d176488..6eacd76da5 100644 --- a/src/backend/electron/renderer/menuBarData.ts +++ b/src/backend/electron/renderer/menuBarData.ts @@ -17,6 +17,14 @@ export const useElectronMenuBarData = ( const engineIcons = useEngineIcons(engineManifests); const enableMultiEngine = computed(() => store.state.enableMultiEngine); + const hasDownloadVvppEngine = computed(() => { + return Object.values(engineInfos.value).some((engineInfo) => { + const manifest = engineInfos.value[engineInfo.uuid]; + // TODO: ちゃんとdownloadVvppかどうかを判定する + return manifest.isDefault && manifest.type === "vvpp"; + }); + }); + // 「エンジン」メニューのエンジン毎の項目 const engineSubMenuData = computed(() => { let singleEngineSubMenuData: MenuItemData[]; @@ -68,41 +76,50 @@ export const useElectronMenuBarData = ( ); } - const allEnginesSubMenuData = enableMultiEngine.value - ? removeNullableAndBoolean([ - { - type: "button", - label: "全てのエンジンを再起動", - onClick: () => { - void store.actions.RESTART_ENGINES({ - engineIds: engineIds.value, - }); - }, - disableWhenUiLocked: false, - }, - { - type: "button", - label: "エンジンの管理", - onClick: () => { - void store.actions.SET_DIALOG_OPEN({ - isEngineManageDialogOpen: true, - }); - }, - disableWhenUiLocked: false, - }, - store.state.isMultiEngineOffMode && { - type: "button", - label: "マルチエンジンをオンにして再読み込み", - onClick() { - void store.actions.RELOAD_APP({ - isMultiEngineOffMode: false, - }); - }, - disableWhenUiLocked: false, - disableWhileReloadingLock: true, + const allEnginesSubMenuData = removeNullableAndBoolean([ + enableMultiEngine.value && { + type: "button", + label: "全てのエンジンを再起動", + onClick: () => { + void store.actions.RESTART_ENGINES({ + engineIds: engineIds.value, + }); + }, + disableWhenUiLocked: false, + }, + enableMultiEngine.value && { + type: "button", + label: "エンジンの管理", + onClick: () => { + void store.actions.SET_DIALOG_OPEN({ + isEngineManageDialogOpen: true, + }); + }, + disableWhenUiLocked: false, + }, + enableMultiEngine.value && + store.state.isMultiEngineOffMode && { + type: "button", + label: "マルチエンジンをオンにして再読み込み", + onClick() { + void store.actions.RELOAD_APP({ + isMultiEngineOffMode: false, + }); }, - ]) - : []; + disableWhenUiLocked: false, + disableWhileReloadingLock: true, + }, + hasDownloadVvppEngine.value && { + type: "button", + label: "エンジンのセットアップ", + onClick: () => { + void store.actions.CHECK_EDITED_AND_NOT_SAVE({ + nextAction: "switchToWelcome", + }); + }, + disableWhenUiLocked: false, + }, + ]); return { singleEngine: singleEngineSubMenuData, diff --git a/src/backend/electron/renderer/preload.ts b/src/backend/electron/renderer/preload.ts index 2de6a21802..0545ad54ea 100644 --- a/src/backend/electron/renderer/preload.ts +++ b/src/backend/electron/renderer/preload.ts @@ -106,7 +106,7 @@ const api: Sandbox = { ipcRenderer.on("CHECK_EDITED_AND_NOT_SAVE", (_, args) => { listeners.checkEditedAndNotSave( args as { - closeOrReload: "close" | "reload"; + nextAction: "close" | "reload" | "switchToWelcome"; isMultiEngineOffMode?: boolean; }, ); @@ -120,6 +120,10 @@ const api: Sandbox = { void ipcRendererInvokeProxy.CLOSE_WINDOW(); }, + launchWelcomeWindow: () => { + void ipcRendererInvokeProxy.SWITCH_TO_WELCOME_WINDOW(); + }, + minimizeWindow: () => { void ipcRendererInvokeProxy.MINIMIZE_WINDOW(); }, diff --git a/src/backend/electron/welcomeIpcMainHandle.ts b/src/backend/electron/welcomeIpcMainHandle.ts new file mode 100644 index 0000000000..0641ea8785 --- /dev/null +++ b/src/backend/electron/welcomeIpcMainHandle.ts @@ -0,0 +1,96 @@ +import { app } from "electron"; +import { getEngineAndVvppController } from "./engineAndVvppController"; +import { getConfigManager } from "./electronConfig"; +import { IpcMainHandle } from "./ipc"; +import { getWelcomeWindowManager } from "./manager/windowManager/welcome"; +import { getAppStateController } from "./appStateController"; +import type { WelcomeIpcIHData } from "@/welcome/backend/ipcType"; +import { createLogger } from "@/helpers/log"; + +const log = createLogger("WelcomeIpcMainHandle"); + +export function getWelcomeIpcMainHandle(): IpcMainHandle { + const engineAndVvppController = getEngineAndVvppController(); + const configManager = getConfigManager(); + + return { + INSTALL_ENGINE: async (_, obj) => { + const welcomeWindowManager = getWelcomeWindowManager(); + const packageStatuses = + await engineAndVvppController.fetchLatestEnginePackageRemoteInfos(); + const status = packageStatuses.find( + (s) => s.package.engineId === obj.engineId, + ); + if (!status) { + throw new Error( + `Engine package status not found for engineId: ${obj.engineId}`, + ); + } + + // ダウンロードしてインストールする + let lastUpdateTime = 0; + let lastLogTime = 0; + const targetPackageInfo = + status.availableRuntimeTargets.find( + (targetInfo) => targetInfo.target === obj.target, + ) ?? status.availableRuntimeTargets[0]; + + if (!targetPackageInfo) { + throw new Error( + `Runtime target not found for engineId: ${obj.engineId}`, + ); + } + + await engineAndVvppController.downloadAndInstallVvppEngine( + app.getPath("downloads"), + targetPackageInfo.packageInfo, + { + onProgress: ({ type, progress }) => { + if (Date.now() - lastUpdateTime > 100) { + lastUpdateTime = Date.now(); + welcomeWindowManager.ipc.UPDATE_ENGINE_DOWNLOAD_PROGRESS({ + engineId: obj.engineId, + progress, + type, + }); + } else if (Date.now() - lastLogTime > 1000) { + lastLogTime = Date.now(); + log.info( + `Engine ${obj.engineId} ${type} progress: ${progress.toFixed(2)}%`, + ); + } + }, + }, + ); + }, + FETCH_ENGINE_PACKAGE_LOCAL_INFOS: () => { + return engineAndVvppController.fetchEnginePackageLocalInfos(); + }, + FETCH_LATEST_ENGINE_PACKAGE_REMOTE_INFOS: async () => { + return engineAndVvppController.fetchLatestEnginePackageRemoteInfos(); + }, + GET_CURRENT_THEME: async () => { + return configManager.get("currentTheme"); + }, + SWITCH_TO_MAIN_WINDOW: async () => { + const appStateController = getAppStateController(); + await appStateController.switchToMainWindow(); + }, + MINIMIZE_WINDOW: () => { + const welcomeWindowManager = getWelcomeWindowManager(); + welcomeWindowManager.minimize(); + }, + TOGGLE_MAXIMIZE_WINDOW: () => { + const welcomeWindowManager = getWelcomeWindowManager(); + welcomeWindowManager.toggleMaximizeWindow(); + }, + CLOSE_WINDOW: () => { + const appStateController = getAppStateController(); + appStateController.shutdown(); + }, + IS_MAXIMIZED_WINDOW: () => { + const welcomeWindowManager = getWelcomeWindowManager(); + return welcomeWindowManager.isMaximized(); + }, + }; +} diff --git a/src/components/Base/BaseSelect.stories.ts b/src/components/Base/BaseSelect.stories.ts index 39d0d6d7dc..c8a3c5219e 100644 --- a/src/components/Base/BaseSelect.stories.ts +++ b/src/components/Base/BaseSelect.stories.ts @@ -18,7 +18,7 @@ const meta: Meta = { - + `, }), }; diff --git a/src/components/Base/BaseSelectItem.vue b/src/components/Base/BaseSelectItem.vue index 8c89766789..f12af0f087 100644 --- a/src/components/Base/BaseSelectItem.vue +++ b/src/components/Base/BaseSelectItem.vue @@ -1,6 +1,9 @@ @@ -16,6 +19,7 @@ import { defineProps<{ value: T; label: string; + hint?: string; disabled?: boolean; }>(); @@ -64,6 +68,18 @@ defineProps<{ } } +.SelectItemContent { + display: flex; + flex-direction: column; + gap: 2px; + flex-grow: 1; +} + +.SelectItemHint { + font-size: 0.8em; + color: colors.$display-sub; +} + .SelectItemIndicator { position: absolute; left: 6px; diff --git a/src/components/Dialog/AcceptDialog/AcceptTermsDialog.vue b/src/components/Dialog/AcceptDialog/AcceptTermsDialog.vue index f17d1baf4d..feb048f8b8 100644 --- a/src/components/Dialog/AcceptDialog/AcceptTermsDialog.vue +++ b/src/components/Dialog/AcceptDialog/AcceptTermsDialog.vue @@ -30,7 +30,7 @@ const handler = (acceptTerms: boolean) => { }); if (!acceptTerms) { void store.actions.CHECK_EDITED_AND_NOT_SAVE({ - closeOrReload: "close", + nextAction: "close", }); } diff --git a/src/components/Dialog/EngineManageDialog.vue b/src/components/Dialog/EngineManageDialog.vue index 78327624e3..0f98bc1bff 100644 --- a/src/components/Dialog/EngineManageDialog.vue +++ b/src/components/Dialog/EngineManageDialog.vue @@ -526,7 +526,7 @@ const requireReload = async (message: string) => { toInitialState(); if (result === "OK") { void store.actions.CHECK_EDITED_AND_NOT_SAVE({ - closeOrReload: "reload", + nextAction: "reload", }); } }; diff --git a/src/components/EngineStartupOverlay.vue b/src/components/EngineStartupOverlay.vue index a826ebf25d..5c2457466c 100644 --- a/src/components/EngineStartupOverlay.vue +++ b/src/components/EngineStartupOverlay.vue @@ -92,7 +92,7 @@ watch(allEngineState, (newEngineState) => { const reloadAppWithMultiEngineOffMode = () => { void store.actions.CHECK_EDITED_AND_NOT_SAVE({ - closeOrReload: "reload", + nextAction: "reload", isMultiEngineOffMode: true, }); }; diff --git a/src/components/Menu/MenuBar/MinMaxCloseButtons.vue b/src/components/Menu/MenuBar/MinMaxCloseButtons.vue index 236aaf0fd4..50415ad655 100644 --- a/src/components/Menu/MenuBar/MinMaxCloseButtons.vue +++ b/src/components/Menu/MenuBar/MinMaxCloseButtons.vue @@ -1,47 +1,5 @@ + + diff --git a/src/welcome/components/EngineCard.vue b/src/welcome/components/EngineCard.vue new file mode 100644 index 0000000000..b853d670d7 --- /dev/null +++ b/src/welcome/components/EngineCard.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/src/welcome/components/MenuBar.vue b/src/welcome/components/MenuBar.vue new file mode 100644 index 0000000000..b4ab2839fc --- /dev/null +++ b/src/welcome/components/MenuBar.vue @@ -0,0 +1,112 @@ + + + + + + + diff --git a/src/welcome/components/WelcomeHeader.vue b/src/welcome/components/WelcomeHeader.vue new file mode 100644 index 0000000000..a2997c5624 --- /dev/null +++ b/src/welcome/components/WelcomeHeader.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/welcome/components/WindowControls.vue b/src/welcome/components/WindowControls.vue new file mode 100644 index 0000000000..44f5315312 --- /dev/null +++ b/src/welcome/components/WindowControls.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/src/welcome/index.html b/src/welcome/index.html new file mode 100644 index 0000000000..15baefae8f --- /dev/null +++ b/src/welcome/index.html @@ -0,0 +1,22 @@ + + + + + + + + + VOICEVOX - Welcome + + + +
+ + + + diff --git a/src/welcome/main.ts b/src/welcome/main.ts new file mode 100644 index 0000000000..930f6e9cc5 --- /dev/null +++ b/src/welcome/main.ts @@ -0,0 +1,32 @@ +import { createApp } from "vue"; +import { Quasar, Dialog, Loading, Notify } from "quasar"; +import iconSet from "quasar/icon-set/material-icons"; +import App from "./components/App.vue"; +import { markdownItPlugin } from "@/plugins/markdownItPlugin"; + +import "@quasar/extras/material-icons/material-icons.css"; +import "quasar/dist/quasar.sass"; +import "@/styles/_index.scss"; + +// NOTE: 起動後、設定を読み込んでからvue-gtmを有効化する関係上、dataLayerの用意が間に合わず、値が欠落してしまう箇所が存在する +// ため、それを防止するため自前でdataLayerをあらかじめ用意する +window.dataLayer = []; + +createApp(App) + .use(Quasar, { + config: { + brand: { + primary: "#a5d4ad", + secondary: "#212121", + negative: "var(--color-warning)", + }, + }, + iconSet, + plugins: { + Dialog, + Loading, + Notify, + }, + }) + .use(markdownItPlugin) + .mount("#app"); diff --git a/src/welcome/preload.ts b/src/welcome/preload.ts new file mode 100644 index 0000000000..c76628611e --- /dev/null +++ b/src/welcome/preload.ts @@ -0,0 +1,137 @@ +// eslint-disable-next-line no-restricted-imports +import { contextBridge, ipcRenderer } from "electron"; +import { + welcomeBridgeKey, + type WelcomeSandboxWithTransferableResult, +} from "./backend/apiLoader"; +import type { WelcomeIpcIHData } from "./backend/ipcType"; +import type { WelcomeSandbox } from "./preloadType"; +import { + getOrThrowTransferableResult, + wrapToTransferableResult, +} from "@/backend/electron/transferableResultHelper"; +import type { EngineId } from "@/type/preload"; + +type WelcomeIpcRendererInvoke = { + [K in keyof WelcomeIpcIHData]: ( + ...args: WelcomeIpcIHData[K]["args"] + ) => Promise; +}; + +const ipcRendererInvokeProxy = new Proxy( + {}, + { + get: + (_, channel: string) => + async (...args: unknown[]) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const transferableResult = await ipcRenderer.invoke(channel, ...args); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return getOrThrowTransferableResult(transferableResult); + }, + }, +) as WelcomeIpcRendererInvoke; + +const api: WelcomeSandbox = { + installEngine: (obj) => { + return ipcRendererInvokeProxy.INSTALL_ENGINE(obj); + }, + fetchEnginePackageLocalInfos: () => { + return ipcRendererInvokeProxy.FETCH_ENGINE_PACKAGE_LOCAL_INFOS(); + }, + fetchLatestEnginePackageRemoteInfos: () => { + return ipcRendererInvokeProxy.FETCH_LATEST_ENGINE_PACKAGE_REMOTE_INFOS(); + }, + launchMainWindow: () => { + return ipcRendererInvokeProxy.SWITCH_TO_MAIN_WINDOW(); + }, + getCurrentTheme: () => { + return ipcRendererInvokeProxy.GET_CURRENT_THEME(); + }, + registerIpcHandler: (listeners) => { + if (listeners.updateEngineDownloadProgress) { + ipcRenderer.on("UPDATE_ENGINE_DOWNLOAD_PROGRESS", (_, args) => { + listeners.updateEngineDownloadProgress?.( + args as { + engineId: EngineId; + progress: number; + type: "download" | "install"; + }, + ); + }); + } + if (listeners.detectMaximized) { + ipcRenderer.on("DETECT_MAXIMIZED", () => { + listeners.detectMaximized?.(); + }); + } + if (listeners.detectUnmaximized) { + ipcRenderer.on("DETECT_UNMAXIMIZED", () => { + listeners.detectUnmaximized?.(); + }); + } + if (listeners.detectEnterFullscreen) { + ipcRenderer.on("DETECT_ENTER_FULLSCREEN", () => { + listeners.detectEnterFullscreen?.(); + }); + } + if (listeners.detectLeaveFullscreen) { + ipcRenderer.on("DETECT_LEAVE_FULLSCREEN", () => { + listeners.detectLeaveFullscreen?.(); + }); + } + }, + isMaximizedWindow: () => { + return ipcRendererInvokeProxy.IS_MAXIMIZED_WINDOW(); + }, + minimizeWindow: () => { + return ipcRendererInvokeProxy.MINIMIZE_WINDOW(); + }, + toggleMaximizeWindow: () => { + return ipcRendererInvokeProxy.TOGGLE_MAXIMIZE_WINDOW(); + }, + closeWindow: () => { + return ipcRendererInvokeProxy.CLOSE_WINDOW(); + }, + + logError: (...params) => { + console.error(...params); + ipcRenderer.send("__ELECTRON_LOG__", { + data: [...params], + level: "error", + }); + }, + + logWarn: (...params) => { + console.warn(...params); + ipcRenderer.send("__ELECTRON_LOG__", { + data: [...params], + level: "warn", + }); + }, + + logInfo: (...params) => { + console.info(...params); + ipcRenderer.send("__ELECTRON_LOG__", { + data: [...params], + level: "info", + }); + }, +}; + +const wrapApi = ( + baseApi: WelcomeSandbox, +): WelcomeSandboxWithTransferableResult => { + const wrappedApi = {} as WelcomeSandboxWithTransferableResult; + for (const key in baseApi) { + const propKey = key as keyof WelcomeSandboxWithTransferableResult; + // @ts-expect-error とりあえず動くので無視 + wrappedApi[propKey] = async (...args: unknown[]) => { + // @ts-expect-error とりあえず動くので無視 + return wrapToTransferableResult(() => baseApi[propKey](...args)); + }; + } + return wrappedApi; +}; + +contextBridge.exposeInMainWorld(welcomeBridgeKey, wrapApi(api)); diff --git a/src/welcome/preloadType.ts b/src/welcome/preloadType.ts new file mode 100644 index 0000000000..aa816b1049 --- /dev/null +++ b/src/welcome/preloadType.ts @@ -0,0 +1,37 @@ +import { + EnginePackageLocalInfo, + EnginePackageRemoteInfo, +} from "@/backend/electron/engineAndVvppController"; +import type { EngineId } from "@/type/preload"; +import type { RuntimeTarget } from "@/domain/defaultEngine/latetDefaultEngine"; + +export interface WelcomeSandbox { + installEngine(obj: { + engineId: EngineId; + target: RuntimeTarget; + }): Promise; + fetchEnginePackageLocalInfos(): Promise; + fetchLatestEnginePackageRemoteInfos(): Promise; + launchMainWindow(): Promise; + getCurrentTheme(): Promise; + registerIpcHandler(listeners: { + updateEngineDownloadProgress?: (obj: { + engineId: EngineId; + progress: number; + type: "download" | "install"; + }) => void; + detectMaximized?: () => void; + detectUnmaximized?: () => void; + detectEnterFullscreen?: () => void; + detectLeaveFullscreen?: () => void; + }): void; + isMaximizedWindow(): Promise; + minimizeWindow(): Promise; + toggleMaximizeWindow(): Promise; + closeWindow(): Promise; + logError(...params: unknown[]): void; + logWarn(...params: unknown[]): void; + logInfo(...params: unknown[]): void; +} + +export const welcomeSandboxKey = "welcomeBackend"; diff --git a/tests/e2e/electron/example.spec.ts b/tests/e2e/electron/example.spec.ts index 93eba44b48..9440bceac2 100644 --- a/tests/e2e/electron/example.spec.ts +++ b/tests/e2e/electron/example.spec.ts @@ -1,8 +1,7 @@ import fs from "node:fs/promises"; import { _electron as electron, test } from "@playwright/test"; import dotenv from "dotenv"; -import { MessageBoxSyncOptions } from "electron"; -import { getUserTestDir, setupOldVersionEngine } from "./helper"; +import { getUserTestDir } from "./helper"; test.beforeAll(async () => { console.log("Waiting for main.js to be built..."); @@ -27,84 +26,60 @@ test.beforeEach(async () => { }); }); -/** エンジンテストの共通操作 */ -async function runEngineTest(params: { isUpdate: boolean }) { - const { isUpdate } = params; - if (isUpdate) { - await setupOldVersionEngine(); - } - - const app = await electron.launch({ - args: ["--no-sandbox", "."], // NOTE: --no-sandbox はUbuntu 24.04で動かすのに必要 - timeout: process.env.CI ? 0 : 60000, +test.describe(".env環境", () => { + test.beforeEach(() => { + dotenv.config({ path: ".env", override: true, quiet: true }); }); - // ダイアログのモック - await app.evaluate((electron, isUpdate) => { - // @ts-expect-error 2種のオーバーロードを無視する - electron.dialog.showMessageBoxSync = (options: MessageBoxSyncOptions) => { - // デフォルトエンジンのインストールの確認ダイアログ - if ( - !isUpdate && - options.title == "デフォルトエンジンのインストール" && - options.buttons?.[0] == "インストールする" - ) { - return 0; - } + test("起動したら「利用規約に関するお知らせ」が表示される", async () => { + const app = await electron.launch({ + args: ["--no-sandbox", "."], // NOTE: --no-sandbox はUbuntu 24.04で動かすのに必要 + timeout: process.env.CI ? 0 : 60000, + }); - // デフォルトエンジンのアップデートの確認ダイアログ - if ( - isUpdate && - options.title == "デフォルトエンジンのアップデート" && - options.buttons?.[0] == "アップデートする" - ) { - return 0; - } + // ログを表示 + app.on("console", (msg) => { + console.log(msg.text()); + }); - throw new Error(`Unexpected dialog: ${JSON.stringify(options)}`); - }; - }, params.isUpdate); + const sut = await app.firstWindow({ + timeout: process.env.CI ? 90000 : 60000, + }); + await sut.waitForSelector("text=利用規約に関するお知らせ", { + timeout: 60000, + }); - // ログを表示 - app.on("console", (msg) => { - console.log(msg.text()); + await app.close(); }); +}); - const sut = await app.firstWindow({ - timeout: process.env.CI ? 90000 : 60000, - }); - await sut.waitForSelector("text=利用規約に関するお知らせ", { - timeout: 60000, +test.describe("downloadVvpp環境", () => { + test.beforeEach(async () => { + dotenv.config({ + path: "./tests/env/.env.test-electron-default-vvpp", + override: true, + quiet: true, + }); }); - await app.close(); -} + test("起動したら「エンジンのセットアップ」画面が表示される", async () => { + const app = await electron.launch({ + args: ["--no-sandbox", "."], // NOTE: --no-sandbox はUbuntu 24.04で動かすのに必要 + timeout: process.env.CI ? 0 : 60000, + }); -[ - { - envName: ".env環境", - envPath: ".env", - envId: "dotenv-environment", - }, - { - envName: "VVPPデフォルトエンジン", - envPath: "tests/env/.env.test-electron-default-vvpp", - envId: "vvpp-default-engine", - }, -].forEach(({ envName, envPath, envId }) => { - test.describe(`${envName}`, () => { - test.beforeEach(() => { - dotenv.config({ path: envPath, override: true }); + // ログを表示 + app.on("console", (msg) => { + console.log(msg.text()); }); - test("起動したら「利用規約に関するお知らせ」が表示される", async () => { - await runEngineTest({ isUpdate: false }); + const sut = await app.firstWindow({ + timeout: process.env.CI ? 90000 : 60000, + }); + await sut.waitForSelector("text=エンジンのセットアップ", { + timeout: 60000, }); - if (envId === "vvpp-default-engine") { - test("古いバージョンがインストールされている場合、アップデートが実行される", async () => { - await runEngineTest({ isUpdate: true }); - }); - } + await app.close(); }); }); diff --git a/tests/e2e/electron/helper.ts b/tests/e2e/electron/helper.ts index a40e7ee419..71c0132fcb 100644 --- a/tests/e2e/electron/helper.ts +++ b/tests/e2e/electron/helper.ts @@ -1,6 +1,5 @@ import os from "node:os"; import path from "node:path"; -import fs from "node:fs/promises"; /** テスト用のユーザーディレクトリパスを取得する */ export function getUserTestDir(): string { @@ -16,23 +15,3 @@ export function getUserTestDir(): string { } return path.resolve(appData, `${process.env.VITE_APP_NAME}-test`); } - -/** 古いバージョンのvvpp-enginesディレクトリを作る */ -export async function setupOldVersionEngine() { - const userDir = getUserTestDir(); - const vvppEngineDir = path.join(userDir, "vvpp-engines"); - - await fs.mkdir(vvppEngineDir, { recursive: true }); - const sourceOldEngineDir = path.join(import.meta.dirname, "oldEngine"); - const manifestPath = path.join(sourceOldEngineDir, "engine_manifest.json"); - const manifest = JSON.parse(await fs.readFile(manifestPath, "utf-8")) as { - uuid: string; - name: string; - }; - - const oldEngineDir = path.join( - vvppEngineDir, - `${manifest.name}+${manifest.uuid}`, - ); - await fs.cp(sourceOldEngineDir, oldEngineDir, { recursive: true }); -} diff --git a/tests/env/.env.test-electron-default-vvpp b/tests/env/.env.test-electron-default-vvpp index 94255249d3..46e97f14f0 100644 --- a/tests/env/.env.test-electron-default-vvpp +++ b/tests/env/.env.test-electron-default-vvpp @@ -4,12 +4,12 @@ VITE_APP_NAME=voicevox VITE_DEFAULT_ENGINE_INFOS=`[ { "type": "downloadVvpp", - "name": "VOICEVOX Nemo Engine", - "uuid": "208cf94d-43d2-4cf5-abc0-9783cac36d29", + "name": "VOICEVOX Engine", + "uuid": "074fc39e-678b-4c13-8916-ffca8d505d1d", + "host": "http://127.0.0.1:50021", "executionEnabled": true, "executionArgs": [], - "host": "http://127.0.0.1:50121", - "latestUrl": "https://voicevox.hiroshiba.jp/nemoLatestDefaultEngineInfos.json" + "latestUrl": "https://random-files.sevenc7c.com/latest_engine_info.json" } ]` VITE_OFFICIAL_WEBSITE_URL=https://voicevox.hiroshiba.jp/ diff --git a/tests/unit/domain/defaultEngine/latestDefaultEngineInfos.json b/tests/unit/domain/defaultEngine/latestDefaultEngineInfos.json index 7d7e1a7a1b..f7fbcdbcf0 100644 --- a/tests/unit/domain/defaultEngine/latestDefaultEngineInfos.json +++ b/tests/unit/domain/defaultEngine/latestDefaultEngineInfos.json @@ -2,67 +2,98 @@ "formatVersion": 1, "packages": { "windows-x64-cpu": { - "version": "0.20.0", + "version": "0.25.1", + "displayInfo": { + "label": "CPU版", + "hint": "CPUのみで動作します", + "order": 0 + }, "files": [ { - "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.20.0/voicevox_engine-windows-cpu-0.20.0.vvpp", - "name": "voicevox_engine-windows-cpu-0.20.0.vvpp", - "size": 1374659234 + "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.25.1/voicevox_engine-windows-cpu-0.25.1.vvpp", + "name": "voicevox_engine-windows-cpu-0.25.1.vvpp", + "size": 1830392124 } ] }, "windows-x64-directml": { - "version": "0.20.0", + "version": "0.25.1", + "displayInfo": { + "label": "GPU / CPU両対応版", + "hint": "DirectML対応のGPUが必要です", + "order": 1, + "default": true + }, "files": [ { - "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.20.0/voicevox_engine-windows-directml-0.20.0.vvpp", - "name": "voicevox_engine-windows-directml-0.20.0.vvpp", - "size": 1382829369 + "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.25.1/voicevox_engine-windows-directml-0.25.1.vvpp", + "name": "voicevox_engine-windows-directml-0.25.1.vvpp", + "size": 1839480269 } ] }, "macos-x64-cpu": { - "version": "0.20.0", + "version": "0.25.1", + "displayInfo": { + "label": "CPU版", + "hint": "CPUのみで動作します", + "order": 0 + }, "files": [ { - "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.20.0/voicevox_engine-macos-x64-0.20.0.001.vvppp", - "name": "voicevox_engine-macos-x64-0.20.0.001.vvppp", - "size": 1382766014 + "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.25.1/voicevox_engine-macos-x64-0.25.1.vvpp", + "name": "voicevox_engine-macos-x64-0.25.1.vvpp", + "size": 1826321992 } ] }, "macos-arm64-cpu": { - "version": "0.20.0", + "version": "0.25.1", + "displayInfo": { + "label": "CPU版", + "hint": "CPUのみで動作します", + "order": 0 + }, "files": [ { - "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.20.0/voicevox_engine-macos-arm64-0.20.0.001.vvppp", - "name": "voicevox_engine-macos-arm64-0.20.0.001.vvppp", - "size": 1375008115 + "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.25.1/voicevox_engine-macos-arm64-0.25.1.vvpp", + "name": "voicevox_engine-macos-arm64-0.25.1.vvpp", + "size": 1823105551 } ] }, "linux-x64-cpu": { - "version": "0.20.0", + "version": "0.25.1", + "displayInfo": { + "label": "CPU版", + "hint": "CPUのみで動作します", + "order": 0 + }, "files": [ { - "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.20.0/voicevox_engine-linux-cpu-0.20.0.vvpp", - "name": "voicevox_engine-linux-cpu-0.20.0.vvpp", - "size": 1399437028 + "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.25.1/voicevox_engine-linux-cpu-x64-0.25.1.vvpp", + "name": "voicevox_engine-linux-cpu-x64-0.25.1.vvpp", + "size": 1847590025 } ] }, "linux-x64-cuda": { - "version": "0.20.0", + "version": "0.25.1", + "displayInfo": { + "label": "CUDA版", + "hint": "CUDA対応のNVIDIA製GPUが必要です", + "order": 1 + }, "files": [ { - "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.20.0/voicevox_engine-linux-nvidia-0.20.0.001.vvppp", - "name": "voicevox_engine-linux-nvidia-0.20.0.001.vvppp", + "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.25.1/voicevox_engine-linux-nvidia-0.25.1.001.vvppp", + "name": "voicevox_engine-linux-nvidia-0.25.1.001.vvppp", "size": 1992294400 }, { - "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.20.0/voicevox_engine-linux-nvidia-0.20.0.002.vvppp", - "name": "voicevox_engine-linux-nvidia-0.20.0.002.vvppp", - "size": 645130316 + "url": "https://github.com/VOICEVOX/voicevox_engine/releases/download/0.25.1/voicevox_engine-linux-nvidia-0.25.1.002.vvppp", + "name": "voicevox_engine-linux-nvidia-0.25.1.002.vvppp", + "size": 1007592037 } ] } diff --git a/vite.config.ts b/vite.config.ts index 69fec16156..f157e3fb73 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import path from "node:path"; import { rm } from "node:fs/promises"; -import electronPlugin from "vite-plugin-electron/simple"; +import electronPlugin, { ElectronOptions } from "vite-plugin-electron"; import tsconfigPaths from "vite-tsconfig-paths"; import vue from "@vitejs/plugin-vue"; import electronDefaultImport from "electron"; @@ -32,10 +32,11 @@ const isProduction = process.env.NODE_ENV === "production"; const ignorePaths = (paths: string[]) => paths.map((path) => `!${path}`); -function getElectronTargetVersion(): { +type ElectronTargetVersion = { node: string; chrome: string; -} { +}; +function getElectronTargetVersion(): ElectronTargetVersion { const result = execFileSync( electronPath, [path.join(import.meta.dirname, "build/getElectronVersion.mjs")], @@ -97,6 +98,12 @@ export default defineConfig((options) => { outDir: path.resolve(import.meta.dirname, "dist"), chunkSizeWarningLimit: 10000, sourcemap, + rollupOptions: { + input: { + main: path.resolve(import.meta.dirname, "src/index.html"), + welcome: path.resolve(import.meta.dirname, "src/welcome/index.html"), + }, + }, }, publicDir: path.resolve(import.meta.dirname, "public"), css: { @@ -122,8 +129,8 @@ export default defineConfig((options) => { isElectron && [ cleanDistPlugin(), // TODO: 関数で切り出して共通化できる部分はまとめる - electronPlugin({ - main: { + electronPlugin([ + { entry: "./backend/electron/main.ts", // ref: https://github.com/electron-vite/vite-plugin-electron/pull/122 @@ -152,31 +159,28 @@ export default defineConfig((options) => { }, }, }, - preload: { - input: "./src/backend/electron/renderer/preload.ts", - onstart({ reload }) { - if (!skipLaunchElectron) { - reload(); - } + ...electronPreloadOptions( + { + skipLaunchElectron, + sourcemap, + electronTargetVersion, }, - vite: { - plugins: [ - tsconfigPaths({ root: import.meta.dirname }), - isProduction && checkSuspiciousImportsPlugin({}), - ], - build: { - target: electronTargetVersion?.chrome, - outDir: path.resolve(import.meta.dirname, "dist"), - sourcemap, - }, + { + preload: "./src/backend/electron/renderer/preload.ts", + welcomePreload: "./src/welcome/preload.ts", }, - }, - }), + ), + ]), ], isElectron && injectLoaderScriptPlugin( "./backend/electron/renderer/backendApiLoader.ts", ), + isElectron && + injectLoaderScriptPlugin( + "./backend/apiLoader.ts", + "", + ), isBrowser && injectLoaderScriptPlugin("./backend/browser/backendApiLoader.ts"), ], @@ -276,15 +280,59 @@ const cleanDistPlugin = (): Plugin => { }; }; +const electronPreloadOptions = ( + options: { + skipLaunchElectron: boolean; + sourcemap: BuildOptions["sourcemap"]; + electronTargetVersion: ElectronTargetVersion | undefined; + }, + entries: Record, +): ElectronOptions[] => + Object.entries(entries).map( + ([name, entry]): ElectronOptions => ({ + onstart({ reload }) { + if (!options.skipLaunchElectron) { + reload(); + } + }, + vite: { + plugins: [ + tsconfigPaths({ root: import.meta.dirname }), + isProduction && checkSuspiciousImportsPlugin({}), + ], + build: { + outDir: path.resolve(import.meta.dirname, "dist"), + sourcemap: options.sourcemap, + target: options.electronTargetVersion?.node, + rollupOptions: { + input: { + [name]: path.resolve(import.meta.dirname, entry), + }, + output: { + format: "cjs", + inlineDynamicImports: true, + entryFileNames: `[name].cjs`, + chunkFileNames: `[name].cjs`, + assetFileNames: `[name].[ext]`, + }, + }, + }, + }, + }), + ); + /** バックエンドAPIをフロントエンドから実行するコードを注入する */ -const injectLoaderScriptPlugin = (scriptPath: string): Plugin => { +const injectLoaderScriptPlugin = ( + scriptPath: string, + placeholder = "", +): Plugin => { return { name: "inject-loader-script", transformIndexHtml: { order: "pre", handler: (html: string) => { return html.replace( - "", + placeholder, ``, ); },