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 ? ( + + ) : ( + + )} + + ) + })} ) : ( /* 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",