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.
261 lines
9.4 KiB
TypeScript
261 lines
9.4 KiB
TypeScript
// 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
|
|
}
|