diff --git a/.forgejo/workflows/portable.yml b/.forgejo/workflows/portable.yml index 4472b63..205659b 100644 --- a/.forgejo/workflows/portable.yml +++ b/.forgejo/workflows/portable.yml @@ -51,6 +51,9 @@ jobs: - name: 데스크톱 번들 빌드 run: npm run build --workspace=@d3ro/desktop + - name: 데스크톱 렌더러 번들 검증 + run: node scripts/ci/verify-desktop-renderer-bundles.mjs + - name: 휴대용 ZIP + Scoop 매니페스트 생성 run: node scripts/ci/build-portable.mjs diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 3159540..d42bffd 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -62,6 +62,7 @@ jobs: throw "로컬 개발 인증서는 production 서명 identity가 아닙니다." } npm run build --workspace=@d3ro/desktop + node scripts/ci/verify-desktop-renderer-bundles.mjs Push-Location apps/desktop npx electron-builder --win --x64 --config electron-builder.yml --publish never node scripts/ci/verify-native-abi.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0694180..0db2b41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,6 +169,10 @@ jobs: - name: Build Target Workspace run: ${{ matrix.cmd }} + - name: Verify Desktop Renderer Bundles + if: matrix.target == 'desktop' + run: node scripts/ci/verify-desktop-renderer-bundles.mjs + # ────────────────────────────────────────────────────────────────── # 4. Android x86_64 artifacts and native dependency gate # ────────────────────────────────────────────────────────────────── diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e5c634..75e3d44 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,6 +109,9 @@ jobs: npm run typecheck npm run build --workspace=@d3ro/desktop + - name: Verify Desktop Renderer Bundles + run: node scripts/ci/verify-desktop-renderer-bundles.mjs + - name: Build STT Sidecar (local transcription engine) run: | # Local transcription depends on the faster-whisper sidecar; a release @@ -189,6 +192,9 @@ jobs: npm run typecheck npm run build --workspace=@d3ro/desktop + - name: Verify Desktop Renderer Bundles + run: node scripts/ci/verify-desktop-renderer-bundles.mjs + - name: Build STT Sidecar (local transcription engine) run: | # Local transcription depends on the faster-whisper sidecar; a release diff --git a/apps/desktop/src/main/windows/WindowManager.ts b/apps/desktop/src/main/windows/WindowManager.ts index 6f39eea..2d0d6f8 100644 --- a/apps/desktop/src/main/windows/WindowManager.ts +++ b/apps/desktop/src/main/windows/WindowManager.ts @@ -62,6 +62,99 @@ function getPopupI18nStrings(): Record { } } +// ── Popup window lifecycle ──────────────────────────── + +/** Popup windows whose renderer finished loading and can receive IPC */ +const popupReady = new WeakSet() +/** IPC messages held back until the popup renderer is ready */ +const pendingPopupMessages = new WeakMap>() + +/** + * 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>): 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>, 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) } } diff --git a/apps/desktop/src/renderer/popups/caption-overlay/index.html b/apps/desktop/src/renderer/popups/caption-overlay/index.html index b9b33f8..520866a 100644 --- a/apps/desktop/src/renderer/popups/caption-overlay/index.html +++ b/apps/desktop/src/renderer/popups/caption-overlay/index.html @@ -12,6 +12,7 @@
- + + diff --git a/apps/desktop/src/renderer/popups/command-popup/index.html b/apps/desktop/src/renderer/popups/command-popup/index.html index 664087f..996a62f 100644 --- a/apps/desktop/src/renderer/popups/command-popup/index.html +++ b/apps/desktop/src/renderer/popups/command-popup/index.html @@ -19,6 +19,7 @@ - + + diff --git a/apps/desktop/src/renderer/popups/history-popup/index.html b/apps/desktop/src/renderer/popups/history-popup/index.html index 780a1fb..0a461e5 100644 --- a/apps/desktop/src/renderer/popups/history-popup/index.html +++ b/apps/desktop/src/renderer/popups/history-popup/index.html @@ -17,6 +17,7 @@ - + + diff --git a/apps/desktop/src/renderer/popups/recording-tip/index.html b/apps/desktop/src/renderer/popups/recording-tip/index.html index 58ea4ba..623064c 100644 --- a/apps/desktop/src/renderer/popups/recording-tip/index.html +++ b/apps/desktop/src/renderer/popups/recording-tip/index.html @@ -34,6 +34,7 @@ - + + diff --git a/apps/desktop/src/renderer/popups/result-popup/index.html b/apps/desktop/src/renderer/popups/result-popup/index.html index 552fe47..6457d33 100644 --- a/apps/desktop/src/renderer/popups/result-popup/index.html +++ b/apps/desktop/src/renderer/popups/result-popup/index.html @@ -23,6 +23,7 @@ - + + diff --git a/docs/map/00-index.md b/docs/map/00-index.md index 6599721..3cff6b7 100644 --- a/docs/map/00-index.md +++ b/docs/map/00-index.md @@ -2,6 +2,7 @@ > Status: ACTIVE > Last full audit: 2026-09-13 +> Last update: 2026-09-19 — GAP-INFRA-05 (desktop renderer popup bundle verification wired into CI) > Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.3.6` > Purpose: let any agent (or human) answer two questions in under a minute: > 1. **What infrastructure exists?** (build, CI, services, APIs, data, packages, deploy) diff --git a/docs/map/02-infrastructure.md b/docs/map/02-infrastructure.md index d8233ab..fabfa53 100644 --- a/docs/map/02-infrastructure.md +++ b/docs/map/02-infrastructure.md @@ -78,6 +78,7 @@ npm run release:metadata[:test] npm run release:forgejo[:check] # canonical Forgejo publisher/feed npm run release:tag # annotated/signed immutable release tag npm run security:secrets[:test] # hardcoded-secret scanner +npm run check:desktop-renderer[:test] # built renderer pages reference only assets on disk npm run test:e2e:red # content-report red e2e npm run release:mobile:boundary[:test] npm run release:mobile:config[:test] @@ -136,7 +137,7 @@ See [`03-shared-packages.md`](./03-shared-packages.md). Summary: ### GitLab CI (`.gitlab-ci.yml`) -Stages `validate → test → build → e2e → package → publish → deploy`. Primary pipeline for desktop Windows/macOS releases (Forgejo Generic Registry is the canonical updater feed; GitLab project 1172 is a legacy mirror) and production mobile releases (`mobile-production-release`, manual/protected). Admin NAS deploy job is intentionally **disabled**. `package-windows`/`package-macos` build the faster-whisper sidecar (`sidecar:setup` → `sidecar:build`) and run `scripts/ci/verify-sidecar-bundle.mjs` before electron-builder, so a release can never ship without the local STT engine. +Stages `validate → test → build → e2e → package → publish → deploy`. Primary pipeline for desktop Windows/macOS releases (Forgejo Generic Registry is the canonical updater feed; GitLab project 1172 is a legacy mirror) and production mobile releases (`mobile-production-release`, manual/protected). Admin NAS deploy job is intentionally **disabled**. `package-windows`/`package-macos` build the faster-whisper sidecar (`sidecar:setup` → `sidecar:build`) and run `scripts/ci/verify-sidecar-bundle.mjs` before electron-builder, so a release can never ship without the local STT engine. Every pipeline that runs `npm run build --workspace=@d3ro/desktop` (`.forgejo` release/portable, `.github` CI/release) then runs `scripts/ci/verify-desktop-renderer-bundles.mjs`, which fails packaging when a renderer page references an asset the build did not emit (GAP-INFRA-05). ### Forgejo Actions (`.forgejo/workflows/`) `portable.yml` — 태그/수동 실행으로 **서명 없이** portable 채널(95MiB 7z 분할 볼륨 + Scoop 매니페스트 + 설치 스크립트)을 게시한다. `WIN_CSC_*` 불필요, updater feed는 건드리지 않는다. diff --git a/docs/map/04-desktop-app.md b/docs/map/04-desktop-app.md index dd6e5df..317ae42 100644 --- a/docs/map/04-desktop-app.md +++ b/docs/map/04-desktop-app.md @@ -146,6 +146,12 @@ Preload exposes **`window.electronAPI`** with 33 namespaces: `platform, audio, c `windows/WindowManager.ts` creates 6 windows: main (borderless, custom TitleBar; macOS `hiddenInset`), recording-tip, result-popup, history-popup, command-popup, caption-overlay. Injects popup theme CSS + i18n strings; 2-phase resize. `windows/TrayManager.ts` — tray icon + menu + double-click show. +**Popup invariants** (each shipped broken once — do not regress): + +- 팝업 HTML의 스크립트는 반드시 `') + const goodProblems = collectProblems(goodRoot, pages) + if (goodProblems.length !== 0) { + failures.push(`complete build reported problems: ${goodProblems.join('; ')}`) + } + + const brokenRoot = path.join(tmpRoot, 'broken') + bundlePage(brokenRoot, '') + const brokenProblems = collectProblems(brokenRoot, pages) + if (!brokenProblems.some((problem) => problem.includes('missing asset'))) { + failures.push('missing asset was not detected') + } + if (!brokenProblems.some((problem) => problem.includes('classic script'))) { + failures.push('classic script tag was not detected') + } + + const missingPageProblems = collectProblems(goodRoot, ['popups/absent/index.html']) + if (!missingPageProblems.some((problem) => problem.startsWith('missing built page'))) { + failures.push('missing page was not detected') + } + } finally { + rmSync(tmpRoot, { recursive: true, force: true }) + } + + if (failures.length > 0) { + console.error('verify-desktop-renderer-bundles self-test failed:') + for (const failure of failures) console.error(`- ${failure}`) + process.exit(1) + } + console.log('verify-desktop-renderer-bundles self-test: OK') +} + +if (process.argv.slice(2).includes('--self-test')) { + selfTest() +} else if (!existsSync(builtRendererDir)) { + console.error(`Renderer build not found: ${builtRendererDir}`) + console.error(' build: npm run build --workspace=@d3ro/desktop') + process.exit(1) +} else { + const pages = expectedPages() + const problems = collectProblems(builtRendererDir, pages) + + if (problems.length > 0) { + console.error('Desktop renderer bundle verification failed:') + for (const problem of problems) console.error(`- ${problem}`) + console.error(' rebuild: npm run build --workspace=@d3ro/desktop') + process.exit(1) + } + + console.log(`Desktop renderer bundle verification passed: ${pages.length} page(s) with all assets on disk`) +} \ No newline at end of file