diff --git a/src/background.ts b/src/background.ts index 22991970c0..0b6b78a700 100644 --- a/src/background.ts +++ b/src/background.ts @@ -524,8 +524,8 @@ async function start() { } store.set("engineSettings", engineSettings); - await createWindow(); await engineManager.runEngineAll(win); + await createWindow(); } const menuTemplateForMac: Electron.MenuItemConstructorOptions[] = [ diff --git a/src/background/engineManager.ts b/src/background/engineManager.ts index 9ac0cb9fee..cb0263fb2f 100644 --- a/src/background/engineManager.ts +++ b/src/background/engineManager.ts @@ -9,7 +9,12 @@ import { app, BrowserWindow, dialog } from "electron"; import log from "electron-log"; import { z } from "zod"; -import { PortManager } from "./portManager"; +import { + findAltPort, + getPidFromPort, + getProcessNameFromPid, + url2HostInfo, +} from "./portManager"; import { ipcMainSend } from "@/electron/ipc"; import { @@ -229,33 +234,30 @@ export class EngineManager { return; } - const engineInfoUrl = new URL(engineInfo.host); - const portManager = new PortManager( - engineInfoUrl.hostname, - parseInt(engineInfoUrl.port) - ); + // { hostname (localhost), port (50021) } <- url (http://localhost:50021) + const engineHostInfo = url2HostInfo(new URL(engineInfo.host)); log.info( - `ENGINE ${engineId}: Checking whether port ${engineInfoUrl.port} is assignable...` + `ENGINE ${engineId}: Checking whether port ${engineHostInfo.port} is assignable...` ); - // ポートを既に割り当てられているプロセスidの取得: undefined → ポートが空いている - const processId = await portManager.getProcessIdFromPort(); - if (processId !== undefined) { - const processName = await portManager.getProcessNameFromPid(processId); + // ポートを既に割り当てているプロセスidの取得: undefined → ポートが空いている + const pid = await getPidFromPort(engineHostInfo); + if (pid != undefined) { + const processName = await getProcessNameFromPid(engineHostInfo, pid); log.warn( - `ENGINE ${engineId}: Port ${engineInfoUrl.port} has already been assigned by ${processName} (pid=${processId})` + `ENGINE ${engineId}: Port ${engineHostInfo.port} has already been assigned by ${processName} (pid=${pid})` ); - // 代替ポート検索 - const altPort = await portManager.findAltPort(); + // 代替ポートの検索 + const altPort = await findAltPort(engineHostInfo); // 代替ポートが見つからないとき - if (!altPort) { + if (altPort == undefined) { log.error(`ENGINE ${engineId}: No Alternative Port Found`); dialog.showErrorBox( `${engineInfo.name} の起動に失敗しました`, - `${engineInfoUrl.port}番ポートの代わりに利用可能なポートが見つかりませんでした。PCを再起動してください。` + `${engineHostInfo.port}番ポートの代わりに利用可能なポートが見つかりませんでした。PCを再起動してください。` ); app.exit(1); throw new Error("No Alternative Port Found"); @@ -263,14 +265,14 @@ export class EngineManager { // 代替ポートの情報 this.altPortInfo[engineId] = { - from: parseInt(engineInfoUrl.port), + from: engineHostInfo.port, to: altPort, }; // 代替ポートを設定 - engineInfo.host = `http://${engineInfoUrl.hostname}:${altPort}`; + engineInfo.host = `${engineHostInfo.protocol}//${engineHostInfo.hostname}:${altPort}`; log.warn( - `ENGINE ${engineId}: Applied Alternative Port: ${engineInfoUrl.port} -> ${altPort}` + `ENGINE ${engineId}: Applied Alternative Port: ${engineHostInfo.port} -> ${altPort}` ); } diff --git a/src/background/portManager.ts b/src/background/portManager.ts index c1e2991148..fd579a307b 100644 --- a/src/background/portManager.ts +++ b/src/background/portManager.ts @@ -3,146 +3,205 @@ 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); - } +export type HostInfo = { + protocol: string; + hostname: string; + port: number; +}; + +const portLog = (port: number, message: string, isNested = false) => + log.info(`${isNested ? "| " : ""}PORT ${port}: ${message}`); +const portWarn = (port: number, message: string, isNested = false) => + log.warn(`${isNested ? "| " : ""}PORT ${port}: ${message}`); + +export function url2HostInfo(url: URL): HostInfo { + return { + protocol: url.protocol, + hostname: url.hostname, + port: Number(url.port), + }; +} + +/** + * "netstat -ano" の stdout から, 指定したポートを LISTENING しているプロセスの 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 + * @param hostInfo ホスト情報 + * @returns `process id` or `undefined` (ポートが割り当て可能なとき) + */ +function netstatStdout2pid( + stdout: string, + hostInfo: HostInfo +): number | undefined { + const lines = stdout.split("\n"); + + for (const line of lines) { + if (line.includes(`${hostInfo.hostname}:${hostInfo.port}`)) { + const parts = line.trim().split(/\s+/); + const pid = parts[parts.length - 1]; + const tcpState = parts[parts.length - 2]; + + if (tcpState === "LISTENING") return Number(pid); } - 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..." - ); + return undefined; +} - const pid: (number | undefined)[] = []; - loopbackAddr.forEach((hostname) => - pid.push( - // TODO: インスタンスの再定義を回避するなどのリファクタリング - new PortManager(hostname, this.port).stdout2processId(stdout) - ) +export async function getPidFromPort( + hostInfo: HostInfo, + isNested = false // ログ整形用の引数 +): Promise { + // Windows の場合は, hostname が以下のループバックアドレスが割り当てられているか確認 + const parse4windows = (stdout: string): string | undefined => { + // それぞれのループバックアドレスに対して pid を取得 + const loopbackAddr = ["localhost", "127.0.0.1", "0.0.0.0", "[::1]"]; + if (!loopbackAddr.includes(hostInfo.hostname)) { + portLog( + hostInfo.port, + `Hostname is not loopback address; Getting process id from ${hostInfo.hostname}...`, + isNested + ); + return netstatStdout2pid(stdout, hostInfo)?.toString(); + } else { + portLog( + hostInfo.port, + "Hostname is loopback address; Getting process id from all loopback addresses...", + isNested + ); + + const pidArr: (number | undefined)[] = []; + loopbackAddr.forEach((hostname) => { + // netstat の stdout から pid を取得 + const pid = netstatStdout2pid(stdout, { + protocol: hostInfo.protocol, + hostname, + port: hostInfo.port, + }); + + portLog( + hostInfo.port, + `| ${hostname}\t-> ${ + pid == undefined ? "Assignable" : `pid=${pid} uses this port` + }`, + isNested ); - // pid が undefined (= 割り当て可能) でないものを取得 → 1つ目を取得 → stdoutへ - stdout = pid.filter((pid) => pid !== undefined)[0]?.toString() ?? ""; - } else { - stdout = this.stdout2processId(stdout)?.toString() ?? ""; - } - } + pidArr.push(pid); + }); - if (!stdout || !stdout.length) { - this.portLog("Assignable; Nobody uses this port!"); - return undefined; + // pid が undefined (= 割り当て可能) でないものを取得 → 1つ目を取得 + return pidArr.filter((pid) => pid !== undefined)[0]?.toString(); } + }; - 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]; - } + portLog(hostInfo.port, "Getting process id...", isNested); - this.portLog(`Found process name: ${stdout}`); - return stdout.trim(); + const exec = isWindows + ? { + cmd: "netstat", + args: ["-ano"], + } + : { + cmd: "lsof", + args: ["-i", `:${hostInfo.port}`, "-t", "-sTCP:LISTEN"], + }; + + portLog( + hostInfo.port, + `Running command: "${exec.cmd} ${exec.args.join(" ")}"...`, + isNested + ); + + const stdout = execFileSync(exec.cmd, exec.args, { + shell: true, + }).toString(); + + // Windows の場合は, lsof のように port と pid が 1to1 で取れないので, netstat の stdout をパース + const pid = isWindows ? parse4windows(stdout) : stdout; + + if (pid == undefined || !pid.length) { + portLog(hostInfo.port, "Assignable; Nobody uses this port!", isNested); + return undefined; } - /** - * 割り当て可能な他のポートを探します - * - * @returns 割り当て可能なポート番号 or `undefined` (割り当て可能なポートが見つからなかったとき) - */ - async findAltPort(): Promise { - this.portLog(`Find another assignable port from ${this.port}...`); + portWarn( + hostInfo.port, + `Nonassignable; pid=${pid} uses this port!`, + isNested + ); + return Number(pid); +} - // エンジン指定のポート + 100番までを探索 エフェメラルポートの範囲の最大は超えないようにする - const altPortMax = Math.min(this.port + 100, 65535); +export async function getProcessNameFromPid( + hostInfo: HostInfo, + pid: number +): Promise { + portLog(hostInfo.port, `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="], + }; - 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(); + const stdout = execFileSync(exec.cmd, exec.args, { shell: true }).toString(); - // ポートを既に割り当てられているプロセスidの取得: undefined → ポートが空いている - if (altPid === undefined) return altPort; - } + /* + * ex) stdout: + * ``` + * Name + * node.exe + * ``` + * -> `node.exe` + */ + const processName = isWindows ? stdout.split("\n")[1] : stdout; - this.portWarn(`No alternative port found! ${this.port}...${altPortMax}`); - return undefined; + portLog(hostInfo.port, `Found process name: ${processName}`); + return processName.trim(); +} + +/** + * 割り当て可能な他のポートを探します + * + * @param hostInfo ホスト情報 + * @returns 割り当て可能なポート番号 or `undefined` (割り当て可能なポートが見つからなかったとき) + */ +export async function findAltPort( + hostInfo: HostInfo +): Promise { + const basePort = hostInfo.port; + portLog(basePort, `Find another assignable port from ${basePort}...`); + + // エンジン指定のポート + 100番までを探索 エフェメラルポートの範囲の最大は超えないようにする + const altPortMax = Math.min(basePort + 100, 65535); + + for (let altPort = basePort + 1; altPort <= altPortMax; altPort++) { + portLog(basePort, `Trying whether port ${altPort} is assignable...`); + const altPid = await getPidFromPort( + { + protocol: hostInfo.protocol, + hostname: hostInfo.hostname, + port: altPort, + }, + true + ); + + // ポートを既に割り当てられているプロセスidの取得: undefined → ポートが空いている + if (altPid == undefined) return altPort; } + + portWarn(basePort, `No alternative port found! ${basePort}...${altPortMax}`); + return undefined; } diff --git a/src/components/MenuBar.vue b/src/components/MenuBar.vue index 8c3d6d5a0a..1b4dff7250 100644 --- a/src/components/MenuBar.vue +++ b/src/components/MenuBar.vue @@ -70,13 +70,25 @@ 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)) -); +// デフォルトエンジンの代替先ポート +const defaultEngineAltPortTo = computed(() => { + const altPortInfos = store.state.altPortInfos; + + // ref: https://github.com/VOICEVOX/voicevox/blob/32940eab36f4f729dd0390dca98f18656240d60d/src/views/EditorHome.vue#L522-L528 + const defaultEngineInfo = Object.values(store.state.engineInfos).find( + (engine) => engine.type === "default" + ); + if (defaultEngineInfo == undefined) return undefined; + + // : { from: number, to: number } -> to (代替先ポート) + if (defaultEngineInfo.uuid in altPortInfos) { + return altPortInfos[defaultEngineInfo.uuid].to; + } else { + return undefined; + } +}); + window.electron.getAppInfos().then((obj) => { currentVersion.value = obj.version; }); @@ -100,8 +112,9 @@ const titleText = computed( "VOICEVOX" + (currentVersion.value ? " - Ver. " + currentVersion.value : "") + (isMultiEngineOffMode.value ? " - マルチエンジンオフ" : "") + - // メインエンジン (0番目) の代替ポートの表示のみ - (altPorts.value.length ? " - Port: " + altPorts.value[0] : "") + (defaultEngineAltPortTo.value != null + ? ` - Port: ${defaultEngineAltPortTo.value}` + : "") ); // FIXME: App.vue内に移動する diff --git a/src/store/engine.ts b/src/store/engine.ts index 803b8c9e6e..b9b5bb7c7a 100644 --- a/src/store/engine.ts +++ b/src/store/engine.ts @@ -7,6 +7,7 @@ import type { EngineId, EngineInfo } from "@/type/preload"; export const engineStoreState: EngineStoreState = { engineStates: {}, engineSupportedDevices: {}, + altPortInfos: {}, }; export const engineStore = createPartialStore({ @@ -66,8 +67,16 @@ export const engineStore = createPartialStore({ }, GET_ALT_PORT_INFOS: { - async action() { - return await window.electron.getAltPortInfos(); + async action({ commit }) { + const altPortInfos = await window.electron.getAltPortInfos(); + commit("SET_ALT_PORT_INFOS", { altPortInfos }); + return altPortInfos; + }, + }, + + SET_ALT_PORT_INFOS: { + mutation(state, { altPortInfos }) { + state.altPortInfos = altPortInfos; }, }, @@ -220,6 +229,7 @@ export const engineStore = createPartialStore({ POST_ENGINE_START: { async action({ state, dispatch }, { engineIds }) { + await dispatch("GET_ALT_PORT_INFOS"); const result = await Promise.all( engineIds.map(async (engineId) => { if (state.engineStates[engineId] === "STARTING") { diff --git a/src/store/type.ts b/src/store/type.ts index feba5eb2c2..5e594812a5 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -80,7 +80,9 @@ export type Command = { }; export type EngineState = "STARTING" | "FAILED_STARTING" | "ERROR" | "READY"; -export type AltPortInfos = Record; // ポートが塞がれていたときの代替ポート + +// ポートが塞がれていたときの代替ポート情報 +export type AltPortInfos = Record; export type SaveResult = | "SUCCESS" @@ -728,6 +730,7 @@ export type CommandStoreTypes = { export type EngineStoreState = { engineStates: Record; engineSupportedDevices: Record; + altPortInfos: AltPortInfos; }; export type EngineStoreTypes = { @@ -751,6 +754,10 @@ export type EngineStoreTypes = { action(): Promise; }; + SET_ALT_PORT_INFOS: { + mutation: { altPortInfos: AltPortInfos }; + }; + SET_ENGINE_MANIFESTS: { mutation: { engineManifests: Record }; }; @@ -1127,6 +1134,7 @@ export type UiStoreState = { isPinned: boolean; isFullscreen: boolean; progress: number; + isVuexReady: boolean; }; export type UiStoreTypes = { @@ -1198,6 +1206,7 @@ export type UiStoreTypes = { }; ON_VUEX_READY: { + mutation: void; action(): void; }; diff --git a/src/store/ui.ts b/src/store/ui.ts index 38908d18b9..9ee1c03d07 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -52,6 +52,7 @@ export const uiStoreState: UiStoreState = { isPinned: false, isFullscreen: false, progress: -1, + isVuexReady: false, }; export const uiStore = createPartialStore({ @@ -191,8 +192,12 @@ export const uiStore = createPartialStore({ }, ON_VUEX_READY: { - action() { + mutation(state) { + state.isVuexReady = true; + }, + action({ commit }) { window.electron.vuexReady(); + commit("ON_VUEX_READY"); }, }, diff --git a/src/views/EditorHome.vue b/src/views/EditorHome.vue index 480a6d2f23..f0619ef524 100644 --- a/src/views/EditorHome.vue +++ b/src/views/EditorHome.vue @@ -577,38 +577,6 @@ 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, - }, - }), - }, - ], - }); - } - } }); // エンジン待機 @@ -651,6 +619,47 @@ watch(allEngineState, (newEngineState) => { isEngineWaitingLong.value = false; } }); + +// 代替ポート情報の変更を監視 +watch( + () => [store.state.altPortInfos, store.state.isVuexReady], + async () => { + // この watch がエンジンが起動した時 (=> 設定ファイルを読み込む前) に発火して, "今後この通知をしない" を無視するのを防ぐ + if (!store.state.isVuexReady) return; + + // "今後この通知をしない" を考慮 + if (store.state.confirmedTips.engineStartedOnAltPort) return; + + // 代替ポートをトースト通知する + for (const engineId of store.state.engineIds) { + const engineName = store.state.engineInfos[engineId].name; + const altPort = store.state.altPortInfos[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, + }, + }), + }, + ], + }); + } + } +); + const restartAppWithMultiEngineOffMode = () => { store.dispatch("RESTART_APP", { isMultiEngineOffMode: true }); }; diff --git a/tests/unit/store/Vuex.spec.ts b/tests/unit/store/Vuex.spec.ts index 07e99ef11b..3a72b900f8 100644 --- a/tests/unit/store/Vuex.spec.ts +++ b/tests/unit/store/Vuex.spec.ts @@ -25,6 +25,7 @@ describe("store/vuex.js test", () => { [engineId]: "STARTING", }, engineSupportedDevices: {}, + altPortInfos: {}, characterInfos: {}, morphableTargetsInfo: {}, defaultStyleIds: [], @@ -138,6 +139,7 @@ describe("store/vuex.js test", () => { engineStartedOnAltPort: false, }, progress: -1, + isVuexReady: false, defaultPresetKeys: {}, }, getters: {