Compare commits

..

No commits in common. "main" and "v1.3.6" have entirely different histories.
main ... v1.3.6

45 changed files with 109 additions and 466 deletions

View file

@ -51,9 +51,6 @@ 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

View file

@ -62,7 +62,6 @@ 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

View file

@ -169,10 +169,6 @@ 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
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────

View file

@ -109,9 +109,6 @@ 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
@ -192,9 +189,6 @@ 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

View file

@ -13,17 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Cloud-optional backup (encrypted, opt-in) - Cloud-optional backup (encrypted, opt-in)
- Plugin system for custom pipelines - Plugin system for custom pipelines
## [1.3.7] - 2026-09-19
### Fixed
- **Recording and live caption overlays never worked in installed builds.** Popup
pages loaded their scripts as classic `<script src>` tags, which the renderer
build does not bundle, so a packaged app rendered only the static markup: the
recording tip froze at 0:00 with no wave bars and captions showed nothing.
Popup scripts are now module scripts, a packaging check fails when a renderer
page references an asset that was never emitted, and popups hold IPC until
their renderer is ready and re-assert visibility on every show.
## [1.3.6] - 2026-09-18 ## [1.3.6] - 2026-09-18
### Fixed ### Fixed

View file

@ -3,7 +3,7 @@
"info": { "info": {
"title": "D3RO-VOICE Admin API", "title": "D3RO-VOICE Admin API",
"description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.", "description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.",
"version": "1.3.7" "version": "1.3.6"
}, },
"servers": [ "servers": [
{ {

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/admin", "name": "@d3ro/admin",
"version": "1.3.7", "version": "1.3.6",
"private": true, "private": true,
"description": "D3RO Voice Admin CRM — SaaS 관리 도구", "description": "D3RO Voice Admin CRM — SaaS 관리 도구",
"scripts": { "scripts": {

View file

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Version>1.3.7</Version> <Version>1.3.6</Version>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/desktop", "name": "@d3ro/desktop",
"version": "1.3.7", "version": "1.3.6",
"productName": "d3ro-voice", "productName": "d3ro-voice",
"description": "로컬 AI 음성 어시스턴트 (Electron)", "description": "로컬 AI 음성 어시스턴트 (Electron)",
"main": "./out/main/index.js", "main": "./out/main/index.js",

View file

@ -62,99 +62,6 @@ 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
@ -292,7 +199,9 @@ function createRecordingTipWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/recording-tip/index.html')) win.loadFile(join(__dirname, '../renderer/popups/recording-tip/index.html'))
} }
attachPopupLifecycle(win, 'recording-tip') win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
})
win.on('closed', () => { win.on('closed', () => {
recordingTipWindow = null recordingTipWindow = null
@ -344,9 +253,10 @@ 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 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지)
sendToPopupWindow(win, IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params }) win.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
presentPopup(win, 'screen-saver') if (!win.isVisible()) {
logger.debug(`RecordingTip presented: state=${state} visible=${win.isVisible()} loading=${win.webContents.isLoading()}`) win.showInactive()
}
} }
export function hideRecordingTip(): void { export function hideRecordingTip(): void {
@ -360,20 +270,20 @@ export function updateRecordingTipState(
params?: { text?: string; errorMessage?: string } params?: { text?: string; errorMessage?: string }
): void { ): void {
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) { if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
sendToPopupWindow(recordingTipWindow, IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params }) recordingTipWindow.webContents.send(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()) {
sendToPopupWindow(recordingTipWindow, IPC_CHANNELS.VOICE.AUDIO_LEVEL, { level }) recordingTipWindow.webContents.send(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()) {
sendToPopupWindow(recordingTipWindow, IPC_CHANNELS.VOICE_PARTIAL.PARTIAL_TRANSCRIPT, { text }) recordingTipWindow.webContents.send(IPC_CHANNELS.VOICE_PARTIAL.PARTIAL_TRANSCRIPT, { text })
} }
} }
@ -404,7 +314,9 @@ function createResultPopupWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/result-popup/index.html')) win.loadFile(join(__dirname, '../renderer/popups/result-popup/index.html'))
} }
attachPopupLifecycle(win, 'result-popup') win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
})
win.on('closed', () => { win.on('closed', () => {
resultPopupWindow = null resultPopupWindow = null
@ -428,7 +340,7 @@ export function showResultPopup(text: string, autoHideMs = 5000): void {
const win = getResultPopupWindow() const win = getResultPopupWindow()
// Phase 1: prepare // Phase 1: prepare
sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.PREPARE, { text, _i18n: getPopupI18nStrings() }) win.webContents.send(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()
@ -447,10 +359,12 @@ 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 })
presentPopup(win) if (!win.isVisible()) {
win.showInactive()
}
// Phase 2: show // Phase 2: show
sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.SHOW, { autoHideMs }) win.webContents.send(IPC_CHANNELS.POPUP_RESULT.SHOW, { autoHideMs })
}) })
} }
@ -487,7 +401,9 @@ function createHistoryPopupWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/history-popup/index.html')) win.loadFile(join(__dirname, '../renderer/popups/history-popup/index.html'))
} }
attachPopupLifecycle(win, 'history-popup') win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
})
win.on('closed', () => { win.on('closed', () => {
historyPopupWindow = null historyPopupWindow = null
@ -524,16 +440,18 @@ 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 })
sendToPopupWindow(win, IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() }) win.webContents.send(IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() })
presentPopup(win) if (!win.isVisible()) {
win.showInactive()
}
sendToPopupWindow(win, IPC_CHANNELS.POPUP_HISTORY.SHOW, {}) win.webContents.send(IPC_CHANNELS.POPUP_HISTORY.SHOW, {})
} }
export function hideHistoryPopup(): void { export function hideHistoryPopup(): void {
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) { if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
sendToPopupWindow(historyPopupWindow, IPC_CHANNELS.POPUP_HISTORY.HIDE, {}) historyPopupWindow.webContents.send(IPC_CHANNELS.POPUP_HISTORY.HIDE, {})
setTimeout(() => { setTimeout(() => {
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) { if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
historyPopupWindow.hide() historyPopupWindow.hide()
@ -544,7 +462,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()) {
sendToPopupWindow(historyPopupWindow, IPC_CHANNELS.POPUP_HISTORY.KEY_EVENT, { key }) historyPopupWindow.webContents.send(IPC_CHANNELS.POPUP_HISTORY.KEY_EVENT, { key })
} }
} }
@ -579,7 +497,9 @@ function createCommandPopupWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/command-popup/index.html')) win.loadFile(join(__dirname, '../renderer/popups/command-popup/index.html'))
} }
attachPopupLifecycle(win, 'command-popup') win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
})
win.on('closed', () => { commandPopupWindow = null }) win.on('closed', () => { commandPopupWindow = null })
return win return win
@ -607,15 +527,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 })
sendToPopupWindow(win, IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() }) win.webContents.send(IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() })
presentPopup(win) if (!win.isVisible()) { win.showInactive() }
sendToPopupWindow(win, IPC_CHANNELS.POPUP_COMMAND.SHOW, {}) win.webContents.send(IPC_CHANNELS.POPUP_COMMAND.SHOW, {})
} }
export function hideCommandPopup(): void { export function hideCommandPopup(): void {
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) { if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
sendToPopupWindow(commandPopupWindow, IPC_CHANNELS.POPUP_COMMAND.HIDE, {}) commandPopupWindow.webContents.send(IPC_CHANNELS.POPUP_COMMAND.HIDE, {})
setTimeout(() => { setTimeout(() => {
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) { if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
commandPopupWindow.hide() commandPopupWindow.hide()
@ -626,7 +546,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()) {
sendToPopupWindow(commandPopupWindow, IPC_CHANNELS.POPUP_COMMAND.KEY_EVENT, { key }) commandPopupWindow.webContents.send(IPC_CHANNELS.POPUP_COMMAND.KEY_EVENT, { key })
} }
} }
@ -671,7 +591,9 @@ function createCaptionOverlayWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/caption-overlay/index.html')) win.loadFile(join(__dirname, '../renderer/popups/caption-overlay/index.html'))
} }
attachPopupLifecycle(win, 'caption-overlay') win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
})
win.on('closed', () => { win.on('closed', () => {
captionOverlayWindow = null captionOverlayWindow = null
@ -690,19 +612,21 @@ export function getCaptionOverlayWindow(): BrowserWindow {
export function showCaptionOverlay(): void { export function showCaptionOverlay(): void {
const win = getCaptionOverlayWindow() const win = getCaptionOverlayWindow()
presentPopup(win) if (!win.isVisible()) {
win.showInactive()
}
} }
export function hideCaptionOverlay(): void { export function hideCaptionOverlay(): void {
if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) { if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) {
sendToPopupWindow(captionOverlayWindow, IPC_CHANNELS.POPUP_CAPTION.HIDE, {}) captionOverlayWindow.webContents.send(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()) {
sendToPopupWindow(captionOverlayWindow, channel, data) captionOverlayWindow.webContents.send(channel, data)
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -151,8 +151,8 @@ def versionSettingsValid = configuredVersionName != null &&
configuredVersionName ==~ strictSemver && configuredVersionName ==~ strictSemver &&
configuredVersionCodeValue != null && configuredVersionCodeValue != null &&
configuredVersionCodeValue <= 2100000000L configuredVersionCodeValue <= 2100000000L
def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.7" def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.6"
def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1031007 def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1031006
def requiredReleaseSettings = [ def requiredReleaseSettings = [
D3RO_RELEASE_STORE_FILE: releaseStoreFilePath, D3RO_RELEASE_STORE_FILE: releaseStoreFilePath,

View file

@ -257,7 +257,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1031007; CURRENT_PROJECT_VERSION = 1031006;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = D3ROVoice/Info.plist; INFOPLIST_FILE = D3ROVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
@ -265,7 +265,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.7; MARKETING_VERSION = 1.3.6;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@ -287,14 +287,14 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1031007; CURRENT_PROJECT_VERSION = 1031006;
INFOPLIST_FILE = D3ROVoice/Info.plist; INFOPLIST_FILE = D3ROVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.7; MARKETING_VERSION = 1.3.6;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",

View file

@ -1 +0,0 @@
Overlay fixes: the recording waveform/timer and live captions now display correctly.

View file

@ -1 +0,0 @@
오버레이 수정: 녹음 파형/타이머와 실시간 자막이 정상 표시됩니다.

View file

@ -1,12 +1,12 @@
{ {
"name": "@d3ro/mobile-rn", "name": "@d3ro/mobile-rn",
"version": "1.3.7", "version": "1.3.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@d3ro/mobile-rn", "name": "@d3ro/mobile-rn",
"version": "1.3.7", "version": "1.3.6",
"dependencies": { "dependencies": {
"@d3ro/api-client": "file:../../packages/api-client", "@d3ro/api-client": "file:../../packages/api-client",
"@d3ro/core": "file:../../packages/core", "@d3ro/core": "file:../../packages/core",
@ -62,7 +62,7 @@
}, },
"../..": { "../..": {
"name": "d3ro-voice-monorepo", "name": "d3ro-voice-monorepo",
"version": "1.3.7", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"apps/desktop", "apps/desktop",
@ -81,7 +81,7 @@
}, },
"../../packages/api-client": { "../../packages/api-client": {
"name": "@d3ro/api-client", "name": "@d3ro/api-client",
"version": "1.3.7", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@d3ro/core": "*", "@d3ro/core": "*",
@ -98,7 +98,7 @@
}, },
"../../packages/core": { "../../packages/core": {
"name": "@d3ro/core", "name": "@d3ro/core",
"version": "1.3.7", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"docx": "^9.6.1" "docx": "^9.6.1"
@ -109,7 +109,7 @@
}, },
"../../packages/i18n": { "../../packages/i18n": {
"name": "@d3ro/i18n", "name": "@d3ro/i18n",
"version": "1.3.7", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
@ -120,7 +120,7 @@
}, },
"../../packages/ui-native": { "../../packages/ui-native": {
"name": "@d3ro/ui-native", "name": "@d3ro/ui-native",
"version": "1.3.7", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "*" "@types/react": "*"

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/mobile-rn", "name": "@d3ro/mobile-rn",
"version": "1.3.7", "version": "1.3.6",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/web", "name": "@d3ro/web",
"version": "1.3.7", "version": "1.3.6",
"private": true, "private": true,
"description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스", "description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스",
"scripts": { "scripts": {

View file

@ -139,7 +139,7 @@ export function Sidebar(): React.ReactElement {
</PhosphorText> </PhosphorText>
</Box> </Box>
<Box sx={{ fontSize: '10px', fontFamily: 'ui-monospace, monospace', color: d3roPalette.text.muted }}> <Box sx={{ fontSize: '10px', fontFamily: 'ui-monospace, monospace', color: d3roPalette.text.muted }}>
v1.3.7 v1.3.6
</Box> </Box>
</Box> </Box>

View file

@ -4,10 +4,10 @@
// 설치 파일은 canonical Forgejo feed에서만 배포한다. 웹/사이트 빌드 산출물에는 // 설치 파일은 canonical Forgejo feed에서만 배포한다. 웹/사이트 빌드 산출물에는
// 설치 바이너리가 포함되지 않으므로 `/releases/...` 로컬 경로는 배포 환경에서 404다. // 설치 바이너리가 포함되지 않으므로 `/releases/...` 로컬 경로는 배포 환경에서 404다.
export const DESKTOP_VERSION = '1.3.7' export const DESKTOP_VERSION = '1.2.0'
/** 릴리스 게시일. `release/product-version.json`의 releaseDate와 같아야 한다. */ /** 릴리스 게시일. `release/product-version.json`의 releaseDate와 같아야 한다. */
export const DESKTOP_RELEASE_DATE = '2026-09-19' export const DESKTOP_RELEASE_DATE = '2026-09-16'
const FORGEJO_ORIGIN = 'https://git.chanpaca.net' const FORGEJO_ORIGIN = 'https://git.chanpaca.net'
const FORGEJO_OWNER = 'yunchan' const FORGEJO_OWNER = 'yunchan'

View file

@ -2,8 +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); 1.3.7 published to the updater feed > Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.3.0`
> Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.3.7`
> 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)
> 2. **How far is each feature developed?** (per surface, with file anchors and status) > 2. **How far is each feature developed?** (per surface, with file anchors and status)

View file

@ -78,7 +78,6 @@ 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]
@ -137,12 +136,12 @@ 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. 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). 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.
### 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는 건드리지 않는다.
`deploy-site.yml` / `deploy-site-windows.yml` — build `site`, write release identity, deploy to Cloudflare Pages `d3ro` (`d3ro.pages.dev`), verify live commit/version, app-links, legal URLs. 커스텀 도메인 `d3ro.chanpaca.net` 은 Pages 커스텀 도메인이 DNS CNAME을 요구하므로, DNS를 건드릴 수 없는 동안은 Workers 라우트 브리지 `server/cloudflare-site-bridge/`(`d3ro.chanpaca.net/*` → Pages 프록시, 수동 `npx wrangler deploy`)가 서빙한다. CNAME을 추가한 뒤 브리지를 삭제하면 Pages 커스텀 도메인으로 직접 서빙된다(GAP-REL-09b). `deploy-site.yml` / `deploy-site-windows.yml` — build `site`, write release identity, deploy to Cloudflare Pages `d3ro` (`d3ro.chanpaca.net`), verify live commit/version, app-links, legal URLs.
`release.yml` — tag-triggered Windows build (signed) + `publish-forgejo-release.mjs` to the canonical Forgejo feed/release hub. `release.yml` — tag-triggered Windows build (signed) + `publish-forgejo-release.mjs` to the canonical Forgejo feed/release hub.
--- ---
@ -159,7 +158,7 @@ Stages `validate → test → build → e2e → package → publish → deploy`.
Deploy scripts: `scripts/deploy-nas.ps1`, `scripts/deploy-nas.sh`, `scripts/deploy-site-to-nas.js`, `scripts/nas-control.sh` (start/stop/restart/status/logs/backup/update). Deploy scripts: `scripts/deploy-nas.ps1`, `scripts/deploy-nas.sh`, `scripts/deploy-site-to-nas.js`, `scripts/nas-control.sh` (start/stop/restart/status/logs/backup/update).
Public endpoints (production): `https://d3ro.chanpaca.net` **랜딩/다운로드 센터**(2026-09-19부터 Pages `d3ro` 배포본을 Workers 라우트 브리지가 서빙; 그 이전에는 바인딩이 없어 빈 404였다), `https://admin.chanpaca.net` (admin CRM). Edge: `server/cloudflare-worker` proxying to the NAS origin, plus a **Cron Trigger** (`* * * * *`) that drains the Supabase push outbox via `send-push?mode=drain` (`src/push-drain.ts`; needs `SUPABASE_URL` var + `SUPABASE_SERVICE_ROLE_KEY` secret). Tunnel: Cloudflare Tunnel `kd-nas` (NAS 포털/API는 현재 이 호스트네임에 바인딩되어 있지 않다). Public endpoints (production): `https://d3ro.chanpaca.net` (portal/API), `https://admin.chanpaca.net` (admin CRM). Edge: `server/cloudflare-worker` proxying to the NAS origin, plus a **Cron Trigger** (`* * * * *`) that drains the Supabase push outbox via `send-push?mode=drain` (`src/push-drain.ts`; needs `SUPABASE_URL` var + `SUPABASE_SERVICE_ROLE_KEY` secret). Tunnel: Cloudflare Tunnel `kd-nas`.
--- ---
@ -192,14 +191,13 @@ Full detail: [`09-supabase-backend.md`](./09-supabase-backend.md).
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| `release/product-version.json` | version `1.3.7`, `androidVersionCode`/`iosBuildNumber` `1031007`, releaseDate, desktop license keyId | | `release/product-version.json` | version `1.3.0`, `androidVersionCode`/`iosBuildNumber` `1030001`, releaseDate, desktop license keyId |
| `release/android-release-identity.json` | package `com.d3ro.voice`, Play app ID, app-signing SHA-256, upload cert SHA-256, evidence keyId, AdMob unit IDs | | `release/android-release-identity.json` | package `com.d3ro.voice`, Play app ID, app-signing SHA-256, upload cert SHA-256, evidence keyId, AdMob unit IDs |
| `release/desktop-license-public.pem` | Ed25519 public key for desktop offline licenses | | `release/desktop-license-public.pem` | Ed25519 public key for desktop offline licenses |
| `release/mobile-release-evidence-public.pem` | Ed25519 public key for mobile release evidence | | `release/mobile-release-evidence-public.pem` | Ed25519 public key for mobile release evidence |
| `apps/desktop/electron-builder.yml` | appId `com.d3ro.voice`, NSIS x64 (forced code signing), macOS DMG/ZIP arm64, generic Forgejo publish feed, asarUnpack native modules + `@ffmpeg-installer`, extraResources (icons, sounds, sox, **sidecar**, ffmpeg, ollama) | | `apps/desktop/electron-builder.yml` | appId `com.d3ro.voice`, NSIS x64 (forced code signing), macOS DMG/ZIP arm64, generic Forgejo publish feed, asarUnpack native modules + `@ffmpeg-installer`, extraResources (icons, sounds, sox, **sidecar**, ffmpeg, ollama) |
| `apps/desktop/src/main/update-feed.ts` | Auto-update feed SSOT (canonical Forgejo + legacy GitLab mirror, channels) | | `apps/desktop/src/main/update-feed.ts` | Auto-update feed SSOT (canonical Forgejo + legacy GitLab mirror, channels) |
| `release/update-policy.json` | Update policy SSOT (channels, minimum supported version, forced update, delta/full, staged rollout, kill switch) | | `release/update-policy.json` | Update policy SSOT (channels, minimum supported version, forced update, delta/full, staged rollout, kill switch) |
| `apps/web/src/lib/desktop-release.ts`, `site/src/release.ts` | Download-center desktop release contract (installer filename + release date); version and date are kept on the SSOT by `npm run version:sync` (drifted to 1.2.0 once — GAP-REL-08) |
| `apps/desktop/src/main/update-policy.ts` | Policy parsing/decision logic | | `apps/desktop/src/main/update-policy.ts` | Policy parsing/decision logic |
| `scripts/ci/publish-forgejo-release.mjs` | Canonical Forgejo registry + Release + feed publisher | | `scripts/ci/publish-forgejo-release.mjs` | Canonical Forgejo registry + Release + feed publisher |

View file

@ -146,12 +146,6 @@ 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 |
|---|---| |---|---|

View file

@ -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; the recording-tip popup bundle and its on-disk assets are verified by `scripts/ci/verify-desktop-renderer-bundles.mjs` | | CAP-04 | Recording waveform + level meter | [x] | [x] | [x] | [-] | Desktop 9-bar cos distribution; mobile audio level |
| 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; popup assets verified in the packaged build (`scripts/ci/verify-desktop-renderer-bundles.mjs`); GAP-INFRA-05 | | CAP-13 | Live captions overlay | [x] | [-] | [-] | [-] | Desktop `CaptionService` + caption-overlay popup |
| 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) |

View file

@ -21,14 +21,9 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
| ID | Area | Gap | Evidence | Suggested next step | | ID | Area | Gap | Evidence | Suggested next step |
|---|---|---|---|---| |---|---|---|---|---|
| GAP-QA-01 | Quality | Extreme Red Team: headful end-to-end bug hunting across real desktop Electron, Web Next.js, and CI pipelines. | `red_team_log.md`, `tests/e2e/red_team_cycle*.spec.ts`, `apps/web/e2e/red_team_cycle4_web.spec.ts` | `[x]` 2026-09-15: 18 scenarios executed, 14 defects caught and 100% resolved (infinite chunking loop DEF-008, IPC signature mismatch DEF-004, markdown editor typing rollback DEF-006, Web RSC Link serialization DEF-012, secret scanner lookahead DEF-013, etc.). All 18 scenarios GREEN with zero regressions. | | GAP-QA-01 | Quality | Extreme Red Team: headful end-to-end bug hunting across real desktop Electron, Web Next.js, and CI pipelines. | `red_team_log.md`, `tests/e2e/red_team_cycle*.spec.ts`, `apps/web/e2e/red_team_cycle4_web.spec.ts` | `[x]` 2026-09-15: 18 scenarios executed, 14 defects caught and 100% resolved (infinite chunking loop DEF-008, IPC signature mismatch DEF-004, markdown editor typing rollback DEF-006, Web RSC Link serialization DEF-012, secret scanner lookahead DEF-013, etc.). All 18 scenarios GREEN with zero regressions. |
| GAP-REL-01 | Release | Official release publication to Forgejo and active public download center deployment. | `scripts/ci/publish-forgejo-release.mjs`, `apps/web/src/app/download/page.tsx`, `site/src/sections/Download.tsx`, `apps/web/e2e/red_team_cycle4_web.spec.ts` | `[~]` 2026-09-15: v1.1.0 release assets (`D3RO-Voice-Setup-1.1.0-x64.exe`, `.blockmap`, `latest.yml`, `update-policy.json`) published to canonical Forgejo registry and release hub. 2026-09-16: the published 1.1.0 installer carries no Authenticode signature, so it does not satisfy the release policy; product version moved to `1.2.0` and publication must come from CI with the signing gate GREEN. Download centers in `apps/web` (`/download`) and `site` (`#download`) link the canonical Forgejo feed. 2026-09-19: `1.3.7` (overlay fix) is published to the canonical updater feed (`latest.yml` = 1.3.7, 90.6MiB); the CI signing gate that blocks tag-driven publication is still unresolved, so this went out through the local updater path (GAP-REL-06). | | GAP-REL-01 | Release | Official release publication to Forgejo and active public download center deployment. | `scripts/ci/publish-forgejo-release.mjs`, `apps/web/src/app/download/page.tsx`, `site/src/sections/Download.tsx`, `apps/web/e2e/red_team_cycle4_web.spec.ts` | `[~]` 2026-09-15: v1.1.0 release assets (`D3RO-Voice-Setup-1.1.0-x64.exe`, `.blockmap`, `latest.yml`, `update-policy.json`) published to canonical Forgejo registry and release hub. 2026-09-16: the published 1.1.0 installer carries no Authenticode signature, so it does not satisfy the release policy; product version moved to `1.2.0` and publication must come from CI with the signing gate GREEN. Download centers in `apps/web` (`/download`) and `site` (`#download`) link the canonical Forgejo feed. |
| GAP-REL-08 | Release | 다운로드 센터가 **존재하지 않는 설치 파일**을 가리켰다. `apps/web/src/lib/desktop-release.ts``site/src/release.ts``DESKTOP_VERSION``1.2.0`에 멈춰 있어 설치 URL이 `D3RO-Voice-Setup-1.2.0-x64.exe`였고, 그 경로는 피드에서 404다(실측: 1.2.0=404, 1.3.7=206). `version:sync`가 이 두 표면을 덮지 않아 계속 어긋났다. | `scripts/ci/sync-version.mjs`, `apps/web/src/lib/desktop-release.ts`, `site/src/release.ts` | `[x]` 2026-09-19: 두 다운로드 계약 파일을 `sync-version.mjs` 대상에 추가해 버전·릴리스일이 SSOT에서 자동 반영되도록 하고, 현재 값(1.3.7 / 2026-09-19)으로 정정했다. `version:check`·typecheck·site 빌드 GREEN. | | GAP-REL-02 | Release | Windows stable publication needs an external public-trust Authenticode PFX, its password, the exact signer subject, and a Forgejo token, none of which live in the repository. | `.forgejo/workflows/release.yml`, `.gitlab-ci.yml`, `scripts/ci/set-forgejo-secrets.mjs`, `scripts/ci/verify-windows-release-artifact.ps1` | `[!]` 2026-09-18 measured: the Forgejo repo had **zero** Actions secrets; `FORGEJO_TOKEN` is registered now (2026-09-18) but `WIN_CSC_*` still have no values, so `v1.2.0` (run 49) and `v1.3.0` (run 51) both failed at the signing guard and **no updater-feed release has been published since `1.1.0`**. Inject the four secrets (`WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`, `FORGEJO_TOKEN`) with `npm run release:secrets` (check: `npm run release:secrets:check`), then re-run `release.yml` for the `v1.3.0` tag via `workflow_dispatch` (tags are immutable). |
| GAP-REL-09 | Release | 랜딩 사이트가 **재배포되지 않는다**. `deploy` 워크플로가 main push마다 실패한다. 실측 원인(run#66 로그): `site/src/sections/Hero.tsx`가 타이머 ref를 `NodeJS.Timeout`으로 타이핑해 `@types/node` 네임스페이스가 필요했고, 배포 잡은 `npm ci --prefix site`만 하므로 조상 `node_modules`의 hoisted 타입이 없어 `tsc -b``TS2503: Cannot find namespace 'NodeJS'`로 실패한다. 그래서 `https://d3ro.chanpaca.net/release-identity.json`이 404다(공개 버전 검증 불가). | `.forgejo/workflows/deploy-site.yml`, `site/src/sections/Hero.tsx` | `[x]` 2026-09-19: ref를 `ReturnType<typeof setTimeout>`으로 바꿔 hoisted 타입 의존을 제거했다(격리 `--typeRoots`로 CI 조건 재현 → 수정 전 TS2503, 수정 후 clean). 같은 수정을 push하자 `deploy` run#67이 사이트 빌드를 통과해 `dist/`를 만들었고, 실패는 다음 단계(Cloudflare)로 이동했다. | | GAP-REL-06 | Release | 서명 인증서가 없어 stable(`latest`) 채널에 **무서명** 설치본을 게시했다. electron-updater는 `app-update.yml``publisherName`이 없으면 서명 검증을 건너뛰므로 설치는 동작하지만, SmartScreen 평판은 버전마다 0부터 시작한다. | `scripts/ci/publish-updater-release.mjs`, `release/update-policy.json`, `.forgejo/workflows/release.yml` | `[!]` 2026-09-18: `1.3.2``--ack-unsigned`(명시적 승인 플래그)로 게시. 인증서 확보 시 더 높은 버전으로 서명 게시하여 대체하고, 이 예외를 제거한다. |
| GAP-REL-09b | Release | `d3ro.chanpaca.net`이 404였던 직접 원인: 이 Cloudflare 계정에 Pages 프로젝트 `d3ro`/`d3ro-voice`**존재하지 않아** 커스텀 도메인 바인딩이 없었다(빈 본문 404, `cf-ray`만 반환). Pages 커스텀 도메인은 존 DNS CNAME(`d3ro → d3ro.pages.dev`)을 요구하는데 기존 `d3ro` 레코드가 남아 있어 `CNAME record not set`으로 pending에 머물렀고, 로컬 wrangler 자격증명에는 DNS 스코프가 없다(403 Authentication error). `deploy-site.yml``CF_API_TOKEN` 시크릿이 없어 마지막 게시 단계에서도 `exit 1`이다. | `server/cloudflare-site-bridge/`, `.forgejo/workflows/deploy-site.yml`, `docs/map/02-infrastructure.md` | `[x]` 2026-09-19: Pages 프로젝트 `d3ro` 생성 + `site/dist` production 배포(`d3ro.pages.dev` 200, `release-identity.json` = commit `2407f5a` / 1.3.7) + 커스텀 도메인 연결. DNS 없이 도메인을 살리기 위해 Workers 라우트 브리지(`server/cloudflare-site-bridge`, `d3ro.chanpaca.net/*` → Pages 프록시, `npx wrangler deploy`)를 배포 → 라이브 확인: `/`·`/privacy/`·`/terms/`·`/delete-account/` 200, 라이브 번들이 설치 파일명을 `1.3.7`로 계산, `/download.html``/#download`. 남은 정리 2건: (1) 대시보드에 CNAME을 추가한 뒤 브리지 워커 삭제, (2) CI 자동 게시를 위해 `CF_API_TOKEN`(Pages/Workers Edit) + `CF_ACCOUNT_ID`=`8e83cc130e7329c160cf2b88d6b4c20a`를 Forgejo 시크릿에 등록. |
| GAP-REL-10 | Release | `release-windows`(태그 파이프라인)는 서명 가드에 도달하기 **전에** sidecar 단계에서 죽는다. 이 러너 컨텍스트에서는 `sidecar:setup`이 Python 3.11+를 찾지 못한다(`Python 3.11+ 를 찾을 수 없습니다`) → `sidecar:build``verify-sidecar-bundle.mjs` 연쇄 실패(실측: run#65 `v1.3.7`, run#61 `v1.3.6`). 같은 러너의 portable 잡은 `py -3.11 → Python 3.11.9`를 찾아 사이드카 빌드에 성공하므로, 워크플로/컨테이너 간 PATH 차이다. | `.forgejo/workflows/release.yml`, `apps/desktop/scripts/setup-sidecar.mjs` | `[!]` 2026-09-19: 러너에 Python 3.11+(`py` 런처 포함)를 보장하거나 워크플로에 `actions/setup-python` 단계를 추가한다. 그 전까지 서명 게시는 불가능하다(GAP-REL-02와 별개 선행 차단). |
| GAP-REL-11 | Release | portable 워크플로의 마지막 `actions/upload-artifact@v4` 단계가 Forgejo 러너에서 `GHESNotSupportedError`로 실패한다(증거 보존만 실패, 게시는 성공). | `.forgejo/workflows/portable.yml` | `[x]` 2026-09-19: `v1.3.7` portable 게시는 run#64에서 성공(7z 단일 볼륨 83.7MB + zip 2부, `portable-latest/portable.json`이 1.3.7 보고). 남은 조치: upload-artifact 단계를 제거하거나 v3/다른 보존 방식으로 바꿔 워크플로를 GREEN으로 만든다. |
| GAP-REL-02 | Release | Windows stable publication needs an external public-trust Authenticode PFX, its password, the exact signer subject, and a Forgejo token, none of which live in the repository. | `.forgejo/workflows/release.yml`, `.gitlab-ci.yml`, `scripts/ci/set-forgejo-secrets.mjs`, `scripts/ci/verify-windows-release-artifact.ps1` | `[!]` 2026-09-18 measured: the Forgejo repo had **zero** Actions secrets; `FORGEJO_TOKEN` is registered now (2026-09-18) but `WIN_CSC_*` still have no values, so `v1.2.0` (run 49) and `v1.3.0` (run 51) both failed at the signing guard and **no updater-feed release has been published since `1.1.0`**. Inject the four secrets (`WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`, `FORGEJO_TOKEN`) with `npm run release:secrets` (check: `npm run release:secrets:check`), then re-run `release.yml` for the `v1.3.0` tag via `workflow_dispatch` (tags are immutable). **2026-09-19 정정**: CI 서명 게이트는 여전히 막혀 있지만, updater feed에는 `1.3.2`~`1.3.7`이 로컬 `release:updater` 경로로 게시되어 있다(GAP-REL-06). |
| GAP-REL-06 | Release | 서명 인증서가 없어 stable(`latest`) 채널에 **무서명** 설치본을 게시했다. electron-updater는 `app-update.yml``publisherName`이 없으면 서명 검증을 건너뛰므로 설치는 동작하지만, SmartScreen 평판은 버전마다 0부터 시작한다. | `scripts/ci/publish-updater-release.mjs`, `release/update-policy.json`, `.forgejo/workflows/release.yml` | `[!]` 2026-09-18: `1.3.2``--ack-unsigned`(명시적 승인 플래그)로 게시. **2026-09-19: `1.3.7`도 같은 경로로 게시**(`npm run release:updater -- --ack-unsigned`, 설치본 90.6MiB, `latest.yml`=1.3.7, 설치본 sha512가 피드 메타데이터와 일치). 인증서 확보 시 더 높은 버전으로 서명 게시하여 대체하고, 이 예외를 제거한다. |
| GAP-REL-07 | Release | 패키징된 `better-sqlite3`가 호스트 Node ABI여서 `1.3.2` 설치본이 시작 즉시 죽었다(NODE_MODULE_VERSION 131 vs 130). 원인: 로컬 `npm install`이 네이티브 모듈을 Node용으로 재빌드했고 패키징이 재빌드를 건너었다. | `scripts/ci/verify-native-abi.mjs`, `scripts/ci/fix-native-abi.mjs`, `.gitlab-ci.yml`/`.forgejo`/`.github` 패키징 단계 | `[x]` 2026-09-18: 패키징 후 Electron ABI를 검증하고, 검증된 트리에서만 설치본을 생성(`--prepackaged`)한다. `1.3.3`은 설치본에서 추출한 바이너리로 재검증 GREEN. | | GAP-REL-07 | Release | 패키징된 `better-sqlite3`가 호스트 Node ABI여서 `1.3.2` 설치본이 시작 즉시 죽었다(NODE_MODULE_VERSION 131 vs 130). 원인: 로컬 `npm install`이 네이티브 모듈을 Node용으로 재빌드했고 패키징이 재빌드를 건너었다. | `scripts/ci/verify-native-abi.mjs`, `scripts/ci/fix-native-abi.mjs`, `.gitlab-ci.yml`/`.forgejo`/`.github` 패키징 단계 | `[x]` 2026-09-18: 패키징 후 Electron ABI를 검증하고, 검증된 트리에서만 설치본을 생성(`--prepackaged`)한다. `1.3.3`은 설치본에서 추출한 바이너리로 재검증 GREEN. |
| GAP-ADS-01 | Ads | 9 of 10 desktop ad adapters still extend `UnavailableAdAdapter` (`provider_not_integrated`). | `apps/desktop/src/main/services/ads/*` | `[~]` 2026-09-13: `DirectHouseSponsorAdapter` is now a real configurable REST adapter (bid/impression/click/reward via `endpointUrl`; fail-closed when unconfigured; 22 unit tests GREEN). Remaining 9 need official SDKs/authenticated endpoints. | | GAP-ADS-01 | Ads | 9 of 10 desktop ad adapters still extend `UnavailableAdAdapter` (`provider_not_integrated`). | `apps/desktop/src/main/services/ads/*` | `[~]` 2026-09-13: `DirectHouseSponsorAdapter` is now a real configurable REST adapter (bid/impression/click/reward via `endpointUrl`; fail-closed when unconfigured; 22 unit tests GREEN). Remaining 9 need official SDKs/authenticated endpoints. |
| GAP-ADS-02 | Ads | Desktop mediation reward accounting is not wired to license quota (`claimReward` still returns no tokens). | `AdMediationEngine.ts`, `AppLayout.tsx` | Wire verified `reportRewardCompletion` to `LicenseService` quota after the direct sponsor endpoint exists. | | GAP-ADS-02 | Ads | Desktop mediation reward accounting is not wired to license quota (`claimReward` still returns no tokens). | `AdMediationEngine.ts`, `AppLayout.tsx` | Wire verified `reportRewardCompletion` to `LicenseService` quota after the direct sponsor endpoint exists. |
@ -54,13 +49,11 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
| GAP-STT-03 | Local engines | On hosts where `localhost` resolves only to IPv6, every local engine call (STT sidecar and Ollama) was refused. Audio capture and local LLM appeared dead. | `apps/desktop/src/main/utils/loopback.ts`, `LocalSTTService`, `LocalLLMService`, `RAGService`, `OnlineLLMService`, `STTManager` | `[x]` 2026-09-18: loopback normalization to `127.0.0.1` for all local engine URLs; defaults updated; 9 unit tests. Verified against the live sidecar and Ollama on a host with an IPv6-only `localhost`. | | GAP-STT-03 | Local engines | On hosts where `localhost` resolves only to IPv6, every local engine call (STT sidecar and Ollama) was refused. Audio capture and local LLM appeared dead. | `apps/desktop/src/main/utils/loopback.ts`, `LocalSTTService`, `LocalLLMService`, `RAGService`, `OnlineLLMService`, `STTManager` | `[x]` 2026-09-18: loopback normalization to `127.0.0.1` for all local engine URLs; defaults updated; 9 unit tests. Verified against the live sidecar and Ollama on a host with an IPv6-only `localhost`. |
| GAP-STT-04 | Local STT | Live partial transcript (`CAP-03`, `voice:partialTranscript`) was marked done but had **no producer**: the channel, popup UI, and preload existed, nothing ever emitted. | `apps/desktop/src/main/services/VoiceModeService.ts`, `LocalSTTService.transcribePartial`, `STTManager.transcribePartial` | `[x]` 2026-09-18: 1.5 s cadence over a 7.5 s trailing window, greedy decode, drained before the final transcription; never inserted. | | GAP-STT-04 | Local STT | Live partial transcript (`CAP-03`, `voice:partialTranscript`) was marked done but had **no producer**: the channel, popup UI, and preload existed, nothing ever emitted. | `apps/desktop/src/main/services/VoiceModeService.ts`, `LocalSTTService.transcribePartial`, `STTManager.transcribePartial` | `[x]` 2026-09-18: 1.5 s cadence over a 7.5 s trailing window, greedy decode, drained before the final transcription; never inserted. |
| GAP-STT-05 | Local STT | The bundled sidecar lacked faster-whisper's Silero VAD data, so `vad_filter=true` transcription would have failed at runtime even with the engine bundled. | `apps/desktop/scripts/build-sidecar.mjs`, `scripts/ci/verify-sidecar-bundle.mjs` | `[x]` 2026-09-18: `--collect-all faster_whisper` plus a packaging-time presence check for `assets/silero_vad_v6.onnx`. | | GAP-STT-05 | Local STT | The bundled sidecar lacked faster-whisper's Silero VAD data, so `vad_filter=true` transcription would have failed at runtime even with the engine bundled. | `apps/desktop/scripts/build-sidecar.mjs`, `scripts/ci/verify-sidecar-bundle.mjs` | `[x]` 2026-09-18: `--collect-all faster_whisper` plus a packaging-time presence check for `assets/silero_vad_v6.onnx`. |
| GAP-REL-03 | Release | 서명이 없어 설치할 수 있는 경로가 없다(인증서 발급 전 공백). | `.forgejo/workflows/portable.yml`, `scripts/ci/build-portable.mjs`, `scripts/local/install-d3ro-voice.ps1`, `bucket/d3ro-voice.json` | `[x]` 2026-09-18: 서명 없는 portable 채널 구현 — 95MiB 7z 분할 볼륨(688MB → 162MiB) + Scoop 버킷 + 수동 설치 스크립트를 Forgejo에 게시. 실제 설치 스크립트 end-to-end 검증(볼륨 다운로드 → SHA-256 → 결합 → 해제 → 엔진 포함 확인). updater feed는 건드리지 않음. **2026-09-19: `1.3.7`이 CI portable run#64에서 `portable-latest`에 게시됨**(7z 단일 볼륨 83.7MB + zip 2부, `portable.json`=1.3.7) — 즉 portable 경로는 CI만으로 동작하고, 실패한 것은 마지막 증거 업로드 단계다(GAP-REL-11). | | GAP-REL-03 | Release | 서명이 없어 설치할 수 있는 경로가 없다(인증서 발급 전 공백). | `.forgejo/workflows/portable.yml`, `scripts/ci/build-portable.mjs`, `scripts/local/install-d3ro-voice.ps1`, `bucket/d3ro-voice.json` | `[x]` 2026-09-18: 서명 없는 portable 채널 구현 — 95MiB 7z 분할 볼륨(688MB → 162MiB) + Scoop 버킷 + 수동 설치 스크립트를 Forgejo에 게시. 실제 설치 스크립트 end-to-end 검증(볼륨 다운로드 → SHA-256 → 결합 → 해제 → 엔진 포함 확인). updater feed는 건드리지 않음. |
| GAP-REL-04 | Release | canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 **100MiB**를 넘으면 HTTP 413으로 거부한다. | `scripts/ci/publish-updater-release.mjs`, `docs/deployment/unsigned-distribution.md` | `[~]` 2026-09-18: 설치본을 90.6MiB로 줄여 업데이트 피드 게시를 복구했다(GAP-STT-07). 휴대용/Scoop 채널은 여전히 95MiB 분할이 필요하다. | | GAP-REL-04 | Release | canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 **100MiB**를 넘으면 HTTP 413으로 거부한다. | `scripts/ci/publish-updater-release.mjs`, `docs/deployment/unsigned-distribution.md` | `[~]` 2026-09-18: 설치본을 90.6MiB로 줄여 업데이트 피드 게시를 복구했다(GAP-STT-07). 휴대용/Scoop 채널은 여전히 95MiB 분할이 필요하다. |
| GAP-STT-07 | Local STT | 진(사이드카)을 앱 번들에 넣으면 설치본이 100MiB를 넘고 매 업데이트마다 162MiB를 다시 받는다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts`, `apps/desktop/electron-builder.yml` | `[x]` 2026-09-18: 설치본에서 엔진/ffmpeg를 제거하고 처음 필요할 때 `runtime-latest`에서 내려받는다(부품별 + 결합본 SHA-256 검증). 설치본 189MB → 90.6MiB, 런타임 1회 116MiB(엔진 94.4 + ffmpeg 21.7). 실제 feed로 통합 검증 완료. | | GAP-STT-07 | Local STT | 진(사이드카)을 앱 번들에 넣으면 설치본이 100MiB를 넘고 매 업데이트마다 162MiB를 다시 받는다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts`, `apps/desktop/electron-builder.yml` | `[x]` 2026-09-18: 설치본에서 엔진/ffmpeg를 제거하고 처음 필요할 때 `runtime-latest`에서 내려받는다(부품별 + 결합본 SHA-256 검증). 설치본 189MB → 90.6MiB, 런타임 1회 116MiB(엔진 94.4 + ffmpeg 21.7). 실제 feed로 통합 검증 완료. |
| 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초 만에 성공. |
| 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 로그로 승격한다. |
--- ---

20
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "d3ro-voice-monorepo", "name": "d3ro-voice-monorepo",
"version": "1.3.7", "version": "1.3.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "d3ro-voice-monorepo", "name": "d3ro-voice-monorepo",
"version": "1.3.7", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"apps/desktop", "apps/desktop",
@ -25,7 +25,7 @@
}, },
"apps/admin": { "apps/admin": {
"name": "@d3ro/admin", "name": "@d3ro/admin",
"version": "1.3.7", "version": "1.3.6",
"dependencies": { "dependencies": {
"@d3ro/api-client": "*", "@d3ro/api-client": "*",
"@d3ro/core": "*", "@d3ro/core": "*",
@ -109,7 +109,7 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "@d3ro/desktop", "name": "@d3ro/desktop",
"version": "1.3.7", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@d3ro/core": "*", "@d3ro/core": "*",
@ -158,7 +158,7 @@
}, },
"apps/web": { "apps/web": {
"name": "@d3ro/web", "name": "@d3ro/web",
"version": "1.3.7", "version": "1.3.6",
"dependencies": { "dependencies": {
"@d3ro/api-client": "*", "@d3ro/api-client": "*",
"@d3ro/core": "*", "@d3ro/core": "*",
@ -16861,7 +16861,7 @@
}, },
"packages/api-client": { "packages/api-client": {
"name": "@d3ro/api-client", "name": "@d3ro/api-client",
"version": "1.3.7", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@d3ro/core": "*", "@d3ro/core": "*",
@ -16878,7 +16878,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@d3ro/core", "name": "@d3ro/core",
"version": "1.3.7", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"docx": "^9.6.1" "docx": "^9.6.1"
@ -16889,7 +16889,7 @@
}, },
"packages/i18n": { "packages/i18n": {
"name": "@d3ro/i18n", "name": "@d3ro/i18n",
"version": "1.3.7", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
@ -16900,7 +16900,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@d3ro/ui", "name": "@d3ro/ui",
"version": "1.3.7", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@d3ro/core": "*" "@d3ro/core": "*"
@ -16920,7 +16920,7 @@
}, },
"packages/ui-native": { "packages/ui-native": {
"name": "@d3ro/ui-native", "name": "@d3ro/ui-native",
"version": "1.3.7", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "*" "@types/react": "*"

View file

@ -1,6 +1,6 @@
{ {
"name": "d3ro-voice-monorepo", "name": "d3ro-voice-monorepo",
"version": "1.3.7", "version": "1.3.6",
"private": true, "private": true,
"description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)", "description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)",
"author": "D3RO", "author": "D3RO",
@ -35,8 +35,6 @@
"security:secrets:test": "node scripts/ci/check-no-hardcoded-secrets.mjs --self-test", "security:secrets:test": "node scripts/ci/check-no-hardcoded-secrets.mjs --self-test",
"check:design": "node scripts/ci/check-design-tokens.mjs", "check:design": "node scripts/ci/check-design-tokens.mjs",
"check:design:test": "node scripts/ci/check-design-tokens.mjs --self-test", "check:design:test": "node scripts/ci/check-design-tokens.mjs --self-test",
"check:desktop-renderer": "node scripts/ci/verify-desktop-renderer-bundles.mjs",
"check:desktop-renderer:test": "node scripts/ci/verify-desktop-renderer-bundles.mjs --self-test",
"test:e2e:red": "node server/supabase/tests/content-report-red.e2e.mjs", "test:e2e:red": "node server/supabase/tests/content-report-red.e2e.mjs",
"release:mobile:boundary": "node scripts/ci/verify-mobile-release-boundary.mjs", "release:mobile:boundary": "node scripts/ci/verify-mobile-release-boundary.mjs",
"release:mobile:boundary:test": "node scripts/ci/verify-mobile-release-boundary.mjs --self-test", "release:mobile:boundary:test": "node scripts/ci/verify-mobile-release-boundary.mjs --self-test",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/api-client", "name": "@d3ro/api-client",
"version": "1.3.7", "version": "1.3.6",
"private": true, "private": true,
"description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)", "description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)",
"license": "MIT", "license": "MIT",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/core", "name": "@d3ro/core",
"version": "1.3.7", "version": "1.3.6",
"private": true, "private": true,
"description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸", "description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸",
"license": "MIT", "license": "MIT",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/i18n", "name": "@d3ro/i18n",
"version": "1.3.7", "version": "1.3.6",
"private": true, "private": true,
"description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸", "description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸",
"license": "MIT", "license": "MIT",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/ui-native", "name": "@d3ro/ui-native",
"version": "1.3.7", "version": "1.3.6",
"private": true, "private": true,
"description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.", "description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.",
"license": "MIT", "license": "MIT",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/ui", "name": "@d3ro/ui",
"version": "1.3.7", "version": "1.3.6",
"private": true, "private": true,
"description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵", "description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵",
"license": "MIT", "license": "MIT",

View file

@ -1,8 +1,8 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"version": "1.3.7", "version": "1.3.6",
"androidVersionCode": 1031007, "androidVersionCode": 1031006,
"iosBuildNumber": 1031007, "iosBuildNumber": 1031006,
"releaseDate": "2026-09-19", "releaseDate": "2026-09-18",
"desktopLicensePublicKeyId": "5c52b765135ee2531c681b53cd4dc0e96fff8737f496c0b04ba8334fc81a887f" "desktopLicensePublicKeyId": "5c52b765135ee2531c681b53cd4dc0e96fff8737f496c0b04ba8334fc81a887f"
} }

View file

@ -180,28 +180,6 @@ updateText('apps/web/src/components/layout/sidebar.tsx', (text) =>
), ),
) )
// Download centers build the installer filename from these constants. They drifted
// to 1.2.0 while the feed served newer versions, so the download button pointed at
// an installer that does not exist. Keep both surfaces on the version SSOT.
for (const releaseContractPath of [
'apps/web/src/lib/desktop-release.ts',
'site/src/release.ts',
]) {
updateText(releaseContractPath, (text) =>
replaceExactlyOnce(
replaceExactlyOnce(
text,
/export const DESKTOP_VERSION = '[^']+'/,
`export const DESKTOP_VERSION = '${metadata.version}'`,
`${releaseContractPath} desktop version`,
),
/export const DESKTOP_RELEASE_DATE = '[^']+'/,
`export const DESKTOP_RELEASE_DATE = '${metadata.releaseDate}'`,
`${releaseContractPath} desktop release date`,
),
)
}
const changelog = readFileSync(join(root, 'CHANGELOG.md'), 'utf8') const changelog = readFileSync(join(root, 'CHANGELOG.md'), 'utf8')
if (!new RegExp(`^## \\[${escapeRegExp(metadata.version)}\\] - ${metadata.releaseDate}$`, 'm').test(changelog)) { if (!new RegExp(`^## \\[${escapeRegExp(metadata.version)}\\] - ${metadata.releaseDate}$`, 'm').test(changelog)) {
fail(`CHANGELOG.md is missing [${metadata.version}] - ${metadata.releaseDate}.`) fail(`CHANGELOG.md is missing [${metadata.version}] - ${metadata.releaseDate}.`)

View file

@ -1,160 +0,0 @@
// 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`)
}

View file

@ -1,29 +0,0 @@
// server/cloudflare-site-bridge/src/index.ts
// d3ro.chanpaca.net → Cloudflare Pages(d3ro.pages.dev) 프록시.
//
// Pages 커스텀 도메인은 존 DNS에 CNAME을 요구하므로, DNS를 건드릴 수 없는 동안
// 이 워커가 도메인을 살린다. 콘텐츠 정본은 Pages 배포본 하나이므로 CI가 Pages에
// 배포하면 도메인에도 그대로 반영된다.
const PAGES_ORIGIN = 'https://d3ro.pages.dev'
export default {
async fetch(request: Request): Promise<Response> {
const target = new URL(request.url)
target.protocol = 'https:'
target.hostname = new URL(PAGES_ORIGIN).hostname
target.port = ''
const headers = new Headers(request.headers)
headers.delete('host')
const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
return fetch(target.toString(), {
method: request.method,
headers,
body: hasBody ? request.body : undefined,
redirect: 'manual',
})
},
}

View file

@ -1,20 +0,0 @@
# server/cloudflare-site-bridge/wrangler.toml
#
# d3ro.chanpaca.net 을 Cloudflare Pages 배포본(d3ro.pages.dev)에 연결하는 브리지.
#
# 왜 필요한가: Pages 커스텀 도메인은 존 DNS에 CNAME(d3ro → d3ro.pages.dev)을 요구한다.
# 기존 d3ro 레코드가 남아 있어 Pages가 레코드를 만들지 못하고("CNAME record not set")
# 도메인은 빈 404를 반환했다. DNS 편집 권한 없이 도메인을 살리기 위해, 이미 프록시된
# 호스트네임에 Workers 라우트를 걸어 Pages 배포본을 그대로 서빙한다.
#
# 정리(권장): 대시보드에서 CNAME d3ro → d3ro.pages.dev 를 추가한 뒤 이 라우트와
# 워커를 제거하면 트래픽이 Pages 커스텀 도메인으로 직접 흐른다.
# npx wrangler delete --name d3ro-site-bridge (라우트는 워커 삭제 시 함께 해제)
name = "d3ro-site-bridge"
main = "src/index.ts"
compatibility_date = "2024-04-01"
routes = [
{ pattern = "d3ro.chanpaca.net/*", zone_name = "chanpaca.net" }
]

View file

@ -1,12 +1,12 @@
{ {
"name": "d3ro-voice-site", "name": "d3ro-voice-site",
"version": "1.3.7", "version": "1.3.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "d3ro-voice-site", "name": "d3ro-voice-site",
"version": "1.3.7", "version": "1.3.6",
"dependencies": { "dependencies": {
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"

View file

@ -1,7 +1,7 @@
{ {
"name": "d3ro-voice-site", "name": "d3ro-voice-site",
"private": true, "private": true,
"version": "1.3.7", "version": "1.3.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port 5199 --host", "dev": "vite --port 5199 --host",

View file

@ -5,10 +5,10 @@
// NAS 배포는 바이너리를 포함하지 않으므로 `/releases/...` 같은 로컬 경로는 // NAS 배포는 바이너리를 포함하지 않으므로 `/releases/...` 같은 로컬 경로는
// 실제 배포 환경에서 404가 된다. // 실제 배포 환경에서 404가 된다.
export const DESKTOP_VERSION = '1.3.7' export const DESKTOP_VERSION = '1.2.0'
/** 릴리스 게시일. `release/product-version.json`의 releaseDate와 같아야 한다. */ /** 릴리스 게시일. `release/product-version.json`의 releaseDate와 같아야 한다. */
export const DESKTOP_RELEASE_DATE = '2026-09-19' export const DESKTOP_RELEASE_DATE = '2026-09-16'
const FORGEJO_ORIGIN = 'https://git.chanpaca.net' const FORGEJO_ORIGIN = 'https://git.chanpaca.net'
const FORGEJO_OWNER = 'yunchan' const FORGEJO_OWNER = 'yunchan'

View file

@ -15,7 +15,7 @@ export function Hero() {
const [typedRaw, setTypedRaw] = useState('') const [typedRaw, setTypedRaw] = useState('')
const [typedClean, setTypedClean] = useState('') const [typedClean, setTypedClean] = useState('')
const [waveLevels, setWaveLevels] = useState([0.3, 0.5, 0.8, 1, 0.9, 0.7, 0.4, 0.6, 0.3]) const [waveLevels, setWaveLevels] = useState([0.3, 0.5, 0.8, 1, 0.9, 0.7, 0.4, 0.6, 0.3])
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timerRef = useRef<NodeJS.Timeout | null>(null)
const sampleRawText = locale === 'ko' const sampleRawText = locale === 'ko'
? '어... 이번 프로젝트 배포는 다음 주 금요일까지로 잡으면 될 것 같아요.' ? '어... 이번 프로젝트 배포는 다음 주 금요일까지로 잡으면 될 것 같아요.'