feat(release): publish desktop updates from a tag through one feed
Desktop clients had two competing update sources: the runtime pointed at a legacy GitLab registry while the Forgejo packages were filled in by hardcoded, version-pinned scripts. Operators could not tell which feed was authoritative, and no release could be reproduced from a tag. Auto-update now reads a single canonical Forgejo registry feed, updated by a version-agnostic publisher that runs from the tag on Forgejo, GitLab, and GitHub CI alike. Channel, minimum supported version, forced install, full-versus-delta thresholds, staged rollout, and a remote kill switch come from one policy file the client fetches alongside the feed. Tag creation is gated on a clean tree, matching version surfaces, and a changelog section.
This commit is contained in:
parent
65ecc7aabc
commit
7953706142
21 changed files with 1619 additions and 90 deletions
|
|
@ -1,19 +1,38 @@
|
|||
// src/main/services/UpdateService.ts
|
||||
// electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter.
|
||||
//
|
||||
// feed: public GitLab Generic Registry `d3ro-voice/latest`
|
||||
// feed: Forgejo Generic Registry `d3ro-voice/latest` (canonical)
|
||||
// 정책: release/update-policy.json (채널, 최소 지원 버전, 강제 업데이트, full/delta)
|
||||
// 기능:
|
||||
// 1. 사용자 인가 기반 다운로드 (autoDownload=false)
|
||||
// 2. 이번 버전 건너뛰기 (Skip This Version) 지원
|
||||
// 3. 차분 다운로드 (.blockmap) 및 실시간 프로그레스 스트리밍
|
||||
// 4. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall)
|
||||
// 1. 채널(latest/beta/alpha) 선택 및 prerelease 게이팅
|
||||
// 2. 사용자 인가 기반 다운로드 (autoDownload=false), 강제 업데이트 예외
|
||||
// 3. 이번 버전 건너뛰기 (Skip This Version) — 비강제일 때만
|
||||
// 4. major/버전갭 시 차분(.blockmap) 대신 전체 설치자
|
||||
// 5. staged rollout + 원격 킬 스위치
|
||||
// 6. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall)
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { app, dialog } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet, configSet } from './ConfigService'
|
||||
import { configGet, configGetAll, configSet } from './ConfigService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { UPDATE_FEED_URL } from '../update-feed'
|
||||
import {
|
||||
UPDATE_FEED_URL,
|
||||
UPDATE_POLICY_URL,
|
||||
isUpdateChannel,
|
||||
type UpdateChannel,
|
||||
} from '../update-feed'
|
||||
import {
|
||||
DEFAULT_UPDATE_POLICY,
|
||||
decideUpdate,
|
||||
isChannelAcceptable,
|
||||
isWithinRollout,
|
||||
majorOf,
|
||||
parseUpdatePolicy,
|
||||
type UpdateDecision,
|
||||
type UpdatePolicy,
|
||||
} from '../update-policy'
|
||||
|
||||
const logger = getLogger('UpdateService')
|
||||
|
||||
|
|
@ -21,6 +40,10 @@ 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
|
||||
|
|
@ -29,9 +52,24 @@ export interface UpdateProgressPayload {
|
|||
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': { version: string; releaseNotes?: string; isMandatory?: boolean }
|
||||
'update-available': UpdateAvailablePayload
|
||||
'update-not-available': { version: string }
|
||||
'download-progress': UpdateProgressPayload
|
||||
'update-downloaded': { version: string }
|
||||
|
|
@ -45,6 +83,9 @@ class UpdateService extends EventEmitter {
|
|||
private _promptShown = false
|
||||
private _autoUpdater: import('electron-updater').AppUpdater | null = null
|
||||
private _downloading = false
|
||||
private _policy: UpdatePolicy = DEFAULT_UPDATE_POLICY
|
||||
private _channel: UpdateChannel = 'latest'
|
||||
private _forceFullDownload = false
|
||||
|
||||
/** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */
|
||||
initialize(): void {
|
||||
|
|
@ -79,6 +120,7 @@ class UpdateService extends EventEmitter {
|
|||
// 사용자 인가를 위해 자동 다운로드는 비활성화 (동의 시 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)),
|
||||
|
|
@ -92,20 +134,8 @@ class UpdateService extends EventEmitter {
|
|||
})
|
||||
|
||||
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)
|
||||
logger.info(`업데이트 발견: v${info.version} (채널 ${this._channel})`)
|
||||
void this._handleUpdateAvailable(info)
|
||||
})
|
||||
|
||||
updater.on('update-not-available', (info) => {
|
||||
|
|
@ -135,6 +165,8 @@ class UpdateService extends EventEmitter {
|
|||
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)
|
||||
logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`)
|
||||
|
|
@ -143,6 +175,10 @@ class UpdateService extends EventEmitter {
|
|||
/** 수동 또는 주기적 업데이트 확인 */
|
||||
checkForUpdates(): void {
|
||||
if (!this._autoUpdater || this._downloading) return
|
||||
if (this._policy.killSwitch) {
|
||||
logger.info('원격 킬 스위치 활성 — 업데이트 확인 중단')
|
||||
return
|
||||
}
|
||||
this._autoUpdater.checkForUpdates().catch((err: unknown) => {
|
||||
logger.warn(`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
|
|
@ -152,16 +188,35 @@ class UpdateService extends EventEmitter {
|
|||
async startDownload(): Promise<void> {
|
||||
if (!this._autoUpdater || this._downloading) return
|
||||
this._downloading = true
|
||||
logger.info('차분 업데이트 다운로드 시작 (.blockmap)')
|
||||
logger.info(
|
||||
this._forceFullDownload
|
||||
? '전체 설치자 다운로드 시작 (major/버전갭)'
|
||||
: '차분 업데이트 다운로드 시작 (.blockmap)',
|
||||
)
|
||||
await this._autoUpdater.downloadUpdate()
|
||||
}
|
||||
|
||||
/** 이번 버전 건너뛰기 설정 */
|
||||
skipVersion(version: string): void {
|
||||
configSet('skipped_update_version', version)
|
||||
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)
|
||||
|
|
@ -169,29 +224,162 @@ class UpdateService extends EventEmitter {
|
|||
this._intervalTimer = null
|
||||
}
|
||||
|
||||
// ── 내부 ──
|
||||
|
||||
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._autoUpdater) 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
|
||||
}
|
||||
|
||||
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<string, unknown>)[LEGACY_SKIPPED_VERSION_KEY]
|
||||
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
|
||||
releaseNotes: string | ReadonlyArray<{ version: string; note: string | null }> | null | undefined,
|
||||
decision: UpdateDecision,
|
||||
): Promise<void> {
|
||||
if (this._promptShown || this._downloading) return
|
||||
if (this._downloading) return
|
||||
// forceInstallBelow는 다이얼로그 없이 즉시 설치 (보안 하한선).
|
||||
if (decision.mandatory && decision.reason === 'below-force-install') {
|
||||
logger.info(`v${version} 강제 설치 (${decision.reason})`)
|
||||
void this.startDownload()
|
||||
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 = {
|
||||
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 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 } =
|
||||
|
|
@ -201,7 +389,7 @@ class UpdateService extends EventEmitter {
|
|||
|
||||
if (response === 0) {
|
||||
void this.startDownload()
|
||||
} else if (response === 2) {
|
||||
} else if (!decision.mandatory && response === 2) {
|
||||
this.skipVersion(version)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue