fix(update): re-evaluate policy on every update transition and skip NSIS updater on portable installs
This commit is contained in:
parent
a4acb42573
commit
88f3dbcd69
11 changed files with 1216 additions and 307 deletions
52
apps/desktop/src/main/install-kind.ts
Normal file
52
apps/desktop/src/main/install-kind.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// src/main/install-kind.ts
|
||||
// 설치 형태 판별 (순수 로직). 자동 업데이터(NSIS)를 돌려도 되는 설치본인지 정한다.
|
||||
//
|
||||
// 배경: Scoop(~/scoop/apps/...)·install-d3ro-voice.ps1(%LOCALAPPDATA%\Programs\D3RO Voice)·zip 해제본도
|
||||
// resources/app-update.yml 을 갖고 있어 NsisUpdater 가 켜졌다. NSIS 설치자는 --updated 로 자기 기본
|
||||
// 경로에 두 번째 사본을 깔고 그 사본을 실행할 뿐, 실행 중인 휴대용 사본은 그대로 옛 버전으로 남아
|
||||
// 체크마다 같은 업데이트를 다시 제안했다. 휴대용 설치본의 업데이트는 Scoop/설치 스크립트가 맡는다.
|
||||
|
||||
/**
|
||||
* 휴대용 배포본 표식 파일 (resources/ 아래). scripts/ci/build-portable.mjs 가 이 이름을 읽어 쓴다.
|
||||
* 값을 바꾸면 빌드 스크립트도 자동으로 따라간다(정규식으로 이 줄을 읽는다).
|
||||
*/
|
||||
export const PORTABLE_INSTALL_MARKER = 'portable-install.json'
|
||||
|
||||
export type InstallKind = 'installer' | 'portable'
|
||||
|
||||
export type InstallKindReason =
|
||||
| 'not-windows'
|
||||
| 'nsis-uninstaller'
|
||||
| 'portable-marker'
|
||||
| 'portable-target'
|
||||
| 'no-nsis-uninstaller'
|
||||
|
||||
export interface InstallProbe {
|
||||
platform: NodeJS.Platform
|
||||
/** 실행 파일이 있는 디렉터리의 항목 이름들 */
|
||||
exeDirEntries: readonly string[]
|
||||
/** resources/PORTABLE_INSTALL_MARKER 가 있는지 */
|
||||
hasPortableMarker: boolean
|
||||
/** electron-builder portable 타깃이 설정하는 PORTABLE_EXECUTABLE_DIR */
|
||||
portableExecutableDir: string | undefined
|
||||
}
|
||||
|
||||
/** NSIS 설치자가 설치 디렉터리에 남기는 제거 프로그램: `Uninstall ${productFilename}.exe` */
|
||||
const NSIS_UNINSTALLER = /^Uninstall .+\.exe$/i
|
||||
|
||||
/**
|
||||
* Windows 에서는 NSIS 설치자가 소유한 디렉터리(제거 프로그램 존재)만 'installer' 다 — 그때만
|
||||
* NsisUpdater 가 같은 디렉터리를 제자리 교체한다. 제거 프로그램이 먼저인 이유: 휴대용 트리에는
|
||||
* 제거 프로그램이 절대 없으므로, 표식 파일이 실수로 NSIS 설치본에 섞여도 자동 업데이트가 꺼지지 않는다.
|
||||
* 제거 프로그램이 없으면 표식·portable 타깃 여부와 무관하게 'portable' 이다 (표식은 로그 사유만 바꾼다).
|
||||
* macOS 는 MacUpdater 가 앱 번들을 제자리 교체하므로 'installer'.
|
||||
*/
|
||||
export function detectInstallKind(probe: InstallProbe): { kind: InstallKind; reason: InstallKindReason } {
|
||||
if (probe.platform !== 'win32') return { kind: 'installer', reason: 'not-windows' }
|
||||
if (probe.exeDirEntries.some((name) => NSIS_UNINSTALLER.test(name))) {
|
||||
return { kind: 'installer', reason: 'nsis-uninstaller' }
|
||||
}
|
||||
if (probe.hasPortableMarker) return { kind: 'portable', reason: 'portable-marker' }
|
||||
if (probe.portableExecutableDir) return { kind: 'portable', reason: 'portable-target' }
|
||||
return { kind: 'portable', reason: 'no-nsis-uninstaller' }
|
||||
}
|
||||
|
|
@ -8,34 +8,66 @@
|
|||
// 2. 사용자 인가 기반 다운로드 (autoDownload=false), 강제 업데이트 예외
|
||||
// 3. 이번 버전 건너뛰기 (Skip This Version) — 비강제일 때만
|
||||
// 4. major/버전갭 시 차분(.blockmap) 대신 전체 설치자
|
||||
// 5. staged rollout + 원격 킬 스위치
|
||||
// 6. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall)
|
||||
// 5. staged rollout + 원격 킬 스위치 — 받는 중/받아 둔 설치 파일까지 철회
|
||||
// 6. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall), 필수 업데이트는 재시작을 미룰 수 없음
|
||||
//
|
||||
// 구조: 게이팅 순서는 update-policy.ts 의 순수 함수(evaluateUpdateOffer / shouldPromptForUpdate)가
|
||||
// 정하고, 이 서비스는 포트(UpdaterPort / PolicySource / UpdatePrompter)를 통해 I/O 만 한다.
|
||||
// 기본 어댑터는 electron-updater · fetch · electron dialog 이며, 테스트는 가짜 포트를 주입한다.
|
||||
// 구조 (의존 방향: 서비스 → 순수 정책 · 포트):
|
||||
// - update-policy.ts : 제안 게이팅 (evaluateUpdateOffer / shouldPromptForUpdate)
|
||||
// - update-session.ts : 세션 상태 머신 — 모든 전이(동의·다운로드 완료·정책 갱신·재시작 응답)에서 정책 재평가
|
||||
// - update-ports.ts : UpdaterPort / PolicySource / UpdatePrompter 계약
|
||||
// - update-adapters.ts : electron-updater · fetch · electron dialog 구현
|
||||
// 이 서비스는 이벤트를 세션에 넣고, nextUpdateAction 이 돌려준 동작만 포트로 실행한다.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { app, dialog } from 'electron'
|
||||
import { app } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet, configGetAll, configSet } from './ConfigService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import {
|
||||
UPDATE_FEED_URL,
|
||||
UPDATE_POLICY_URL,
|
||||
isUpdateChannel,
|
||||
type UpdateChannel,
|
||||
} from '../update-feed'
|
||||
import { UPDATE_FEED_URL, isUpdateChannel, type UpdateChannel } from '../update-feed'
|
||||
import {
|
||||
DEFAULT_UPDATE_POLICY,
|
||||
evaluateUpdateOffer,
|
||||
parseUpdatePolicy,
|
||||
shouldPromptForUpdate,
|
||||
type UpdateDecision,
|
||||
type UpdatePolicy,
|
||||
type UpdatePromptRecord,
|
||||
} from '../update-policy'
|
||||
import {
|
||||
IDLE_UPDATE_SESSION,
|
||||
nextUpdateAction,
|
||||
reduceUpdateSession,
|
||||
type UpdateBlockReason,
|
||||
type UpdateGateContext,
|
||||
type UpdateSession,
|
||||
type UpdateSessionAction,
|
||||
type UpdateSessionEvent,
|
||||
type UpdateSessionTrigger,
|
||||
} from '../update-session'
|
||||
import { createDialogPrompter, createElectronUpdater, createRemotePolicySource } from './update-adapters'
|
||||
import type {
|
||||
ConsentAnswer,
|
||||
PolicySource,
|
||||
ReleaseNotes,
|
||||
RestartAnswer,
|
||||
UpdateInfoLike,
|
||||
UpdatePrompter,
|
||||
UpdateProgressPayload,
|
||||
UpdaterEventHandlers,
|
||||
UpdaterPort,
|
||||
} from './update-ports'
|
||||
|
||||
export type {
|
||||
ConsentAnswer,
|
||||
PolicySource,
|
||||
ReleaseNotes,
|
||||
RestartAnswer,
|
||||
UpdateInfoLike,
|
||||
UpdatePrompter,
|
||||
UpdateProgressPayload,
|
||||
UpdaterEventHandlers,
|
||||
UpdaterPort,
|
||||
} from './update-ports'
|
||||
export { createRemotePolicySource } from './update-adapters'
|
||||
|
||||
const logger = getLogger('UpdateService')
|
||||
|
||||
|
|
@ -43,18 +75,9 @@ 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
|
||||
bytesPerSecond: number
|
||||
transferred: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface UpdateAvailablePayload {
|
||||
version: string
|
||||
currentVersion: string
|
||||
|
|
@ -79,48 +102,8 @@ 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(자동 업데이트 비활성). */
|
||||
/** 지원 환경(패키지 빌드·플랫폼·feed·설치 형태)일 때 업데이터를 만든다. 아니면 null(자동 업데이트 비활성). */
|
||||
createUpdater(handlers: UpdaterEventHandlers): UpdaterPort | null
|
||||
policySource: PolicySource
|
||||
prompter: UpdatePrompter
|
||||
|
|
@ -136,12 +119,18 @@ class UpdateService extends EventEmitter {
|
|||
private _intervalTimer: NodeJS.Timeout | 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
|
||||
/** 동의 대화상자 / 설치 파일 슬롯 — 전이는 reduceUpdateSession 만 한다 */
|
||||
private _session: UpdateSession = IDLE_UPDATE_SESSION
|
||||
/**
|
||||
* 업데이터가 마지막으로 알린 버전. electron-updater 는 update-available 을 내보내기 직전에
|
||||
* 대상을 교체하고 downloadUpdate() 는 인자 없이 그 대상을 받으므로, 동의한 버전과 대조한다.
|
||||
*/
|
||||
private _offeredVersion: string | null = null
|
||||
/** 재시작 대화상자 중복 방지 */
|
||||
private _restartPrompting = false
|
||||
private readonly _deps: UpdateServiceDeps
|
||||
|
||||
constructor(deps: Partial<UpdateServiceDeps> = {}) {
|
||||
|
|
@ -175,15 +164,14 @@ class UpdateService extends EventEmitter {
|
|||
},
|
||||
onProgress: (progress) => this.emit('download-progress', progress),
|
||||
onDownloaded: (version) => {
|
||||
this._downloading = false
|
||||
this._dispatch({ type: 'download-finished', version })
|
||||
logger.info(`업데이트 다운로드 완료: v${version}`)
|
||||
this.emit('update-downloaded', { version })
|
||||
void this._promptRestart(version)
|
||||
// 동기로 판단해야 한다 — electron-updater 는 이 이벤트 직후 autoInstallOnAppQuit 를 보고 종료 핸들러를 건다.
|
||||
this._run({ type: 'download-finished' })
|
||||
},
|
||||
onError: (message) => {
|
||||
// 수락한 다운로드가 실패했으면 다음 체크에서 다시 물을 수 있게 기록을 지운다
|
||||
if (this._downloading) this._lastPrompt = null
|
||||
this._downloading = false
|
||||
this._failDownload()
|
||||
logger.warn(`업데이트 체크 또는 다운로드 실패: ${message}`)
|
||||
this.emit('update-error', { message })
|
||||
},
|
||||
|
|
@ -198,16 +186,23 @@ class UpdateService extends EventEmitter {
|
|||
|
||||
/**
|
||||
* 수동 또는 주기적 업데이트 확인.
|
||||
* 체크할 때마다 원격 정책을 다시 받는다 — 예전엔 시작 시 한 번만 받아, 오래 켜 둔 클라이언트는
|
||||
* 킬 스위치·rollout·최소 지원 버전 변경을 재시작 전까지 전혀 반영하지 못했다.
|
||||
* 체크할 때마다 원격 정책을 다시 받고, 받는 중·받아 둔 설치 파일에도 새 정책을 적용한다 —
|
||||
* 킬 스위치·rollout 중단이 이미 받은 설치 파일의 '종료 시 설치'까지 막아야 하기 때문이다.
|
||||
*/
|
||||
async checkForUpdates(): Promise<void> {
|
||||
if (!this._updater || this._downloading) return
|
||||
if (!this._updater) return
|
||||
await this._refreshPolicy()
|
||||
this._run({ type: 'policy-refreshed' })
|
||||
if (this._policy.killSwitch) {
|
||||
logger.info('원격 킬 스위치 활성 — 업데이트 확인 중단')
|
||||
return
|
||||
}
|
||||
if (this._session.payload?.phase === 'downloading') return
|
||||
if (this._session.consenting) {
|
||||
// 체크하면 electron-updater 가 대기 대상을 바꿔, 열린 대화상자의 동의가 다른 버전에 묶인다.
|
||||
logger.info(`v${this._session.consenting} 동의 대화상자 대기 중 — 이번 체크 생략`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this._updater.checkForUpdates()
|
||||
} catch (err) {
|
||||
|
|
@ -215,26 +210,10 @@ class UpdateService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/** 사용자 수락 시 업데이트 다운로드 시작 */
|
||||
/** 사용자 수락 시 업데이트 다운로드 시작 — 업데이터가 마지막으로 알린 버전에 대한 동의로 본다. */
|
||||
async startDownload(): Promise<void> {
|
||||
if (!this._updater || this._downloading) return
|
||||
if (this._policy.killSwitch) {
|
||||
logger.info('원격 킬 스위치 활성 — 다운로드 중단')
|
||||
return
|
||||
}
|
||||
this._downloading = true
|
||||
logger.info(
|
||||
this._forceFullDownload
|
||||
? '전체 설치자 다운로드 시작 (major/버전갭)'
|
||||
: '차분 업데이트 다운로드 시작 (.blockmap)',
|
||||
)
|
||||
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)}`)
|
||||
}
|
||||
if (!this._offeredVersion) return
|
||||
await this._consent(this._offeredVersion)
|
||||
}
|
||||
|
||||
/** 이번 버전 건너뛰기 설정 */
|
||||
|
|
@ -267,6 +246,8 @@ class UpdateService extends EventEmitter {
|
|||
|
||||
/** 업데이터가 새 버전을 알렸을 때 — 게이팅은 evaluateUpdateOffer 가 정한다. */
|
||||
async handleUpdateAvailable(info: UpdateInfoLike): Promise<void> {
|
||||
// 게이팅에서 무시하더라도 업데이터는 이미 이 버전을 다운로드 대상으로 들고 있다.
|
||||
this._offeredVersion = info.version
|
||||
const currentVersion = this._deps.currentVersion()
|
||||
const offer = evaluateUpdateOffer({
|
||||
policy: this._policy,
|
||||
|
|
@ -278,30 +259,11 @@ class UpdateService extends EventEmitter {
|
|||
})
|
||||
|
||||
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
|
||||
}
|
||||
logger.info(`v${info.version} 제안 무시 — ${this._describeBlock(offer.reason)}`)
|
||||
return
|
||||
}
|
||||
|
||||
const { decision } = offer
|
||||
// major 승격 또는 버전 갭이면 차분 패치를 시도하지 않는다.
|
||||
this._forceFullDownload = decision.forceFull
|
||||
this._updater?.setDifferentialDisabled(decision.forceFull)
|
||||
|
||||
this.emit('update-available', {
|
||||
version: info.version,
|
||||
currentVersion,
|
||||
|
|
@ -313,19 +275,120 @@ class UpdateService extends EventEmitter {
|
|||
reason: decision.reason,
|
||||
})
|
||||
|
||||
if (this._downloading) return
|
||||
if (this._session.payload?.phase === 'downloading') return
|
||||
|
||||
// forceInstallBelow는 다이얼로그 없이 즉시 설치 (보안 하한선).
|
||||
// forceInstallBelow는 동의 대화상자 없이 받는다 (보안 하한선). 재시작 단계도 미룰 수 없다.
|
||||
if (offer.action === 'force-download') {
|
||||
logger.info(`v${info.version} 강제 설치 (${decision.reason})`)
|
||||
void this.startDownload()
|
||||
void this._consent(info.version)
|
||||
return
|
||||
}
|
||||
|
||||
await this._promptUserConsent(info.version, info.releaseNotes, decision)
|
||||
}
|
||||
|
||||
// ── 내부 ──
|
||||
// ── 내부: 세션 ──
|
||||
|
||||
private _dispatch(event: UpdateSessionEvent): void {
|
||||
this._session = reduceUpdateSession(this._session, event)
|
||||
}
|
||||
|
||||
private _gateContext(): UpdateGateContext {
|
||||
return {
|
||||
policy: this._policy,
|
||||
channel: this._channel,
|
||||
currentVersion: this._deps.currentVersion(),
|
||||
deviceId: this._deviceId(),
|
||||
}
|
||||
}
|
||||
|
||||
/** 현재 세션·정책으로 다음 동작을 정해 실행한다. 포트 호출의 동기 부분은 즉시 실행된다. */
|
||||
private _run(trigger: UpdateSessionTrigger): void {
|
||||
void this._execute(nextUpdateAction(this._session, trigger, this._gateContext()))
|
||||
}
|
||||
|
||||
/** 다운로드 동의 — 업데이터 대상이 동의한 버전인지, 정책이 여전히 허용하는지 다시 본다. */
|
||||
private async _consent(version: string): Promise<void> {
|
||||
if (!this._updater || this._session.payload?.phase === 'downloading') return
|
||||
const action = nextUpdateAction(
|
||||
this._session,
|
||||
{ type: 'consented', version, offeredVersion: this._offeredVersion },
|
||||
this._gateContext(),
|
||||
)
|
||||
await this._execute(action)
|
||||
}
|
||||
|
||||
private async _execute(action: UpdateSessionAction): Promise<void> {
|
||||
const updater = this._updater
|
||||
if (!updater) return
|
||||
switch (action.type) {
|
||||
case 'none':
|
||||
return
|
||||
case 'download':
|
||||
await this._download(updater, action.version, action.decision)
|
||||
return
|
||||
case 'drop-consent':
|
||||
logger.info(`v${action.version} 다운로드 동의 무효 — ${this._describeBlock(action.reason)}`)
|
||||
return
|
||||
case 'keep-pending':
|
||||
updater.setAutoInstallOnQuit(true)
|
||||
return
|
||||
case 'cancel-pending':
|
||||
updater.setAutoInstallOnQuit(false)
|
||||
logger.info(`v${action.version} 설치 보류 (종료 시 설치 해제) — ${this._describeBlock(action.reason)}`)
|
||||
return
|
||||
case 'ask-restart':
|
||||
updater.setAutoInstallOnQuit(true)
|
||||
void this._promptRestart(action.version, action.mandatory)
|
||||
return
|
||||
case 'install':
|
||||
logger.info(`v${action.version} 설치 — 프로세스 리소스 해제 후 quitAndInstall 실행`)
|
||||
// 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행.
|
||||
// quitAndInstall 은 app.quit() 경로를 타므로 종료 등록부(ShutdownRegistry)가 자원을 정리한다.
|
||||
updater.quitAndInstall(false, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async _download(updater: UpdaterPort, version: string, decision: UpdateDecision): Promise<void> {
|
||||
// major 승격 또는 버전 갭이면 차분 패치를 시도하지 않는다 (받는 시점의 정책·버전으로 정한다).
|
||||
updater.setDifferentialDisabled(decision.forceFull)
|
||||
this._dispatch({ type: 'download-started', version })
|
||||
logger.info(
|
||||
decision.forceFull
|
||||
? `v${version} 전체 설치자 다운로드 시작 (major/버전갭)`
|
||||
: `v${version} 차분 업데이트 다운로드 시작 (.blockmap)`,
|
||||
)
|
||||
try {
|
||||
await updater.downloadUpdate()
|
||||
} catch (err) {
|
||||
this._failDownload()
|
||||
logger.warn(`업데이트 다운로드 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private _failDownload(): void {
|
||||
// 수락한 다운로드가 실패했으면 다음 체크에서 다시 물을 수 있게 기록을 지운다
|
||||
if (this._session.payload?.phase === 'downloading') this._lastPrompt = null
|
||||
this._dispatch({ type: 'download-failed' })
|
||||
}
|
||||
|
||||
private _describeBlock(reason: UpdateBlockReason): string {
|
||||
switch (reason) {
|
||||
case 'kill-switch':
|
||||
return '원격 킬 스위치 활성'
|
||||
case 'channel':
|
||||
return `채널 ${this._channel} 정책상 허용되지 않음`
|
||||
case 'skipped':
|
||||
return '사용자가 건너뛴 버전'
|
||||
case 'rollout':
|
||||
return `staged rollout(${this._policy.stagingPercentage}%) 밖`
|
||||
case 'version-changed':
|
||||
return `업데이터 대상이 v${this._offeredVersion ?? '?'} 로 바뀜`
|
||||
}
|
||||
}
|
||||
|
||||
// ── 내부: 정책 · 설정 ──
|
||||
|
||||
private async _refreshPolicy(): Promise<void> {
|
||||
this._policy = await this._deps.policySource.load()
|
||||
|
|
@ -359,45 +422,55 @@ class UpdateService extends EventEmitter {
|
|||
return typeof legacy === 'string' && legacy ? legacy : null
|
||||
}
|
||||
|
||||
// ── 내부: 대화상자 ──
|
||||
|
||||
/** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */
|
||||
private async _promptUserConsent(
|
||||
version: string,
|
||||
releaseNotes: ReleaseNotes,
|
||||
decision: UpdateDecision,
|
||||
): Promise<void> {
|
||||
if (this._prompting) return
|
||||
if (this._session.consenting) return
|
||||
const now = this._deps.now()
|
||||
if (!shouldPromptForUpdate(this._lastPrompt, { version, mandatory: decision.mandatory }, now)) {
|
||||
logger.info(`v${version} 은 최근에 물어봤음 — 다음 간격까지 다시 묻지 않음`)
|
||||
return
|
||||
}
|
||||
this._lastPrompt = { version, mandatory: decision.mandatory, at: now }
|
||||
this._prompting = true
|
||||
this._dispatch({ type: 'consent-opened', version })
|
||||
let answer: ConsentAnswer
|
||||
try {
|
||||
const answer = await this._deps.prompter.askConsent({
|
||||
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
|
||||
this._dispatch({ type: 'consent-closed' })
|
||||
}
|
||||
if (answer === 'download') {
|
||||
// 대화상자가 열려 있는 동안 정책·대상이 바뀌었을 수 있다 — _consent 가 다시 평가한다.
|
||||
void this._consent(version)
|
||||
} else if (!decision.mandatory && answer === 'skip') {
|
||||
this.skipVersion(version)
|
||||
}
|
||||
}
|
||||
|
||||
/** 2단계: 다운로드 완료 시 안전한 재시작 및 설치 다이얼로그 */
|
||||
private async _promptRestart(version: string): Promise<void> {
|
||||
const answer = await this._deps.prompter.askRestart(version)
|
||||
if (answer === 'restart' && this._updater) {
|
||||
logger.info('사용자 재시작 수락 — 프로세스 리소스 해제 후 quitAndInstall 실행')
|
||||
// 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행.
|
||||
// quitAndInstall 은 app.quit() 경로를 타므로 종료 등록부(ShutdownRegistry)가 자원을 정리한다.
|
||||
this._updater.quitAndInstall(false, true)
|
||||
/** 2단계: 다운로드 완료 시 재시작 및 설치 다이얼로그. 필수 업데이트는 미루기 선택지가 없다. */
|
||||
private async _promptRestart(version: string, mandatory: boolean): Promise<void> {
|
||||
if (this._restartPrompting) return
|
||||
this._restartPrompting = true
|
||||
let answer: RestartAnswer
|
||||
try {
|
||||
answer = await this._deps.prompter.askRestart(version, { mandatory })
|
||||
} finally {
|
||||
this._restartPrompting = false
|
||||
}
|
||||
// 설치 직전에 정책을 다시 받는다 — 대화상자가 떠 있는 동안 킬 스위치가 켜졌을 수 있다.
|
||||
if (answer === 'restart' || mandatory) await this._refreshPolicy()
|
||||
const payload = this._session.payload
|
||||
if (!payload || payload.version !== version || payload.phase !== 'downloaded') return
|
||||
this._run({ type: 'restart-answered', answer })
|
||||
}
|
||||
|
||||
// ── 타입 안전한 이벤트 메서드 오버라이드 ──
|
||||
|
|
@ -417,177 +490,6 @@ 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
|
||||
|
|
|
|||
237
apps/desktop/src/main/services/update-adapters.ts
Normal file
237
apps/desktop/src/main/services/update-adapters.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
// src/main/services/update-adapters.ts
|
||||
// UpdateService 포트의 기본 구현 (electron-updater / fetch / electron dialog).
|
||||
// 게이팅 판단은 여기 두지 않는다 — update-policy.ts · update-session.ts 가 정하고 UpdateService 가 실행한다.
|
||||
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { app, dialog } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { UPDATE_FEED_URL, UPDATE_POLICY_URL } from '../update-feed'
|
||||
import { DEFAULT_UPDATE_POLICY, parseUpdatePolicy, type UpdatePolicy } from '../update-policy'
|
||||
import { PORTABLE_INSTALL_MARKER, detectInstallKind, type InstallProbe } from '../install-kind'
|
||||
import type {
|
||||
ConsentAnswer,
|
||||
PolicySource,
|
||||
RestartAnswer,
|
||||
UpdatePrompter,
|
||||
UpdaterEventHandlers,
|
||||
UpdaterPort,
|
||||
} from './update-ports'
|
||||
|
||||
const logger = getLogger('UpdateService')
|
||||
|
||||
/** 원격 정책 fetch 타임아웃 */
|
||||
const POLICY_FETCH_TIMEOUT_MS = 8_000
|
||||
|
||||
/** 실행 중인 설치본의 형태를 파일 시스템에서 읽는다 (판단은 detectInstallKind). */
|
||||
function probeInstall(): InstallProbe {
|
||||
const exeDir = dirname(process.execPath)
|
||||
let exeDirEntries: string[] = []
|
||||
try {
|
||||
exeDirEntries = readdirSync(exeDir)
|
||||
} catch (err) {
|
||||
logger.warn(`설치 디렉터리 확인 실패(${exeDir}): ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
return {
|
||||
platform: process.platform,
|
||||
exeDirEntries,
|
||||
hasPortableMarker: existsSync(join(process.resourcesPath, PORTABLE_INSTALL_MARKER)),
|
||||
portableExecutableDir: process.env.PORTABLE_EXECUTABLE_DIR,
|
||||
}
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
// Scoop·설치 스크립트·zip 해제본에서 NSIS 설치자를 돌리면 다른 경로에 두 번째 사본이 깔리고
|
||||
// 실행 중인 사본은 옛 버전으로 남는다. 이런 설치본의 업데이트는 Scoop/설치 스크립트가 맡는다.
|
||||
const install = detectInstallKind(probeInstall())
|
||||
if (install.kind === 'portable') {
|
||||
logger.info(`휴대용 설치본(${install.reason}) — 내장 자동 업데이트 비활성 (Scoop/설치 스크립트로 업데이트)`)
|
||||
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
|
||||
// 받아 둔 설치 파일이 정책상 철회되면 UpdateService 가 setAutoInstallOnQuit(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
|
||||
},
|
||||
setAutoInstallOnQuit: (enabled) => {
|
||||
updater.autoInstallOnAppQuit = enabled
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 원격 정책 공급원 — 실패하면 마지막으로 성공한 정책, 그것도 없으면 내장 기본값 */
|
||||
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() 로 옮긴다.
|
||||
*/
|
||||
export 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, { mandatory }): Promise<RestartAnswer> {
|
||||
const isKo = isKorean()
|
||||
if (mandatory) {
|
||||
// 필수 업데이트는 미룰 수 없다 — 작업을 마칠 시간만 주고, 닫아도 재시작한다.
|
||||
await show({
|
||||
type: 'warning',
|
||||
title: isKo ? '필수 업데이트 설치' : 'Required Update',
|
||||
message: isKo
|
||||
? `D3RO Voice v${version} 필수 업데이트가 준비되었습니다. 확인을 누르면 앱을 재시작하여 설치합니다.`
|
||||
: `Required update D3RO Voice v${version} is ready. The app will restart to install it when you continue.`,
|
||||
buttons: isKo ? ['지금 재시작 및 설치'] : ['Restart & Install Now'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
})
|
||||
return 'restart'
|
||||
}
|
||||
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'
|
||||
},
|
||||
}
|
||||
}
|
||||
58
apps/desktop/src/main/services/update-ports.ts
Normal file
58
apps/desktop/src/main/services/update-ports.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// src/main/services/update-ports.ts
|
||||
// UpdateService 가 의존하는 포트(인터페이스). 서비스는 이 계약에만 의존하고,
|
||||
// electron-updater · fetch · electron dialog 구현은 update-adapters.ts 에 있다 (테스트는 가짜 포트 주입).
|
||||
|
||||
import type { UpdateChannel } from '../update-feed'
|
||||
import type { UpdatePolicy } from '../update-policy'
|
||||
|
||||
export interface UpdateProgressPayload {
|
||||
percent: number
|
||||
bytesPerSecond: number
|
||||
transferred: number
|
||||
total: number
|
||||
}
|
||||
|
||||
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>
|
||||
/** 인자 없이 업데이터가 마지막으로 알린(update-available) 버전을 받는다. */
|
||||
downloadUpdate(): Promise<unknown>
|
||||
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void
|
||||
setChannel(channel: UpdateChannel, allowPrerelease: boolean): void
|
||||
setDifferentialDisabled(disabled: boolean): void
|
||||
/**
|
||||
* 받아 둔 설치 파일을 앱 종료 시 설치할지. 킬 스위치·rollout 중단으로 설치 파일이 더 이상
|
||||
* 허용되지 않으면 false 로 내려 종료 시 조용히 설치되는 것을 막는다.
|
||||
*/
|
||||
setAutoInstallOnQuit(enabled: 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>
|
||||
/** mandatory 면 미루기('종료 시 설치') 선택지 없이 재시작만 안내한다. */
|
||||
askRestart(version: string, options: { mandatory: boolean }): Promise<RestartAnswer>
|
||||
}
|
||||
|
|
@ -27,7 +27,10 @@ export interface UpdatePolicy {
|
|||
fullInstallVersionGap: number
|
||||
/** 이 비율(%)의 사용자에게만 stable 업데이트를 노출한다. 100=전체. */
|
||||
stagingPercentage: number
|
||||
/** true면 업데이트 확인 자체를 중단한다 (원격 킬 스위치). */
|
||||
/**
|
||||
* true면 업데이트 확인 자체를 중단하고, 받는 중·받아 둔 설치 파일의 종료 시 설치도 해제한다
|
||||
* (원격 킬 스위치). 세션 재평가는 update-session.ts 참조.
|
||||
*/
|
||||
killSwitch: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
146
apps/desktop/src/main/update-session.ts
Normal file
146
apps/desktop/src/main/update-session.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// src/main/update-session.ts
|
||||
// 업데이트 세션 상태 머신 (순수 로직).
|
||||
//
|
||||
// 예전엔 세션이 UpdateService 의 독립 필드(_prompting/_downloading/_forceFullDownload)로 흩어져 있었고
|
||||
// 정책은 제안이 도착할 때만 확인됐다. 그래서
|
||||
// - 킬 스위치·rollout 중단이 받는 중/받아 둔 설치 파일에 닿지 않아 종료 시 그대로 설치됐고,
|
||||
// - 열려 있던 동의 대화상자가 electron-updater 가 마지막에 본(게이팅에서 걸러진) 버전을 받았고,
|
||||
// - 강제/필수 업데이트가 재시작 단계에서 '종료 시 설치'로 무기한 미뤄졌다.
|
||||
//
|
||||
// 이제 세션은 두 슬롯(동의 대화상자 / 설치 파일)만 가진 값이고, 모든 전이에서 정책을 다시 평가한다.
|
||||
// UpdateService 는 이벤트를 reduceUpdateSession 에 넣고 nextUpdateAction 이 돌려준 동작만 포트로 실행한다.
|
||||
|
||||
import type { UpdateChannel } from './update-feed'
|
||||
import {
|
||||
evaluateUpdateOffer,
|
||||
type UpdateDecision,
|
||||
type UpdateOfferIgnoreReason,
|
||||
type UpdatePolicy,
|
||||
} from './update-policy'
|
||||
|
||||
export type UpdatePayloadPhase = 'downloading' | 'downloaded'
|
||||
|
||||
export interface UpdateSession {
|
||||
/** 동의 대화상자가 열려 있는 버전 (없으면 null) */
|
||||
consenting: string | null
|
||||
/** 받는 중이거나 받아 둔 설치 파일 (없으면 null) */
|
||||
payload: { version: string; phase: UpdatePayloadPhase } | null
|
||||
}
|
||||
|
||||
export const IDLE_UPDATE_SESSION: UpdateSession = { consenting: null, payload: null }
|
||||
|
||||
export type UpdateSessionEvent =
|
||||
| { type: 'consent-opened'; version: string }
|
||||
| { type: 'consent-closed' }
|
||||
| { type: 'download-started'; version: string }
|
||||
| { type: 'download-finished'; version: string }
|
||||
| { type: 'download-failed' }
|
||||
|
||||
/** 세션 전이. 알 수 없는 조합은 상태를 그대로 둔다. */
|
||||
export function reduceUpdateSession(state: UpdateSession, event: UpdateSessionEvent): UpdateSession {
|
||||
switch (event.type) {
|
||||
case 'consent-opened':
|
||||
return { ...state, consenting: event.version }
|
||||
case 'consent-closed':
|
||||
return { ...state, consenting: null }
|
||||
case 'download-started':
|
||||
return { ...state, payload: { version: event.version, phase: 'downloading' } }
|
||||
case 'download-finished':
|
||||
return { ...state, payload: { version: event.version, phase: 'downloaded' } }
|
||||
case 'download-failed':
|
||||
// 체크 실패도 같은 오류 이벤트로 온다 — 받아 둔 설치 파일은 건드리지 않는다.
|
||||
return state.payload?.phase === 'downloading' ? { ...state, payload: null } : state
|
||||
}
|
||||
}
|
||||
|
||||
/** 게이팅에 필요한 현재 환경 (정책은 체크마다 새로 받은 값) */
|
||||
export interface UpdateGateContext {
|
||||
policy: UpdatePolicy
|
||||
channel: UpdateChannel
|
||||
currentVersion: string
|
||||
deviceId: string
|
||||
}
|
||||
|
||||
export type UpdateSessionTrigger =
|
||||
/** 사용자가(또는 강제 경로가) version 다운로드에 동의했다. offeredVersion 은 업데이터가 지금 들고 있는 버전. */
|
||||
| { type: 'consented'; version: string; offeredVersion: string | null }
|
||||
| { type: 'download-finished' }
|
||||
| { type: 'policy-refreshed' }
|
||||
| { type: 'restart-answered'; answer: 'restart' | 'later' }
|
||||
|
||||
export type UpdateBlockReason = UpdateOfferIgnoreReason | 'version-changed'
|
||||
|
||||
export type UpdateSessionAction =
|
||||
| { type: 'none' }
|
||||
/** 동의한 버전을 받는다 */
|
||||
| { type: 'download'; version: string; decision: UpdateDecision }
|
||||
/** 동의를 무효로 한다 (업데이터가 다른 버전을 들고 있거나 정책이 막았다) */
|
||||
| { type: 'drop-consent'; version: string; reason: UpdateBlockReason }
|
||||
/** 설치 파일이 여전히 허용된다 — 종료 시 설치 유지 */
|
||||
| { type: 'keep-pending'; version: string }
|
||||
/** 설치 파일이 더 이상 허용되지 않는다 — 종료 시 설치 해제, 재시작 제안 안 함 */
|
||||
| { type: 'cancel-pending'; version: string; reason: UpdateOfferIgnoreReason }
|
||||
/** 재시작을 묻는다. mandatory 면 미루기 선택지가 없다. */
|
||||
| { type: 'ask-restart'; version: string; mandatory: boolean }
|
||||
/** 지금 설치한다 (quitAndInstall) */
|
||||
| { type: 'install'; version: string }
|
||||
|
||||
type Gate = { allowed: true; decision: UpdateDecision } | { allowed: false; reason: UpdateOfferIgnoreReason }
|
||||
|
||||
/**
|
||||
* 운영자 정책(킬 스위치 · 채널 · staged rollout)으로 특정 버전이 여전히 허용되는지.
|
||||
* 사용자 '건너뛰기'는 여기서 보지 않는다 — 이 단계는 사용자가 이미 동의한 버전이다.
|
||||
*/
|
||||
function gateVersion(version: string, ctx: UpdateGateContext): Gate {
|
||||
const offer = evaluateUpdateOffer({
|
||||
policy: ctx.policy,
|
||||
channel: ctx.channel,
|
||||
currentVersion: ctx.currentVersion,
|
||||
targetVersion: version,
|
||||
skippedVersion: null,
|
||||
deviceId: ctx.deviceId,
|
||||
})
|
||||
return offer.action === 'ignore'
|
||||
? { allowed: false, reason: offer.reason }
|
||||
: { allowed: true, decision: offer.decision }
|
||||
}
|
||||
|
||||
/** 전이마다 정책을 다시 평가해 실행할 동작을 정한다. */
|
||||
export function nextUpdateAction(
|
||||
state: UpdateSession,
|
||||
trigger: UpdateSessionTrigger,
|
||||
ctx: UpdateGateContext,
|
||||
): UpdateSessionAction {
|
||||
if (trigger.type === 'consented') {
|
||||
// electron-updater 의 downloadUpdate() 는 인자 없이 '마지막으로 본' 버전을 받는다.
|
||||
// 동의한 버전과 다르면 받지 않는다 (게이팅에서 걸러진 버전이 끼어드는 것 방지).
|
||||
if (trigger.offeredVersion !== trigger.version) {
|
||||
return { type: 'drop-consent', version: trigger.version, reason: 'version-changed' }
|
||||
}
|
||||
const gate = gateVersion(trigger.version, ctx)
|
||||
return gate.allowed
|
||||
? { type: 'download', version: trigger.version, decision: gate.decision }
|
||||
: { type: 'drop-consent', version: trigger.version, reason: gate.reason }
|
||||
}
|
||||
|
||||
const payload = state.payload
|
||||
if (!payload) return { type: 'none' }
|
||||
|
||||
const gate = gateVersion(payload.version, ctx)
|
||||
if (!gate.allowed) return { type: 'cancel-pending', version: payload.version, reason: gate.reason }
|
||||
|
||||
switch (trigger.type) {
|
||||
case 'policy-refreshed':
|
||||
return { type: 'keep-pending', version: payload.version }
|
||||
case 'download-finished':
|
||||
return payload.phase === 'downloaded'
|
||||
? { type: 'ask-restart', version: payload.version, mandatory: gate.decision.mandatory }
|
||||
: { type: 'keep-pending', version: payload.version }
|
||||
case 'restart-answered':
|
||||
if (payload.phase !== 'downloaded') return { type: 'keep-pending', version: payload.version }
|
||||
// 필수(지원 종료·강제 설치 하한) 업데이트는 재시작 단계에서 미룰 수 없다.
|
||||
return trigger.answer === 'restart' || gate.decision.mandatory
|
||||
? { type: 'install', version: payload.version }
|
||||
: { type: 'keep-pending', version: payload.version }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue