// src/main/services/UpdateService.ts // electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter. // // feed: public GitLab Generic Registry `d3ro-voice/latest` // 기능: // 1. 사용자 인가 기반 다운로드 (autoDownload=false) // 2. 이번 버전 건너뛰기 (Skip This Version) 지원 // 3. 차분 다운로드 (.blockmap) 및 실시간 프로그레스 스트리밍 // 4. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall) import { EventEmitter } from 'events' import { app, dialog } from 'electron' import { getLogger } from './LoggerService' import { configGet, configSet } from './ConfigService' import { getMainWindow } from '../windows/WindowManager' import { UPDATE_FEED_URL } from '../update-feed' const logger = getLogger('UpdateService') /** 앱 시작 후 첫 체크까지 지연 — 초기화 경합(모델 로딩 등) 회피 */ const INITIAL_CHECK_DELAY_MS = 15_000 /** 주기 체크 간격 (4시간) */ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000 export interface UpdateProgressPayload { percent: number bytesPerSecond: number transferred: number total: number } export interface UpdateServiceEvents { 'checking-for-update': void 'update-available': { version: string; releaseNotes?: string; isMandatory?: boolean } 'update-not-available': { version: string } 'download-progress': UpdateProgressPayload 'update-downloaded': { version: string } 'update-error': { message: string } } class UpdateService extends EventEmitter { private _initialized = false private _initialTimer: NodeJS.Timeout | null = null private _intervalTimer: NodeJS.Timeout | null = null private _promptShown = false private _autoUpdater: import('electron-updater').AppUpdater | null = null private _downloading = false /** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */ initialize(): void { if (this._initialized) return this._initialized = true if (!app.isPackaged) { logger.info('dev 실행 — 자동 업데이트 비활성') return } if (process.platform !== 'win32' && process.platform !== 'darwin') { logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원`) return } if (!UPDATE_FEED_URL) { logger.info('UPDATE_FEED_URL 미설정 — 자동 업데이트 비활성') return } try { // eslint-disable-next-line @typescript-eslint/no-require-imports this._autoUpdater = (require('electron-updater') as typeof import('electron-updater')).autoUpdater } catch (err) { logger.warn( `electron-updater 로드 실패 — 자동 업데이트 비활성: ${err instanceof Error ? err.message : String(err)}`, ) return } const updater = this._autoUpdater updater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL }) // 사용자 인가를 위해 자동 다운로드는 비활성화 (동의 시 downloadUpdate 호출) updater.autoDownload = false updater.autoInstallOnAppQuit = true updater.logger = { info: (msg: unknown) => logger.info(String(msg)), warn: (msg: unknown) => logger.warn(String(msg)), error: (msg: unknown) => logger.error(String(msg)), debug: (msg: unknown) => logger.debug(String(msg)), } updater.on('checking-for-update', () => { logger.info('업데이트 확인 중...') this.emit('checking-for-update', undefined as unknown as void) }) updater.on('update-available', (info) => { logger.info(`업데이트 발견: v${info.version}`) const skippedVersion = configGet('skipped_update_version') as string | undefined if (skippedVersion === info.version) { logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`) return } this.emit('update-available', { version: info.version, releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined }) void this._promptUserConsent(info.version, info.releaseNotes) }) updater.on('update-not-available', (info) => { logger.info(`최신 버전 사용 중: v${info.version}`) this.emit('update-not-available', { version: info.version }) }) updater.on('download-progress', (progress) => { this.emit('download-progress', { percent: progress.percent, bytesPerSecond: progress.bytesPerSecond, transferred: progress.transferred, total: progress.total }) }) updater.on('update-downloaded', (info) => { this._downloading = false logger.info(`업데이트 다운로드 완료: v${info.version}`) this.emit('update-downloaded', { version: info.version }) void this._promptRestart(info.version) }) updater.on('error', (err) => { this._downloading = false logger.warn(`업데이트 체크 또는 다운로드 실패: ${err.message}`) this.emit('update-error', { message: err.message }) }) this._initialTimer = setTimeout(() => this.checkForUpdates(), INITIAL_CHECK_DELAY_MS) this._intervalTimer = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL_MS) logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`) } /** 수동 또는 주기적 업데이트 확인 */ checkForUpdates(): void { if (!this._autoUpdater || this._downloading) return this._autoUpdater.checkForUpdates().catch((err: unknown) => { logger.warn(`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`) }) } /** 사용자 수락 시 업데이트 다운로드 시작 */ async startDownload(): Promise { if (!this._autoUpdater || this._downloading) return this._downloading = true logger.info('차분 업데이트 다운로드 시작 (.blockmap)') await this._autoUpdater.downloadUpdate() } /** 이번 버전 건너뛰기 설정 */ skipVersion(version: string): void { configSet('skipped_update_version', version) logger.info(`버전 v${version} 건너뛰기 등록 완료`) } dispose(): void { if (this._initialTimer) clearTimeout(this._initialTimer) if (this._intervalTimer) clearInterval(this._intervalTimer) this._initialTimer = null this._intervalTimer = null } /** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */ private async _promptUserConsent( version: string, releaseNotes?: string | ReadonlyArray<{ version: string; note: string | null }> | null ): Promise { if (this._promptShown || this._downloading) return this._promptShown = true const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : '' const options = { type: 'info' as const, title: isKo ? '새 버전 업데이트' : 'Software Update', message: isKo ? `D3RO Voice v${version} 새 버전이 출시되었습니다. 지금 다운로드할까요?${notesText}` : `A new version of D3RO Voice (v${version}) is available. Would you like to download it now?${notesText}`, buttons: isKo ? ['지금 다운로드', '나중에', '이 버전 건너뛰기'] : ['Download Now', 'Later', 'Skip This Version'], defaultId: 0, cancelId: 1, } const win = getMainWindow() const { response } = win && !win.isDestroyed() ? await dialog.showMessageBox(win, options) : await dialog.showMessageBox(options) if (response === 0) { void this.startDownload() } else if (response === 2) { this.skipVersion(version) } } /** 2단계: 다운로드 완료 시 안전한 재시작 및 설치 다이얼로그 */ private async _promptRestart(version: string): Promise { const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true const options = { type: 'info' as const, title: isKo ? '업데이트 준비 완료' : 'Update Ready', message: isKo ? `D3RO Voice v${version} 다운로드가 완료되었습니다. 지금 앱을 재시작하여 설치를 완료할까요?` : `D3RO Voice v${version} has been downloaded. Restart now to complete installation?`, buttons: isKo ? ['지금 재시작 및 설치', '종료 시 자동 설치'] : ['Restart & Install Now', 'Install on Exit'], defaultId: 0, cancelId: 1, } const win = getMainWindow() const { response } = win && !win.isDestroyed() ? await dialog.showMessageBox(win, options) : await dialog.showMessageBox(options) if (response === 0 && this._autoUpdater) { logger.info('사용자 재시작 수락 — 프로세스 리소스 해제 후 quitAndInstall 실행') // 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행 this._autoUpdater.quitAndInstall(false, true) } } // ── 타입 안전한 이벤트 메서드 오버라이드 ── override emit( event: K, payload: UpdateServiceEvents[K], ): boolean { return super.emit(event, payload) } override on( event: K, listener: (payload: UpdateServiceEvents[K]) => void, ): this { return super.on(event, listener) } } // ── 싱글톤 ── let instance: UpdateService | null = null export function getUpdateService(): UpdateService { if (!instance) { instance = new UpdateService() } return instance } export { UpdateService }