d3ro-voice/src/main/windows/WindowManager.ts
Yun Chan 788600b66b RecordingTip hide race condition 수정
- showRecordingTip: 이전 측정 리스너 제거 후 새로 등록
- hideRecordingTip: _tipHidden 플래그 + pending 리스너 즉시 제거
- 측정 응답이 hide 이후에 도착해도 다시 show하지 않음
2026-04-05 10:42:07 +09:00

406 lines
12 KiB
TypeScript

// src/main/windows/WindowManager.ts
// 설계서 01 WindowManagerService: 메인 윈도우 + 팝업 프리로딩 + 2-phase 리사이즈
import { BrowserWindow, shell, screen, ipcMain, Menu } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import { WINDOW_SIZE } from '@shared/constants'
import { getLogger } from '../services/LoggerService'
import { getIsQuitting } from '../lifecycle'
import { configGet } from '../services/ConfigService'
const logger = getLogger('WindowManager')
// ── 윈도우 참조 ───────────────────────────────────────
let mainWindow: BrowserWindow | null = null
let recordingTipWindow: BrowserWindow | null = null
let resultPopupWindow: BrowserWindow | null = null
let historyPopupWindow: BrowserWindow | null = null
// ── 메인 윈도우 ───────────────────────────────────────
export function getMainWindow(): BrowserWindow | null {
return mainWindow
}
export function createMainWindow(): BrowserWindow {
mainWindow = new BrowserWindow({
width: WINDOW_SIZE.MAIN.width,
height: WINDOW_SIZE.MAIN.height,
minWidth: 800,
minHeight: 600,
show: false,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
// Alt 키로 메뉴 활성화 방지: 메뉴 완전 제거
mainWindow.setMenu(null)
Menu.setApplicationMenu(null)
mainWindow.on('ready-to-show', () => {
mainWindow?.show()
if (is.dev) {
mainWindow?.webContents.openDevTools({ mode: 'detach' })
}
logger.info('Main window shown')
})
mainWindow.on('close', (event) => {
if (!getIsQuitting() && configGet('closeToTray')) {
event.preventDefault()
mainWindow?.hide()
logger.info('Main window hidden to tray')
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
logger.info('Main window created')
return mainWindow
}
// ── RecordingTip 팝업 ─────────────────────────────────
function createRecordingTipWindow(): BrowserWindow {
const win = new BrowserWindow({
width: WINDOW_SIZE.RECORDING_TIP.width,
height: WINDOW_SIZE.RECORDING_TIP.height,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/recording-tip/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/recording-tip/index.html'))
}
win.on('closed', () => {
recordingTipWindow = null
})
return win
}
export function getRecordingTipWindow(): BrowserWindow {
if (!recordingTipWindow || recordingTipWindow.isDestroyed()) {
recordingTipWindow = createRecordingTipWindow()
logger.info('RecordingTip window created (preloaded)')
}
return recordingTipWindow
}
/**
* RecordingTip 표시 (2-phase 리사이즈)
* Phase 1: prepare → 측정
* Phase 2: resize → show
*/
// RecordingTip 측정 리스너 관리 (race condition 방지)
let _tipMeasuredHandler: ((_event: Electron.IpcMainEvent, data: { width: number; height: number }) => void) | null = null
let _tipHidden = false
export function showRecordingTip(
state: string,
params?: { text?: string; errorMessage?: string }
): void {
const win = getRecordingTipWindow()
_tipHidden = false
// 이전 측정 리스너 제거 (중복 방지)
if (_tipMeasuredHandler) {
ipcMain.removeListener('window:tipMeasured', _tipMeasuredHandler)
_tipMeasuredHandler = null
}
// Phase 1: prepare (크기 측정)
win.webContents.send('window:tipPrepare', { state, ...params })
// tipMeasured 이벤트를 한번만 처리
_tipMeasuredHandler = (_event: Electron.IpcMainEvent, data: { width: number; height: number }) => {
if (_tipMeasuredHandler) {
ipcMain.removeListener('window:tipMeasured', _tipMeasuredHandler)
_tipMeasuredHandler = null
}
// hide가 이미 호출되었으면 show하지 않음
if (_tipHidden) return
// 커서 위치에 표시
const cursorPos = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPos)
let x = cursorPos.x - Math.round(data.width / 2)
let y = cursorPos.y - data.height - 20
// 화면 밖 보정
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - data.width))
if (y < display.workArea.y) {
y = cursorPos.y + 20
}
win.setBounds({ x, y, width: data.width, height: data.height })
if (!win.isVisible()) {
win.showInactive()
}
// Phase 2: show
win.webContents.send('window:tipShow', { state })
}
ipcMain.on('window:tipMeasured', _tipMeasuredHandler)
}
export function hideRecordingTip(): void {
_tipHidden = true
// pending 측정 리스너 제거 (늦게 도착하면 다시 show되는 버그 방지)
if (_tipMeasuredHandler) {
ipcMain.removeListener('window:tipMeasured', _tipMeasuredHandler)
_tipMeasuredHandler = null
}
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
recordingTipWindow.hide()
}
}
export function updateRecordingTipState(
state: string,
params?: { text?: string; errorMessage?: string }
): void {
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
recordingTipWindow.webContents.send('window:tipStateChanged', { state, ...params })
}
}
export function sendAudioLevelToTip(level: number): void {
if (recordingTipWindow && !recordingTipWindow.isDestroyed() && recordingTipWindow.isVisible()) {
recordingTipWindow.webContents.send('voice:audioLevel', { level })
}
}
// ── ResultPopup 팝업 ──────────────────────────────────
function createResultPopupWindow(): BrowserWindow {
const win = new BrowserWindow({
width: WINDOW_SIZE.RESULT_POPUP.width,
height: WINDOW_SIZE.RESULT_POPUP.height,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/result-popup/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/result-popup/index.html'))
}
win.on('closed', () => {
resultPopupWindow = null
})
return win
}
export function getResultPopupWindow(): BrowserWindow {
if (!resultPopupWindow || resultPopupWindow.isDestroyed()) {
resultPopupWindow = createResultPopupWindow()
logger.info('ResultPopup window created (preloaded)')
}
return resultPopupWindow
}
/**
* ResultPopup 표시 (2-phase 리사이즈)
*/
export function showResultPopup(text: string, autoHideMs = 5000): void {
const win = getResultPopupWindow()
// Phase 1: prepare
win.webContents.send('result:prepare', { text })
const handler = (_event: Electron.IpcMainEvent, data: { width: number; height: number }) => {
ipcMain.removeListener('result:measured', handler)
const cursorPos = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPos)
let x = cursorPos.x - Math.round(data.width / 2)
let y = cursorPos.y - data.height - 20
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - data.width))
if (y < display.workArea.y) {
y = cursorPos.y + 20
}
win.setBounds({ x, y, width: data.width, height: data.height })
if (!win.isVisible()) {
win.showInactive()
}
// Phase 2: show
win.webContents.send('result:show', { autoHideMs })
}
ipcMain.on('result:measured', handler)
}
export function hideResultPopup(): void {
if (resultPopupWindow && !resultPopupWindow.isDestroyed()) {
resultPopupWindow.hide()
}
}
// ── HistoryPopup 팝업 ─────────────────────────────────
function createHistoryPopupWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 360,
height: 400,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/history-popup/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/history-popup/index.html'))
}
win.on('closed', () => {
historyPopupWindow = null
})
return win
}
export function getHistoryPopupWindow(): BrowserWindow {
if (!historyPopupWindow || historyPopupWindow.isDestroyed()) {
historyPopupWindow = createHistoryPopupWindow()
logger.info('HistoryPopup window created')
}
return historyPopupWindow
}
export function showHistoryPopup(entries: Array<Record<string, unknown>>): void {
const win = getHistoryPopupWindow()
win.webContents.send('history:showItems', { entries })
const handler = (_event: Electron.IpcMainEvent, data: { width: number; height: number }) => {
ipcMain.removeListener('history:popupMeasured', handler)
const cursorPos = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPos)
let x = cursorPos.x - Math.round(data.width / 2)
let y = cursorPos.y - data.height - 20
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - data.width))
if (y < display.workArea.y) {
y = cursorPos.y + 20
}
win.setBounds({ x, y, width: data.width, height: data.height })
if (!win.isVisible()) {
win.showInactive()
}
win.webContents.send('history:show', {})
}
ipcMain.on('history:popupMeasured', handler)
}
export function hideHistoryPopup(): void {
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
historyPopupWindow.webContents.send('history:hide', {})
setTimeout(() => {
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
historyPopupWindow.hide()
}
}, 150)
}
}
export function sendKeyToHistoryPopup(key: string): void {
if (historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible()) {
historyPopupWindow.webContents.send('history:keyEvent', { key })
}
}
export function isHistoryPopupVisible(): boolean {
return !!(historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible())
}
// ── 프리로딩 ──────────────────────────────────────────
export function preloadPopupWindows(): void {
getRecordingTipWindow()
getResultPopupWindow()
getHistoryPopupWindow()
logger.info('Popup windows preloaded')
}
// ── clipboard:copy IPC (ResultPopup에서 사용) ─────────
ipcMain.on('clipboard:copy', (_event, text: string) => {
const { clipboard } = require('electron')
clipboard.writeText(text)
})