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:
Yun Chan 2026-09-16 23:23:00 +09:00
parent 65ecc7aabc
commit 7953706142
21 changed files with 1619 additions and 90 deletions

View file

@ -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)
}
}

View file

@ -1,15 +1,42 @@
// src/main/update-feed.ts
// 자동 업데이트 feed URL SSOT.
//
// release-create CI 잡이 GitLab Generic Package Registry의
// `d3ro-voice/latest` 패키지에 latest.yml + 설치파일을 게시하며,
// electron-updater가 이 URL에서 latest.yml을 읽어 업데이트를 감지한다.
// canonical feed는 Forgejo Generic Package Registry의 `d3ro-voice/latest`다.
// `publish-forgejo-release.mjs`가 latest.yml + 설치파일 + update-policy.json을
// 게시하면 electron-updater가 이 URL에서 latest.yml을 읽어 업데이트를 감지한다.
//
// 전제: 프로젝트 설정에서 "Allow anyone to pull from Package Registry" 활성화
// 전제: Forgejo에서 해당 owner/package의 무인증 pull이 허용돼야 한다
// (비활성 시 무인증 다운로드가 401 — 앱은 조용히 업데이트 체크를 건너뜀).
//
// 빈 문자열이면 UpdateService가 비활성 상태로 동작한다.
// d3r0/voice 프로젝트 ID = 1172. electron-builder.yml publish.url과 동일 값 유지.
// 버전 없는 `latest` 패키지를 가리켜야 기존 설치본이 새 릴리스를 계속 찾을 수 있다.
export const UPDATE_FEED_URL =
'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest'
// GitLab Generic Registry legacy mirror. 2026-08 이전 설치본(0.2.1-alpha)은
// 이 feed를 폴링하므로, 새 설치자가 Forgejo feed를 내장할 때까지 publisher가
// 함께 게시한다. 마이그레이션 완료 후 제거 가능. 런타임은 참조하지 않는다.
export const LEGACY_UPDATE_FEED_URL =
'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest'
/** 업데이트 채널. `latest`=stable, `beta`, `alpha` 순으로 불안정. */
export type UpdateChannel = 'latest' | 'beta' | 'alpha'
export const UPDATE_CHANNELS: readonly UpdateChannel[] = ['latest', 'beta', 'alpha']
/** 채널별 electron-builder update metadata 파일명. */
export function channelMetadataName(channel: UpdateChannel): string {
return channel === 'latest' ? 'latest.yml' : `${channel}.yml`
}
/** 원격 업데이트 정책 파일명. feed 루트에서 내려받는다. */
export const UPDATE_POLICY_FILENAME = 'update-policy.json'
/** 원격 업데이트 정책 URL (없으면 앱 내장 기본 정책으로 폴백). */
export const UPDATE_POLICY_URL = UPDATE_FEED_URL
? `${UPDATE_FEED_URL}/${UPDATE_POLICY_FILENAME}`
: ''
export function isUpdateChannel(value: unknown): value is UpdateChannel {
return typeof value === 'string' && (UPDATE_CHANNELS as readonly string[]).includes(value)
}

View file

@ -0,0 +1,261 @@
// src/main/update-policy.ts
// 업데이트 정책 SSOT (런타임). release/update-policy.json과 동일 스키마.
//
// 정책은 빌드에 내장된 기본값(DEFAULT_UPDATE_POLICY)으로 시작하고,
// feed의 `update-policy.json`을 내려받아 오버라이드할 수 있다. 서버가 강제
// 업데이트·킬 스위치·staged rollout을 즉시 조정할 수 있게 하기 위함이다.
//
// 순수 로직만 두어 단위 테스트가 가능하도록 한다 (UpdateService는 I/O 담당).
import { UPDATE_CHANNELS, isUpdateChannel, type UpdateChannel } from './update-feed'
export interface UpdateChannelPolicy {
allowPrerelease: boolean
}
export interface UpdatePolicy {
schemaVersion: number
defaultChannel: UpdateChannel
channels: Record<UpdateChannel, UpdateChannelPolicy>
/** 이 버전 미만 클라이언트는 업데이트가 필수다 (연기·건너뛰기 불가). */
minimumSupportedVersion: string
/** 이 버전 미만은 다이얼로그 없이 강제 설치한다. null이면 비활성. */
forceInstallBelow: string | null
/** major가 달라지면 차분 대신 전체 설치자를 받는다. */
fullInstallOnMajorChange: boolean
/** minor 갭이 이 값 이상이면 전체 설치자를 받는다. */
fullInstallVersionGap: number
/** 이 비율(%)의 사용자에게만 stable 업데이트를 노출한다. 100=전체. */
stagingPercentage: number
/** true면 업데이트 확인 자체를 중단한다 (원격 킬 스위치). */
killSwitch: boolean
}
export const DEFAULT_UPDATE_POLICY: UpdatePolicy = {
schemaVersion: 1,
defaultChannel: 'latest',
channels: {
latest: { allowPrerelease: false },
beta: { allowPrerelease: true },
alpha: { allowPrerelease: true },
},
minimumSupportedVersion: '0.0.0',
forceInstallBelow: null,
fullInstallOnMajorChange: true,
fullInstallVersionGap: 3,
stagingPercentage: 100,
killSwitch: false,
}
export type UpdateDecisionReason =
| 'below-expiry'
| 'below-minimum-supported'
| 'below-force-install'
| 'standard'
export interface UpdateDecision {
/** 연기·건너뛰기 불가 여부. */
mandatory: boolean
/** 차분 대신 전체 설치자를 받아야 하는지. */
forceFull: boolean
reason: UpdateDecisionReason
}
interface ParsedVersion {
major: number
minor: number
patch: number
prerelease: readonly (string | number)[]
}
/** semver의 major/minor/patch/prerelease를 파싱한다. 실패 시 null. */
export function parseVersion(value: unknown): ParsedVersion | null {
if (typeof value !== 'string') return null
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value.trim())
if (!match) return null
const prerelease = match[4]
? match[4]
.split('.')
.map((part) => (/^\d+$/.test(part) ? Number(part) : part))
: []
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease,
}
}
/** semver 비교. a<b=-1, a==b=0, a>b=1. 파싱 실패 시 null. */
export function compareVersions(a: unknown, b: unknown): -1 | 0 | 1 | null {
const left = parseVersion(a)
const right = parseVersion(b)
if (!left || !right) return null
for (const key of ['major', 'minor', 'patch'] as const) {
if (left[key] !== right[key]) return left[key] < right[key] ? -1 : 1
}
return comparePrerelease(left.prerelease, right.prerelease)
}
function comparePrerelease(
left: readonly (string | number)[],
right: readonly (string | number)[],
): -1 | 0 | 1 {
// prerelease가 없으면 stable이 더 높다 (semver 11항).
if (left.length === 0 && right.length === 0) return 0
if (left.length === 0) return 1
if (right.length === 0) return -1
const length = Math.max(left.length, right.length)
for (let index = 0; index < length; index++) {
const a = left[index]
const b = right[index]
if (a === undefined) return -1
if (b === undefined) return 1
if (a === b) continue
if (typeof a === 'number' && typeof b === 'number') return a < b ? -1 : 1
if (typeof a === 'number') return -1
if (typeof b === 'number') return 1
return a < b ? -1 : 1
}
return 0
}
/** major 버전 숫자. 파싱 실패 시 null. */
export function majorOf(value: unknown): number | null {
return parseVersion(value)?.major ?? null
}
/**
* . prerelease이고
* stable prerelease를 .
*/
export function isChannelAcceptable(
allowPrerelease: boolean,
currentVersion: string,
targetVersion: string,
): boolean {
const current = parseVersion(currentVersion)
const target = parseVersion(targetVersion)
if (!current || !target) return false
const currentIsPrerelease = current.prerelease.length > 0
const targetIsPrerelease = target.prerelease.length > 0
// stable 채널은 더 높은 prerelease로 올라가지 않는다.
if (!allowPrerelease && targetIsPrerelease && !currentIsPrerelease) return false
// prerelease가 아닌 현재 버전보다 낮은/같은 버전으로 내려가지 않는다.
const ordering = compareVersions(currentVersion, targetVersion)
if (ordering === null) return false
if (ordering >= 0) {
// 다운그레이드는 채널이 prerelease를 허용할 때만 (예: beta → stable 정렬).
return allowPrerelease ? ordering >= 0 : false
}
return true
}
/** 현재 버전·대상 버전·정책으로 업데이트 결정을 계산한다. */
export function decideUpdate(
policy: UpdatePolicy,
currentVersion: string,
targetVersion: string,
): UpdateDecision {
const current = parseVersion(currentVersion)
const target = parseVersion(targetVersion)
if (!current || !target) {
// 현재 버전을 신뢰할 수 없으면 차분 패치를 시도하지 않는다.
return { mandatory: false, forceFull: true, reason: 'standard' }
}
let mandatory = false
let reason: UpdateDecisionReason = 'standard'
if (compareVersions(currentVersion, policy.minimumSupportedVersion) === -1) {
mandatory = true
reason = 'below-minimum-supported'
}
if (
policy.forceInstallBelow !== null &&
compareVersions(currentVersion, policy.forceInstallBelow) === -1
) {
mandatory = true
reason = 'below-force-install'
}
const majorChanged = current.major !== target.major
const minorGap = target.minor - current.minor
const forceFull = majorChanged
? policy.fullInstallOnMajorChange
: minorGap >= policy.fullInstallVersionGap
return { mandatory, forceFull, reason }
}
/** 기기 ID와 버전으로 0..99 staged rollout 버킷을 계산한다 (영구적, 무작위적). */
export function rolloutBucket(deviceId: string, version: string): number {
let hash = 2166136261
const input = `${deviceId}:${version}`
for (let index = 0; index < input.length; index++) {
hash ^= input.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0) % 100
}
/** staged rollout에 포함되는지 판단한다. */
export function isWithinRollout(
policy: UpdatePolicy,
deviceId: string,
version: string,
decision: UpdateDecision,
): boolean {
// 강제 업데이트는 staged rollout을 무시한다.
if (decision.mandatory) return true
const percentage = Math.min(100, Math.max(0, Math.floor(policy.stagingPercentage)))
if (percentage >= 100) return true
if (percentage <= 0) return false
return rolloutBucket(deviceId, version) < percentage
}
/** 알 수 없는 JSON을 검증·정규화해 정책으로 만든다. 실패 필드는 기본값 유지. */
export function parseUpdatePolicy(raw: unknown): UpdatePolicy {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return DEFAULT_UPDATE_POLICY
const record = raw as Record<string, unknown>
const policy: UpdatePolicy = {
...DEFAULT_UPDATE_POLICY,
channels: { ...DEFAULT_UPDATE_POLICY.channels },
}
if (record.schemaVersion === 1) policy.schemaVersion = 1
if (isUpdateChannel(record.defaultChannel)) policy.defaultChannel = record.defaultChannel
if (typeof record.minimumSupportedVersion === 'string' && parseVersion(record.minimumSupportedVersion)) {
policy.minimumSupportedVersion = record.minimumSupportedVersion
}
if (typeof record.forceInstallBelow === 'string' && parseVersion(record.forceInstallBelow)) {
policy.forceInstallBelow = record.forceInstallBelow
} else if (record.forceInstallBelow === null) {
policy.forceInstallBelow = null
}
if (typeof record.fullInstallOnMajorChange === 'boolean') {
policy.fullInstallOnMajorChange = record.fullInstallOnMajorChange
}
if (Number.isSafeInteger(record.fullInstallVersionGap) && (record.fullInstallVersionGap as number) >= 0) {
policy.fullInstallVersionGap = record.fullInstallVersionGap as number
}
if (Number.isSafeInteger(record.stagingPercentage)) {
policy.stagingPercentage = Math.min(100, Math.max(0, record.stagingPercentage as number))
}
if (typeof record.killSwitch === 'boolean') policy.killSwitch = record.killSwitch
if (record.channels && typeof record.channels === 'object' && !Array.isArray(record.channels)) {
const channels = record.channels as Record<string, unknown>
for (const channel of UPDATE_CHANNELS) {
const entry = channels[channel]
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
const allowPrerelease = (entry as Record<string, unknown>).allowPrerelease
if (typeof allowPrerelease === 'boolean') {
policy.channels[channel] = { allowPrerelease }
}
}
}
}
return policy
}