// src/main/services/UpdateService.ts // electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter. // // feed: Forgejo Generic Registry `d3ro-voice/latest` (canonical) // 정책: release/update-policy.json (채널, 최소 지원 버전, 강제 업데이트, full/delta) // 기능: // 1. 채널(latest/beta/alpha) 선택 및 prerelease 게이팅 // 2. 사용자 인가 기반 다운로드 (autoDownload=false), 강제 업데이트 예외 // 3. 이번 버전 건너뛰기 (Skip This Version) — 비강제일 때만 // 4. major/버전갭 시 차분(.blockmap) 대신 전체 설치자 // 5. staged rollout + 원격 킬 스위치 // 6. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall) // // 구조: 게이팅 순서는 update-policy.ts 의 순수 함수(evaluateUpdateOffer / shouldPromptForUpdate)가 // 정하고, 이 서비스는 포트(UpdaterPort / PolicySource / UpdatePrompter)를 통해 I/O 만 한다. // 기본 어댑터는 electron-updater · fetch · electron dialog 이며, 테스트는 가짜 포트를 주입한다. import { EventEmitter } from 'events' import { randomUUID } from 'node:crypto' import { app, dialog } from 'electron' import { getLogger } from './LoggerService' import { configGet, configGetAll, configSet } from './ConfigService' import { getMainWindow } from '../windows/WindowManager' import { UPDATE_FEED_URL, UPDATE_POLICY_URL, isUpdateChannel, type UpdateChannel, } from '../update-feed' import { DEFAULT_UPDATE_POLICY, evaluateUpdateOffer, parseUpdatePolicy, shouldPromptForUpdate, type UpdateDecision, type UpdatePolicy, type UpdatePromptRecord, } from '../update-policy' const logger = getLogger('UpdateService') /** 앱 시작 후 첫 체크까지 지연 — 초기화 경합(모델 로딩 등) 회피 */ const INITIAL_CHECK_DELAY_MS = 15_000 /** 주기 체크 간격 (4시간) */ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000 /** 원격 정책 fetch 타임아웃 */ const POLICY_FETCH_TIMEOUT_MS = 8_000 /** legacy 설치본이 저장한 skip 키 (AppConfig에 없는 과거 문자열 키) */ const LEGACY_SKIPPED_VERSION_KEY = 'skipped_update_version' export interface UpdateProgressPayload { percent: number bytesPerSecond: number transferred: number total: number } export interface UpdateAvailablePayload { version: string currentVersion: string channel: UpdateChannel releaseNotes?: string /** 연기·건너뛰기 불가 */ isMandatory: boolean /** major 승격 여부 */ isMajorUpgrade: boolean /** 차분 대신 전체 설치자로 받는지 */ isFullDownload: boolean /** 강제 사유 (진단/로그) */ reason: UpdateDecision['reason'] } export interface UpdateServiceEvents { 'checking-for-update': void 'update-available': UpdateAvailablePayload 'update-not-available': { version: string } 'download-progress': UpdateProgressPayload 'update-downloaded': { version: string } 'update-error': { message: string } } // ── 포트 ───────────────────────────────────────────────────── export type ReleaseNotes = string | ReadonlyArray<{ version: string; note: string | null }> | null | undefined export interface UpdateInfoLike { version: string releaseNotes?: ReleaseNotes } /** 업데이터(electron-updater) 이벤트를 서비스로 넘기는 콜백 */ export interface UpdaterEventHandlers { onChecking(): void onAvailable(info: UpdateInfoLike): void onNotAvailable(version: string): void onProgress(progress: UpdateProgressPayload): void onDownloaded(version: string): void onError(message: string): void } export interface UpdaterPort { checkForUpdates(): Promise downloadUpdate(): Promise quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void setChannel(channel: UpdateChannel, allowPrerelease: boolean): void setDifferentialDisabled(disabled: boolean): void } /** 원격 정책 공급원. 실패하면 마지막으로 성공한 정책(없으면 기본값)을 돌려준다(fail-open). */ export interface PolicySource { load(): Promise } export type ConsentAnswer = 'download' | 'later' | 'skip' export type RestartAnswer = 'restart' | 'later' export interface UpdatePrompter { askConsent(input: { version: string; releaseNotes: ReleaseNotes; mandatory: boolean }): Promise askRestart(version: string): Promise } export interface UpdateServiceDeps { /** 지원 환경(패키지 빌드·플랫폼·feed)일 때 업데이터를 만든다. 아니면 null(자동 업데이트 비활성). */ createUpdater(handlers: UpdaterEventHandlers): UpdaterPort | null policySource: PolicySource prompter: UpdatePrompter currentVersion(): string now(): number } // ── 서비스 ─────────────────────────────────────────────────── class UpdateService extends EventEmitter { private _initialized = false private _initialTimer: NodeJS.Timeout | null = null private _intervalTimer: NodeJS.Timeout | null = null /** 마지막으로 사용자에게 물어본 제안 — 새 버전/필수 격상/간격 경과 시 다시 묻는다 */ private _lastPrompt: UpdatePromptRecord | null = null private _prompting = false private _updater: UpdaterPort | null = null private _downloading = false private _policy: UpdatePolicy = DEFAULT_UPDATE_POLICY private _channel: UpdateChannel = 'latest' private _forceFullDownload = false private readonly _deps: UpdateServiceDeps constructor(deps: Partial = {}) { super() this._deps = { createUpdater: deps.createUpdater ?? createElectronUpdater, policySource: deps.policySource ?? createRemotePolicySource(), prompter: deps.prompter ?? createDialogPrompter(), currentVersion: deps.currentVersion ?? (() => app.getVersion()), now: deps.now ?? (() => Date.now()), } } /** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */ initialize(): void { if (this._initialized) return this._initialized = true this._updater = this._deps.createUpdater({ onChecking: () => { logger.info('업데이트 확인 중...') this.emit('checking-for-update', undefined as unknown as void) }, onAvailable: (info) => { logger.info(`업데이트 발견: v${info.version} (채널 ${this._channel})`) void this.handleUpdateAvailable(info) }, onNotAvailable: (version) => { logger.info(`최신 버전 사용 중: v${version}`) this.emit('update-not-available', { version }) }, onProgress: (progress) => this.emit('download-progress', progress), onDownloaded: (version) => { this._downloading = false logger.info(`업데이트 다운로드 완료: v${version}`) this.emit('update-downloaded', { version }) void this._promptRestart(version) }, onError: (message) => { // 수락한 다운로드가 실패했으면 다음 체크에서 다시 물을 수 있게 기록을 지운다 if (this._downloading) this._lastPrompt = null this._downloading = false logger.warn(`업데이트 체크 또는 다운로드 실패: ${message}`) this.emit('update-error', { message }) }, }) if (!this._updater) return this._applyChannel(this._resolveChannel(DEFAULT_UPDATE_POLICY)) this._initialTimer = setTimeout(() => void this.checkForUpdates(), INITIAL_CHECK_DELAY_MS) this._intervalTimer = setInterval(() => void this.checkForUpdates(), CHECK_INTERVAL_MS) logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`) } /** * 수동 또는 주기적 업데이트 확인. * 체크할 때마다 원격 정책을 다시 받는다 — 예전엔 시작 시 한 번만 받아, 오래 켜 둔 클라이언트는 * 킬 스위치·rollout·최소 지원 버전 변경을 재시작 전까지 전혀 반영하지 못했다. */ async checkForUpdates(): Promise { if (!this._updater || this._downloading) return await this._refreshPolicy() if (this._policy.killSwitch) { logger.info('원격 킬 스위치 활성 — 업데이트 확인 중단') return } try { await this._updater.checkForUpdates() } catch (err) { logger.warn(`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`) } } /** 사용자 수락 시 업데이트 다운로드 시작 */ async startDownload(): Promise { if (!this._updater || this._downloading) return if (this._policy.killSwitch) { logger.info('원격 킬 스위치 활성 — 다운로드 중단') return } this._downloading = true logger.info( this._forceFullDownload ? '전체 설치자 다운로드 시작 (major/버전갭)' : '차분 업데이트 다운로드 시작 (.blockmap)', ) try { await this._updater.downloadUpdate() } catch (err) { if (this._downloading) this._lastPrompt = null this._downloading = false logger.warn(`업데이트 다운로드 실패: ${err instanceof Error ? err.message : String(err)}`) } } /** 이번 버전 건너뛰기 설정 */ skipVersion(version: string): void { configSet('skippedUpdateVersion', version) logger.info(`버전 v${version} 건너뛰기 등록 완료`) } /** 업데이트 채널을 전환한다. (설정 UI용) */ setChannel(channel: UpdateChannel): void { configSet('updateChannel', channel) this._applyChannel(channel) logger.info(`업데이트 채널 전환: ${channel}`) } getChannel(): UpdateChannel { return this._channel } getPolicy(): UpdatePolicy { return this._policy } dispose(): void { if (this._initialTimer) clearTimeout(this._initialTimer) if (this._intervalTimer) clearInterval(this._intervalTimer) this._initialTimer = null this._intervalTimer = null } /** 업데이터가 새 버전을 알렸을 때 — 게이팅은 evaluateUpdateOffer 가 정한다. */ async handleUpdateAvailable(info: UpdateInfoLike): Promise { const currentVersion = this._deps.currentVersion() const offer = evaluateUpdateOffer({ policy: this._policy, channel: this._channel, currentVersion, targetVersion: info.version, skippedVersion: this._skippedVersion(), deviceId: this._deviceId(), }) if (offer.action === 'ignore') { switch (offer.reason) { case 'kill-switch': logger.info(`원격 킬 스위치 활성 — v${info.version} 무시`) break case 'channel': logger.info(`채널 ${this._channel} 정책상 v${info.version} 무시`) break case 'skipped': logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`) break case 'rollout': logger.info( `staged rollout(${this._policy.stagingPercentage}%) 밖 — v${info.version} 이번엔 노출하지 않음`, ) break } return } const { decision } = offer // major 승격 또는 버전 갭이면 차분 패치를 시도하지 않는다. this._forceFullDownload = decision.forceFull this._updater?.setDifferentialDisabled(decision.forceFull) this.emit('update-available', { version: info.version, currentVersion, channel: this._channel, releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, isMandatory: decision.mandatory, isMajorUpgrade: offer.isMajorUpgrade, isFullDownload: decision.forceFull, reason: decision.reason, }) if (this._downloading) return // forceInstallBelow는 다이얼로그 없이 즉시 설치 (보안 하한선). if (offer.action === 'force-download') { logger.info(`v${info.version} 강제 설치 (${decision.reason})`) void this.startDownload() return } await this._promptUserConsent(info.version, info.releaseNotes, decision) } // ── 내부 ── private async _refreshPolicy(): Promise { this._policy = await this._deps.policySource.load() this._applyChannel(this._resolveChannel(this._policy)) } private _resolveChannel(policy: UpdatePolicy): UpdateChannel { const configured = configGet('updateChannel') return isUpdateChannel(configured) ? configured : policy.defaultChannel } private _applyChannel(channel: UpdateChannel): void { this._channel = channel if (!this._updater) return const channelPolicy = this._policy.channels[channel] ?? { allowPrerelease: false } this._updater.setChannel(channel, channelPolicy.allowPrerelease) } private _deviceId(): string { const existing = configGet('updateDeviceId') if (typeof existing === 'string' && existing.length > 0) return existing const generated = randomUUID() configSet('updateDeviceId', generated) return generated } private _skippedVersion(): string | null { const current = configGet('skippedUpdateVersion') if (typeof current === 'string' && current) return current const legacy = (configGetAll() as unknown as Record)[LEGACY_SKIPPED_VERSION_KEY] return typeof legacy === 'string' && legacy ? legacy : null } /** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */ private async _promptUserConsent( version: string, releaseNotes: ReleaseNotes, decision: UpdateDecision, ): Promise { if (this._prompting) return const now = this._deps.now() if (!shouldPromptForUpdate(this._lastPrompt, { version, mandatory: decision.mandatory }, now)) { logger.info(`v${version} 은 최근에 물어봤음 — 다음 간격까지 다시 묻지 않음`) return } this._lastPrompt = { version, mandatory: decision.mandatory, at: now } this._prompting = true try { const answer = await this._deps.prompter.askConsent({ version, releaseNotes, mandatory: decision.mandatory, }) if (answer === 'download') { void this.startDownload() } else if (!decision.mandatory && answer === 'skip') { this.skipVersion(version) } } finally { this._prompting = false } } /** 2단계: 다운로드 완료 시 안전한 재시작 및 설치 다이얼로그 */ private async _promptRestart(version: string): Promise { const answer = await this._deps.prompter.askRestart(version) if (answer === 'restart' && this._updater) { logger.info('사용자 재시작 수락 — 프로세스 리소스 해제 후 quitAndInstall 실행') // 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행. // quitAndInstall 은 app.quit() 경로를 타므로 종료 등록부(ShutdownRegistry)가 자원을 정리한다. this._updater.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) } } // ── 기본 어댑터 (electron-updater / fetch / electron dialog) ───── function createElectronUpdater(handlers: UpdaterEventHandlers): UpdaterPort | null { if (!app.isPackaged) { logger.info('dev 실행 — 자동 업데이트 비활성') return null } if (process.platform !== 'win32' && process.platform !== 'darwin') { logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원`) return null } if (!UPDATE_FEED_URL) { logger.info('UPDATE_FEED_URL 미설정 — 자동 업데이트 비활성') return null } let updater: import('electron-updater').AppUpdater try { // eslint-disable-next-line @typescript-eslint/no-require-imports updater = (require('electron-updater') as typeof import('electron-updater')).autoUpdater } catch (err) { logger.warn( `electron-updater 로드 실패 — 자동 업데이트 비활성: ${err instanceof Error ? err.message : String(err)}`, ) return null } 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', () => handlers.onChecking()) updater.on('update-available', (info) => handlers.onAvailable(info)) updater.on('update-not-available', (info) => handlers.onNotAvailable(info.version)) updater.on('download-progress', (progress) => handlers.onProgress({ percent: progress.percent, bytesPerSecond: progress.bytesPerSecond, transferred: progress.transferred, total: progress.total, }), ) updater.on('update-downloaded', (info) => handlers.onDownloaded(info.version)) updater.on('error', (err) => handlers.onError(err.message)) return { checkForUpdates: () => updater.checkForUpdates(), downloadUpdate: () => updater.downloadUpdate(), quitAndInstall: (isSilent, isForceRunAfter) => updater.quitAndInstall(isSilent, isForceRunAfter), setChannel: (channel, allowPrerelease) => { updater.channel = channel updater.allowPrerelease = allowPrerelease }, setDifferentialDisabled: (disabled) => { // NSIS 이외 업데이터는 이 속성을 무시한다 ;(updater as unknown as { disableDifferentialDownload?: boolean }).disableDifferentialDownload = disabled }, } } /** 원격 정책 공급원 — 실패하면 마지막으로 성공한 정책, 그것도 없으면 내장 기본값 */ export function createRemotePolicySource( url: string | null | undefined = UPDATE_POLICY_URL, fetchImpl: typeof fetch = (input, init) => fetch(input, init), ): PolicySource { let lastGood: UpdatePolicy | null = null return { async load(): Promise { if (!url) return lastGood ?? DEFAULT_UPDATE_POLICY try { const response = await fetchImpl(url, { cache: 'no-store', signal: AbortSignal.timeout(POLICY_FETCH_TIMEOUT_MS), }) if (!response.ok) throw new Error(`HTTP ${response.status}`) lastGood = parseUpdatePolicy(await response.json()) logger.info( `원격 업데이트 정책 적용 — min=${lastGood.minimumSupportedVersion}, killSwitch=${lastGood.killSwitch}, staging=${lastGood.stagingPercentage}%`, ) return lastGood } catch (err) { // 네트워크 실패 시 마지막 정책(없으면 내장 기본값)으로 fail-open (업데이트 자체는 계속). logger.debug( `원격 업데이트 정책 로드 실패 — ${lastGood ? '마지막 정책 유지' : '내장 기본값 사용'}: ${err instanceof Error ? err.message : String(err)}`, ) return lastGood ?? DEFAULT_UPDATE_POLICY } }, } } /** * electron dialog 기반 프롬프터. * TODO(i18n): 문구가 아직 ko/en 인라인이다 — update.* 키를 locale 파일에 추가한 뒤 t() 로 옮긴다. */ function createDialogPrompter(): UpdatePrompter { const isKorean = (): boolean => (configGet('language') as string | undefined)?.startsWith('ko') ?? true const show = async (options: Electron.MessageBoxOptions): Promise => { const win = getMainWindow() const { response } = win && !win.isDestroyed() ? await dialog.showMessageBox(win, options) : await dialog.showMessageBox(options) return response } return { async askConsent({ version, releaseNotes, mandatory }): Promise { const isKo = isKorean() const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : '' const mandatoryNote = mandatory ? isKo ? '\n\n이 업데이트는 필수입니다 (지원 종료 버전).' : '\n\nThis update is required (end of support).' : '' if (mandatory) { const response = await show({ type: 'info', title: isKo ? '필수 업데이트' : 'Required Update', message: isKo ? `D3RO Voice v${version} 업데이트가 필요합니다.${mandatoryNote}${notesText}` : `D3RO Voice v${version} is required.${mandatoryNote}${notesText}`, buttons: isKo ? ['지금 업데이트'] : ['Update Now'], defaultId: 0, cancelId: -1, }) return response === 0 ? 'download' : 'later' } const response = await show({ type: 'info', 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, }) if (response === 0) return 'download' if (response === 2) return 'skip' return 'later' }, async askRestart(version): Promise { const isKo = isKorean() const response = await show({ type: 'info', 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, }) return response === 0 ? 'restart' : 'later' }, } } // ── 싱글톤 ── let instance: UpdateService | null = null export function getUpdateService(): UpdateService { if (!instance) { instance = new UpdateService() } return instance } export { UpdateService }