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:
parent
74cbc8f6ae
commit
ae7efb6acf
16 changed files with 317 additions and 52 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./script.js"></script>
|
||||
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
|
||||
<script type="module" src="./script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./script.js"></script>
|
||||
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
|
||||
<script type="module" src="./script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./script.js"></script>
|
||||
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
|
||||
<script type="module" src="./script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./script.js"></script>
|
||||
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
|
||||
<script type="module" src="./script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue