fix(desktop): render popup overlays in packaged builds

Popup pages loaded their scripts as classic <script src> tags, which the
renderer build never bundles, so an installed app rendered only the static
markup: the recording tip stayed at 0:00 with no wave bars and live captions
showed nothing.

- declare popup scripts as modules so the build emits them, and fail
  packaging when a renderer page references an asset that was never produced
- hold popup IPC until the renderer has loaded and re-assert visibility on
  every show, so a popup hidden once still appears next time
- surface popup renderer console and load failures in the main log

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
This commit is contained in:
Yun Chan 2026-09-19 08:24:03 +09:00
parent 74cbc8f6ae
commit ae7efb6acf
16 changed files with 317 additions and 52 deletions

View file

@ -62,6 +62,99 @@ function getPopupI18nStrings(): Record<string, string> {
}
}
// ── Popup window lifecycle ────────────────────────────
/** Popup windows whose renderer finished loading and can receive IPC */
const popupReady = new WeakSet<BrowserWindow>()
/** IPC messages held back until the popup renderer is ready */
const pendingPopupMessages = new WeakMap<BrowserWindow, Array<{ channel: string; data: unknown }>>()
/**
* Promote popup renderer console output and load failures into the main log.
* Popups have no renderer logging otherwise, so a missing asset or a script
* exception stays invisible and the popup just renders static markup.
*/
function hookPopupDiagnostics(win: BrowserWindow, name: string): void {
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
logger.info(`[${name}] [Renderer] [${level}] ${message} (${sourceId}:${line})`)
})
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
logger.error(`[${name}] renderer load failed [${errorCode}] ${errorDescription} (${validatedURL})`)
})
win.webContents.on('render-process-gone', (_event, details) => {
logger.error(`[${name}] renderer process gone: ${details.reason} (exitCode=${details.exitCode})`)
})
}
/**
* Deliver IPC to a popup even while its renderer is still loading.
* webContents.send before load is silently dropped, which leaves popups stuck
* on their initial markup (for example a frozen 0:00 timer).
*/
function sendToPopupWindow(win: BrowserWindow, channel: string, data: unknown): void {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
if (popupReady.has(win)) {
win.webContents.send(channel, data)
return
}
const queue = pendingPopupMessages.get(win) ?? []
queue.push({ channel, data })
if (queue.length > 32) {
queue.splice(0, queue.length - 32)
}
pendingPopupMessages.set(win, queue)
}
function flushPopupMessages(win: BrowserWindow): void {
popupReady.add(win)
const queue = pendingPopupMessages.get(win)
if (!queue || queue.length === 0) return
pendingPopupMessages.delete(win)
for (const message of queue) {
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
win.webContents.send(message.channel, message.data)
}
}
}
/** Shared popup lifecycle: diagnostics + load tracking + theme injection */
function attachPopupLifecycle(win: BrowserWindow, name: string): void {
hookPopupDiagnostics(win, name)
win.webContents.on('did-start-loading', () => {
popupReady.delete(win)
})
win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
flushPopupMessages(win)
})
}
/**
* Show a popup without stealing focus, reliably on every call.
* After hide(), showInactive() can lose z-order and repaint, so popups stopped
* appearing from the second show onward: re-assert topmost and force a repaint.
*/
function presentPopup(win: BrowserWindow, level?: 'floating' | 'screen-saver'): void {
if (!win.isVisible()) {
win.showInactive()
}
// Z-order/repaint adjustments must never break a capture session.
try {
win.setAlwaysOnTop(true, level)
win.moveTop()
win.webContents.invalidate()
} catch (err) {
logger.warn(`Popup present adjustment failed: ${err instanceof Error ? err.message : String(err)}`)
}
}
// ── 윈도우 참조 ───────────────────────────────────────
let mainWindow: BrowserWindow | null = null
@ -199,9 +292,7 @@ function createRecordingTipWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/recording-tip/index.html'))
}
win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
})
attachPopupLifecycle(win, 'recording-tip')
win.on('closed', () => {
recordingTipWindow = null
@ -253,10 +344,9 @@ export function showRecordingTip(
win.setBounds({ x, y, width: TIP_WIDTH, height: TIP_HEIGHT })
// 상태 전송 후 즉시 show (2-phase 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지)
win.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
if (!win.isVisible()) {
win.showInactive()
}
sendToPopupWindow(win, IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
presentPopup(win, 'screen-saver')
logger.debug(`RecordingTip presented: state=${state} visible=${win.isVisible()} loading=${win.webContents.isLoading()}`)
}
export function hideRecordingTip(): void {
@ -270,20 +360,20 @@ export function updateRecordingTipState(
params?: { text?: string; errorMessage?: string }
): void {
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
recordingTipWindow.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
sendToPopupWindow(recordingTipWindow, IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
}
}
export function sendAudioLevelToTip(level: number): void {
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
recordingTipWindow.webContents.send(IPC_CHANNELS.VOICE.AUDIO_LEVEL, { level })
sendToPopupWindow(recordingTipWindow, IPC_CHANNELS.VOICE.AUDIO_LEVEL, { level })
}
}
/** 실시간 부분 전사 텍스트를 RecordingTip에 전달 */
export function sendPartialTranscriptToTip(text: string): void {
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
recordingTipWindow.webContents.send(IPC_CHANNELS.VOICE_PARTIAL.PARTIAL_TRANSCRIPT, { text })
sendToPopupWindow(recordingTipWindow, IPC_CHANNELS.VOICE_PARTIAL.PARTIAL_TRANSCRIPT, { text })
}
}
@ -314,9 +404,7 @@ function createResultPopupWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/result-popup/index.html'))
}
win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
})
attachPopupLifecycle(win, 'result-popup')
win.on('closed', () => {
resultPopupWindow = null
@ -340,7 +428,7 @@ export function showResultPopup(text: string, autoHideMs = 5000): void {
const win = getResultPopupWindow()
// Phase 1: prepare
win.webContents.send(IPC_CHANNELS.POPUP_RESULT.PREPARE, { text, _i18n: getPopupI18nStrings() })
sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.PREPARE, { text, _i18n: getPopupI18nStrings() })
ipcMain.once(IPC_CHANNELS.POPUP_RESULT.MEASURED, (_event, data: { width: number; height: number }) => {
const cursorPos = screen.getCursorScreenPoint()
@ -359,12 +447,10 @@ export function showResultPopup(text: string, autoHideMs = 5000): void {
win.setBounds({ x, y, width: data.width, height: data.height })
if (!win.isVisible()) {
win.showInactive()
}
presentPopup(win)
// Phase 2: show
win.webContents.send(IPC_CHANNELS.POPUP_RESULT.SHOW, { autoHideMs })
sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.SHOW, { autoHideMs })
})
}
@ -401,9 +487,7 @@ function createHistoryPopupWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/history-popup/index.html'))
}
win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
})
attachPopupLifecycle(win, 'history-popup')
win.on('closed', () => {
historyPopupWindow = null
@ -440,18 +524,16 @@ export function showHistoryPopup(entries: Array<Record<string, unknown>>): void
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
win.webContents.send(IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() })
sendToPopupWindow(win, IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() })
if (!win.isVisible()) {
win.showInactive()
}
presentPopup(win)
win.webContents.send(IPC_CHANNELS.POPUP_HISTORY.SHOW, {})
sendToPopupWindow(win, IPC_CHANNELS.POPUP_HISTORY.SHOW, {})
}
export function hideHistoryPopup(): void {
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
historyPopupWindow.webContents.send(IPC_CHANNELS.POPUP_HISTORY.HIDE, {})
sendToPopupWindow(historyPopupWindow, IPC_CHANNELS.POPUP_HISTORY.HIDE, {})
setTimeout(() => {
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
historyPopupWindow.hide()
@ -462,7 +544,7 @@ export function hideHistoryPopup(): void {
export function sendKeyToHistoryPopup(key: string): void {
if (historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible()) {
historyPopupWindow.webContents.send(IPC_CHANNELS.POPUP_HISTORY.KEY_EVENT, { key })
sendToPopupWindow(historyPopupWindow, IPC_CHANNELS.POPUP_HISTORY.KEY_EVENT, { key })
}
}
@ -497,9 +579,7 @@ function createCommandPopupWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/command-popup/index.html'))
}
win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
})
attachPopupLifecycle(win, 'command-popup')
win.on('closed', () => { commandPopupWindow = null })
return win
@ -527,15 +607,15 @@ export function showCommandPopup(commands: Array<Record<string, unknown>>, activ
if (y < display.workArea.y) { y = cursorPos.y + 20 }
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
win.webContents.send(IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() })
sendToPopupWindow(win, IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() })
if (!win.isVisible()) { win.showInactive() }
win.webContents.send(IPC_CHANNELS.POPUP_COMMAND.SHOW, {})
presentPopup(win)
sendToPopupWindow(win, IPC_CHANNELS.POPUP_COMMAND.SHOW, {})
}
export function hideCommandPopup(): void {
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
commandPopupWindow.webContents.send(IPC_CHANNELS.POPUP_COMMAND.HIDE, {})
sendToPopupWindow(commandPopupWindow, IPC_CHANNELS.POPUP_COMMAND.HIDE, {})
setTimeout(() => {
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
commandPopupWindow.hide()
@ -546,7 +626,7 @@ export function hideCommandPopup(): void {
export function sendKeyToCommandPopup(key: string): void {
if (commandPopupWindow && !commandPopupWindow.isDestroyed() && commandPopupWindow.isVisible()) {
commandPopupWindow.webContents.send(IPC_CHANNELS.POPUP_COMMAND.KEY_EVENT, { key })
sendToPopupWindow(commandPopupWindow, IPC_CHANNELS.POPUP_COMMAND.KEY_EVENT, { key })
}
}
@ -591,9 +671,7 @@ function createCaptionOverlayWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/caption-overlay/index.html'))
}
win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
})
attachPopupLifecycle(win, 'caption-overlay')
win.on('closed', () => {
captionOverlayWindow = null
@ -612,21 +690,19 @@ export function getCaptionOverlayWindow(): BrowserWindow {
export function showCaptionOverlay(): void {
const win = getCaptionOverlayWindow()
if (!win.isVisible()) {
win.showInactive()
}
presentPopup(win)
}
export function hideCaptionOverlay(): void {
if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) {
captionOverlayWindow.webContents.send(IPC_CHANNELS.POPUP_CAPTION.HIDE, {})
sendToPopupWindow(captionOverlayWindow, IPC_CHANNELS.POPUP_CAPTION.HIDE, {})
captionOverlayWindow.hide()
}
}
export function sendToCaptionOverlay(channel: string, data: unknown): void {
if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) {
captionOverlayWindow.webContents.send(channel, data)
sendToPopupWindow(captionOverlayWindow, channel, data)
}
}

View file

@ -12,6 +12,7 @@
<div id="lines"></div>
</div>
</div>
<script src="./script.js"></script>
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다(누락 시 자막이 렌더되지 않는다). -->
<script type="module" src="./script.js"></script>
</body>
</html>

View file

@ -19,6 +19,7 @@
</div>
</div>
</div>
<script src="./script.js"></script>
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
<script type="module" src="./script.js"></script>
</body>
</html>

View file

@ -17,6 +17,7 @@
</div>
</div>
</div>
<script src="./script.js"></script>
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
<script type="module" src="./script.js"></script>
</body>
</html>

View file

@ -34,6 +34,7 @@
</div>
</div>
</div>
<script src="./script.js"></script>
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
<script type="module" src="./script.js"></script>
</body>
</html>

View file

@ -23,6 +23,7 @@
</div>
</div>
</div>
<script src="./script.js"></script>
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
<script type="module" src="./script.js"></script>
</body>
</html>