diff --git a/src/background.ts b/src/background.ts index 97d1133e15..22991970c0 100644 --- a/src/background.ts +++ b/src/background.ts @@ -599,6 +599,10 @@ ipcMainHandle("GET_PRIVACY_POLICY_TEXT", () => { return privacyPolicyText; }); +ipcMainHandle("GET_ALT_PORT_INFOS", () => { + return engineManager.altPortInfo; +}); + ipcMainHandle("SHOW_AUDIO_SAVE_DIALOG", async (_, { title, defaultPath }) => { const result = await dialog.showSaveDialog(win, { title, diff --git a/src/background/engineManager.ts b/src/background/engineManager.ts index 88c799ec13..9ac0cb9fee 100644 --- a/src/background/engineManager.ts +++ b/src/background/engineManager.ts @@ -5,10 +5,11 @@ import treeKill from "tree-kill"; import Store from "electron-store"; import shlex from "shlex"; -import { BrowserWindow, dialog } from "electron"; +import { app, BrowserWindow, dialog } from "electron"; import log from "electron-log"; import { z } from "zod"; +import { PortManager } from "./portManager"; import { ipcMainSend } from "@/electron/ipc"; import { @@ -20,6 +21,7 @@ import { engineIdSchema, minimumEngineManifestSchema, } from "@/type/preload"; +import { AltPortInfos } from "@/store/type"; type EngineProcessContainer = { willQuitEngine: boolean; @@ -66,6 +68,8 @@ export class EngineManager { defaultEngineInfos: EngineInfo[]; engineProcessContainers: Record; + public altPortInfo: AltPortInfos = {}; + constructor({ store, defaultEngineDir, @@ -209,6 +213,7 @@ export class EngineManager { const engineInfo = engineInfos.find( (engineInfo) => engineInfo.uuid === engineId ); + if (!engineInfo) throw new Error(`No such engineInfo registered: engineId == ${engineId}`); @@ -224,6 +229,51 @@ export class EngineManager { return; } + const engineInfoUrl = new URL(engineInfo.host); + const portManager = new PortManager( + engineInfoUrl.hostname, + parseInt(engineInfoUrl.port) + ); + + log.info( + `ENGINE ${engineId}: Checking whether port ${engineInfoUrl.port} is assignable...` + ); + + // ポートを既に割り当てられているプロセスidの取得: undefined → ポートが空いている + const processId = await portManager.getProcessIdFromPort(); + if (processId !== undefined) { + const processName = await portManager.getProcessNameFromPid(processId); + log.warn( + `ENGINE ${engineId}: Port ${engineInfoUrl.port} has already been assigned by ${processName} (pid=${processId})` + ); + + // 代替ポート検索 + const altPort = await portManager.findAltPort(); + + // 代替ポートが見つからないとき + if (!altPort) { + log.error(`ENGINE ${engineId}: No Alternative Port Found`); + dialog.showErrorBox( + `${engineInfo.name} の起動に失敗しました`, + `${engineInfoUrl.port}番ポートの代わりに利用可能なポートが見つかりませんでした。PCを再起動してください。` + ); + app.exit(1); + throw new Error("No Alternative Port Found"); + } + + // 代替ポートの情報 + this.altPortInfo[engineId] = { + from: parseInt(engineInfoUrl.port), + to: altPort, + }; + + // 代替ポートを設定 + engineInfo.host = `http://${engineInfoUrl.hostname}:${altPort}`; + log.warn( + `ENGINE ${engineId}: Applied Alternative Port: ${engineInfoUrl.port} -> ${altPort}` + ); + } + log.info(`ENGINE ${engineId}: Starting process`); if (!(engineId in this.engineProcessContainers)) { @@ -244,7 +294,12 @@ export class EngineManager { // エンジンプロセスの起動 const enginePath = engineInfo.executionFilePath; - const args = engineInfo.executionArgs.concat(useGpu ? ["--use_gpu"] : []); + const args = engineInfo.executionArgs.concat(useGpu ? ["--use_gpu"] : [], [ + "--host", + new URL(engineInfo.host).hostname, + "--port", + new URL(engineInfo.host).port, + ]); log.info(`ENGINE ${engineId} path: ${enginePath}`); log.info(`ENGINE ${engineId} args: ${JSON.stringify(args)}`); diff --git a/src/background/portManager.ts b/src/background/portManager.ts new file mode 100644 index 0000000000..c1e2991148 --- /dev/null +++ b/src/background/portManager.ts @@ -0,0 +1,148 @@ +import { execFileSync } from "child_process"; +import log from "electron-log"; + +const isWindows = process.platform === "win32"; + +export class PortManager { + constructor(private hostname: string, private port: number) {} + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + portLog = (...message: any) => + log.info(`PORT ${this.port} (${this.hostname}): ${message}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + portWarn = (...message: any) => + log.warn(`PORT ${this.port} (${this.hostname}): ${message}`); + + /** + * "netstat -ano" の stdout から, 指定したポートを使用しているプロセスの process id を取得する + * + * ex) stdout: + * ``` cmd + * TCP 127.0.0.1:5173 127.0.0.1:50170 TIME_WAIT 0 + * TCP 127.0.0.1:6463 0.0.0.0:0 LISTENING 18692 + * TCP 127.0.0.1:50021 0.0.0.0:0 LISTENING 17320 + * ``` + * -> `17320` + * + * @param stdout netstat の stdout + * @returns `process id` or `undefined` (ポートが使用されていないとき) + */ + private stdout2processId(stdout: string): number | undefined { + const lines = stdout.split("\n"); + for (const line of lines) { + if (line.includes(`${this.hostname}:${this.port}`)) { + const parts = line.trim().split(/\s+/); + return parseInt(parts[parts.length - 1], 10); + } + } + return undefined; + } + + async getProcessIdFromPort(): Promise { + this.portLog("Getting process id..."); + const exec = isWindows + ? { + cmd: "netstat", + args: ["-ano"], + } + : { + cmd: "lsof", + args: ["-i", `:${this.port}`, "-t", "-sTCP:LISTEN"], + }; + + this.portLog(`Running command: "${exec.cmd} ${exec.args.join(" ")}"`); + + let stdout = execFileSync(exec.cmd, exec.args, { + shell: true, + }).toString(); + + if (isWindows) { + // Windows の場合は, lsof のように port と pid が 1to1 で取れないので, 3つのループバックアドレスが割り当てられているか確認 + const loopbackAddr = ["127.0.0.1", "0.0.0.0", "[::1]"]; + + // hostname が3つループバックアドレスのどれかの場合, それぞれのループバックアドレスに対して pid を取得 + if (loopbackAddr.includes(this.hostname)) { + this.portLog( + "Hostname is loopback address; Getting process id from all loopback addresses..." + ); + + const pid: (number | undefined)[] = []; + loopbackAddr.forEach((hostname) => + pid.push( + // TODO: インスタンスの再定義を回避するなどのリファクタリング + new PortManager(hostname, this.port).stdout2processId(stdout) + ) + ); + + // pid が undefined (= 割り当て可能) でないものを取得 → 1つ目を取得 → stdoutへ + stdout = pid.filter((pid) => pid !== undefined)[0]?.toString() ?? ""; + } else { + stdout = this.stdout2processId(stdout)?.toString() ?? ""; + } + } + + if (!stdout || !stdout.length) { + this.portLog("Assignable; Nobody uses this port!"); + return undefined; + } + + this.portWarn(`Nonassignable; pid=${stdout} uses this port!`); + return parseInt(stdout); + } + + async getProcessNameFromPid(pid: number): Promise { + this.portLog(`Getting process name from pid=${pid}...`); + const exec = isWindows + ? { + cmd: "wmic", + args: ["process", "where", `"ProcessID=${pid}"`, "get", "name"], + } + : { + cmd: "ps", + args: ["-p", pid.toString(), "-o", "comm="], + }; + + let stdout = execFileSync(exec.cmd, exec.args, { shell: true }).toString(); + + if (isWindows) { + /* + * ex) stdout: + * ``` + * Name + * node.exe + * ``` + * -> `node.exe` + */ + stdout = stdout.split("\r\n")[1]; + } + + this.portLog(`Found process name: ${stdout}`); + return stdout.trim(); + } + + /** + * 割り当て可能な他のポートを探します + * + * @returns 割り当て可能なポート番号 or `undefined` (割り当て可能なポートが見つからなかったとき) + */ + async findAltPort(): Promise { + this.portLog(`Find another assignable port from ${this.port}...`); + + // エンジン指定のポート + 100番までを探索 エフェメラルポートの範囲の最大は超えないようにする + const altPortMax = Math.min(this.port + 100, 65535); + + for (let altPort = this.port + 1; altPort <= altPortMax; altPort++) { + this.portLog(`Trying whether port ${altPort} is assignable...`); + const altPid = await new PortManager( // TODO: インスタンスの再定義を回避するなどのリファクタリング + this.hostname, + altPort + ).getProcessIdFromPort(); + + // ポートを既に割り当てられているプロセスidの取得: undefined → ポートが空いている + if (altPid === undefined) return altPort; + } + + this.portWarn(`No alternative port found! ${this.port}...${altPortMax}`); + return undefined; + } +} diff --git a/src/components/MenuBar.vue b/src/components/MenuBar.vue index f556269744..86751337ff 100644 --- a/src/components/MenuBar.vue +++ b/src/components/MenuBar.vue @@ -81,6 +81,13 @@ export type MenuItemType = MenuItemData["type"]; const store = useStore(); const $q = useQuasar(); const currentVersion = ref(""); +const altPorts = ref([]); + +store.dispatch("GET_ALT_PORT_INFOS").then( + (altPortInfo) => + // {[engineId]: {from: number, to: number}} -> to: number[] + (altPorts.value = Object.values(altPortInfo).map(({ to }) => to)) +); window.electron.getAppInfos().then((obj) => { currentVersion.value = obj.version; }); @@ -103,7 +110,9 @@ const titleText = computed( (projectName.value !== undefined ? projectName.value + " - " : "") + "VOICEVOX" + (currentVersion.value ? " - Ver. " + currentVersion.value : "") + - (isMultiEngineOffMode.value ? " - マルチエンジンオフ" : "") + (isMultiEngineOffMode.value ? " - マルチエンジンオフ" : "") + + // メインエンジン (0番目) の代替ポートの表示のみ + (altPorts.value.length ? " - Port: " + altPorts.value[0] : "") ); // FIXME: App.vue内に移動する diff --git a/src/electron/preload.ts b/src/electron/preload.ts index 4fb0640f7b..bb083f5e74 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -71,6 +71,10 @@ const api: Sandbox = { return await ipcRendererInvoke("GET_PRIVACY_POLICY_TEXT"); }, + getAltPortInfos: async () => { + return await ipcRendererInvoke("GET_ALT_PORT_INFOS"); + }, + saveTempAudioFile: async ({ relativePath, buffer }) => { if (!tempDir) { tempDir = await ipcRendererInvoke("GET_TEMP_DIR"); diff --git a/src/main.ts b/src/main.ts index bebc364f13..90039b663d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { createApp } from "vue"; import { createGtm } from "@gtm-support/vue-gtm"; -import { Quasar, Dialog, Loading } from "quasar"; +import { Quasar, Dialog, Loading, Notify } from "quasar"; import iconSet from "quasar/icon-set/material-icons"; import App from "./App.vue"; import router from "./router"; @@ -39,6 +39,7 @@ createApp(App) plugins: { Dialog, Loading, + Notify, }, }) .use(ipcMessageReceiver, { store }) diff --git a/src/store/engine.ts b/src/store/engine.ts index 357a7375bd..f8cca51a22 100644 --- a/src/store/engine.ts +++ b/src/store/engine.ts @@ -44,6 +44,13 @@ export const engineStore = createPartialStore({ }); }, }, + + GET_ALT_PORT_INFOS: { + async action() { + return await window.electron.getAltPortInfos(); + }, + }, + SET_ENGINE_INFOS: { mutation( state, diff --git a/src/store/setting.ts b/src/store/setting.ts index cbd2a3aca3..28c14b0ec6 100644 --- a/src/store/setting.ts +++ b/src/store/setting.ts @@ -57,6 +57,7 @@ export const settingStoreState: SettingStoreState = { }, confirmedTips: { tweakableSliderByScroll: false, + engineStartedOnAltPort: false, }, engineSettings: {}, }; diff --git a/src/store/type.ts b/src/store/type.ts index 51df5d9538..1c5ab4c99c 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -80,6 +80,8 @@ export type Command = { }; export type EngineState = "STARTING" | "FAILED_STARTING" | "ERROR" | "READY"; +export type AltPortInfos = Record; // ポートが塞がれていたときの代替ポート + export type SaveResult = | "SUCCESS" | "WRITE_ERROR" @@ -737,6 +739,10 @@ export type EngineStoreTypes = { getter: EngineInfo[]; }; + GET_ALT_PORT_INFOS: { + action(): Promise; + }; + SET_ENGINE_MANIFESTS: { mutation: { engineManifests: Record }; }; diff --git a/src/styles/_index.scss b/src/styles/_index.scss index 8de858bebb..cdae833cb6 100644 --- a/src/styles/_index.scss +++ b/src/styles/_index.scss @@ -102,6 +102,11 @@ img { background: transparent; // デフォルトの設定だと全画面ダイアログが出る際に黒背景がちらつく } +// トースト通知内にあるボタン +.q-notification__actions .q-btn { + font-weight: bold; +} + // 設定とかのヘッダーの色 .q-layout__section--marginal { background: colors.$toolbar !important; @@ -232,3 +237,13 @@ img { .bg-toolbar-button-display { background: colors.$toolbar-button-display; } + +.bg-toast { + background: colors.$toast; +} +.text-toast-display { + color: colors.$toast-display; +} +.text-toast-button-display { + color: colors.$toast-button-display; +} diff --git a/src/styles/colors.scss b/src/styles/colors.scss index 555c46f869..e0c6d372c7 100644 --- a/src/styles/colors.scss +++ b/src/styles/colors.scss @@ -44,6 +44,10 @@ $active-point-hover-rgb: var(--color-active-point-hover-rgb); --color-splitter: var(--color-primary); --color-splitter-rgb: var(--color-primary-rgb); + + --color-toast: rgba(var(--color-primary-light-rgb), 0.4); + --color-toast-display: var(--color-display); + --color-toast-button-display: rgba(var(--color-display-rgb), 0.6); } :root[is-dark-theme="true"] { @@ -58,6 +62,10 @@ $active-point-hover-rgb: var(--color-active-point-hover-rgb); --color-splitter: var(--color-surface); --color-splitter-rgb: var(--color-surface-rgb); + + --color-toast: rgba(var(--color-primary-light-rgb), 0.4); + --color-toast-display: var(--color-display); + --color-toast-button-display: var(--color-primary); } $toolbar: var(--color-toolbar); @@ -74,3 +82,7 @@ $splitter-rgb: var(--color-splitter-rgb); $warning: var(--color-warning); $warning-rgb: var(--color-warning-rgb); + +$toast: var(--color-toast); +$toast-display: var(--color-toast-display); +$toast-button-display: var(--color-toast-button-display); diff --git a/src/type/ipc.ts b/src/type/ipc.ts index df7b78b8ce..6309a30a52 100644 --- a/src/type/ipc.ts +++ b/src/type/ipc.ts @@ -13,6 +13,7 @@ import { EngineId, MessageBoxReturnValue, } from "@/type/preload"; +import { AltPortInfos } from "@/store/type"; /** * invoke, handle @@ -68,6 +69,11 @@ export type IpcIHData = { return: string; }; + GET_ALT_PORT_INFOS: { + args: []; + return: AltPortInfos; + }; + SHOW_AUDIO_SAVE_DIALOG: { args: [obj: { title: string; defaultPath?: string }]; return?: string; diff --git a/src/type/preload.ts b/src/type/preload.ts index 71e51e7b14..2cd4ede42f 100644 --- a/src/type/preload.ts +++ b/src/type/preload.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { IpcSOData } from "./ipc"; +import { AltPortInfos } from "@/store/type"; export const isMac = typeof process === "undefined" @@ -142,6 +143,7 @@ export interface Sandbox { getQAndAText(): Promise; getContactText(): Promise; getPrivacyPolicyText(): Promise; + getAltPortInfos(): Promise; saveTempAudioFile(obj: { relativePath: string; buffer: ArrayBuffer }): void; loadTempFile(): Promise; showAudioSaveDialog(obj: { @@ -494,6 +496,7 @@ export type SplitterPosition = z.infer; export type ConfirmedTips = { tweakableSliderByScroll: boolean; + engineStartedOnAltPort: boolean; // エンジンのポート変更の通知 }; export const electronStoreSchema = z @@ -583,6 +586,7 @@ export const electronStoreSchema = z confirmedTips: z .object({ tweakableSliderByScroll: z.boolean().default(false), + engineStartedOnAltPort: z.boolean().default(false), }) .passthrough() .default({}), diff --git a/src/views/EditorHome.vue b/src/views/EditorHome.vue index 233a8e6138..5b8b1c6a3d 100644 --- a/src/views/EditorHome.vue +++ b/src/views/EditorHome.vue @@ -544,6 +544,38 @@ onMounted(async () => { store.state.acceptTerms !== "Accepted"; isCompletedInitialStartup.value = true; + + // 代替ポートをトースト通知する + // FIXME: トーストが何度も出るようにする(altPortInfoをstateに持たせてwatchする) + if (!store.state.confirmedTips.engineStartedOnAltPort) { + const altPortInfo = await store.dispatch("GET_ALT_PORT_INFOS"); + for (const engineId of store.state.engineIds) { + const engineName = store.state.engineInfos[engineId].name; + const altPort = altPortInfo[engineId]; + + if (!altPort) return; + $q.notify({ + message: `${altPort.from}番ポートが使用中であるため ${engineName} は、${altPort.to}番ポートで起動しました`, + color: "toast", + textColor: "toast-display", + icon: "compare_arrows", + timeout: 5000, + actions: [ + { + label: "今後この通知をしない", + textColor: "toast-button-display", + handler: () => + store.dispatch("SET_CONFIRMED_TIPS", { + confirmedTips: { + ...store.state.confirmedTips, + engineStartedOnAltPort: true, + }, + }), + }, + ], + }); + } + } }); // エンジン待機 diff --git a/tests/unit/store/Vuex.spec.ts b/tests/unit/store/Vuex.spec.ts index bbd54c9333..07e99ef11b 100644 --- a/tests/unit/store/Vuex.spec.ts +++ b/tests/unit/store/Vuex.spec.ts @@ -135,6 +135,7 @@ describe("store/vuex.js test", () => { }, confirmedTips: { tweakableSliderByScroll: false, + engineStartedOnAltPort: false, }, progress: -1, defaultPresetKeys: {}, @@ -250,5 +251,7 @@ describe("store/vuex.js test", () => { ); assert.propertyVal(store.state.splitterPosition, "audioInfoPaneWidth", 20); assert.propertyVal(store.state.splitterPosition, "portraitPaneWidth", 50); + assert.equal(store.state.confirmedTips.tweakableSliderByScroll, false); + assert.equal(store.state.confirmedTips.engineStartedOnAltPort, false); }); });