diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7f86eb..9373919 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Cloud-optional backup (encrypted, opt-in)
- Plugin system for custom pipelines
+## [1.3.2] - 2026-09-18
+
+### Changed
+- **Auto-update works again**: the installer no longer carries the local speech engine
+ and ffmpeg. Bundling them pushed the installer to 189 MB, and the download feed sits
+ behind Cloudflare, which rejects uploads over ~100 MiB with HTTP 413 — so update
+ metadata could not be published at all. The installer is now 90.5 MiB and the update
+ feed is published again.
+- **The app fetches its speech engine and ffmpeg when they are first needed**, verifying
+ every part and the joined archive by SHA-256 before installing them under the app data
+ folder. Updates stay small (the engine is not re-downloaded on every release), and the
+ Settings > STT tab shows the runtime status with a manual download action.
+
## [1.3.1] - 2026-09-18
### Fixed
diff --git a/apps/admin-swagger/openapi.json b/apps/admin-swagger/openapi.json
index ef5d178..8dce225 100644
--- a/apps/admin-swagger/openapi.json
+++ b/apps/admin-swagger/openapi.json
@@ -3,7 +3,7 @@
"info": {
"title": "D3RO-VOICE Admin API",
"description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.",
- "version": "1.3.1"
+ "version": "1.3.2"
},
"servers": [
{
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 78c4008..e2a9b30 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -1,6 +1,6 @@
{
"name": "@d3ro/admin",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"description": "D3RO Voice Admin CRM — SaaS 관리 도구",
"scripts": {
diff --git a/apps/api-server/D3ROVoice.Api.csproj b/apps/api-server/D3ROVoice.Api.csproj
index 5cb08ba..aeb40ea 100644
--- a/apps/api-server/D3ROVoice.Api.csproj
+++ b/apps/api-server/D3ROVoice.Api.csproj
@@ -2,7 +2,7 @@
net10.0
- 1.3.1
+ 1.3.2
enable
enable
diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml
index 290de60..8985bbb 100644
--- a/apps/desktop/electron-builder.yml
+++ b/apps/desktop/electron-builder.yml
@@ -4,6 +4,10 @@ copyright: Copyright © 2026 D3RO
# monorepo(npm workspaces)에서 electron이 루트 node_modules로 호이스팅되어
# 자동 감지가 실패하는 문제를 피하려고 명시적으로 버전 고정.
electronVersion: "33.4.11"
+# 설치 크기 절감 — 지원 로케일만 포함한다
+electronLanguages:
+ - ko
+ - en-US
directories:
# buildResources를 build/로 지정 — resources/icons/가 비어있어 아이콘 자동 스캔이 실패하는 것을 우회.
@@ -14,6 +18,8 @@ directories:
files:
- out/**/*
- "!out/**/*.map"
+ # ffmpeg 정적 바이너리(61MB)는 설치본에 넣지 않는다 — 필요할 때 런타임으로 내려받는다
+ - "!node_modules/@ffmpeg-installer/**"
# ────────────────────────────────────────────────────────────────────
# 자동 업데이트 feed — 이 설정이 있어야 electron-builder가
@@ -36,8 +42,6 @@ asarUnpack:
- "node_modules/better-sqlite3/**"
- "node_modules/uiohook-napi/**"
- "node_modules/@nut-tree-fork/**"
- # ffmpeg 정적 바이너리는 실행 파일이므로 asar 내부에서 spawn할 수 없다
- - "node_modules/@ffmpeg-installer/**"
# ────────────────────────────────────────────────────────────────────
# Windows
@@ -115,20 +119,11 @@ extraResources:
- from: resources/sox/
to: sox/
- # faster-whisper STT 사이드카 (PyInstaller onedir: sidecar.exe + _internal/).
- # 반드시 존재해야 한다. 누락되면 로컬 전사가 전혀 동작하지 않는다.
- # 빌드: npm --prefix apps/desktop run sidecar:build
- # electron-builder는 이 트리를 재귀로 복사한다(_internal 포함).
- # 서명 검증에 실패하면 복사가 중간에 끊겨 _internal이 빠지므로,
- # 서명 없이 로컬 검증할 때는 -c.win.forceCodeSigning=false 를 사용한다.
- - from: sidecar-dist/sidecar/
- to: sidecar/
- filter:
- - "**/*"
+ # 주의: 로컬 AI 런타임(사이드카 엔진, ffmpeg)은 여기에 넣지 않는다.
+ # 포함하면 설치본이 Cloudflare 업로드 한도(100MiB)를 넘어 자동 업데이트 메타데이터를
+ # 게시할 수 없다(실측: 엔진 포함 189MB vs 엔진 제외 90.5MiB). 런타임은 처음 필요할 때
+ # RuntimeProvisioner가 feed에서 내려받아 검증한 뒤 해제해 설치한다.
- # ffmpeg (파일 전사/미디어 변환용). CI가 resources/ffmpeg/에 배치한다.
- - from: resources/ffmpeg/
- to: ffmpeg/
# Ollama 바이너리 (포터블 zip을 사전 배치)
- from: resources/ollama/
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 26006f5..3806fbd 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@d3ro/desktop",
- "version": "1.3.1",
+ "version": "1.3.2",
"productName": "d3ro-voice",
"description": "로컬 AI 음성 어시스턴트 (Electron)",
"main": "./out/main/index.js",
@@ -65,6 +65,7 @@
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
+ "tar": "^7.5.13",
"uiohook-napi": "^1.5.5"
},
"optionalDependencies": {
diff --git a/apps/desktop/src/main/ipc/stt-handlers.ts b/apps/desktop/src/main/ipc/stt-handlers.ts
index 1daf232..62c9711 100644
--- a/apps/desktop/src/main/ipc/stt-handlers.ts
+++ b/apps/desktop/src/main/ipc/stt-handlers.ts
@@ -5,6 +5,8 @@ import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors'
import { getLocalSTTService } from '../services/LocalSTTService'
+import { getRuntimeProvisioner } from '../services/RuntimeProvisioner'
+import type { RuntimeComponent } from '../services/RuntimeProvisioner'
import { getSTTManager } from '../services/stt/STTManager'
import { configGet, configSet } from '../services/ConfigService'
import { getMainWindow } from '../windows/WindowManager'
@@ -26,6 +28,31 @@ function safeSendToRenderer(channel: string, data: unknown): void {
}
export function registerSTTHandlers(): void {
+ // 로컬 AI 런타임(진/ffmpeg) 진행률 — 필요할 때 자동으로 내려받는다
+ getRuntimeProvisioner().on('progress', (payload) => {
+ safeSendToRenderer(IPC_CHANNELS.RUNTIME.PROGRESS, payload)
+ })
+
+ ipcMain.handle(IPC_CHANNELS.RUNTIME.GET_STATUS, () => {
+ try {
+ return ipcSuccess(getRuntimeProvisioner().getStatus())
+ } catch (error) {
+ return ipcError(error, ErrorCode.ConfigReadFailed)
+ }
+ })
+
+ ipcMain.handle(IPC_CHANNELS.RUNTIME.ENSURE, async (_event, params: { component: RuntimeComponent }) => {
+ try {
+ const component = params?.component
+ if (component !== 'sidecar' && component !== 'ffmpeg') {
+ throw new D3ROError(ErrorCode.ConfigInvalidValue, `알 수 없는 런타임 구성 요소: ${String(component)}`)
+ }
+ const binaryPath = await getRuntimeProvisioner().ensure(component)
+ return ipcSuccess({ component, binaryPath })
+ } catch (error) {
+ return ipcError(error, ErrorCode.STTSidecarSpawnFailed)
+ }
+ })
getLocalSTTService().on('download-progress', (payload) => {
safeSendToRenderer(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, payload)
})
diff --git a/apps/desktop/src/main/services/FileTranscriptionService.ts b/apps/desktop/src/main/services/FileTranscriptionService.ts
index 85341f7..40fe8ef 100644
--- a/apps/desktop/src/main/services/FileTranscriptionService.ts
+++ b/apps/desktop/src/main/services/FileTranscriptionService.ts
@@ -11,6 +11,7 @@ import { getSTTManager } from './stt/STTManager'
import { getHistoryService } from './HistoryService'
import { configGet } from './ConfigService'
import { getFfmpegPath } from '../utils/paths'
+import { getRuntimeProvisioner } from './RuntimeProvisioner'
import { getMainWindow } from '../windows/WindowManager'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
@@ -250,6 +251,18 @@ class FileTranscriptionService extends EventEmitter {
}
}
+ /**
+ * ffmpeg 실행 파일을 확보한다. 설치본에는 ffmpeg을 넣지 않으므로
+ * 없으면 feed에서 내려받는다(파일 전사/회의 모드에서만 필요).
+ */
+ private async _ensureFfmpeg(): Promise {
+ const resolved = getFfmpegPath()
+ if (resolved !== 'ffmpeg') return resolved
+
+ logger.info('ffmpeg이 없습니다 — 자동 다운로드를 시작합니다')
+ return getRuntimeProvisioner().ensure('ffmpeg')
+ }
+
/**
* ffmpeg로 미디어 파일을 PCM16 16kHz mono WAV로 변환
*/
diff --git a/apps/desktop/src/main/services/LocalSTTService.ts b/apps/desktop/src/main/services/LocalSTTService.ts
index a52227f..3f72185 100644
--- a/apps/desktop/src/main/services/LocalSTTService.ts
+++ b/apps/desktop/src/main/services/LocalSTTService.ts
@@ -11,6 +11,7 @@ import { join } from 'path'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import { getSidecarCommand, getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths'
+import { getRuntimeProvisioner } from './RuntimeProvisioner'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type {
STTModel,
@@ -101,6 +102,15 @@ export interface LocalSTTEvents {
'transcription-complete': { result: TranscriptionResult }
'model-loaded': { model: STTModel; loadTimeMs: number }
'download-progress': DownloadProgressEvent
+ /** 런타임(엔진/ffmpeg) 내려받기 진행률 — 필요할 때 자동 설치 */
+ 'runtime-progress': {
+ component: string
+ phase: 'index' | 'downloading' | 'extracting' | 'done'
+ percent: number
+ downloadedBytes: number
+ totalBytes: number
+ bytesPerSecond: number
+ }
'error': { error: D3ROError }
}
@@ -183,6 +193,10 @@ class LocalSTTService extends EventEmitter {
// (실측: sidecar crash 루프 중 ERR_UNHANDLED_ERROR). 기본 sink로 방지 —
// 실제 로깅은 _emitError에서 수행.
this.on('error', () => { /* default sink */ })
+ // 런타임 내려받기 진행률을 그대로 중계한다 (IPC가 renderer로 전달)
+ getRuntimeProvisioner().on('progress', (payload) => {
+ this.emit('runtime-progress', payload)
+ })
}
private _state: STTState = STTState.Uninitialized
@@ -638,8 +652,8 @@ class LocalSTTService extends EventEmitter {
// dev mode HMR/재시작으로 이전 sidecar가 orphan으로 남아있을 수 있음.
this._port = await this._findFreePort(SIDECAR_PORT, 20)
- // 번들/venv 경로가 깨졌으면 여기서 즉시 실패한다 (조용한 PATH 폴백 금지).
- const launch = getSidecarCommand()
+ // 설치본에는 엔진이 없다 — 없으면 여기서 feed에서 내려받고 산다. dev는 venv/번들 경로를 쓴다.
+ const launch = await this._resolveSidecarLaunch()
const fullArgs = [
...launch.args,
'--port',
@@ -726,6 +740,31 @@ class LocalSTTService extends EventEmitter {
consume(child.stderr, (message) => sidecarLogger.warn(message))
}
+ /**
+ * 사이드카 실행 방법을 결정한다.
+ * 설치본에서 엔진이 아직 없으면 feed에서 내려받아 설치한 뒤 경로를 돌려준다.
+ * 진행률은 runtime-progress 이벤트로 노출된다.
+ */
+ private async _resolveSidecarLaunch(): Promise<{
+ command: string
+ args: string[]
+ source: 'bundled' | 'provisioned' | 'venv' | 'python'
+ }> {
+ try {
+ return getSidecarCommand()
+ } catch (err) {
+ const needsInstall =
+ err instanceof D3ROError && err.code === ErrorCode.STTEngineNotInstalled
+ if (!needsInstall) throw err
+ }
+
+ logger.info('로컬 음성 엔진이 없습니다 — 자동 다운로드를 시작합니다')
+ await getRuntimeProvisioner().ensure('sidecar')
+ const launch = getSidecarCommand()
+ logger.info(`런타임 설치 후 사이드카 경로: ${launch.command} (${launch.source})`)
+ return launch
+ }
+
/** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */
private _spawnFailureError(
err: Error,
diff --git a/apps/desktop/src/main/services/RuntimeProvisioner.ts b/apps/desktop/src/main/services/RuntimeProvisioner.ts
new file mode 100644
index 0000000..923a3c6
--- /dev/null
+++ b/apps/desktop/src/main/services/RuntimeProvisioner.ts
@@ -0,0 +1,343 @@
+// src/main/services/RuntimeProvisioner.ts
+// 로컬 AI 타임(사이드카 엔진 / ffmpeg)을 설치 시점이 아니라 "필요할 때" 내려받는다.
+//
+// 왜: 사이드카(242MB)를 설치본에 넣으면 NSIS가 189MB가 되어 canonical feed의 업로드
+// 한도(Cloudflare 100MiB)를 넘고, 그 결과 자동 업데이트(latest.yml)를 갱신할 수 없다.
+// 엔진을 분리하면 설치본이 90MiB대로 내려가 updater가 정상 동작하고, 업데이트마다
+// 162MB를 다시 받지 않아도 된다.
+//
+// 안전:
+// - 부품별 SHA-256 + 결합본 SHA-256을 모두 검증한 뒤에만 설치한다.
+// - tar 경로 탈출(..) 항목은 건너뛴다.
+// - 실패하면 부분 다운로드를 지우고 기존 설치를 건드리지 않는다.
+
+import { EventEmitter, once } from 'events'
+import { createHash } from 'node:crypto'
+import { createReadStream, createWriteStream, existsSync, statSync } from 'node:fs'
+import { mkdir, rm } from 'node:fs/promises'
+import { Readable } from 'node:stream'
+import { pipeline } from 'node:stream/promises'
+import { join } from 'node:path'
+import { app } from 'electron'
+import * as tar from 'tar'
+import { getLogger } from './LoggerService'
+import { D3ROError, ErrorCode } from '@d3ro/core/errors'
+import { RUNTIME_FEED_URL } from '../update-feed'
+
+const logger = getLogger('RuntimeProvisioner')
+
+/** 이 내려받아야 하는 런타임 구성 요소 */
+export type RuntimeComponent = 'sidecar' | 'ffmpeg'
+
+export const RUNTIME_COMPONENTS: readonly RuntimeComponent[] = ['sidecar', 'ffmpeg']
+
+interface RuntimePart {
+ name: string
+ size: number
+ sha256: string
+ url: string
+}
+
+interface RuntimeComponentIndex {
+ archive: string
+ sha256: string
+ totalSize: number
+ parts: RuntimePart[]
+}
+
+interface RuntimeIndex {
+ schemaVersion: number
+ version: string
+ components: Record
+}
+
+export interface RuntimeProgressEvent {
+ component: RuntimeComponent
+ phase: 'index' | 'downloading' | 'extracting' | 'done'
+ percent: number
+ downloadedBytes: number
+ totalBytes: number
+ bytesPerSecond: number
+}
+
+export interface RuntimeStatus {
+ component: RuntimeComponent
+ installed: boolean
+ path: string
+ sizeBytes: number
+}
+
+const RUNTIME_DIR_NAME = 'runtime'
+const DOWNLOAD_TIMEOUT_MS = 120_000
+
+class RuntimeProvisioner extends EventEmitter {
+ constructor() {
+ super()
+ this.on('error', () => {
+ /* 기본 sink — EventEmitter 'error' 미처리 예외 방지 */
+ })
+ }
+
+ private _inFlight = new Map>()
+
+ /** 설치된 런타임 트 (%APPDATA%/d3ro-voice/runtime/) */
+ componentDir(component: RuntimeComponent): string {
+ return join(app.getPath('userData'), RUNTIME_DIR_NAME, component)
+ }
+
+ /** 구성 요소 실행 파일 경로 (설치 여부와 무관하게 경로만 계산) */
+ binaryPath(component: RuntimeComponent): string {
+ const dir = this.componentDir(component)
+ if (component === 'sidecar') {
+ return join(dir, process.platform === 'win32' ? 'sidecar.exe' : 'sidecar')
+ }
+ return join(dir, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')
+ }
+
+ isInstalled(component: RuntimeComponent): boolean {
+ const binary = this.binaryPath(component)
+ if (!existsSync(binary)) return false
+ if (component === 'sidecar' && !existsSync(join(this.componentDir('sidecar'), '_internal'))) {
+ // PyInstaller onedir은 _internal 없이는 동작하지 않는다 (부분 설치 방어)
+ return false
+ }
+ return true
+ }
+
+ getStatus(): RuntimeStatus[] {
+ return RUNTIME_COMPONENTS.map((component) => {
+ const binary = this.binaryPath(component)
+ let sizeBytes = 0
+ try {
+ sizeBytes = existsSync(binary) ? statSync(binary).size : 0
+ } catch {
+ sizeBytes = 0
+ }
+ return {
+ component,
+ installed: this.isInstalled(component),
+ path: binary,
+ sizeBytes,
+ }
+ })
+ }
+
+ /**
+ * 구성 요소가 설치되어 있으면 경로를, 없으면 내려받아 설치한 뒤 경로를 돌려준다.
+ * 동시 호출은 같은 작업을 공유한다.
+ */
+ async ensure(component: RuntimeComponent): Promise {
+ if (this.isInstalled(component)) {
+ return this.binaryPath(component)
+ }
+
+ const existing = this._inFlight.get(component)
+ if (existing) {
+ logger.debug(`런타임 설치 진행 중 — 기존 작업에 합류: ${component}`)
+ return existing
+ }
+
+ const task = this._install(component).finally(() => {
+ this._inFlight.delete(component)
+ })
+ this._inFlight.set(component, task)
+ return task
+ }
+
+ private async _install(component: RuntimeComponent): Promise {
+ const started = Date.now()
+ logger.info(`런타임 설치 시작: ${component}`)
+ this._emitProgress(component, 'index', 0, 0, 0, 0)
+
+ const index = await this._fetchIndex()
+ const entry = index.components[component]
+ if (!entry) {
+ throw new D3ROError(
+ ErrorCode.ConfigReadFailed,
+ `런타임 인덱스에 ${component} 구성 요소가 없습니다 (version=${index.version})`,
+ )
+ }
+
+ const targetDir = this.componentDir(component)
+ const tempDir = join(app.getPath('userData'), RUNTIME_DIR_NAME, `.download-${component}`)
+ await rm(tempDir, { recursive: true, force: true })
+ await mkdir(tempDir, { recursive: true })
+
+ try {
+ const archivePath = join(tempDir, entry.archive)
+ await this._downloadParts(component, entry, tempDir, archivePath)
+
+ if (component === 'sidecar' || component === 'ffmpeg') {
+ // 기존 설치를 지우고 새로 배치한다 (부분 상태 방지: 먼저 temp에 풀고 검증 후 교체)
+ await rm(targetDir, { recursive: true, force: true })
+ await mkdir(targetDir, { recursive: true })
+ }
+
+ this._emitProgress(component, 'extracting', 100, entry.totalSize, entry.totalSize, 0)
+ await tar.x({
+ file: archivePath,
+ cwd: targetDir,
+ // 경로 탈출 항목은 건너뛴다
+ filter: (path) => !path.split('/').includes('..'),
+ })
+
+ if (!this.isInstalled(component)) {
+ throw new D3ROError(
+ ErrorCode.STTSidecarSpawnFailed,
+ `런타임 설치 후 실행 파일을 찾을 수 없습니다: ${this.binaryPath(component)}`,
+ )
+ }
+
+ this._emitProgress(component, 'done', 100, entry.totalSize, entry.totalSize, 0)
+ logger.info(
+ `런타임 설치 완료: ${component} (${(entry.totalSize / 1048576).toFixed(1)}MiB, ${Date.now() - started}ms)`,
+ )
+ return this.binaryPath(component)
+ } catch (err) {
+ // 실패 시 부분 산출물 정리 — 반쯤 풀린 설치를 남기지 않는다
+ await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
+ if (!this.isInstalled(component)) {
+ await rm(targetDir, { recursive: true, force: true }).catch(() => undefined)
+ }
+ const message = err instanceof Error ? err.message : String(err)
+ logger.error(`런타임 설치 실패: ${component} — ${message}`)
+ if (err instanceof D3ROError) throw err
+ throw new D3ROError(
+ ErrorCode.STTSidecarSpawnFailed,
+ `런타임 설치 실패(${component}): ${message}`,
+ )
+ } finally {
+ await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
+ }
+ }
+
+ private async _fetchIndex(): Promise {
+ if (!RUNTIME_FEED_URL) {
+ throw new D3ROError(
+ ErrorCode.ConfigReadFailed,
+ '런타임 feed가 설정되지 않았습니다 (자동 업데이트 비활성 상태)',
+ )
+ }
+ const url = `${RUNTIME_FEED_URL}/runtime.json`
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) })
+ if (!response.ok) {
+ throw new D3ROError(
+ ErrorCode.ConfigReadFailed,
+ `런타임 인덱스를 받을 수 없습니다 (HTTP ${response.status}): ${url}`,
+ )
+ }
+ const index = (await response.json()) as RuntimeIndex
+ if (!index?.components) {
+ throw new D3ROError(ErrorCode.ConfigReadFailed, '런타임 인덱스 형식이 올바르지 않습니다')
+ }
+ return index
+ }
+
+ private async _downloadParts(
+ component: RuntimeComponent,
+ entry: RuntimeComponentIndex,
+ tempDir: string,
+ archivePath: string,
+ ): Promise {
+ const totalBytes = entry.totalSize > 0
+ ? entry.totalSize
+ : entry.parts.reduce((sum, part) => sum + part.size, 0)
+
+ let downloadedBytes = 0
+ const startedAt = Date.now()
+
+ for (const part of entry.parts) {
+ const partPath = join(tempDir, part.name)
+ const response = await fetch(part.url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) })
+ if (!response.ok || !response.body) {
+ throw new D3ROError(
+ ErrorCode.STTSidecarSpawnFailed,
+ `런타임 부품을 받을 수 없습니다 (HTTP ${response.status}): ${part.name}`,
+ )
+ }
+
+ const hash = createHash('sha256')
+ let partBytes = 0
+ const source = Readable.fromWeb(response.body as never)
+ source.on('data', (chunk: Buffer) => {
+ hash.update(chunk)
+ partBytes += chunk.length
+ downloadedBytes += chunk.length
+ const elapsed = Math.max(0.001, (Date.now() - startedAt) / 1000)
+ this._emitProgress(
+ component,
+ 'downloading',
+ totalBytes > 0 ? Math.min(100, Math.round((downloadedBytes * 100) / totalBytes)) : 0,
+ downloadedBytes,
+ totalBytes,
+ Math.round(downloadedBytes / elapsed),
+ )
+ })
+
+ await pipeline(source, createWriteStream(partPath))
+
+ const actual = hash.digest('hex')
+ if (part.sha256 && actual !== part.sha256) {
+ throw new D3ROError(
+ ErrorCode.STTSidecarSpawnFailed,
+ `런타임 부품 해시 불일치 (${part.name})`,
+ )
+ }
+ if (partBytes !== part.size) {
+ throw new D3ROError(
+ ErrorCode.STTSidecarSpawnFailed,
+ `런타임 부품 크기 불일치 (${part.name}: ${partBytes} != ${part.size})`,
+ )
+ }
+ }
+
+ // 부품을 순서대로 이어 인다 (스트리밍 — 메모리에 통째로 올리지 않는다)
+ const archiveHash = createHash('sha256')
+ const archiveStream = createWriteStream(archivePath)
+ for (const part of entry.parts) {
+ const source = createReadStream(join(tempDir, part.name))
+ source.on('data', (chunk: string | Buffer) => archiveHash.update(chunk))
+ await pipeline(source, archiveStream, { end: false })
+ }
+ archiveStream.end()
+ await once(archiveStream, 'finish')
+ const actualArchive = archiveHash.digest('hex')
+ if (entry.sha256 && actualArchive !== entry.sha256) {
+ throw new D3ROError(
+ ErrorCode.STTSidecarSpawnFailed,
+ `런타임 아카이브 해시 불일치 (${component})`,
+ )
+ }
+ }
+
+ private _emitProgress(
+ component: RuntimeComponent,
+ phase: RuntimeProgressEvent['phase'],
+ percent: number,
+ downloadedBytes: number,
+ totalBytes: number,
+ bytesPerSecond: number,
+ ): void {
+ this.emit('progress', {
+ component,
+ phase,
+ percent,
+ downloadedBytes,
+ totalBytes,
+ bytesPerSecond,
+ } satisfies RuntimeProgressEvent)
+ }
+}
+
+let _instance: RuntimeProvisioner | null = null
+
+export function getRuntimeProvisioner(): RuntimeProvisioner {
+ if (!_instance) {
+ _instance = new RuntimeProvisioner()
+ }
+ return _instance
+}
+
+export function resetRuntimeProvisionerForTests(): void {
+ _instance = null
+}
\ No newline at end of file
diff --git a/apps/desktop/src/main/update-feed.ts b/apps/desktop/src/main/update-feed.ts
index 484872f..72b6ea6 100644
--- a/apps/desktop/src/main/update-feed.ts
+++ b/apps/desktop/src/main/update-feed.ts
@@ -16,6 +16,17 @@ export const UPDATE_FEED_URL =
// GitLab Generic Registry legacy mirror. 2026-08 이전 설치본(0.2.1-alpha)은
// 이 feed를 폴링하므로, 새 설치자가 Forgejo feed를 내장할 때까지 publisher가
// 함께 게시한다. 마이그레이션 완료 후 제거 가능. 런타임은 참조하지 않는다.
+/**
+ * 로컬 AI 런타임(사이드카 엔진 / ffmpeg) 배포 위치.
+ * 진을 설치본에 넣으면 installer가 Cloudflare 업로드 한도(100MiB)를 넘어 업데이트
+ * 메타데이터(latest.yml)를 게시할 수 없다. 그래서 런타임은 별도 경로에서 필요할 때 받는다.
+ * 자동 업데이트 채널과 분리된 경로이며 서명이 필요 없다.
+ */
+export const RUNTIME_FEED_URL = UPDATE_FEED_URL.replace(/\/latest$/, '/runtime-latest')
+
+/** 런타임 인덱스 파일명 (feed 루트) */
+export const RUNTIME_INDEX_FILENAME = 'runtime.json'
+
export const LEGACY_UPDATE_FEED_URL =
'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest'
diff --git a/apps/desktop/src/main/utils/paths.ts b/apps/desktop/src/main/utils/paths.ts
index 958c782..e60e4f7 100644
--- a/apps/desktop/src/main/utils/paths.ts
+++ b/apps/desktop/src/main/utils/paths.ts
@@ -86,6 +86,31 @@ function packagedResourcePath(...segments: string[]): string {
return path.join(process.resourcesPath, ...segments)
}
+/**
+ * 설치 후 내려받은 런타임(사이드카 엔진 / ffmpeg) 설치 위치.
+ * RuntimeProvisioner가 여기에 배치하고, 이 파일은 경로만 계산한다.
+ */
+export function getProvisionedRuntimeDir(): string {
+ return path.join(app.getPath('userData'), 'runtime')
+}
+
+function provisionedBinary(component: 'sidecar' | 'ffmpeg'): string {
+ const name = component === 'sidecar' ? `sidecar${EXE_SUFFIX}` : `ffmpeg${EXE_SUFFIX}`
+ return path.join(getProvisionedRuntimeDir(), component, name)
+}
+
+/** 내려받은 사이드카 실행 파일 경로 (없으면 null) */
+export function getProvisionedSidecarPath(): string | null {
+ const candidate = provisionedBinary('sidecar')
+ return existsSync(candidate) ? candidate : null
+}
+
+/** 내려받은 ffmpeg 실행 파일 경로 (없으면 null) */
+export function getProvisionedFfmpegPath(): string | null {
+ const candidate = provisionedBinary('ffmpeg')
+ return existsSync(candidate) ? candidate : null
+}
+
let cachedSoxPath: string | undefined
/**
@@ -138,7 +163,7 @@ export interface SidecarLaunch {
command: string
args: string[]
/** 어디에서 결정되었는지 (로그/진단용) */
- source: 'bundled' | 'venv' | 'python'
+ source: 'bundled' | 'provisioned' | 'venv' | 'python'
}
/**
@@ -154,11 +179,16 @@ export function getSidecarCommand(): SidecarLaunch {
if (existsSync(exePath)) {
return { command: exePath, args: [], source: 'bundled' }
}
+
+ // 설치본에는 엔진을 넣지 않는다(업데이트 게시 크기 한도). 필요할 때 내려받은 경로를 쓴다.
+ const provisioned = getProvisionedSidecarPath()
+ if (provisioned) {
+ return { command: provisioned, args: [], source: 'provisioned' }
+ }
+
throw new D3ROError(
- ErrorCode.STTSidecarSpawnFailed,
- `번들된 STT 사이드카를 찾을 수 없습니다: ${exePath}. ` +
- '설치 패키지에 sidecar 리소스가 누락되었습니다(로컬 전사 불가). ' +
- '앱을 다시 설치하거나 개발 모드에서 `npm run sidecar:build`로 빌드하세요.',
+ ErrorCode.STTEngineNotInstalled,
+ '로컬 음성 엔진이 아직 설치되지 않았습니다. 설정 > STT에서 "엔진 다운로드"를 실행하세요.',
)
}
@@ -185,7 +215,6 @@ export function getSidecarCommand(): SidecarLaunch {
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
return { command: pythonCmd, args: [sidecarPath], source: 'python' }
}
-
/** STT 사이드카 HTTP 기본 URL. sidecar는 IPv4 프백에만 바인딩한다. */
export function getSidecarBaseUrl(port: number): string {
return loopbackUrl(port)
@@ -232,6 +261,12 @@ export function getFfmpegPath(): string {
return bundled
}
+ // 설치본에서는 ffmpeg도 필요할 때 내려받는다 (설치/업데이트 크기 절감)
+ const provisionedFfmpeg = getProvisionedFfmpegPath()
+ if (provisionedFfmpeg) {
+ return provisionedFfmpeg
+ }
+
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const installer = require('@ffmpeg-installer/ffmpeg') as { path?: string }
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index b1ec420..788bbbf 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -180,6 +180,25 @@ import type {
import { Feature } from '@d3ro/core/types'
import type { IPCResult } from '@d3ro/core/errors'
+/** 로컬 AI 런타임(사이드카 엔진/ffmpeg) 상태 — RuntimeProvisioner가 제공한다 */
+type RuntimeComponentName = 'sidecar' | 'ffmpeg'
+
+interface RuntimeStatusPayload {
+ component: RuntimeComponentName
+ installed: boolean
+ path: string
+ sizeBytes: number
+}
+
+interface RuntimeProgressPayload {
+ component: RuntimeComponentName
+ phase: 'index' | 'downloading' | 'extracting' | 'done'
+ percent: number
+ downloadedBytes: number
+ totalBytes: number
+ bytesPerSecond: number
+}
+
type Unsubscribe = () => void
function invoke(channel: string, ...args: unknown[]): Promise> {
@@ -284,6 +303,18 @@ const electronAPI = {
on(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, cb)
},
+ // ── Local AI runtime (엔진/ffmpeg — 필요할 때 내려받음) ──
+ runtime: {
+ getStatus: () => invoke(IPC_CHANNELS.RUNTIME.GET_STATUS),
+ ensure: (params: { component: RuntimeComponentName }) =>
+ invoke<{ component: RuntimeComponentName; binaryPath: string }>(
+ IPC_CHANNELS.RUNTIME.ENSURE,
+ params,
+ ),
+ onProgress: (cb: (e: RuntimeProgressPayload) => void): Unsubscribe =>
+ on(IPC_CHANNELS.RUNTIME.PROGRESS, cb)
+ },
+
// ── Hotkey ─────────────────────────────────────────────
hotkey: {
getDictationShortcut: () =>
diff --git a/apps/desktop/src/renderer/components/STTTab.tsx b/apps/desktop/src/renderer/components/STTTab.tsx
index da7117d..3c2f53e 100644
--- a/apps/desktop/src/renderer/components/STTTab.tsx
+++ b/apps/desktop/src/renderer/components/STTTab.tsx
@@ -69,6 +69,18 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
const [downloadingModelId, setDownloadingModelId] = useState(null)
const [downloadPercent, setDownloadPercent] = useState(0)
+ // 로컬 AI 런타임(사이드카 엔진/ffmpeg) — 설치본에는 없고 필요할 때 내려받는다
+ type RuntimeComponentName = 'sidecar' | 'ffmpeg'
+ interface RuntimeStatusRow {
+ component: RuntimeComponentName
+ installed: boolean
+ path: string
+ sizeBytes: number
+ }
+ const [runtimeStatus, setRuntimeStatus] = useState([])
+ const [runtimeBusy, setRuntimeBusy] = useState(null)
+ const [runtimePercent, setRuntimePercent] = useState(0)
+
// Test connection state
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; latencyMs: number; message: string } | null>(null)
@@ -96,9 +108,30 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
}
})
+ const loadRuntime = () => {
+ window.electronAPI.runtime.getStatus().then((res) => {
+ if (res.success && res.data) setRuntimeStatus(res.data)
+ })
+ }
+ loadRuntime()
+
+ const unsubRuntime = window.electronAPI.runtime.onProgress((e) => {
+ if (e.component !== 'sidecar' && e.component !== 'ffmpeg') return
+ if (e.phase === 'done') {
+ setRuntimeBusy(null)
+ setRuntimePercent(100)
+ loadRuntime()
+ return
+ }
+ setRuntimeBusy(e.component)
+ setRuntimePercent(e.percent)
+ })
+
return () => {
unsubDownload()
}
+ unsubDownload()
+ unsubRuntime()
}, [])
// Load specific provider config when activeProvider changes
@@ -133,6 +166,19 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
[activeProvider, providerConfig]
)
+ const handleEnsureRuntime = useCallback(async (component: RuntimeComponentName) => {
+ setRuntimeBusy(component)
+ setRuntimePercent(0)
+ try {
+ const res = await window.electronAPI.runtime.ensure({ component })
+ if (!res.success) setRuntimePercent(0)
+ } finally {
+ setRuntimeBusy(null)
+ const status = await window.electronAPI.runtime.getStatus()
+ if (status.success && status.data) setRuntimeStatus(status.data)
+ }
+ }, [])
+
const handleTestConnection = useCallback(async () => {
setTesting(true)
setTestResult(null)
@@ -364,6 +410,55 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
}
return null
})()}
+
+ {/* 로컬 AI 런타임(엔진/ffmpeg): 설치본에는 없고 처음 필요할 때 내려받는다 */}
+ {runtimeStatus.map((row) => {
+ const busy = runtimeBusy === row.component
+ return (
+
+
+
+ {row.component === 'sidecar'
+ ? '로컬 음성 엔진 (faster-whisper)'
+ : '미디어 변환기 (ffmpeg)'}
+
+
+ {row.installed
+ ? `설치됨 · ${Math.round(row.sizeBytes / 1_000_000)} MB`
+ : busy
+ ? `다운로드 중 (${runtimePercent}%)`
+ : '설치되지 않음 — 로컬 전사에 필요합니다'}
+
+
+ {busy ? (
+
+ ) : (
+ }
+ onClick={() => handleEnsureRuntime(row.component)}
+ sx={{ fontFamily: d3roFontMono, fontSize: '11px' }}
+ >
+ {row.installed ? '다시 설치' : '내려받기'}
+
+ )}
+
+ )
+ })}
) : (
/* Cloud STT (OpenAI, Groq, Deepgram, AssemblyAI, Google, Custom) 설정 */
diff --git a/apps/mobile-rn/android/app/build.gradle b/apps/mobile-rn/android/app/build.gradle
index 43fde46..b344ba1 100644
--- a/apps/mobile-rn/android/app/build.gradle
+++ b/apps/mobile-rn/android/app/build.gradle
@@ -151,8 +151,8 @@ def versionSettingsValid = configuredVersionName != null &&
configuredVersionName ==~ strictSemver &&
configuredVersionCodeValue != null &&
configuredVersionCodeValue <= 2100000000L
-def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.1"
-def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1031001
+def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.2"
+def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1031002
def requiredReleaseSettings = [
D3RO_RELEASE_STORE_FILE: releaseStoreFilePath,
diff --git a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj
index fb6b4d2..3d87074 100644
--- a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj
+++ b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj
@@ -257,7 +257,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 1031001;
+ CURRENT_PROJECT_VERSION = 1031002;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = D3ROVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
@@ -265,7 +265,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.3.1;
+ MARKETING_VERSION = 1.3.2;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -287,14 +287,14 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 1031001;
+ CURRENT_PROJECT_VERSION = 1031002;
INFOPLIST_FILE = D3ROVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.3.1;
+ MARKETING_VERSION = 1.3.2;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
diff --git a/apps/mobile-rn/metadata/android/en-US/changelogs/1031002.txt b/apps/mobile-rn/metadata/android/en-US/changelogs/1031002.txt
new file mode 100644
index 0000000..cd36bea
--- /dev/null
+++ b/apps/mobile-rn/metadata/android/en-US/changelogs/1031002.txt
@@ -0,0 +1 @@
+Reworked the install layout so updates flow again; required components are verified and fetched only when first needed.
diff --git a/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031002.txt b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031002.txt
new file mode 100644
index 0000000..ce9e668
--- /dev/null
+++ b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031002.txt
@@ -0,0 +1 @@
+업데이트가 정상 동작하도록 설치 구조를 정리했습니다. 필요한 기능은 처음 사용할 때 검증하여 내려받습니다.
diff --git a/apps/mobile-rn/package-lock.json b/apps/mobile-rn/package-lock.json
index 713eaad..465b152 100644
--- a/apps/mobile-rn/package-lock.json
+++ b/apps/mobile-rn/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@d3ro/mobile-rn",
- "version": "1.3.1",
+ "version": "1.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@d3ro/mobile-rn",
- "version": "1.3.1",
+ "version": "1.3.2",
"dependencies": {
"@d3ro/api-client": "file:../../packages/api-client",
"@d3ro/core": "file:../../packages/core",
@@ -62,7 +62,7 @@
},
"../..": {
"name": "d3ro-voice-monorepo",
- "version": "1.3.1",
+ "version": "1.3.2",
"license": "MIT",
"workspaces": [
"apps/desktop",
@@ -81,7 +81,7 @@
},
"../../packages/api-client": {
"name": "@d3ro/api-client",
- "version": "1.3.1",
+ "version": "1.3.2",
"license": "MIT",
"dependencies": {
"@d3ro/core": "*",
@@ -98,7 +98,7 @@
},
"../../packages/core": {
"name": "@d3ro/core",
- "version": "1.3.1",
+ "version": "1.3.2",
"license": "MIT",
"dependencies": {
"docx": "^9.6.1"
@@ -109,7 +109,7 @@
},
"../../packages/i18n": {
"name": "@d3ro/i18n",
- "version": "1.3.1",
+ "version": "1.3.2",
"license": "MIT",
"devDependencies": {
"@types/react": "^19.0.0"
@@ -120,7 +120,7 @@
},
"../../packages/ui-native": {
"name": "@d3ro/ui-native",
- "version": "1.3.1",
+ "version": "1.3.2",
"license": "MIT",
"devDependencies": {
"@types/react": "*"
diff --git a/apps/mobile-rn/package.json b/apps/mobile-rn/package.json
index dc24dfb..f86ab26 100644
--- a/apps/mobile-rn/package.json
+++ b/apps/mobile-rn/package.json
@@ -1,6 +1,6 @@
{
"name": "@d3ro/mobile-rn",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"scripts": {
"android": "react-native run-android",
diff --git a/apps/web/package.json b/apps/web/package.json
index 85e5343..7b715a4 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@d3ro/web",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스",
"scripts": {
diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx
index 255994d..83bb17b 100644
--- a/apps/web/src/components/layout/sidebar.tsx
+++ b/apps/web/src/components/layout/sidebar.tsx
@@ -139,7 +139,7 @@ export function Sidebar(): React.ReactElement {
- v1.3.1
+ v1.3.2
diff --git a/bucket/d3ro-voice.json b/bucket/d3ro-voice.json
index f09fb46..740685d 100644
--- a/bucket/d3ro-voice.json
+++ b/bucket/d3ro-voice.json
@@ -1,17 +1,15 @@
{
- "version": "1.3.1",
+ "version": "1.3.2",
"description": "로컬 AI 음성 어시스턴트 (faster-whisper + Ollama, 100% 오프라인 지원)",
"homepage": "https://d3ro.chanpaca.net",
"license": "MIT",
"architecture": {
"64bit": {
"url": [
- "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.1/D3RO-Voice-1.3.1-x64-portable.7z.001",
- "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.1/D3RO-Voice-1.3.1-x64-portable.7z.002"
+ "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.2/D3RO-Voice-1.3.2-x64-portable.7z.001"
],
"hash": [
- "917f9d6e078517b6c924c30d26b59cf530d8c69e4adbdf679d2196bc645fa993",
- "06f32ca809fed42cda9900e04a8e50da42506ed71944462314ecef40773a3197"
+ "62c1fab862c542839cd64c795e5714fda3e5332f0be22a051137f42ceb400324"
]
}
},
@@ -29,8 +27,7 @@
"architecture": {
"64bit": {
"url": [
- "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.1-x64-portable.7z.001",
- "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.1-x64-portable.7z.002"
+ "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.2-x64-portable.7z.001"
]
}
}
diff --git a/docs/deployment/release-guide.md b/docs/deployment/release-guide.md
index 685ffd0..eb28939 100644
--- a/docs/deployment/release-guide.md
+++ b/docs/deployment/release-guide.md
@@ -141,6 +141,24 @@ node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write
10. Forgejo Release note/asset, `latest.yml`, `update-policy.json`, installer hash를 외부 public URL에서 다시 검증한다.
11. 이전 설치본에서 자동 업데이트 E2E를 실행하고 실행 중 버전·프로세스·사용자 데이터 보존, 그리고 **업데이트 후 로컬 받아쓰기 1회 성공**을 확인한다.
+## 자동 업데이트 게시 (런타임 분리 전제)
+
+설치본은 **90MiB대**를 유지해야 한다. 로컬 AI 런타임(사이드카 엔진/ffmpeg)을 설치본에 넣으면
+Cloudflare 업로드 한도(100MiB)를 넘어 `latest.yml`을 게시할 수 없다.
+
+```bash
+npm run build --workspace=@d3ro/desktop # 렌더러/메인 번들
+npm run release:portable:build # 런타임 번들 생성(엔진/ffmpeg)
+npm run release:portable # portable + runtime 채널 게시
+npm run release:updater:check # 업데이터 게시 예정 확인
+npm run release:updater -- --ack-unsigned # (인증서 없을 때만) 무서명 게시
+```
+
+- 서명 인증서가 있으면 `--ack-unsigned` 없이 게시한다(권장). 무서명 게시는 명시적 예외이며
+ 스크립트가 플래그 없이는 즉시 실패한다(GAP-REL-06에 기록).
+- 런타임은 `runtime-latest/runtime.json`이 정본이고 앱이 처음 필요할 때 내려받는다.
+ 런타임을 바꾸면 반드시 `release:portable`로 먼저 게시한 뒤 설치본을 게시한다.
+
## 서명 없이 내놓기 (portable 채널)
인증서가 없어도 사용자가 설치할 수 있어야 할 때 사용한다. 자세한 조사·비교·제약은
diff --git a/docs/deployment/unsigned-distribution.md b/docs/deployment/unsigned-distribution.md
index 6455451..aaa75e0 100644
--- a/docs/deployment/unsigned-distribution.md
+++ b/docs/deployment/unsigned-distribution.md
@@ -102,7 +102,18 @@
---
-## 5. 현재 상태 (2026-09-18)
+## 5. 자동 업데이트가 다시 동작한다 (2026-09-18)
+
+- 설치본에서 로컬 AI 런타임(사이드카 94.4MiB, ffmpeg 21.7MiB)을 분리했다 → 설치본 **90.6MiB**.
+- 그래서 `latest.yml` + 설치본을 canonical feed에 게시할 수 있게 되어 **자동 업데이트가 복구**됐다
+ (`1.3.2` 게시). 서명이 없어 electron-updater는 `publisherName` 부재로 서명 검증을 건너뛴다
+ (`node_modules/electron-updater/out/NsisUpdater.js:84-99`).
+- 런타임은 `runtime-` / `runtime-latest`에 게시되고 앱이 처음 필요할 때 내려받는다
+ (부품별 + 결합본 SHA-256 검증, tar 해제, 실패 시 부분 설치 정리).
+- 주의: 무서명 stable 게시는 명시적 예외이며 `--ack-unsigned` 없이는 스크립트가 거부한다.
+- 1.0.x 이하 설치본은 여전히 legacy GitLab mirror를 보고 있으므로 **1회 수동 설치**가 필요하다.
+
+## 6. 채널 현황 (2026-09-18)
- 게시된 채널: `portable-1.3.1` + `portable-latest` (7z 볼륨 2개, zip 부품 3개, 인덱스, 설치 스크립트).
- `portable-1.3.0`에는 7z 볼륨 2개만 있다(부분 게시, zip 부품 없음) — `1.3.1`이 대체한다.
diff --git a/docs/map/10-feature-catalog.md b/docs/map/10-feature-catalog.md
index 6ee02cb..9d0287a 100644
--- a/docs/map/10-feature-catalog.md
+++ b/docs/map/10-feature-catalog.md
@@ -194,6 +194,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
| INFRA-15 | Update & release system | [x] | Canonical Forgejo feed + channels/policy (`release/update-policy.json`, `src/main/update-policy.ts`), canonical publisher `scripts/ci/publish-forgejo-release.mjs`, legacy GitLab mirror; `npm run release:metadata:test`. v1.1.0 was published to Forgejo on 2026-09-15; product version moved to `1.2.0` as a forward-fix with CI-only publication, a same-version re-release guard, and download centers that link the feed instead of repository paths. `1.3.0` (2026-09-18) carries the local-STT fixes; Windows publication still needs the CI signing secrets (`11` GAP-REL-02). |
| INFRA-16 | Desktop STT engine packaging | [x] | `apps/desktop/scripts/setup-sidecar.mjs` + `build-sidecar.mjs`, `electron-builder.yml` `extraResources` (`sidecar-dist/sidecar` → `resources/sidecar`, `resources/ffmpeg`), and `scripts/ci/verify-sidecar-bundle.mjs` run in `package-windows`/`package-macos` before electron-builder. Verified on the real bundle: `sidecar.exe` + `_internal` including `faster_whisper/assets/silero_vad_v6.onnx`, plus a packaged-engine transcription round-trip on GPU. |
| INFRA-17 | 서명 없는 배포 채널 (portable + Scoop) | [x] | `scripts/ci/build-portable.mjs` (95MiB 7z 분할 볼륨 + Scoop 매니페스트), `scripts/ci/publish-portable-release.mjs`, `scripts/local/install-d3ro-voice.ps1`, `bucket/` 버킷, `.forgejo/workflows/portable.yml`; 7z 분할 볼륨(Scoop, 162MiB) + zip 분할 부품(수동 설치, 243MiB, 7-Zip 불필요); updater feed와 분리. 2026-09-18 `portable-1.3.1` 게시 + 실제 설치 검증. |
+| INFRA-18 | 로컬 런타임 온디맨드 설치 | [x] | `RuntimeProvisioner`(부품 다운로드 + SHA-256 검증 + tar 해제, `%APPDATA%/d3ro-voice/runtime`), `POST runtime:ensure` / `runtime:progress` IPC, 설정 > STT 상태/내려받기 UI. 설치본에서 엔진/ffmpeg를 분리해 189MB → 90.6MiB, 업데이트 피드 게시 복구. 2026-09-18 실제 feed 통합 검증(엔진 94.4MiB/17초, ffmpeg 21.7MiB/5초). |
---
diff --git a/docs/map/11-gap-backlog.md b/docs/map/11-gap-backlog.md
index 8d72fbe..eab72a9 100644
--- a/docs/map/11-gap-backlog.md
+++ b/docs/map/11-gap-backlog.md
@@ -23,6 +23,7 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
| 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. |
| 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-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-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-ID-01 | Identity | Supabase, .NET JWT/SQLite, and the desktop offline license each had their own tier/role shape. | `@d3ro/core/entitlement`, `LicenseService`, `entitlement-context` | `[~]` 2026-09-13: canonical `EntitlementSnapshot` + `resolveEntitlement` added with tests; desktop tier normalization + `isPro` fixed. Full adoption tracked as GAP-ID-02. |
@@ -48,8 +49,8 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
| 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-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으로 거부한다. 사이드카를 포함한 NSIS 설치본은 189MB라 **인증서가 있어도 게시할 수 없다**. | `scripts/ci/publish-portable-release.mjs`, `.forgejo/workflows/release.yml`, `docs/deployment/unsigned-distribution.md` | `[ ]` 2026-09-18 실측(60MiB 201 · 110MiB 413). 해결: 앱 번들을 100MiB 이하로 만들고 엔진은 분할 다운로드로 받거나(GAP-STT-07), 게시 경로를 바꾼다. |
-| GAP-STT-07 | Local STT | 엔진(사이드카)를 앱 번들에 넣으면 설치본이 100MiB를 넘고 매 업데이트마다 162MiB를 다시 받는다. | `apps/desktop/electron-builder.yml`, `LocalSTTService` | `[ ]` 앱은 엔진을 첫 실행 시 분할 다운로드 + SHA-256 검증으로 받도록 분리한다. 설치 크기/업데이트 크기/패키징 실패 지점이 모두 줄어든다. |
+| 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-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). |
---
diff --git a/memory/project_status.md b/memory/project_status.md
index e634eb0..617b18b 100644
--- a/memory/project_status.md
+++ b/memory/project_status.md
@@ -1,5 +1,38 @@
# D3RO-VOICE 프로젝트 현황
+## v1.3.2 — 자동 업데이트 복구: 런타임을 설치본에서 분리 (2026-09-18) 🔄
+
+자동 업데이트가 왜 안 되는지 끝까지 추적했다. 원인은 서명만이 아니라 **업로드 크기 한도**였다.
+
+**실측한 사슬**
+1. canonical feed는 Cloudflare 뒤에 있고 업로드 본문이 100MiB를 넘으면 **HTTP 413**으로 거부한다
+ (60MiB → 201, 110MiB → 413. chunked 업로드도 413).
+2. 사이드카 엔진(242MB)을 포함한 NSIS는 **189MB** → 인증서가 있어도 `latest.yml`을 게시할 수 없다.
+3. 게다가 이 PC의 설치본(1.0.0)은 `app-update.yml`이 legacy GitLab mirror(0.2.1-alpha)를 봐서
+ 어떤 릴리스가 나와도 스스로 올라오지 못한다 → 1회 수동 설치 필수.
+
+**해결**
+- `RuntimeProvisioner` 신설: 설치본에서 엔진/ffmpeg를 빼고, **처음 필요할 때** feed에서
+ 부품 단위로 내려받아 SHA-256(부품 + 결합본) 검증 후 tar 해제 (`%APPDATA%/d3ro-voice/runtime`).
+ 실패 시 부분 설치 정리, 동시 요청 공유, 진행률 이벤트(`runtime:progress`).
+- 패키징: extraResources/asarUnpack에서 엔진·ffmpeg 제거 + `electronLanguages: ko, en-US`.
+ **설치본 688.5MiB → 345.4MiB, NSIS 189MB → 90.6MiB** (한도 통과).
+- 런타임 번들 게시: `runtime-`/`runtime-latest` (sidecar 94.4MiB = 2부품, ffmpeg 21.7MiB = 1부품).
+ tar.gz는 242MB → 94.4MiB로 줄어 첫 설치 다운로드가 116MiB로 끝난다.
+- 설정 > STT에 런타임 상태 + 내려받기 버튼 추가(진행률 표시).
+- 업데이터 게시 스크립트(`release:updater`) 추가: 100MiB 가드 + 409 대응(메타데이터는 교체) +
+ 무서명 게시는 `--ack-unsigned` 명시 승인 필수(정책 예외를 조용히 만들지 않는다).
+
+**검증**: 설치본 90.6MiB → canonical feed `latest`에 게시 완료(`latest.yml` = 1.3.2, 익명 다운로드 확인).
+실제 feed로 런타임 통합 테스트 GREEN(엔진 94.4MiB/17초, ffmpeg 21.7MiB/5초 → 실행 파일 + `_internal` 확인).
+strict typecheck 신규 오류 0(기존 38건은 무관), lint clean, version/metadata/secret 게이트 GREEN.
+
+**남은 것(외부)**: 인증서. 지금은 무서명으로 stable 게시한 예외 상태(GAP-REL-06)라 인증서 확보 후
+더 높은 버전을 서명 게시하여 대체해야 한다. 1.0.x 이하 설치는 1.3.2를 1회 수동 설치하면 이후 자동.
+
+---
+
+
## v1.3.0 — 로컬 전사가 실제로 동작하게 (2026-09-18) 🎙️
설치본에서 로컬 전사가 **한 번도 성공한 적 없던** 원인을 끝까지 추적해 수정. 엔진 자체는
diff --git a/package-lock.json b/package-lock.json
index fc196af..310189d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "d3ro-voice-monorepo",
- "version": "1.3.1",
+ "version": "1.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "d3ro-voice-monorepo",
- "version": "1.3.1",
+ "version": "1.3.2",
"license": "MIT",
"workspaces": [
"apps/desktop",
@@ -25,7 +25,7 @@
},
"apps/admin": {
"name": "@d3ro/admin",
- "version": "1.3.1",
+ "version": "1.3.2",
"dependencies": {
"@d3ro/api-client": "*",
"@d3ro/core": "*",
@@ -109,7 +109,7 @@
},
"apps/desktop": {
"name": "@d3ro/desktop",
- "version": "1.3.1",
+ "version": "1.3.2",
"license": "MIT",
"dependencies": {
"@d3ro/core": "*",
@@ -135,6 +135,7 @@
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
+ "tar": "^7.5.13",
"uiohook-napi": "^1.5.5"
},
"devDependencies": {
@@ -157,7 +158,7 @@
},
"apps/web": {
"name": "@d3ro/web",
- "version": "1.3.1",
+ "version": "1.3.2",
"dependencies": {
"@d3ro/api-client": "*",
"@d3ro/core": "*",
@@ -360,7 +361,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -441,7 +442,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@@ -457,7 +458,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@@ -2519,7 +2520,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
- "dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
@@ -3537,7 +3537,7 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -3551,7 +3551,7 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -3561,7 +3561,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -6281,7 +6281,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -6426,7 +6426,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
- "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
@@ -6505,7 +6504,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
"integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"restore-cursor": "^3.1.0"
@@ -6518,7 +6517,7 @@
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
"integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6583,7 +6582,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
"integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8"
@@ -7486,7 +7485,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
"integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"clone": "^1.0.2"
@@ -7996,7 +7995,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -8472,7 +8471,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -9065,7 +9064,7 @@
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -9255,7 +9254,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
@@ -9460,7 +9459,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -9485,7 +9484,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -9733,7 +9732,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -10198,7 +10197,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -10223,7 +10222,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -10246,7 +10245,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
"integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -10297,7 +10296,7 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -10801,7 +10800,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
@@ -10856,7 +10855,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
"integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.0",
@@ -11014,7 +11013,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -11946,7 +11945,7 @@
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
@@ -11959,7 +11958,7 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -11969,7 +11968,7 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@@ -11982,7 +11981,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -12053,7 +12052,6 @@
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
- "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -12193,7 +12191,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
@@ -12586,7 +12583,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^2.1.0"
@@ -12620,7 +12617,7 @@
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
"integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"bl": "^4.1.0",
@@ -12678,7 +12675,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
@@ -12694,7 +12691,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
@@ -12841,7 +12838,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -13408,7 +13405,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -14616,7 +14613,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
"integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"onetime": "^5.1.0",
@@ -14640,7 +14637,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -14749,7 +14746,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -15580,7 +15577,6 @@
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
- "dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@@ -15645,7 +15641,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
- "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
@@ -16047,7 +16042,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -16564,7 +16559,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
"integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"defaults": "^1.0.3"
@@ -16845,7 +16840,7 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -16866,7 +16861,7 @@
},
"packages/api-client": {
"name": "@d3ro/api-client",
- "version": "1.3.1",
+ "version": "1.3.2",
"license": "MIT",
"dependencies": {
"@d3ro/core": "*",
@@ -16883,7 +16878,7 @@
},
"packages/core": {
"name": "@d3ro/core",
- "version": "1.3.1",
+ "version": "1.3.2",
"license": "MIT",
"dependencies": {
"docx": "^9.6.1"
@@ -16894,7 +16889,7 @@
},
"packages/i18n": {
"name": "@d3ro/i18n",
- "version": "1.3.1",
+ "version": "1.3.2",
"license": "MIT",
"devDependencies": {
"@types/react": "^19.0.0"
@@ -16905,7 +16900,7 @@
},
"packages/ui": {
"name": "@d3ro/ui",
- "version": "1.3.1",
+ "version": "1.3.2",
"license": "MIT",
"dependencies": {
"@d3ro/core": "*"
@@ -16925,7 +16920,7 @@
},
"packages/ui-native": {
"name": "@d3ro/ui-native",
- "version": "1.3.1",
+ "version": "1.3.2",
"license": "MIT",
"devDependencies": {
"@types/react": "*"
diff --git a/package.json b/package.json
index 6109887..aa1f21d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "d3ro-voice-monorepo",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)",
"author": "D3RO",
@@ -50,7 +50,9 @@
"typecheck:mobile": "npm --prefix apps/mobile-rn run typecheck",
"lint:mobile": "npm --prefix apps/mobile-rn run lint",
"test:mobile": "npm --prefix apps/mobile-rn test",
- "verify:all": "npm run typecheck && npm run typecheck:mobile && npm run lint && npm run lint:mobile && npm run test && npm run test:mobile && npm run check:design"
+ "verify:all": "npm run typecheck && npm run typecheck:mobile && npm run lint && npm run lint:mobile && npm run test && npm run test:mobile && npm run check:design",
+ "release:updater": "node --env-file-if-exists=.env scripts/ci/publish-updater-release.mjs",
+ "release:updater:check": "node scripts/ci/publish-updater-release.mjs --check"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.0.0",
diff --git a/packages/api-client/package.json b/packages/api-client/package.json
index 2ec3d14..d84c6c4 100644
--- a/packages/api-client/package.json
+++ b/packages/api-client/package.json
@@ -1,6 +1,6 @@
{
"name": "@d3ro/api-client",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)",
"license": "MIT",
diff --git a/packages/core/package.json b/packages/core/package.json
index 0653bcb..29f5241 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@d3ro/core",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸",
"license": "MIT",
diff --git a/packages/core/src/ipc-channels.ts b/packages/core/src/ipc-channels.ts
index e28de33..b4b3f04 100644
--- a/packages/core/src/ipc-channels.ts
+++ b/packages/core/src/ipc-channels.ts
@@ -29,6 +29,14 @@ export const IPC_CHANNELS = {
TEST_LEVEL: 'audio:testLevel'
},
+ /** 로컬 AI 런타임(사이드카 엔진/ffmpeg) 설치 — 앱이 feed에서 내려받아 검증한다 */
+ RUNTIME: {
+ GET_STATUS: 'runtime:getStatus',
+ ENSURE: 'runtime:ensure',
+ // Main → Renderer events
+ PROGRESS: 'runtime:progress'
+ },
+
STT: {
GET_STATUS: 'stt:getStatus',
GET_MODELS: 'stt:getModels',
diff --git a/packages/i18n/package.json b/packages/i18n/package.json
index 9c9d457..a1199d2 100644
--- a/packages/i18n/package.json
+++ b/packages/i18n/package.json
@@ -1,6 +1,6 @@
{
"name": "@d3ro/i18n",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸",
"license": "MIT",
diff --git a/packages/ui-native/package.json b/packages/ui-native/package.json
index 8ad0f52..d0703f5 100644
--- a/packages/ui-native/package.json
+++ b/packages/ui-native/package.json
@@ -1,6 +1,6 @@
{
"name": "@d3ro/ui-native",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.",
"license": "MIT",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index c949d91..7ad7a3e 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@d3ro/ui",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵",
"license": "MIT",
diff --git a/release/product-version.json b/release/product-version.json
index 941ec32..b224c85 100644
--- a/release/product-version.json
+++ b/release/product-version.json
@@ -1,8 +1,8 @@
{
"schemaVersion": 1,
- "version": "1.3.1",
- "androidVersionCode": 1031001,
- "iosBuildNumber": 1031001,
+ "version": "1.3.2",
+ "androidVersionCode": 1031002,
+ "iosBuildNumber": 1031002,
"releaseDate": "2026-09-18",
"desktopLicensePublicKeyId": "5c52b765135ee2531c681b53cd4dc0e96fff8737f496c0b04ba8334fc81a887f"
}
diff --git a/scripts/ci/build-portable.mjs b/scripts/ci/build-portable.mjs
index a1143fc..7753da4 100644
--- a/scripts/ci/build-portable.mjs
+++ b/scripts/ci/build-portable.mjs
@@ -25,12 +25,17 @@ import {
existsSync,
readFileSync,
readdirSync,
+ mkdirSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { createRequire } from 'node:module'
+import { createGzip } from 'node:zlib'
+import { pipeline } from 'node:stream/promises'
+import { createReadStream, createWriteStream } from 'node:fs'
+import * as tar from 'tar'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -306,6 +311,90 @@ writeFileSync(
`${JSON.stringify(indexJson, null, 2)}\n`,
'utf8',
)
+// ── 로컬 AI 런타임 번들 (설치본에 넣지 않고 처음 필요할 때 내려받는다) ──────
+const RUNTIME_DIR = join(releaseDir, 'runtime')
+rmSync(RUNTIME_DIR, { recursive: true, force: true })
+mkdirSync(RUNTIME_DIR, { recursive: true })
+
+/** 디렉터리를 tar.gz으로 묶어 90MiB 부품으로 나누고 인덱스 항목을 돌려준다 */
+async function packRuntime(component, sourceDir, archiveBase) {
+ const archivePath = join(RUNTIME_DIR, `${archiveBase}.tar.gz`)
+ await pipeline(
+ tar.c({ cwd: sourceDir, portable: true, gzip: false }, ['.']),
+ createGzip({ level: 6 }),
+ createWriteStream(archivePath),
+ )
+
+ const archiveBytes = readFileSync(archivePath)
+ const sha256 = createHash('sha256').update(archiveBytes).digest('hex')
+ const partSize = 90 * 1024 * 1024
+ const parts = []
+ for (let offset = 0, index = 1; offset < archiveBytes.length; offset += partSize, index += 1) {
+ const slice = archiveBytes.subarray(offset, Math.min(offset + partSize, archiveBytes.length))
+ const name = `${archiveBase}.tar.gz.${String(index).padStart(3, '0')}`
+ if (slice.length > MAX_ZIP_PART_BYTES) {
+ throw new Error(`런타임 부품이 너무 큽니다: ${name}`)
+ }
+ writeFileSync(join(RUNTIME_DIR, name), slice)
+ parts.push({
+ name,
+ size: slice.length,
+ sha256: createHash('sha256').update(slice).digest('hex'),
+ })
+ }
+
+ return {
+ component,
+ archive: `${archiveBase}.tar.gz`,
+ sha256,
+ totalSize: archiveBytes.length,
+ parts,
+ }
+}
+const runtimeComponents = {}
+const sidecarSource = join(desktopDir, 'sidecar-dist', 'sidecar')
+// @ffmpeg-installer가 플랫폼별로 제공하는 실행 파일 경로를 그대로 사용한다
+const ffmpegInstaller = (() => {
+ try {
+ return require('@ffmpeg-installer/ffmpeg')
+ } catch {
+ return null
+ }
+})()
+const ffmpegSource = ffmpegInstaller?.path ? dirname(ffmpegInstaller.path) : null
+
+if (existsSync(sidecarSource)) {
+ console.log('[portable] 런타임 번들 생성: sidecar (faster-whisper 진)')
+ runtimeComponents.sidecar = await packRuntime('sidecar', sidecarSource, 'd3ro-runtime-sidecar')
+} else {
+ console.error('[portable] 경고: sidecar-dist가 없어 런타임 번들을 만들 수 없습니다')
+}
+
+if (ffmpegSource && existsSync(ffmpegSource)) {
+ console.log('[portable] 런타임 번들 생성: ffmpeg')
+ runtimeComponents.ffmpeg = await packRuntime('ffmpeg', ffmpegSource, 'd3ro-runtime-ffmpeg')
+} else {
+ console.error('[portable] 경고: @ffmpeg-installer가 없어 ffmpeg 런타임을 만들 수 없습니다')
+}
+
+const runtimeIndex = {
+ schemaVersion: 1,
+ version,
+ generatedAt: new Date().toISOString(),
+ components: Object.fromEntries(
+ Object.entries(runtimeComponents).map(([name, entry]) => [
+ name,
+ {
+ ...entry,
+ parts: entry.parts.map((part) => ({
+ ...part,
+ url: `${FEED}/runtime-${version}/${part.name}`,
+ })),
+ },
+ ]),
+ ),
+}
+writeFileSync(join(RUNTIME_DIR, 'runtime.json'), `${JSON.stringify(runtimeIndex, null, 2)}\n`, 'utf8')
console.log(
[
'[portable] 완료',
@@ -316,6 +405,7 @@ console.log(
` zip : ${zipParts.length}개 부품 / 합계 ${(zipBytes.length / 1048576).toFixed(1)}MiB`,
` 인덱스 : ${join(releaseDir, 'portable.json')}`,
` scoop : ${join(root, 'bucket', 'd3ro-voice.json')}`,
+ ` 런타임 : ${Object.keys(runtimeComponents).join(', ') || '(없음)'} → ${join(RUNTIME_DIR, 'runtime.json')}`,
` 게시 : node scripts/ci/publish-portable-release.mjs`,
].join('\n'),
)
\ No newline at end of file
diff --git a/scripts/ci/publish-portable-release.mjs b/scripts/ci/publish-portable-release.mjs
index 5fafdb0..944c181 100644
--- a/scripts/ci/publish-portable-release.mjs
+++ b/scripts/ci/publish-portable-release.mjs
@@ -93,6 +93,36 @@ payloads.push({
contentType: 'text/plain',
})
+// ── 로컬 AI 런타임 번들 게시 (설치본에는 없고, 앱이 처음 필요할 때 내려받는다) ──
+const runtimeDir = join(releaseDir, 'runtime')
+const runtimePayloads = []
+const runtimeIndexPath = join(runtimeDir, 'runtime.json')
+if (existsSync(runtimeIndexPath)) {
+ const runtimeIndex = JSON.parse(readFileSync(runtimeIndexPath, 'utf8'))
+ for (const [component, entry] of Object.entries(runtimeIndex.components ?? {})) {
+ for (const part of entry.parts) {
+ const partPath = join(runtimeDir, part.name)
+ if (!existsSync(partPath)) {
+ console.error(`[portable] 런타임 부품이 없습니다: ${partPath}`)
+ process.exit(1)
+ }
+ const bytes = await readFile(partPath)
+ const sha = createHash('sha256').update(bytes).digest('hex')
+ if (sha !== part.sha256) {
+ console.error(`[portable] 런타임 부품 sha256 불일치: ${part.name}`)
+ process.exit(1)
+ }
+ runtimePayloads.push({ name: part.name, bytes, contentType: 'application/octet-stream' })
+ }
+ void component
+ }
+ runtimePayloads.push({
+ name: 'runtime.json',
+ bytes: Buffer.from(`${JSON.stringify(runtimeIndex, null, 2)}\n`, 'utf8'),
+ contentType: 'application/json',
+ })
+}
+
const authorization = forgejoAuthorization()
const bases = [`${FEED}/portable-${version}`, `${FEED}/portable-latest`]
@@ -142,6 +172,13 @@ async function upload(url, body, contentType) {
console.log(`[portable] uploaded ${url}`)
}
+const runtimeBases = [`${FEED}/runtime-${version}`, `${FEED}/runtime-latest`]
+for (const base of runtimeBases) {
+ for (const payload of runtimePayloads) {
+ await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.contentType)
+ }
+}
+
for (const base of bases) {
for (const payload of payloads) {
await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.contentType)
@@ -155,6 +192,9 @@ console.log(
` 볼륨 : ${index.volumeCount}개 / 합계 ${(index.totalSize / 1048576).toFixed(1)}MiB`,
` 인덱스 : ${FEED}/portable-latest/portable.json`,
` 스크립트: ${FEED}/portable-latest/install-d3ro-voice.ps1`,
+ runtimePayloads.length
+ ? ` 런타임 : ${FEED}/runtime-latest/runtime.json (${runtimePayloads.length}개 파일)`
+ : ' 런타임 : (없음)',
'',
' 설치(Scoop, 권장):',
' scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git',
diff --git a/scripts/ci/publish-updater-release.mjs b/scripts/ci/publish-updater-release.mjs
new file mode 100644
index 0000000..8e265ca
--- /dev/null
+++ b/scripts/ci/publish-updater-release.mjs
@@ -0,0 +1,213 @@
+// scripts/ci/publish-updater-release.mjs
+// 자동 업데이트 채널(latest)에 디스크톱 설치본을 게시한다.
+//
+// 전제: 설치본이 Cloudflare 업로드 한도(100MiB) 아래여야 한다. 그래서 로컬 AI
+// 런타임(사이드카/ffmpeg)은 설치본에 넣지 않고, 앱이 처음 필요할 때
+// `runtime-latest`에서 내려받는다(RuntimeProvisioner, `npm run release:portable`가 게시).
+//
+// 정책 예외(명시):
+// - 이 채널은 일반적으로 Authenticode 서명을 요구한다. 서명 인증서가 준비되기 전까지
+// 업데이트를 전달할 수 없어, **무서명 빌드를 명시적 승인(--ack-unsigned)으로만** 게시한다.
+// - 검증되지 않은 서명을 조용히 게시하지 않는다: 승인 플래그가 없으면 즉시 실패한다.
+//
+// 사용:
+// npm run build --workspace=@d3ro/desktop
+// node scripts/ci/publish-updater-release.mjs --build --ack-unsigned
+// node scripts/ci/publish-updater-release.mjs --check # 게시 예정만 확인
+
+import credentialHelpers from '../lib/credentials.cjs'
+import { spawnSync } from 'node:child_process'
+import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
+import { readFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const { forgejoAuthorization } = credentialHelpers
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
+const desktopDir = join(root, 'apps', 'desktop')
+const args = process.argv.slice(2)
+const check = args.includes('--check')
+const ackUnsigned = args.includes('--ack-unsigned')
+const build = args.includes('--build')
+
+const FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice'
+/** Cloudflare 업로드 본문 한도 (실측: 110MiB → 413) */
+const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
+
+const version = JSON.parse(
+ readFileSync(join(root, 'release', 'product-version.json'), 'utf8'),
+).version
+const releaseDir = join(desktopDir, 'release', version)
+
+if (build) {
+ console.log('[updater] electron-builder NSIS 빌드 ( 전용 — 런타임 제외)')
+ const result = spawnSync(
+ 'npx',
+ [
+ 'electron-builder',
+ '--win',
+ 'nsis',
+ '--x64',
+ '--config',
+ 'electron-builder.yml',
+ '--publish',
+ 'never',
+ '-c.win.forceCodeSigning=false',
+ '-c.npmRebuild=false',
+ ],
+ { cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' },
+ )
+ if (result.status !== 0) {
+ console.error(`[updater] 빌드 실패 (exit ${result.status ?? 'null'})`)
+ process.exit(result.status ?? 1)
+ }
+}
+
+const metadataPath = join(releaseDir, 'latest.yml')
+if (!existsSync(metadataPath)) {
+ console.error(
+ `[updater] latest.yml이 없습니다: ${metadataPath}\n --build로 먼저 빌드하세요.`,
+ )
+ process.exit(1)
+}
+
+const candidates = readdirSync(releaseDir).filter(
+ (name) => /\.exe$/.test(name) && !/__uninstaller|apponly/i.test(name),
+)
+const installer = candidates.find((name) => name.includes('Setup')) ?? candidates[0]
+if (!installer) {
+ console.error(`[updater] 설치본을 찾을 수 없습니다: ${releaseDir}`)
+ process.exit(1)
+}
+
+const installerPath = join(releaseDir, installer)
+const blockmapPath = `${installerPath}.blockmap`
+const policyPath = join(root, 'release', 'update-policy.json')
+
+const payloads = [
+ { name: installer, path: installerPath, type: 'application/octet-stream' },
+ { name: `${installer}.blockmap`, path: blockmapPath, type: 'application/octet-stream' },
+ { name: 'latest.yml', path: metadataPath, type: 'text/yaml' },
+ { name: 'update-policy.json', path: policyPath, type: 'application/json' },
+].filter((payload) => existsSync(payload.path))
+
+const installerSize = statSync(installerPath).size
+console.log(
+ [
+ `[updater] 버전 ${version}`,
+ ` 설치본 : ${installer} (${(installerSize / 1048576).toFixed(1)}MiB)`,
+ ` 한도 : ${(MAX_UPLOAD_BYTES / 1048576).toFixed(0)}MiB (Cloudflare 업로드 본문 한도)`,
+ ].join('\n'),
+)
+
+if (installerSize > MAX_UPLOAD_BYTES) {
+ console.error(
+ [
+ '[updater] 설치본이 업로드 한도를 넘습니다 — 게시할 수 없습니다.',
+ ' 런타임(사이드카/ffmpeg)을 설치본에 다시 넣지 않았는지 확인하세요:',
+ ' `apps/desktop/electron-builder.yml`의 extraResources / files / asarUnpack.',
+ ].join('\n'),
+ )
+ process.exit(1)
+}
+
+const metadata = readFileSync(metadataPath, 'utf8')
+if (!metadata.includes(`version: ${version}`)) {
+ console.error('[updater] latest.yml의 버전이 product-version.json과 다릅니다.')
+ process.exit(1)
+}
+
+const targets = [`${FEED}/${version}`, `${FEED}/latest`]
+
+if (check) {
+ console.log('[updater] (check) 게시 예정:')
+ for (const target of targets) {
+ for (const payload of payloads) {
+ console.log(` PUT ${target}/${payload.name} (${statSync(payload.path).size} bytes)`)
+ }
+ }
+ process.exit(0)
+}
+
+if (!ackUnsigned) {
+ console.error(
+ [
+ '[updater] 무서명 빌드를 stable 채널에 게시하려면 명시적 승인이 필요합니다.',
+ ' 서명 인증서가 준비되면 이 플래그 없이 게시하세요(권장).',
+ ' 승인: --ack-unsigned',
+ ].join('\n'),
+ )
+ process.exit(1)
+}
+
+const authorization = forgejoAuthorization()
+
+async function forgejoFetch(url, init = {}) {
+ return fetch(url, {
+ ...init,
+ headers: { Authorization: authorization, ...(init.headers ?? {}) },
+ })
+}
+
+console.log(
+ [
+ '[updater] 경고: 무서명 설치본을 stable(latest) 채널에 게시합니다.',
+ ' - electron-updater는 app-update.yml에 publisherName이 없으면 서명 검증을 건너뛰므로',
+ ' 설치 자체는 정상 동작합니다.',
+ ' - 인증서가 준비되면 이 버전보다 높은 버전으로 서명 게시하여 대체하세요.',
+ ].join('\n'),
+)
+
+for (const target of targets) {
+ for (const payload of payloads) {
+ const url = `${target}/${encodeURIComponent(payload.name)}`
+ const body = await readFile(payload.path)
+
+ // Forgejo generic registry는 같은 경로에 다른 바이트가 있으면 409를 돌려준다.
+ // 메타데이터(latest.yml / update-policy.json)는 최신을 가리켜야 하므로 먼저 지운다.
+ // (설치본/블록맵은 파일명에 버전이 있어 충돌하지 않는다.)
+ const existing = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(
+ () => null,
+ )
+ if (existing?.ok) {
+ const contentRange = existing.headers.get('content-range')
+ const remoteSize = contentRange ? Number(contentRange.split('/')[1]) : NaN
+ if (remoteSize === body.length) {
+ console.log(`[updater] 이미 동일한 파일이 있습니다(건너뜀): ${url}`)
+ continue
+ }
+ await forgejoFetch(url, { method: 'DELETE' }).catch(() => null)
+ }
+
+ const response = await forgejoFetch(url, {
+ method: 'PUT',
+ headers: { 'Content-Type': payload.type },
+ body,
+ })
+ if (!response.ok) {
+ const hint =
+ response.status === 409
+ ? ' (409: 같은 경로에 다른 내용이 이미 있음 — 게시된 버전을 덮어쓰지 않습니다)'
+ : response.status === 413
+ ? ' (413: Cloudflare 업로드 한도 초과 — 런타임 분리 확인)'
+ : ''
+ console.error(
+ `[updater] 업로드 실패 (HTTP ${response.status}): ${target}/${payload.name}${hint}`,
+ )
+ process.exit(1)
+ }
+ console.log(`[updater] uploaded ${target}/${payload.name}`)
+ }
+}
+
+console.log(
+ [
+ '',
+ `[updater] 게시 완료: ${version}`,
+ ` 피드 : ${FEED}/latest`,
+ ` 메타 : ${FEED}/latest/latest.yml`,
+ ' 기존 설치본(canonical feed 사용)은 다음 업데이트 확인 때 이 버전을 받습니다.',
+ ' legacy GitLab mirror를 보는 1.0.x 이하 설치는 1회 수동 설치가 필요합니다.',
+ ].join('\n'),
+)
\ No newline at end of file
diff --git a/site/package-lock.json b/site/package-lock.json
index 5f33c08..b2e4770 100644
--- a/site/package-lock.json
+++ b/site/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "d3ro-voice-site",
- "version": "1.3.1",
+ "version": "1.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "d3ro-voice-site",
- "version": "1.3.1",
+ "version": "1.3.2",
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
diff --git a/site/package.json b/site/package.json
index 7aaef9d..ce27e94 100644
--- a/site/package.json
+++ b/site/package.json
@@ -1,7 +1,7 @@
{
"name": "d3ro-voice-site",
"private": true,
- "version": "1.3.1",
+ "version": "1.3.2",
"type": "module",
"scripts": {
"dev": "vite --port 5199 --host",