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
|
|
@ -51,6 +51,9 @@ jobs:
|
||||||
- name: 데스크톱 번들 빌드
|
- name: 데스크톱 번들 빌드
|
||||||
run: npm run build --workspace=@d3ro/desktop
|
run: npm run build --workspace=@d3ro/desktop
|
||||||
|
|
||||||
|
- name: 데스크톱 렌더러 번들 검증
|
||||||
|
run: node scripts/ci/verify-desktop-renderer-bundles.mjs
|
||||||
|
|
||||||
- name: 휴대용 ZIP + Scoop 매니페스트 생성
|
- name: 휴대용 ZIP + Scoop 매니페스트 생성
|
||||||
run: node scripts/ci/build-portable.mjs
|
run: node scripts/ci/build-portable.mjs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ jobs:
|
||||||
throw "로컬 개발 인증서는 production 서명 identity가 아닙니다."
|
throw "로컬 개발 인증서는 production 서명 identity가 아닙니다."
|
||||||
}
|
}
|
||||||
npm run build --workspace=@d3ro/desktop
|
npm run build --workspace=@d3ro/desktop
|
||||||
|
node scripts/ci/verify-desktop-renderer-bundles.mjs
|
||||||
Push-Location apps/desktop
|
Push-Location apps/desktop
|
||||||
npx electron-builder --win --x64 --config electron-builder.yml --publish never
|
npx electron-builder --win --x64 --config electron-builder.yml --publish never
|
||||||
node scripts/ci/verify-native-abi.mjs
|
node scripts/ci/verify-native-abi.mjs
|
||||||
|
|
|
||||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -169,6 +169,10 @@ jobs:
|
||||||
- name: Build Target Workspace
|
- name: Build Target Workspace
|
||||||
run: ${{ matrix.cmd }}
|
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
|
# 4. Android x86_64 artifacts and native dependency gate
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -109,6 +109,9 @@ jobs:
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run build --workspace=@d3ro/desktop
|
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)
|
- name: Build STT Sidecar (local transcription engine)
|
||||||
run: |
|
run: |
|
||||||
# Local transcription depends on the faster-whisper sidecar; a release
|
# Local transcription depends on the faster-whisper sidecar; a release
|
||||||
|
|
@ -189,6 +192,9 @@ jobs:
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run build --workspace=@d3ro/desktop
|
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)
|
- name: Build STT Sidecar (local transcription engine)
|
||||||
run: |
|
run: |
|
||||||
# Local transcription depends on the faster-whisper sidecar; a release
|
# Local transcription depends on the faster-whisper sidecar; a release
|
||||||
|
|
|
||||||
|
|
@ -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
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
@ -199,9 +292,7 @@ function createRecordingTipWindow(): BrowserWindow {
|
||||||
win.loadFile(join(__dirname, '../renderer/popups/recording-tip/index.html'))
|
win.loadFile(join(__dirname, '../renderer/popups/recording-tip/index.html'))
|
||||||
}
|
}
|
||||||
|
|
||||||
win.webContents.on('did-finish-load', () => {
|
attachPopupLifecycle(win, 'recording-tip')
|
||||||
injectPopupTheme(win)
|
|
||||||
})
|
|
||||||
|
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
recordingTipWindow = null
|
recordingTipWindow = null
|
||||||
|
|
@ -253,10 +344,9 @@ export function showRecordingTip(
|
||||||
win.setBounds({ x, y, width: TIP_WIDTH, height: TIP_HEIGHT })
|
win.setBounds({ x, y, width: TIP_WIDTH, height: TIP_HEIGHT })
|
||||||
|
|
||||||
// 상태 전송 후 즉시 show (2-phase 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지)
|
// 상태 전송 후 즉시 show (2-phase 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지)
|
||||||
win.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
|
sendToPopupWindow(win, IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
|
||||||
if (!win.isVisible()) {
|
presentPopup(win, 'screen-saver')
|
||||||
win.showInactive()
|
logger.debug(`RecordingTip presented: state=${state} visible=${win.isVisible()} loading=${win.webContents.isLoading()}`)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hideRecordingTip(): void {
|
export function hideRecordingTip(): void {
|
||||||
|
|
@ -270,20 +360,20 @@ export function updateRecordingTipState(
|
||||||
params?: { text?: string; errorMessage?: string }
|
params?: { text?: string; errorMessage?: string }
|
||||||
): void {
|
): void {
|
||||||
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
|
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 {
|
export function sendAudioLevelToTip(level: number): void {
|
||||||
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
|
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
|
||||||
recordingTipWindow.webContents.send(IPC_CHANNELS.VOICE.AUDIO_LEVEL, { level })
|
sendToPopupWindow(recordingTipWindow, IPC_CHANNELS.VOICE.AUDIO_LEVEL, { level })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 실시간 부분 전사 텍스트를 RecordingTip에 전달 */
|
/** 실시간 부분 전사 텍스트를 RecordingTip에 전달 */
|
||||||
export function sendPartialTranscriptToTip(text: string): void {
|
export function sendPartialTranscriptToTip(text: string): void {
|
||||||
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
|
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.loadFile(join(__dirname, '../renderer/popups/result-popup/index.html'))
|
||||||
}
|
}
|
||||||
|
|
||||||
win.webContents.on('did-finish-load', () => {
|
attachPopupLifecycle(win, 'result-popup')
|
||||||
injectPopupTheme(win)
|
|
||||||
})
|
|
||||||
|
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
resultPopupWindow = null
|
resultPopupWindow = null
|
||||||
|
|
@ -340,7 +428,7 @@ export function showResultPopup(text: string, autoHideMs = 5000): void {
|
||||||
const win = getResultPopupWindow()
|
const win = getResultPopupWindow()
|
||||||
|
|
||||||
// Phase 1: prepare
|
// 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 }) => {
|
ipcMain.once(IPC_CHANNELS.POPUP_RESULT.MEASURED, (_event, data: { width: number; height: number }) => {
|
||||||
const cursorPos = screen.getCursorScreenPoint()
|
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 })
|
win.setBounds({ x, y, width: data.width, height: data.height })
|
||||||
|
|
||||||
if (!win.isVisible()) {
|
presentPopup(win)
|
||||||
win.showInactive()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: show
|
// 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.loadFile(join(__dirname, '../renderer/popups/history-popup/index.html'))
|
||||||
}
|
}
|
||||||
|
|
||||||
win.webContents.on('did-finish-load', () => {
|
attachPopupLifecycle(win, 'history-popup')
|
||||||
injectPopupTheme(win)
|
|
||||||
})
|
|
||||||
|
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
historyPopupWindow = null
|
historyPopupWindow = null
|
||||||
|
|
@ -440,18 +524,16 @@ export function showHistoryPopup(entries: Array<Record<string, unknown>>): void
|
||||||
|
|
||||||
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
|
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()) {
|
presentPopup(win)
|
||||||
win.showInactive()
|
|
||||||
}
|
|
||||||
|
|
||||||
win.webContents.send(IPC_CHANNELS.POPUP_HISTORY.SHOW, {})
|
sendToPopupWindow(win, IPC_CHANNELS.POPUP_HISTORY.SHOW, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hideHistoryPopup(): void {
|
export function hideHistoryPopup(): void {
|
||||||
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
|
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
|
||||||
historyPopupWindow.webContents.send(IPC_CHANNELS.POPUP_HISTORY.HIDE, {})
|
sendToPopupWindow(historyPopupWindow, IPC_CHANNELS.POPUP_HISTORY.HIDE, {})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
|
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
|
||||||
historyPopupWindow.hide()
|
historyPopupWindow.hide()
|
||||||
|
|
@ -462,7 +544,7 @@ export function hideHistoryPopup(): void {
|
||||||
|
|
||||||
export function sendKeyToHistoryPopup(key: string): void {
|
export function sendKeyToHistoryPopup(key: string): void {
|
||||||
if (historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible()) {
|
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.loadFile(join(__dirname, '../renderer/popups/command-popup/index.html'))
|
||||||
}
|
}
|
||||||
|
|
||||||
win.webContents.on('did-finish-load', () => {
|
attachPopupLifecycle(win, 'command-popup')
|
||||||
injectPopupTheme(win)
|
|
||||||
})
|
|
||||||
|
|
||||||
win.on('closed', () => { commandPopupWindow = null })
|
win.on('closed', () => { commandPopupWindow = null })
|
||||||
return win
|
return win
|
||||||
|
|
@ -527,15 +607,15 @@ export function showCommandPopup(commands: Array<Record<string, unknown>>, activ
|
||||||
if (y < display.workArea.y) { y = cursorPos.y + 20 }
|
if (y < display.workArea.y) { y = cursorPos.y + 20 }
|
||||||
|
|
||||||
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
|
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() }
|
presentPopup(win)
|
||||||
win.webContents.send(IPC_CHANNELS.POPUP_COMMAND.SHOW, {})
|
sendToPopupWindow(win, IPC_CHANNELS.POPUP_COMMAND.SHOW, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hideCommandPopup(): void {
|
export function hideCommandPopup(): void {
|
||||||
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
|
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
|
||||||
commandPopupWindow.webContents.send(IPC_CHANNELS.POPUP_COMMAND.HIDE, {})
|
sendToPopupWindow(commandPopupWindow, IPC_CHANNELS.POPUP_COMMAND.HIDE, {})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
|
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
|
||||||
commandPopupWindow.hide()
|
commandPopupWindow.hide()
|
||||||
|
|
@ -546,7 +626,7 @@ export function hideCommandPopup(): void {
|
||||||
|
|
||||||
export function sendKeyToCommandPopup(key: string): void {
|
export function sendKeyToCommandPopup(key: string): void {
|
||||||
if (commandPopupWindow && !commandPopupWindow.isDestroyed() && commandPopupWindow.isVisible()) {
|
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.loadFile(join(__dirname, '../renderer/popups/caption-overlay/index.html'))
|
||||||
}
|
}
|
||||||
|
|
||||||
win.webContents.on('did-finish-load', () => {
|
attachPopupLifecycle(win, 'caption-overlay')
|
||||||
injectPopupTheme(win)
|
|
||||||
})
|
|
||||||
|
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
captionOverlayWindow = null
|
captionOverlayWindow = null
|
||||||
|
|
@ -612,21 +690,19 @@ export function getCaptionOverlayWindow(): BrowserWindow {
|
||||||
|
|
||||||
export function showCaptionOverlay(): void {
|
export function showCaptionOverlay(): void {
|
||||||
const win = getCaptionOverlayWindow()
|
const win = getCaptionOverlayWindow()
|
||||||
if (!win.isVisible()) {
|
presentPopup(win)
|
||||||
win.showInactive()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hideCaptionOverlay(): void {
|
export function hideCaptionOverlay(): void {
|
||||||
if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) {
|
if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) {
|
||||||
captionOverlayWindow.webContents.send(IPC_CHANNELS.POPUP_CAPTION.HIDE, {})
|
sendToPopupWindow(captionOverlayWindow, IPC_CHANNELS.POPUP_CAPTION.HIDE, {})
|
||||||
captionOverlayWindow.hide()
|
captionOverlayWindow.hide()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendToCaptionOverlay(channel: string, data: unknown): void {
|
export function sendToCaptionOverlay(channel: string, data: unknown): void {
|
||||||
if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) {
|
if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) {
|
||||||
captionOverlayWindow.webContents.send(channel, data)
|
sendToPopupWindow(captionOverlayWindow, channel, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
<div id="lines"></div>
|
<div id="lines"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="./script.js"></script>
|
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다(누락 시 자막이 렌더되지 않는다). -->
|
||||||
|
<script type="module" src="./script.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="./script.js"></script>
|
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
|
||||||
|
<script type="module" src="./script.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="./script.js"></script>
|
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
|
||||||
|
<script type="module" src="./script.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="./script.js"></script>
|
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
|
||||||
|
<script type="module" src="./script.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="./script.js"></script>
|
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
|
||||||
|
<script type="module" src="./script.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
> Status: ACTIVE
|
> Status: ACTIVE
|
||||||
> Last full audit: 2026-09-13
|
> 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`
|
> 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:
|
> Purpose: let any agent (or human) answer two questions in under a minute:
|
||||||
> 1. **What infrastructure exists?** (build, CI, services, APIs, data, packages, deploy)
|
> 1. **What infrastructure exists?** (build, CI, services, APIs, data, packages, deploy)
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ npm run release:metadata[:test]
|
||||||
npm run release:forgejo[:check] # canonical Forgejo publisher/feed
|
npm run release:forgejo[:check] # canonical Forgejo publisher/feed
|
||||||
npm run release:tag # annotated/signed immutable release tag
|
npm run release:tag # annotated/signed immutable release tag
|
||||||
npm run security:secrets[:test] # hardcoded-secret scanner
|
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 test:e2e:red # content-report red e2e
|
||||||
npm run release:mobile:boundary[:test]
|
npm run release:mobile:boundary[:test]
|
||||||
npm run release:mobile:config[: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`)
|
### 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/`)
|
### Forgejo Actions (`.forgejo/workflows/`)
|
||||||
`portable.yml` — 태그/수동 실행으로 **서명 없이** portable 채널(95MiB 7z 분할 볼륨 + Scoop 매니페스트 + 설치 스크립트)을 게시한다. `WIN_CSC_*` 불필요, updater feed는 건드리지 않는다.
|
`portable.yml` — 태그/수동 실행으로 **서명 없이** portable 채널(95MiB 7z 분할 볼륨 + Scoop 매니페스트 + 설치 스크립트)을 게시한다. `WIN_CSC_*` 불필요, updater feed는 건드리지 않는다.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
`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의 스크립트는 반드시 `<script type="module">`로 선언한다. Vite는 모듈 스크립트만 번들에 포함하므로 classic `<script src="./script.js">`는 dev에서만 로드되고 패키징 산출물에서는 파일 자체가 사라진다(오버레이가 정적 HTML로 멈춘 원인). `scripts/ci/verify-desktop-renderer-bundles.mjs`가 빌드 HTML이 참조하는 모든 로컬 asset의 존재를 검사한다.
|
||||||
|
- 렌더러 로드 전의 `webContents.send`는 조용히 버려진다. 팝업 전송은 `sendToPopupWindow`를 쓰고, 이 함수가 `did-finish-load`까지 메시지를 보관했다가 전달한다. `attachPopupLifecycle`이 로드 상태 추적·테마 주입·팝업 렌더러 진단 로그를 한 곳에서 묶는다.
|
||||||
|
- 팝업 표시는 `presentPopup`으로 통일한다(`showInactive` + topmost 재선언 + `moveTop` + `webContents.invalidate`). 한 번 `hide()`된 팝업이 두 번째 표시에서 z-order/repaint를 잃어 보이지 않던 문제를 막는다.
|
||||||
|
|
||||||
Vanilla popups (`src/renderer/popups/`):
|
Vanilla popups (`src/renderer/popups/`):
|
||||||
| Popup | Purpose |
|
| Popup | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
|
||||||
| CAP-01 | Push-to-talk dictation (hold/release) | [x] | [-] | [x] | [-] | Desktop `VoiceModeService`; mobile RecordScreen via app CTA/notification action (no global hotkey) |
|
| CAP-01 | Push-to-talk dictation (hold/release) | [x] | [-] | [x] | [-] | Desktop `VoiceModeService`; mobile RecordScreen via app CTA/notification action (no global hotkey) |
|
||||||
| CAP-02 | Hands-free toggle dictation | [x] | [-] | [x] | [-] | Desktop double-press; mobile toggle |
|
| CAP-02 | Hands-free toggle dictation | [x] | [-] | [x] | [-] | Desktop double-press; mobile toggle |
|
||||||
| CAP-03 | Live partial transcript while recording | [x] | [ ] | [ ] | [-] | Desktop `voice:partialTranscript` + recording-tip; producer added in `1.3.0` (`VoiceModeService._runPartial` → `LocalSTTService.transcribePartial`, 1.5 s cadence / 7.5 s window, never inserted). The row was `[x]` before any producer existed. |
|
| CAP-03 | Live partial transcript while recording | [x] | [ ] | [ ] | [-] | Desktop `voice:partialTranscript` + recording-tip; producer added in `1.3.0` (`VoiceModeService._runPartial` → `LocalSTTService.transcribePartial`, 1.5 s cadence / 7.5 s window, never inserted). The row was `[x]` before any producer existed. |
|
||||||
| CAP-04 | Recording waveform + level meter | [x] | [x] | [x] | [-] | Desktop 9-bar cos distribution; mobile audio level |
|
| CAP-04 | Recording waveform + level meter | [x] | [x] | [x] | [-] | Desktop 9-bar cos distribution; mobile audio level; the recording-tip popup bundle and its on-disk assets are verified by `scripts/ci/verify-desktop-renderer-bundles.mjs` |
|
||||||
| CAP-05 | Device/mic selection | [x] | [ ] | [~] | [-] | Desktop config; mobile uses system default |
|
| CAP-05 | Device/mic selection | [x] | [ ] | [~] | [-] | Desktop config; mobile uses system default |
|
||||||
| CAP-06 | System/loopback audio capture | [x] | [-] | [ ] | [-] | Desktop only (caption source); mobile policy-limited |
|
| CAP-06 | System/loopback audio capture | [x] | [-] | [ ] | [-] | Desktop only (caption source); mobile policy-limited |
|
||||||
| CAP-07 | Local Whisper STT | [x] | [-] | [x] | [-] | Desktop ships the faster-whisper sidecar (`resources/sidecar`, built by `sidecar:build`, verified by `scripts/ci/verify-sidecar-bundle.mjs`), warms it up at app start, and connects over IPv4 loopback; mobile on-device Whisper (supported devices) |
|
| CAP-07 | Local Whisper STT | [x] | [-] | [x] | [-] | Desktop ships the faster-whisper sidecar (`resources/sidecar`, built by `sidecar:build`, verified by `scripts/ci/verify-sidecar-bundle.mjs`), warms it up at app start, and connects over IPv4 loopback; mobile on-device Whisper (supported devices) |
|
||||||
|
|
@ -26,7 +26,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
|
||||||
| CAP-10 | STT model download/management UI | [x] | [-] | [~] | [-] | Desktop model manager + onboarding; mobile bundled model |
|
| CAP-10 | STT model download/management UI | [x] | [-] | [~] | [-] | Desktop model manager + onboarding; mobile bundled model |
|
||||||
| CAP-11 | File transcription (audio/video) | [x] | [ ] | [x] | [~] | Desktop ffmpeg chunking; mobile import picker; web deferred |
|
| CAP-11 | File transcription (audio/video) | [x] | [ ] | [x] | [~] | Desktop ffmpeg chunking; mobile import picker; web deferred |
|
||||||
| CAP-12 | Audio import from other apps (share intent) | [-] | [-] | [x] | [-] | Mobile Android `ACTION_SEND`/`ACTION_VIEW` (SSOT R-016 GREEN) |
|
| CAP-12 | Audio import from other apps (share intent) | [-] | [-] | [x] | [-] | Mobile Android `ACTION_SEND`/`ACTION_VIEW` (SSOT R-016 GREEN) |
|
||||||
| CAP-13 | Live captions overlay | [x] | [-] | [-] | [-] | Desktop `CaptionService` + caption-overlay popup |
|
| CAP-13 | Live captions overlay | [x] | [-] | [-] | [-] | Desktop `CaptionService` + caption-overlay popup; popup assets verified in the packaged build (`scripts/ci/verify-desktop-renderer-bundles.mjs`); GAP-INFRA-05 |
|
||||||
| CAP-14 | Recording persistence / crash recovery | [x] | [ ] | [x] | [-] | Desktop WAV persist; mobile durable queue + process-kill WAV recovery |
|
| CAP-14 | Recording persistence / crash recovery | [x] | [ ] | [x] | [-] | Desktop WAV persist; mobile durable queue + process-kill WAV recovery |
|
||||||
| CAP-15 | Android foreground recording service | [-] | [-] | [x] | [-] | Mobile API 34 FGS + persistent notification (SSOT R-005 GREEN) |
|
| CAP-15 | Android foreground recording service | [-] | [-] | [x] | [-] | Mobile API 34 FGS + persistent notification (SSOT R-005 GREEN) |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
|
||||||
| GAP-STT-06 | Local STT | Decode settings were untuned: previous-text conditioning let repeated hallucinations compound, and no VAD parameters meant slow, uneven segments. | `apps/desktop/sidecar/main.py` | `[x]` 2026-09-18: `condition_on_previous_text=false`, bounded low-temperature fallback, `no_speech`/`compression_ratio`/`log_prob` thresholds, 300 ms silence trimming. Same transcript, ~5x faster on the reference machine (7.7 s audio: 1609 ms → 303 ms). |
|
| GAP-STT-06 | Local STT | Decode settings were untuned: previous-text conditioning let repeated hallucinations compound, and no VAD parameters meant slow, uneven segments. | `apps/desktop/sidecar/main.py` | `[x]` 2026-09-18: `condition_on_previous_text=false`, bounded low-temperature fallback, `no_speech`/`compression_ratio`/`log_prob` thresholds, 300 ms silence trimming. Same transcript, ~5x faster on the reference machine (7.7 s audio: 1609 ms → 303 ms). |
|
||||||
| GAP-STT-08 | Local STT | 1.3.5 설치본에서 엔진 설치가 "런타임 아카이브 해시 불일치 (sidecar)"로 항상 실패했다. 부품 검증은 **메모리 스트림**에서 센 값으로, 결합 검증은 **디스크 파일**에서 계산해 기준이 서로 달랐다. 디스크 쓰기가 잘려도 부품 검사를 통과하고 결합 단계에서만 터지므로 원인 파악도 불가능했다. 재시도가 없어 전송이 한 번 끊기면 곧바로 설치 실패였다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts` | `[x]` 2026-09-18: 부품 크기·해시를 디스크 파일 기준으로 통일하고, 결합본은 크기를 먼저 검사한 뒤 해시를 본다(오류 메시지에 실제/기대값 포함). 부품 다운로드는 실패 시 해당 파일을 지우고 최대 3회 재시도한다. 서버 아티팩트는 무결함을 확인했고(부품 2개 해시 일치, 결합본 `e203aa53…` = 인덱스 기대값), 실제 feed로 설치를 재현해 18초 만에 성공. **1.3.6으로 게시 완료** — `latest.yml`이 1.3.6/90.6MiB를 서빙하고 설치본 sha512가 피드 메타데이터와 일치. 설치본 asar에 수정 코드가 포함되고 구버전 `archiveHash` 경로는 제거됨을 확인. |
|
| GAP-STT-08 | Local STT | 1.3.5 설치본에서 엔진 설치가 "런타임 아카이브 해시 불일치 (sidecar)"로 항상 실패했다. 부품 검증은 **메모리 스트림**에서 센 값으로, 결합 검증은 **디스크 파일**에서 계산해 기준이 서로 달랐다. 디스크 쓰기가 잘려도 부품 검사를 통과하고 결합 단계에서만 터지므로 원인 파악도 불가능했다. 재시도가 없어 전송이 한 번 끊기면 곧바로 설치 실패였다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts` | `[x]` 2026-09-18: 부품 크기·해시를 디스크 파일 기준으로 통일하고, 결합본은 크기를 먼저 검사한 뒤 해시를 본다(오류 메시지에 실제/기대값 포함). 부품 다운로드는 실패 시 해당 파일을 지우고 최대 3회 재시도한다. 서버 아티팩트는 무결함을 확인했고(부품 2개 해시 일치, 결합본 `e203aa53…` = 인덱스 기대값), 실제 feed로 설치를 재현해 18초 만에 성공. **1.3.6으로 게시 완료** — `latest.yml`이 1.3.6/90.6MiB를 서빙하고 설치본 sha512가 피드 메타데이터와 일치. 설치본 asar에 수정 코드가 포함되고 구버전 `archiveHash` 경로는 제거됨을 확인. |
|
||||||
|
|
||||||
|
| GAP-INFRA-05 | Build | 패키징된 렌더러 팝업 스크립트가 번들에 없었다. 팝업 HTML이 classic `<script src="./script.js">`를 참조해 Vite가 처리하지 않았고, dev에서는 로드되지만 설치본에는 파일이 없었다. 그래서 녹음 오버레이가 0:00에서 멈추고 웨이브 바가 뜨지 않았으며 실시간 자막이 렌더되지 않았다. 로드 전 `webContents.send`가 조용히 버려지는 문제와 `hide()` 이후 재표시의 z-order/repaint 유실도 함께 있었다. | `apps/desktop/src/renderer/popups/*/index.html`, `apps/desktop/src/main/windows/WindowManager.ts`, `scripts/ci/verify-desktop-renderer-bundles.mjs` | `[x]` 2026-09-19: 팝업 5종을 `type="module"`로 전환해 Vite가 해시된 번들로 방출하도록 고쳤고, 빌드 HTML이 참조하는 모든 로컬 asset이 디스크에 있는지 검사하는 `verify-desktop-renderer-bundles.mjs`(+ self-test)를 `.forgejo`/`.github` 패키징 파이프라인에 연결했다. WindowManager는 렌더러 준비 전 IPC를 `did-finish-load`까지 보관하고, 팝업을 표시할 때마다 topmost 재선언 + 강제 repaint를 수행하며, 팝업 렌더러 콘솔/로드 실패를 main 로그로 승격한다. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Mobile checklist roll-up (from `MOBILE_APP_COMPLETION_SSOT.md` §4)
|
## 2. Mobile checklist roll-up (from `MOBILE_APP_COMPLETION_SSOT.md` §4)
|
||||||
|
|
|
||||||
160
scripts/ci/verify-desktop-renderer-bundles.mjs
Normal file
160
scripts/ci/verify-desktop-renderer-bundles.mjs
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
// scripts/ci/verify-desktop-renderer-bundles.mjs
|
||||||
|
//
|
||||||
|
// Fails packaging when a renderer page ships without the assets it references.
|
||||||
|
//
|
||||||
|
// Why this exists: Vite only bundles <script type="module"> tags. A page that
|
||||||
|
// keeps a classic <script src="./script.js"> points at a file the build never
|
||||||
|
// emits, so the packaged window renders its static markup forever. That is how
|
||||||
|
// the recording overlay froze at 0:00 without wave bars and live captions never
|
||||||
|
// showed up. Comparing built HTML against disk catches the whole class of bug.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node scripts/ci/verify-desktop-renderer-bundles.mjs
|
||||||
|
// node scripts/ci/verify-desktop-renderer-bundles.mjs --self-test
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const repoRoot = path.resolve(scriptDir, '..', '..')
|
||||||
|
const desktopDir = path.join(repoRoot, 'apps', 'desktop')
|
||||||
|
const sourcePopupDir = path.join(desktopDir, 'src', 'renderer', 'popups')
|
||||||
|
const builtRendererDir = path.join(desktopDir, 'out', 'renderer')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages the packaged renderer must contain, relative to the renderer root.
|
||||||
|
* Popup directories are discovered from source so a new popup cannot be added
|
||||||
|
* without also being built.
|
||||||
|
*/
|
||||||
|
function expectedPages() {
|
||||||
|
const pages = ['index.html']
|
||||||
|
if (!existsSync(sourcePopupDir)) return pages
|
||||||
|
|
||||||
|
for (const entry of readdirSync(sourcePopupDir).sort()) {
|
||||||
|
const entryPath = path.join(sourcePopupDir, entry)
|
||||||
|
if (statSync(entryPath).isDirectory() && existsSync(path.join(entryPath, 'index.html'))) {
|
||||||
|
pages.push(path.posix.join('popups', entry, 'index.html'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local script/link references inside a built HTML page. */
|
||||||
|
function localReferences(html) {
|
||||||
|
const refs = []
|
||||||
|
const pattern = /<(?:script|link)\b[^>]*?\b(?:src|href)="([^"]+)"/g
|
||||||
|
let match
|
||||||
|
while ((match = pattern.exec(html)) !== null) {
|
||||||
|
const ref = match[1]
|
||||||
|
if (/^[a-z]+:/i.test(ref) || ref.startsWith('//') || ref.startsWith('#')) continue
|
||||||
|
refs.push(ref.split('?')[0])
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} rendererDir built renderer root
|
||||||
|
* @param {string[]} pages page paths relative to that root
|
||||||
|
* @returns {string[]} problems, empty when the build is complete
|
||||||
|
*/
|
||||||
|
function collectProblems(rendererDir, pages) {
|
||||||
|
const problems = []
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const pagePath = path.join(rendererDir, page)
|
||||||
|
if (!existsSync(pagePath)) {
|
||||||
|
problems.push(`missing built page: ${pagePath}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = readFileSync(pagePath, 'utf8')
|
||||||
|
|
||||||
|
for (const ref of localReferences(html)) {
|
||||||
|
const assetPath = ref.startsWith('/')
|
||||||
|
? path.join(rendererDir, ref.slice(1))
|
||||||
|
: path.resolve(path.dirname(pagePath), ref)
|
||||||
|
if (!existsSync(assetPath)) {
|
||||||
|
problems.push(`${page} references a missing asset: ${ref} -> ${assetPath}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A classic script tag is never emitted by the renderer build.
|
||||||
|
if (/<script\b(?![^>]*\btype="module")[^>]*\bsrc=/.test(html)) {
|
||||||
|
problems.push(`${page} loads a classic script; add type="module" so Vite bundles it`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
function selfTest() {
|
||||||
|
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), 'd3ro-renderer-bundles-'))
|
||||||
|
const failures = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bundlePage = (root, scriptTag) => {
|
||||||
|
mkdirSync(path.join(root, 'assets'), { recursive: true })
|
||||||
|
mkdirSync(path.join(root, 'popups', 'recording-tip'), { recursive: true })
|
||||||
|
writeFileSync(path.join(root, 'assets', 'app.js'), '')
|
||||||
|
writeFileSync(
|
||||||
|
path.join(root, 'popups', 'recording-tip', 'index.html'),
|
||||||
|
`<!DOCTYPE html>\n<html><body>${scriptTag}</body></html>\n`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = ['popups/recording-tip/index.html']
|
||||||
|
|
||||||
|
const goodRoot = path.join(tmpRoot, 'good')
|
||||||
|
bundlePage(goodRoot, '<script type="module" src="../../assets/app.js"></script>')
|
||||||
|
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, '<script src="./script.js"></script>')
|
||||||
|
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`)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue