fix(release): restore automatic updates by shipping the speech engine on demand
Some checks failed
deploy-site / deploy (push) Failing after 1m15s
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:
parent
0411f389d9
commit
0fbbbc1756
42 changed files with 1137 additions and 123 deletions
13
CHANGELOG.md
13
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
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/admin",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"description": "D3RO Voice Admin CRM — SaaS 관리 도구",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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로 변환
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
343
apps/desktop/src/main/services/RuntimeProvisioner.ts
Normal file
343
apps/desktop/src/main/services/RuntimeProvisioner.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
|
|
|
|||
|
|
@ -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) 설정 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
Reworked the install layout so updates flow again; required components are verified and fetched only when first needed.
|
||||
|
|
@ -0,0 +1 @@
|
|||
업데이트가 정상 동작하도록 설치 구조를 정리했습니다. 필요한 기능은 처음 사용할 때 검증하여 내려받습니다.
|
||||
14
apps/mobile-rn/package-lock.json
generated
14
apps/mobile-rn/package-lock.json
generated
|
|
@ -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": "*"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/mobile-rn",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/web",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 채널)
|
||||
|
||||
인증서가 없어도 사용자가 설치할 수 있어야 할 때 사용한다. 자세한 조사·비교·제약은
|
||||
|
|
|
|||
|
|
@ -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-<version>` / `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`이 대체한다.
|
||||
|
|
|
|||
|
|
@ -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초). |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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). |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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-<v>`/`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) 🎙️
|
||||
|
||||
설치본에서 로컬 전사가 **한 번도 성공한 적 없던** 원인을 끝까지 추적해 수정. 엔진 자체는
|
||||
|
|
|
|||
109
package-lock.json
generated
109
package-lock.json
generated
|
|
@ -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": "*"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/core",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/ui",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
)
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
213
scripts/ci/publish-updater-release.mjs
Normal file
213
scripts/ci/publish-updater-release.mjs
Normal file
|
|
@ -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'),
|
||||
)
|
||||
4
site/package-lock.json
generated
4
site/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue