// 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 /** 이 버전 미만 클라이언트는 업데이트가 필수다 (연기·건너뛰기 불가). */ 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 비교. ab=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 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 for (const channel of UPDATE_CHANNELS) { const entry = channels[channel] if (entry && typeof entry === 'object' && !Array.isArray(entry)) { const allowPrerelease = (entry as Record).allowPrerelease if (typeof allowPrerelease === 'boolean') { policy.channels[channel] = { allowPrerelease } } } } } return policy }