refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes
This commit is contained in:
parent
0adedf5e7b
commit
83cf9133ac
39 changed files with 4787 additions and 1061 deletions
|
|
@ -10,6 +10,10 @@
|
|||
// 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'
|
||||
|
|
@ -25,13 +29,12 @@ import {
|
|||
} from '../update-feed'
|
||||
import {
|
||||
DEFAULT_UPDATE_POLICY,
|
||||
decideUpdate,
|
||||
isChannelAcceptable,
|
||||
isWithinRollout,
|
||||
majorOf,
|
||||
evaluateUpdateOffer,
|
||||
parseUpdatePolicy,
|
||||
shouldPromptForUpdate,
|
||||
type UpdateDecision,
|
||||
type UpdatePolicy,
|
||||
type UpdatePromptRecord,
|
||||
} from '../update-policy'
|
||||
|
||||
const logger = getLogger('UpdateService')
|
||||
|
|
@ -76,124 +79,162 @@ export interface UpdateServiceEvents {
|
|||
'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<unknown>
|
||||
downloadUpdate(): Promise<unknown>
|
||||
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void
|
||||
setChannel(channel: UpdateChannel, allowPrerelease: boolean): void
|
||||
setDifferentialDisabled(disabled: boolean): void
|
||||
}
|
||||
|
||||
/** 원격 정책 공급원. 실패하면 마지막으로 성공한 정책(없으면 기본값)을 돌려준다(fail-open). */
|
||||
export interface PolicySource {
|
||||
load(): Promise<UpdatePolicy>
|
||||
}
|
||||
|
||||
export type ConsentAnswer = 'download' | 'later' | 'skip'
|
||||
export type RestartAnswer = 'restart' | 'later'
|
||||
|
||||
export interface UpdatePrompter {
|
||||
askConsent(input: { version: string; releaseNotes: ReleaseNotes; mandatory: boolean }): Promise<ConsentAnswer>
|
||||
askRestart(version: string): Promise<RestartAnswer>
|
||||
}
|
||||
|
||||
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 _promptShown = false
|
||||
private _autoUpdater: import('electron-updater').AppUpdater | 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<UpdateServiceDeps> = {}) {
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
|
||||
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
|
||||
this._applyChannel(this._resolveChannel(DEFAULT_UPDATE_POLICY))
|
||||
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} (채널 ${this._channel})`)
|
||||
void this._handleUpdateAvailable(info)
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
// 원격 정책을 비동기로 내려받아 채널·강제 정책을 갱신한다.
|
||||
void this._loadPolicy()
|
||||
this._initialTimer = setTimeout(() => this.checkForUpdates(), INITIAL_CHECK_DELAY_MS)
|
||||
this._intervalTimer = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL_MS)
|
||||
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}`)
|
||||
}
|
||||
|
||||
/** 수동 또는 주기적 업데이트 확인 */
|
||||
checkForUpdates(): void {
|
||||
if (!this._autoUpdater || this._downloading) return
|
||||
/**
|
||||
* 수동 또는 주기적 업데이트 확인.
|
||||
* 체크할 때마다 원격 정책을 다시 받는다 — 예전엔 시작 시 한 번만 받아, 오래 켜 둔 클라이언트는
|
||||
* 킬 스위치·rollout·최소 지원 버전 변경을 재시작 전까지 전혀 반영하지 못했다.
|
||||
*/
|
||||
async checkForUpdates(): Promise<void> {
|
||||
if (!this._updater || this._downloading) return
|
||||
await this._refreshPolicy()
|
||||
if (this._policy.killSwitch) {
|
||||
logger.info('원격 킬 스위치 활성 — 업데이트 확인 중단')
|
||||
return
|
||||
}
|
||||
this._autoUpdater.checkForUpdates().catch((err: unknown) => {
|
||||
try {
|
||||
await this._updater.checkForUpdates()
|
||||
} catch (err) {
|
||||
logger.warn(`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 사용자 수락 시 업데이트 다운로드 시작 */
|
||||
async startDownload(): Promise<void> {
|
||||
if (!this._autoUpdater || this._downloading) return
|
||||
if (!this._updater || this._downloading) return
|
||||
if (this._policy.killSwitch) {
|
||||
logger.info('원격 킬 스위치 활성 — 다운로드 중단')
|
||||
return
|
||||
}
|
||||
this._downloading = true
|
||||
logger.info(
|
||||
this._forceFullDownload
|
||||
? '전체 설치자 다운로드 시작 (major/버전갭)'
|
||||
: '차분 업데이트 다운로드 시작 (.blockmap)',
|
||||
)
|
||||
await this._autoUpdater.downloadUpdate()
|
||||
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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 이번 버전 건너뛰기 설정 */
|
||||
|
|
@ -224,8 +265,73 @@ class UpdateService extends EventEmitter {
|
|||
this._intervalTimer = null
|
||||
}
|
||||
|
||||
/** 업데이터가 새 버전을 알렸을 때 — 게이팅은 evaluateUpdateOffer 가 정한다. */
|
||||
async handleUpdateAvailable(info: UpdateInfoLike): Promise<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
|
@ -233,38 +339,9 @@ class UpdateService extends EventEmitter {
|
|||
|
||||
private _applyChannel(channel: UpdateChannel): void {
|
||||
this._channel = channel
|
||||
if (!this._autoUpdater) return
|
||||
if (!this._updater) return
|
||||
const channelPolicy = this._policy.channels[channel] ?? { allowPrerelease: false }
|
||||
this._autoUpdater.channel = channel
|
||||
this._autoUpdater.allowPrerelease = channelPolicy.allowPrerelease
|
||||
}
|
||||
|
||||
private async _loadPolicy(): Promise<void> {
|
||||
if (!UPDATE_POLICY_URL) return
|
||||
try {
|
||||
const response = await fetch(UPDATE_POLICY_URL, {
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(POLICY_FETCH_TIMEOUT_MS),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
this._policy = parseUpdatePolicy(await response.json())
|
||||
this._applyChannel(this._resolveChannel(this._policy))
|
||||
logger.info(
|
||||
`원격 업데이트 정책 적용 — channel=${this._channel}, min=${this._policy.minimumSupportedVersion}, killSwitch=${this._policy.killSwitch}`,
|
||||
)
|
||||
} catch (err) {
|
||||
// 네트워크 실패 시 내장 기본 정책으로 fail-open (업데이트 자체는 계속).
|
||||
logger.debug(
|
||||
`원격 업데이트 정책 로드 실패 — 내장 기본값 사용: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** electron-updater의 차분 다운로드 여부를 설정한다 (NSIS 이외 업데이터는 무시). */
|
||||
private _setDifferentialDisabled(disabled: boolean): void {
|
||||
if (!this._autoUpdater) return
|
||||
;(this._autoUpdater as unknown as { disableDifferentialDownload?: boolean }).disableDifferentialDownload =
|
||||
disabled
|
||||
this._updater.setChannel(channel, channelPolicy.allowPrerelease)
|
||||
}
|
||||
|
||||
private _deviceId(): string {
|
||||
|
|
@ -282,142 +359,44 @@ class UpdateService extends EventEmitter {
|
|||
return typeof legacy === 'string' && legacy ? legacy : null
|
||||
}
|
||||
|
||||
private async _handleUpdateAvailable(
|
||||
info: import('electron-updater').UpdateInfo,
|
||||
): Promise<void> {
|
||||
const currentVersion = app.getVersion()
|
||||
const decision = decideUpdate(this._policy, currentVersion, info.version)
|
||||
const channelPolicy = this._policy.channels[this._channel] ?? { allowPrerelease: false }
|
||||
|
||||
if (!isChannelAcceptable(channelPolicy.allowPrerelease, currentVersion, info.version)) {
|
||||
logger.info(`채널 ${this._channel} 정책상 v${info.version} 무시`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!decision.mandatory && this._skippedVersion() === info.version) {
|
||||
logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isWithinRollout(this._policy, this._deviceId(), info.version, decision)) {
|
||||
logger.info(
|
||||
`staged rollout(${this._policy.stagingPercentage}%) 밖 — v${info.version} 이번엔 노출하지 않음`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// major 승격 또는 버전 갭이면 차분 패치를 시도하지 않는다.
|
||||
this._forceFullDownload = decision.forceFull
|
||||
this._setDifferentialDisabled(decision.forceFull)
|
||||
|
||||
const currentMajor = majorOf(currentVersion)
|
||||
const targetMajor = majorOf(info.version)
|
||||
const isMajorUpgrade =
|
||||
currentMajor !== null && targetMajor !== null && currentMajor !== targetMajor
|
||||
|
||||
this.emit('update-available', {
|
||||
version: info.version,
|
||||
currentVersion,
|
||||
channel: this._channel,
|
||||
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
|
||||
isMandatory: decision.mandatory,
|
||||
isMajorUpgrade,
|
||||
isFullDownload: decision.forceFull,
|
||||
reason: decision.reason,
|
||||
})
|
||||
|
||||
await this._promptUserConsent(
|
||||
info.version,
|
||||
info.releaseNotes,
|
||||
decision,
|
||||
)
|
||||
}
|
||||
|
||||
/** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */
|
||||
private async _promptUserConsent(
|
||||
version: string,
|
||||
releaseNotes: string | ReadonlyArray<{ version: string; note: string | null }> | null | undefined,
|
||||
releaseNotes: ReleaseNotes,
|
||||
decision: UpdateDecision,
|
||||
): Promise<void> {
|
||||
if (this._downloading) return
|
||||
// forceInstallBelow는 다이얼로그 없이 즉시 설치 (보안 하한선).
|
||||
if (decision.mandatory && decision.reason === 'below-force-install') {
|
||||
logger.info(`v${version} 강제 설치 (${decision.reason})`)
|
||||
void this.startDownload()
|
||||
if (this._prompting) return
|
||||
const now = this._deps.now()
|
||||
if (!shouldPromptForUpdate(this._lastPrompt, { version, mandatory: decision.mandatory }, now)) {
|
||||
logger.info(`v${version} 은 최근에 물어봤음 — 다음 간격까지 다시 묻지 않음`)
|
||||
return
|
||||
}
|
||||
if (this._promptShown) return
|
||||
this._promptShown = true
|
||||
|
||||
const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true
|
||||
const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : ''
|
||||
const mandatoryNote = decision.mandatory
|
||||
? isKo
|
||||
? '\n\n이 업데이트는 필수입니다 (지원 종료 버전).'
|
||||
: '\n\nThis update is required (end of support).'
|
||||
: ''
|
||||
|
||||
const options = decision.mandatory
|
||||
? {
|
||||
type: 'info' as const,
|
||||
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,
|
||||
}
|
||||
: {
|
||||
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 (!decision.mandatory && response === 2) {
|
||||
this.skipVersion(version)
|
||||
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<void> {
|
||||
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) {
|
||||
const answer = await this._deps.prompter.askRestart(version)
|
||||
if (answer === 'restart' && this._updater) {
|
||||
logger.info('사용자 재시작 수락 — 프로세스 리소스 해제 후 quitAndInstall 실행')
|
||||
// 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행
|
||||
this._autoUpdater.quitAndInstall(false, true)
|
||||
// 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행.
|
||||
// quitAndInstall 은 app.quit() 경로를 타므로 종료 등록부(ShutdownRegistry)가 자원을 정리한다.
|
||||
this._updater.quitAndInstall(false, true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -438,6 +417,177 @@ class UpdateService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
// ── 기본 어댑터 (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<UpdatePolicy> {
|
||||
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<number> => {
|
||||
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<ConsentAnswer> {
|
||||
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<RestartAnswer> {
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue