fix(release): restore automatic updates by shipping the speech engine on demand
Some checks failed
deploy-site / deploy (push) Failing after 1m15s

Auto-update could not work at all: the installer was 189 MB because it carried
the local speech engine and ffmpeg, and the download feed rejects uploads over
about 100 MiB, so update metadata could never be published.

The installer now leaves those components out and the app fetches them the first
time they are needed, verifying every part and the joined archive before
installing. The installer is 90.6 MiB, the update feed is published again, and
updates stay small because the engine is not re-sent on every release.

The fetch is visible and recoverable: the download runs with progress, a failed
install cleans up after itself, and Settings > STT shows the runtime status with
a manual download action for when the automatic one cannot run.
This commit is contained in:
Yun Chan 2026-09-18 13:51:49 +09:00
parent 0411f389d9
commit 0fbbbc1756
42 changed files with 1137 additions and 123 deletions

View file

@ -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": [
{

View file

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

View file

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

View file

@ -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/

View file

@ -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": {

View file

@ -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)
})

View file

@ -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<string> {
const resolved = getFfmpegPath()
if (resolved !== 'ffmpeg') return resolved
logger.info('ffmpeg이 없습니다 — 자동 다운로드를 시작합니다')
return getRuntimeProvisioner().ensure('ffmpeg')
}
/**
* ffmpeg로 PCM16 16kHz mono WAV로
*/

View file

@ -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,

View file

@ -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<string, RuntimeComponentIndex>
}
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<RuntimeComponent, Promise<string>>()
/** 설치된 런타임 트 (%APPDATA%/d3ro-voice/runtime/<component>) */
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<string> {
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<string> {
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<RuntimeIndex> {
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<void> {
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
}

View file

@ -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'

View file

@ -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 }

View file

@ -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<TResult>(channel: string, ...args: unknown[]): Promise<IPCResult<TResult>> {
@ -284,6 +303,18 @@ const electronAPI = {
on(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, cb)
},
// ── Local AI runtime (엔진/ffmpeg — 필요할 때 내려받음) ──
runtime: {
getStatus: () => invoke<RuntimeStatusPayload[]>(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: () =>

View file

@ -69,6 +69,18 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
const [downloadingModelId, setDownloadingModelId] = useState<string | null>(null)
const [downloadPercent, setDownloadPercent] = useState<number>(0)
// 로컬 AI 런타임(사이드카 엔진/ffmpeg) — 설치본에는 없고 필요할 때 내려받는다
type RuntimeComponentName = 'sidecar' | 'ffmpeg'
interface RuntimeStatusRow {
component: RuntimeComponentName
installed: boolean
path: string
sizeBytes: number
}
const [runtimeStatus, setRuntimeStatus] = useState<RuntimeStatusRow[]>([])
const [runtimeBusy, setRuntimeBusy] = useState<RuntimeComponentName | null>(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 (
<Paper
key={row.component}
elevation={0}
sx={{
p: 1.5,
bgcolor: d3roPalette.bg.elevated,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.small,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{row.component === 'sidecar'
? '로컬 음성 엔진 (faster-whisper)'
: '미디어 변환기 (ffmpeg)'}
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
{row.installed
? `설치됨 · ${Math.round(row.sizeBytes / 1_000_000)} MB`
: busy
? `다운로드 중 (${runtimePercent}%)`
: '설치되지 않음 — 로컬 전사에 필요합니다'}
</Typography>
</Box>
{busy ? (
<CircularProgress size={16} />
) : (
<Button
size="small"
variant={row.installed ? 'outlined' : 'contained'}
startIcon={<HardDriveDownload size={14} />}
onClick={() => handleEnsureRuntime(row.component)}
sx={{ fontFamily: d3roFontMono, fontSize: '11px' }}
>
{row.installed ? '다시 설치' : '내려받기'}
</Button>
)}
</Paper>
)
})}
</Box>
) : (
/* Cloud STT (OpenAI, Groq, Deepgram, AssemblyAI, Google, Custom) 설정 */

View file

@ -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,

View file

@ -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",

View file

@ -0,0 +1 @@
Reworked the install layout so updates flow again; required components are verified and fetched only when first needed.

View file

@ -0,0 +1 @@
업데이트가 정상 동작하도록 설치 구조를 정리했습니다. 필요한 기능은 처음 사용할 때 검증하여 내려받습니다.

View file

@ -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": "*"

View file

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

View file

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

View file

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