// 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 '@d3ro/core/constants' import { getLogger } from '../services/LoggerService' import { getIsQuitting } from '../lifecycle' import { configGet } from '../services/ConfigService' import { buildPopupThemeCss } from '@d3ro/ui/theme-vars' const logger = getLogger('WindowManager') // ── 팝업 테마 주입 ──────────────────────────────────────── /** * 팝업 BrowserWindow에 현재 설정 테마의 CSS 변수를 insertCSS로 주입. * did-finish-load 이후 호출해야 한다. */ function injectPopupTheme(win: BrowserWindow): void { const theme = configGet('theme') as string // 'system'은 'dark'로 폴백 (팝업은 시스템 다크모드 감지 불가) const resolvedTheme = theme === 'system' ? 'dark' : theme const css = buildPopupThemeCss(resolvedTheme) win.webContents.insertCSS(css).catch((err: unknown) => { logger.warn('팝업 테마 CSS 주입 실패', err) }) } // ── 윈도우 참조 ─────────────────────────────────────── let mainWindow: BrowserWindow | null = null let recordingTipWindow: BrowserWindow | null = null let resultPopupWindow: BrowserWindow | null = null let historyPopupWindow: BrowserWindow | null = null let commandPopupWindow: BrowserWindow | null = null let captionOverlayWindow: 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, backgroundThrottling: 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.webContents.on('did-finish-load', () => { injectPopupTheme(win) }) 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 기본 크기 (고정 — 2-phase 복잡도 제거) const TIP_WIDTH = 280 const TIP_HEIGHT = 80 export function showRecordingTip( state: string, params?: { text?: string; errorMessage?: string } ): void { const win = getRecordingTipWindow() // 커서 위치에 배치 const cursorPos = screen.getCursorScreenPoint() const display = screen.getDisplayNearestPoint(cursorPos) let x = cursorPos.x - Math.round(TIP_WIDTH / 2) let y = cursorPos.y - TIP_HEIGHT - 20 // 화면 밖 보정 x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - TIP_WIDTH)) if (y < display.workArea.y) { y = cursorPos.y + 20 } win.setBounds({ x, y, width: TIP_WIDTH, height: TIP_HEIGHT }) // 상태 전송 후 즉시 show (2-phase 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지) win.webContents.send('window:tipStateChanged', { state, ...params }) if (!win.isVisible()) { win.showInactive() } } export function hideRecordingTip(): void { 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.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.webContents.on('did-finish-load', () => { injectPopupTheme(win) }) 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.webContents.on('did-finish-load', () => { injectPopupTheme(win) }) 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>): void { const win = getHistoryPopupWindow() // 커서 위치에 즉시 배치+표시 (2-phase 제거 — 숨겨진 윈도우 렌더러 비활성 문제 방지) const cursorPos = screen.getCursorScreenPoint() const display = screen.getDisplayNearestPoint(cursorPos) const popupWidth = 380 const popupHeight = Math.min(60 + entries.length * 44, 500) let x = cursorPos.x - Math.round(popupWidth / 2) let y = cursorPos.y - popupHeight - 20 x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - popupWidth)) if (y < display.workArea.y) { y = cursorPos.y + 20 } win.setBounds({ x, y, width: popupWidth, height: popupHeight }) win.webContents.send('history:showItems', { entries }) if (!win.isVisible()) { win.showInactive() } win.webContents.send('history:show', {}) } 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()) } // ── CommandPopup 팝업 ───────────────────────────────── function createCommandPopupWindow(): BrowserWindow { const win = new BrowserWindow({ width: 340, height: 300, 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/command-popup/index.html`) } else { win.loadFile(join(__dirname, '../renderer/popups/command-popup/index.html')) } win.webContents.on('did-finish-load', () => { injectPopupTheme(win) }) win.on('closed', () => { commandPopupWindow = null }) return win } export function getCommandPopupWindow(): BrowserWindow { if (!commandPopupWindow || commandPopupWindow.isDestroyed()) { commandPopupWindow = createCommandPopupWindow() } return commandPopupWindow } export function showCommandPopup(commands: Array>, activeId: string | null): void { const win = getCommandPopupWindow() const cursorPos = screen.getCursorScreenPoint() const display = screen.getDisplayNearestPoint(cursorPos) const popupWidth = 340 const popupHeight = Math.min(80 + commands.length * 48, 400) let x = cursorPos.x - Math.round(popupWidth / 2) let y = cursorPos.y - popupHeight - 20 x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - popupWidth)) if (y < display.workArea.y) { y = cursorPos.y + 20 } win.setBounds({ x, y, width: popupWidth, height: popupHeight }) win.webContents.send('command:showItems', { commands, activeId }) if (!win.isVisible()) { win.showInactive() } win.webContents.send('command:show', {}) } export function hideCommandPopup(): void { if (commandPopupWindow && !commandPopupWindow.isDestroyed()) { commandPopupWindow.webContents.send('command:hide', {}) setTimeout(() => { if (commandPopupWindow && !commandPopupWindow.isDestroyed()) { commandPopupWindow.hide() } }, 150) } } export function sendKeyToCommandPopup(key: string): void { if (commandPopupWindow && !commandPopupWindow.isDestroyed() && commandPopupWindow.isVisible()) { commandPopupWindow.webContents.send('command:keyEvent', { key }) } } export function isCommandPopupVisible(): boolean { return !!(commandPopupWindow && !commandPopupWindow.isDestroyed() && commandPopupWindow.isVisible()) } // ── CaptionOverlay 팝업 (Phase 10.1) ───────────────── function createCaptionOverlayWindow(): BrowserWindow { const primaryDisplay = screen.getPrimaryDisplay() const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize const overlayWidth = Math.round(screenWidth * 0.8) const overlayHeight = 120 const win = new BrowserWindow({ width: overlayWidth, height: overlayHeight, x: Math.round((screenWidth - overlayWidth) / 2), y: screenHeight - overlayHeight - 40, 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 } }) // 클릭 통과: 마우스 이벤트를 무시하되, CSS hover 등을 위해 forward 활성화 win.setIgnoreMouseEvents(true, { forward: true }) if (is.dev && process.env['ELECTRON_RENDERER_URL']) { win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/caption-overlay/index.html`) } else { win.loadFile(join(__dirname, '../renderer/popups/caption-overlay/index.html')) } win.webContents.on('did-finish-load', () => { injectPopupTheme(win) }) win.on('closed', () => { captionOverlayWindow = null }) return win } export function getCaptionOverlayWindow(): BrowserWindow { if (!captionOverlayWindow || captionOverlayWindow.isDestroyed()) { captionOverlayWindow = createCaptionOverlayWindow() logger.info('CaptionOverlay window created') } return captionOverlayWindow } export function showCaptionOverlay(): void { const win = getCaptionOverlayWindow() if (!win.isVisible()) { win.showInactive() } } export function hideCaptionOverlay(): void { if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) { captionOverlayWindow.webContents.send('caption:hide', {}) captionOverlayWindow.hide() } } export function sendToCaptionOverlay(channel: string, data: unknown): void { if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) { captionOverlayWindow.webContents.send(channel, data) } } // ── 프리로딩 ────────────────────────────────────────── export function preloadPopupWindows(): void { getRecordingTipWindow() getResultPopupWindow() getHistoryPopupWindow() getCommandPopupWindow() logger.info('Popup windows preloaded') } // ── 테마 재주입 (설정에서 테마 변경 시 호출) ────────── /** * 현재 살아있는 팝업 윈도우에 테마 CSS를 재주입. * config SET_THEME 핸들러에서 호출한다. */ export function reapplyThemeToAllPopups(): void { const popupWindows = [ recordingTipWindow, resultPopupWindow, historyPopupWindow, commandPopupWindow, captionOverlayWindow, ] for (const win of popupWindows) { if (win && !win.isDestroyed()) { injectPopupTheme(win) } } } // ── clipboard:copy IPC (ResultPopup에서 사용) ───────── ipcMain.on('clipboard:copy', (_event, text: string) => { const { clipboard } = require('electron') clipboard.writeText(text) })