From 88f3dbcd69b6ae6a247239561aa029fff86950aa Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 02:16:18 +0900 Subject: [PATCH] fix(update): re-evaluate policy on every update transition and skip NSIS updater on portable installs --- apps/desktop/src/main/install-kind.ts | 52 ++ .../src/main/services/UpdateService.ts | 514 +++++++----------- .../src/main/services/update-adapters.ts | 237 ++++++++ .../desktop/src/main/services/update-ports.ts | 58 ++ apps/desktop/src/main/update-policy.ts | 5 +- apps/desktop/src/main/update-session.ts | 146 +++++ apps/desktop/tests/main/install-kind.test.ts | 43 ++ .../update-service-redteam-r1-3.test.ts | 1 + .../update-service-redteam-r2-11.test.ts | 349 ++++++++++++ .../desktop/tests/main/update-session.test.ts | 96 ++++ scripts/ci/build-portable.mjs | 22 + 11 files changed, 1216 insertions(+), 307 deletions(-) create mode 100644 apps/desktop/src/main/install-kind.ts create mode 100644 apps/desktop/src/main/services/update-adapters.ts create mode 100644 apps/desktop/src/main/services/update-ports.ts create mode 100644 apps/desktop/src/main/update-session.ts create mode 100644 apps/desktop/tests/main/install-kind.test.ts create mode 100644 apps/desktop/tests/main/services/update-service-redteam-r2-11.test.ts create mode 100644 apps/desktop/tests/main/update-session.test.ts diff --git a/apps/desktop/src/main/install-kind.ts b/apps/desktop/src/main/install-kind.ts new file mode 100644 index 0000000..ca88b35 --- /dev/null +++ b/apps/desktop/src/main/install-kind.ts @@ -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' } +} diff --git a/apps/desktop/src/main/services/UpdateService.ts b/apps/desktop/src/main/services/UpdateService.ts index bcdfbdd..d89ff50 100644 --- a/apps/desktop/src/main/services/UpdateService.ts +++ b/apps/desktop/src/main/services/UpdateService.ts @@ -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 - downloadUpdate(): Promise - quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void - setChannel(channel: UpdateChannel, allowPrerelease: boolean): void - setDifferentialDisabled(disabled: boolean): void -} - -/** 원격 정책 공급원. 실패하면 마지막으로 성공한 정책(없으면 기본값)을 돌려준다(fail-open). */ -export interface PolicySource { - load(): Promise -} - -export type ConsentAnswer = 'download' | 'later' | 'skip' -export type RestartAnswer = 'restart' | 'later' - -export interface UpdatePrompter { - askConsent(input: { version: string; releaseNotes: ReleaseNotes; mandatory: boolean }): Promise - askRestart(version: string): Promise -} - 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 = {}) { @@ -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 { - 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 { - 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 { + // 게이팅에서 무시하더라도 업데이터는 이미 이 버전을 다운로드 대상으로 들고 있다. + 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 { + 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 { + 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 { + // 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 { 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 { - 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 { - 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 { + 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 { - 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 => { - 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 { - 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 { - 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 diff --git a/apps/desktop/src/main/services/update-adapters.ts b/apps/desktop/src/main/services/update-adapters.ts new file mode 100644 index 0000000..7b1cee7 --- /dev/null +++ b/apps/desktop/src/main/services/update-adapters.ts @@ -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 { + 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 => { + 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 { + 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 { + 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' + }, + } +} diff --git a/apps/desktop/src/main/services/update-ports.ts b/apps/desktop/src/main/services/update-ports.ts new file mode 100644 index 0000000..c09ee98 --- /dev/null +++ b/apps/desktop/src/main/services/update-ports.ts @@ -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 + /** 인자 없이 업데이터가 마지막으로 알린(update-available) 버전을 받는다. */ + downloadUpdate(): Promise + 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 +} + +export type ConsentAnswer = 'download' | 'later' | 'skip' +export type RestartAnswer = 'restart' | 'later' + +export interface UpdatePrompter { + askConsent(input: { version: string; releaseNotes: ReleaseNotes; mandatory: boolean }): Promise + /** mandatory 면 미루기('종료 시 설치') 선택지 없이 재시작만 안내한다. */ + askRestart(version: string, options: { mandatory: boolean }): Promise +} diff --git a/apps/desktop/src/main/update-policy.ts b/apps/desktop/src/main/update-policy.ts index 78344de..4bb41bc 100644 --- a/apps/desktop/src/main/update-policy.ts +++ b/apps/desktop/src/main/update-policy.ts @@ -27,7 +27,10 @@ export interface UpdatePolicy { fullInstallVersionGap: number /** 이 비율(%)의 사용자에게만 stable 업데이트를 노출한다. 100=전체. */ stagingPercentage: number - /** true면 업데이트 확인 자체를 중단한다 (원격 킬 스위치). */ + /** + * true면 업데이트 확인 자체를 중단하고, 받는 중·받아 둔 설치 파일의 종료 시 설치도 해제한다 + * (원격 킬 스위치). 세션 재평가는 update-session.ts 참조. + */ killSwitch: boolean } diff --git a/apps/desktop/src/main/update-session.ts b/apps/desktop/src/main/update-session.ts new file mode 100644 index 0000000..b1a64f3 --- /dev/null +++ b/apps/desktop/src/main/update-session.ts @@ -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 } + } +} diff --git a/apps/desktop/tests/main/install-kind.test.ts b/apps/desktop/tests/main/install-kind.test.ts new file mode 100644 index 0000000..3c83b52 --- /dev/null +++ b/apps/desktop/tests/main/install-kind.test.ts @@ -0,0 +1,43 @@ +// tests/main/install-kind.test.ts +// 설치 형태 판별 — 휴대용(Scoop·설치 스크립트·zip) 설치본에서 NSIS 자동 업데이터를 켜지 않는다. + +import { describe, it, expect } from 'vitest' +import { detectInstallKind, type InstallProbe } from '../../src/main/install-kind' + +function probe(overrides: Partial = {}): InstallProbe { + return { + platform: 'win32', + exeDirEntries: ['D3RO Voice.exe', 'resources', 'locales'], + hasPortableMarker: false, + portableExecutableDir: undefined, + ...overrides, + } +} + +describe('detectInstallKind', () => { + it('NSIS 제거 프로그램이 있으면 설치자 소유 디렉터리다', () => { + expect(detectInstallKind(probe({ exeDirEntries: ['D3RO Voice.exe', 'Uninstall D3RO Voice.exe'] }))).toEqual({ + kind: 'installer', + reason: 'nsis-uninstaller', + }) + }) + + it('제거 프로그램이 없으면(Scoop/설치 스크립트/zip) 휴대용이다', () => { + expect(detectInstallKind(probe()).kind).toBe('portable') + }) + + it('휴대용 표식·portable 타깃은 사유만 바꾼다', () => { + expect(detectInstallKind(probe({ hasPortableMarker: true })).reason).toBe('portable-marker') + expect(detectInstallKind(probe({ portableExecutableDir: 'C:\\apps' })).reason).toBe('portable-target') + }) + + it('표식이 NSIS 설치본에 섞여도 제거 프로그램이 있으면 자동 업데이트를 끄지 않는다', () => { + expect( + detectInstallKind(probe({ hasPortableMarker: true, exeDirEntries: ['Uninstall D3RO Voice.exe'] })).kind, + ).toBe('installer') + }) + + it('macOS 는 설치 형태와 무관하게 업데이터를 쓴다', () => { + expect(detectInstallKind(probe({ platform: 'darwin' })).kind).toBe('installer') + }) +}) diff --git a/apps/desktop/tests/main/services/update-service-redteam-r1-3.test.ts b/apps/desktop/tests/main/services/update-service-redteam-r1-3.test.ts index 29e35b4..0f6c3e4 100644 --- a/apps/desktop/tests/main/services/update-service-redteam-r1-3.test.ts +++ b/apps/desktop/tests/main/services/update-service-redteam-r1-3.test.ts @@ -57,6 +57,7 @@ function harness(opts: { answers?: ConsentAnswer[]; policies?: UpdatePolicy[] } quitAndInstall: vi.fn(), setChannel: vi.fn(), setDifferentialDisabled: vi.fn(), + setAutoInstallOnQuit: vi.fn(), } const policySource: PolicySource & { loads: number } = { loads: 0, diff --git a/apps/desktop/tests/main/services/update-service-redteam-r2-11.test.ts b/apps/desktop/tests/main/services/update-service-redteam-r2-11.test.ts new file mode 100644 index 0000000..33fd6c1 --- /dev/null +++ b/apps/desktop/tests/main/services/update-service-redteam-r2-11.test.ts @@ -0,0 +1,349 @@ +// tests/main/services/update-service-redteam-r2-11.test.ts +// 자동 업데이트 회귀 테스트 (라운드 2, 가짜 포트 주입): +// 1) 킬 스위치·rollout 중단이 받는 중/받아 둔 설치 파일의 '종료 시 설치'까지 막는다 +// 2) 열린 동의 대화상자의 동의가 electron-updater 가 나중에 본(게이팅된) 버전에 묶이지 않는다 +// 3) 강제·필수 업데이트는 재시작 단계에서 미룰 수 없다 +// +// 가짜 업데이터는 electron-updater 의 관련 동작만 흉내 낸다: +// - update-available 직전에 대상 버전을 교체하고, downloadUpdate() 는 그 대상을 받는다 +// - 다운로드가 끝나면 autoInstallOnAppQuit 가 켜져 있을 때만 종료 핸들러를 건다 +// - 종료 시 autoInstallOnAppQuit 가 여전히 켜져 있을 때만 설치한다 + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { DEFAULT_UPDATE_POLICY, rolloutBucket, type UpdatePolicy } from '../../../src/main/update-policy' +import type { + ConsentAnswer, + PolicySource, + RestartAnswer, + UpdatePrompter, + UpdaterEventHandlers, + UpdaterPort, +} from '../../../src/main/services/UpdateService' + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +const store = vi.hoisted(() => ({ values: {} as Record })) +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn((key: string) => store.values[key]), + configSet: vi.fn((key: string, value: unknown) => { + store.values[key] = value + }), + configGetAll: vi.fn(() => store.values), +})) + +type ServiceModule = typeof import('../../../src/main/services/UpdateService') +let UpdateService: ServiceModule['UpdateService'] + +function policy(overrides: Partial = {}): UpdatePolicy { + return { ...DEFAULT_UPDATE_POLICY, channels: { ...DEFAULT_UPDATE_POLICY.channels }, ...overrides } +} + +async function flush(): Promise { + for (let i = 0; i < 10; i++) await new Promise((resolve) => setImmediate(resolve)) +} + +interface Deferred { + promise: Promise + resolve(value: T): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +function harness(opts: { + policy?: UpdatePolicy + currentVersion?: string + consent?: () => Promise + restart?: () => Promise +}) { + let currentPolicy = opts.policy ?? policy() + let handlers: UpdaterEventHandlers | null = null + + const fake = { + /** electron-updater 의 updateInfoAndProvider 에 해당 */ + target: null as string | null, + downloading: null as string | null, + downloaded: null as string | null, + autoInstallOnAppQuit: true, + quitHandlerAdded: false, + downloads: [] as string[], + checks: 0, + installs: [] as string[], + } + + const updater: UpdaterPort = { + checkForUpdates: vi.fn(async () => { + fake.checks++ + }), + downloadUpdate: vi.fn(async () => { + if (fake.target) { + fake.downloads.push(fake.target) + fake.downloading = fake.target + } + }), + quitAndInstall: vi.fn(() => { + if (fake.downloaded) fake.installs.push(fake.downloaded) + }), + setChannel: vi.fn(), + setDifferentialDisabled: vi.fn(), + setAutoInstallOnQuit: vi.fn((enabled: boolean) => { + fake.autoInstallOnAppQuit = enabled + }), + } + + const policySource: PolicySource = { load: vi.fn(async () => currentPolicy) } + const prompter: UpdatePrompter = { + askConsent: vi.fn(opts.consent ?? (async () => 'download' as const)), + askRestart: vi.fn(opts.restart ?? (async () => 'later' as const)), + } + + const service = new UpdateService({ + createUpdater: (h) => { + handlers = h + return updater + }, + policySource, + prompter, + currentVersion: () => opts.currentVersion ?? '1.9.0', + now: () => 1_000_000, + }) + service.initialize() + service.dispose() + + return { + service, + updater, + prompter, + fake, + setPolicy(next: UpdatePolicy) { + currentPolicy = next + }, + /** electron-updater 가 새 버전을 발견: 대상 교체 → update-available */ + offer(version: string) { + fake.target = version + ;(handlers as UpdaterEventHandlers).onAvailable({ version }) + }, + /** 다운로드 완료: update-downloaded → (동기로) 종료 핸들러 등록 */ + finishDownload() { + const version = fake.downloading as string + fake.downloading = null + fake.downloaded = version + ;(handlers as UpdaterEventHandlers).onDownloaded(version) + if (fake.autoInstallOnAppQuit) fake.quitHandlerAdded = true + }, + /** 앱 종료: electron-updater 종료 핸들러 흉내 */ + quitApp() { + if (fake.quitHandlerAdded && fake.autoInstallOnAppQuit && fake.downloaded) { + fake.installs.push(fake.downloaded) + } + }, + } +} + +beforeEach(async () => { + vi.resetModules() + store.values = { updateDeviceId: 'device-1' } + UpdateService = (await import('../../../src/main/services/UpdateService')).UpdateService +}) + +describe('1) 킬 스위치·rollout 중단은 받아 둔 설치 파일에도 적용된다', () => { + it("'종료 시 설치'를 고른 뒤 킬 스위치가 켜지면 종료해도 설치하지 않는다", async () => { + const h = harness({}) + await h.service.checkForUpdates() + h.offer('1.9.1') + await flush() + expect(h.fake.downloads).toEqual(['1.9.1']) + h.finishDownload() + await flush() + expect(h.prompter.askRestart).toHaveBeenCalledTimes(1) + + h.setPolicy(policy({ killSwitch: true })) + await h.service.checkForUpdates() + expect(h.fake.autoInstallOnAppQuit).toBe(false) + + h.quitApp() + expect(h.fake.installs).toEqual([]) + }) + + it('stagingPercentage=0 으로 rollout 을 멈춰도 받아 둔 설치 파일은 설치하지 않는다', async () => { + const h = harness({}) + await h.service.checkForUpdates() + h.offer('1.9.1') + await flush() + h.finishDownload() + await flush() + + h.setPolicy(policy({ stagingPercentage: 0 })) + await h.service.checkForUpdates() + h.quitApp() + expect(h.fake.installs).toEqual([]) + }) + + it('받는 중에 킬 스위치가 켜지면 다운로드가 끝나도 재시작을 묻지 않고 종료 핸들러도 걸리지 않는다', async () => { + const h = harness({}) + await h.service.checkForUpdates() + h.offer('1.9.1') + await flush() + expect(h.fake.downloading).toBe('1.9.1') + + h.setPolicy(policy({ killSwitch: true })) + await h.service.checkForUpdates() + expect(h.fake.autoInstallOnAppQuit).toBe(false) + + h.finishDownload() + await flush() + expect(h.prompter.askRestart).not.toHaveBeenCalled() + h.quitApp() + expect(h.fake.installs).toEqual([]) + }) + + it('킬 스위치가 풀리면 종료 시 설치를 되살린다', async () => { + const h = harness({}) + await h.service.checkForUpdates() + h.offer('1.9.1') + await flush() + h.finishDownload() + await flush() + + h.setPolicy(policy({ killSwitch: true })) + await h.service.checkForUpdates() + expect(h.fake.autoInstallOnAppQuit).toBe(false) + + h.setPolicy(policy()) + await h.service.checkForUpdates() + expect(h.fake.autoInstallOnAppQuit).toBe(true) + h.quitApp() + expect(h.fake.installs).toEqual(['1.9.1']) + }) + + it("재시작 대화상자가 떠 있는 동안 킬 스위치가 켜지면 '지금 재시작'이어도 설치하지 않는다", async () => { + const answer = deferred() + const h = harness({ restart: () => answer.promise }) + await h.service.checkForUpdates() + h.offer('1.9.1') + await flush() + h.finishDownload() + await flush() + + h.setPolicy(policy({ killSwitch: true })) + answer.resolve('restart') + await flush() + expect(h.updater.quitAndInstall).not.toHaveBeenCalled() + expect(h.fake.autoInstallOnAppQuit).toBe(false) + }) +}) + +describe('2) 동의는 동의한 버전에만 묶인다', () => { + it('동의 대화상자가 열려 있는 동안에는 업데이터 체크를 건너뛴다(정책은 갱신)', async () => { + const consent = deferred() + const h = harness({ consent: () => consent.promise }) + await h.service.checkForUpdates() + h.offer('1.9.1') + await flush() + expect(h.prompter.askConsent).toHaveBeenCalledTimes(1) + + const checksBefore = vi.mocked(h.updater.checkForUpdates).mock.calls.length + await h.service.checkForUpdates() + expect(vi.mocked(h.updater.checkForUpdates).mock.calls.length).toBe(checksBefore) + + consent.resolve('download') + await flush() + expect(h.fake.downloads).toEqual(['1.9.1']) + }) + + it('대화상자가 열린 사이 rollout 밖의 1.9.2 가 대상이 되면, 1.9.1 동의로 1.9.2 를 받지 않는다', async () => { + // 1.9.1 은 rollout 안, 1.9.2 는 rollout 밖인 기기를 고른다 + let deviceId = '' + for (let i = 0; i < 1000 && !deviceId; i++) { + const candidate = `device-${i}` + if (rolloutBucket(candidate, '1.9.1') < 50 && rolloutBucket(candidate, '1.9.2') >= 50) deviceId = candidate + } + expect(deviceId).not.toBe('') + store.values.updateDeviceId = deviceId + + const consent = deferred() + const h = harness({ policy: policy({ stagingPercentage: 50 }), consent: () => consent.promise }) + await h.service.checkForUpdates() + h.offer('1.9.1') + await flush() + + // 누군가(수동 체크 등) 업데이터를 다시 돌려 1.9.2 가 대상이 됐다 — 게이팅은 1.9.2 를 무시 + h.offer('1.9.2') + await flush() + expect(h.prompter.askConsent).toHaveBeenCalledTimes(1) + + consent.resolve('download') + await flush() + expect(h.updater.downloadUpdate).not.toHaveBeenCalled() + expect(h.fake.downloads).toEqual([]) + }) + + it('대화상자가 열린 사이 킬 스위치가 켜지면 동의해도 받지 않는다', async () => { + const consent = deferred() + const h = harness({ consent: () => consent.promise }) + await h.service.checkForUpdates() + h.offer('1.9.1') + await flush() + + h.setPolicy(policy({ killSwitch: true })) + await h.service.checkForUpdates() + consent.resolve('download') + await flush() + expect(h.fake.downloads).toEqual([]) + }) +}) + +describe('3) 강제·필수 업데이트는 재시작 단계에서 미룰 수 없다', () => { + it('forceInstallBelow 미만이면 동의 없이 받고, 재시작 대화상자에서 미뤄도 설치한다', async () => { + const h = harness({ policy: policy({ forceInstallBelow: '1.9.1' }), restart: async () => 'later' }) + await h.service.checkForUpdates() + h.offer('1.9.1') + await flush() + expect(h.prompter.askConsent).not.toHaveBeenCalled() + expect(h.fake.downloads).toEqual(['1.9.1']) + + h.finishDownload() + await flush() + expect(h.prompter.askRestart).toHaveBeenCalledWith('1.9.1', { mandatory: true }) + expect(h.updater.quitAndInstall).toHaveBeenCalledWith(false, true) + expect(h.fake.installs).toEqual(['1.9.1']) + }) + + it('최소 지원 버전 미만(필수)도 재시작을 미룰 수 없다', async () => { + const h = harness({ policy: policy({ minimumSupportedVersion: '1.9.1' }), restart: async () => 'later' }) + await h.service.checkForUpdates() + h.offer('1.9.1') + await flush() + h.finishDownload() + await flush() + expect(h.prompter.askRestart).toHaveBeenCalledWith('1.9.1', { mandatory: true }) + expect(h.updater.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + it("일반 업데이트는 '종료 시 설치'를 고르면 지금 설치하지 않고, '지금 재시작'이면 설치한다", async () => { + const later = harness({ restart: async () => 'later' }) + await later.service.checkForUpdates() + later.offer('1.9.1') + await flush() + later.finishDownload() + await flush() + expect(later.prompter.askRestart).toHaveBeenCalledWith('1.9.1', { mandatory: false }) + expect(later.updater.quitAndInstall).not.toHaveBeenCalled() + expect(later.fake.autoInstallOnAppQuit).toBe(true) + + const now = harness({ restart: async () => 'restart' }) + await now.service.checkForUpdates() + now.offer('1.9.1') + await flush() + now.finishDownload() + await flush() + expect(now.updater.quitAndInstall).toHaveBeenCalledWith(false, true) + }) +}) diff --git a/apps/desktop/tests/main/update-session.test.ts b/apps/desktop/tests/main/update-session.test.ts new file mode 100644 index 0000000..6bdefe0 --- /dev/null +++ b/apps/desktop/tests/main/update-session.test.ts @@ -0,0 +1,96 @@ +// tests/main/update-session.test.ts +// 업데이트 세션 상태 머신 (순수) — 전이마다 정책을 다시 평가하는지 표로 검증한다. + +import { describe, it, expect } from 'vitest' +import { DEFAULT_UPDATE_POLICY, type UpdatePolicy } from '../../src/main/update-policy' +import { + IDLE_UPDATE_SESSION, + nextUpdateAction, + reduceUpdateSession, + type UpdateGateContext, + type UpdateSession, +} from '../../src/main/update-session' + +function ctx(overrides: Partial = {}): UpdateGateContext { + return { + policy: { ...DEFAULT_UPDATE_POLICY, channels: { ...DEFAULT_UPDATE_POLICY.channels }, ...overrides }, + channel: 'latest', + currentVersion: '1.9.0', + deviceId: 'device-1', + } +} + +const downloading: UpdateSession = { consenting: null, payload: { version: '1.9.1', phase: 'downloading' } } +const downloaded: UpdateSession = { consenting: null, payload: { version: '1.9.1', phase: 'downloaded' } } + +describe('reduceUpdateSession', () => { + it('동의 대화상자와 설치 파일 슬롯을 따로 추적한다', () => { + let s = reduceUpdateSession(downloaded, { type: 'consent-opened', version: '1.9.2' }) + expect(s).toEqual({ consenting: '1.9.2', payload: { version: '1.9.1', phase: 'downloaded' } }) + s = reduceUpdateSession(s, { type: 'consent-closed' }) + s = reduceUpdateSession(s, { type: 'download-started', version: '1.9.2' }) + expect(s.payload).toEqual({ version: '1.9.2', phase: 'downloading' }) + s = reduceUpdateSession(s, { type: 'download-finished', version: '1.9.2' }) + expect(s.payload).toEqual({ version: '1.9.2', phase: 'downloaded' }) + }) + + it('실패는 받는 중인 설치 파일만 비운다 (체크 실패가 받아 둔 파일을 지우지 않는다)', () => { + expect(reduceUpdateSession(downloading, { type: 'download-failed' }).payload).toBeNull() + expect(reduceUpdateSession(downloaded, { type: 'download-failed' })).toBe(downloaded) + expect(reduceUpdateSession(IDLE_UPDATE_SESSION, { type: 'download-failed' })).toBe(IDLE_UPDATE_SESSION) + }) +}) + +describe('nextUpdateAction', () => { + it.each([ + // [설명, 세션, 트리거, 정책, 기대 동작] + ['동의 → 허용', IDLE_UPDATE_SESSION, { type: 'consented', version: '1.9.1', offeredVersion: '1.9.1' }, {}, 'download'], + ['동의 → 대상 바뀜', IDLE_UPDATE_SESSION, { type: 'consented', version: '1.9.1', offeredVersion: '1.9.2' }, {}, 'drop-consent'], + ['동의 → 킬 스위치', IDLE_UPDATE_SESSION, { type: 'consented', version: '1.9.1', offeredVersion: '1.9.1' }, { killSwitch: true }, 'drop-consent'], + ['동의 → rollout 중단', IDLE_UPDATE_SESSION, { type: 'consented', version: '1.9.1', offeredVersion: '1.9.1' }, { stagingPercentage: 0 }, 'drop-consent'], + ['설치 파일 없음 → 정책 갱신', IDLE_UPDATE_SESSION, { type: 'policy-refreshed' }, { killSwitch: true }, 'none'], + ['받는 중 → 킬 스위치', downloading, { type: 'policy-refreshed' }, { killSwitch: true }, 'cancel-pending'], + ['받는 중 → 허용 유지', downloading, { type: 'policy-refreshed' }, {}, 'keep-pending'], + ['받아 둠 → 킬 스위치', downloaded, { type: 'policy-refreshed' }, { killSwitch: true }, 'cancel-pending'], + ['받아 둠 → rollout 중단', downloaded, { type: 'policy-refreshed' }, { stagingPercentage: 0 }, 'cancel-pending'], + ['받아 둠 → 허용 유지', downloaded, { type: 'policy-refreshed' }, {}, 'keep-pending'], + ['완료 → 재시작 묻기', downloaded, { type: 'download-finished' }, {}, 'ask-restart'], + ['완료 → 킬 스위치', downloaded, { type: 'download-finished' }, { killSwitch: true }, 'cancel-pending'], + ['재시작 수락', downloaded, { type: 'restart-answered', answer: 'restart' }, {}, 'install'], + ['재시작 미룸', downloaded, { type: 'restart-answered', answer: 'later' }, {}, 'keep-pending'], + ['재시작 미룸 (강제 설치 하한)', downloaded, { type: 'restart-answered', answer: 'later' }, { forceInstallBelow: '1.9.1' }, 'install'], + ['재시작 미룸 (최소 지원)', downloaded, { type: 'restart-answered', answer: 'later' }, { minimumSupportedVersion: '1.9.1' }, 'install'], + ['재시작 수락 → 그 사이 킬 스위치', downloaded, { type: 'restart-answered', answer: 'restart' }, { killSwitch: true }, 'cancel-pending'], + ] as const)('%s', (_label, session, trigger, overrides, expected) => { + expect(nextUpdateAction(session, trigger, ctx(overrides)).type).toBe(expected) + }) + + it('완료 시 필수 여부를 재시작 대화상자에 넘긴다', () => { + expect(nextUpdateAction(downloaded, { type: 'download-finished' }, ctx({ forceInstallBelow: '1.9.1' }))).toEqual({ + type: 'ask-restart', + version: '1.9.1', + mandatory: true, + }) + expect(nextUpdateAction(downloaded, { type: 'download-finished' }, ctx())).toEqual({ + type: 'ask-restart', + version: '1.9.1', + mandatory: false, + }) + }) + + it('필수 업데이트는 rollout 중단(stagingPercentage=0)에도 설치 파일을 유지한다', () => { + expect( + nextUpdateAction(downloaded, { type: 'policy-refreshed' }, ctx({ stagingPercentage: 0, minimumSupportedVersion: '1.9.1' })) + .type, + ).toBe('keep-pending') + }) + + it('다운로드 결정은 동의 시점의 정책으로 다시 계산한다 (전체/차분)', () => { + const action = nextUpdateAction( + IDLE_UPDATE_SESSION, + { type: 'consented', version: '2.0.0', offeredVersion: '2.0.0' }, + ctx(), + ) + expect(action).toMatchObject({ type: 'download', version: '2.0.0', decision: { forceFull: true } }) + }) +}) diff --git a/scripts/ci/build-portable.mjs b/scripts/ci/build-portable.mjs index 91fa3c1..1bcac8d 100644 --- a/scripts/ci/build-portable.mjs +++ b/scripts/ci/build-portable.mjs @@ -150,6 +150,28 @@ runNodeScript('scripts/ci/verify-native-abi.mjs', ['--dir', appDir]) // 이 파일이 없으면 electron-updater가 설정을 읽지 못해 자동 업데이트가 죽는다(실측). runNodeScript('scripts/ci/write-app-update-yml.mjs', ['--dir', appDir]) +// 휴대용 표식: 이 트리(Scoop 7z · 수동 설치 zip)는 NSIS 설치자가 소유하지 않는다. +// 런타임(src/main/install-kind.ts)은 NSIS 제거 프로그램이 없는 설치본에서 내장 자동 업데이트(NsisUpdater)를 +// 끈다 — 켜 두면 NSIS 설치자가 다른 경로에 두 번째 사본을 깔고, 실행 중인 사본은 옛 버전으로 남는다. +// 표식은 그 판단을 명시하고 로그 사유를 남긴다. 파일 이름은 install-kind.ts 가 정본이다. +const installKindSource = readFileSync(join(desktopDir, 'src', 'main', 'install-kind.ts'), 'utf8') +const markerMatch = installKindSource.match(/export const PORTABLE_INSTALL_MARKER\s*=\s*'([^']+)'/) +if (!markerMatch) { + console.error('[portable] install-kind.ts에서 PORTABLE_INSTALL_MARKER를 찾을 수 없습니다.') + process.exit(1) +} +const portableMarkerPath = join(appDir, 'resources', markerMatch[1]) +writeFileSync( + portableMarkerPath, + `${JSON.stringify({ kind: 'portable', version, updater: 'scoop-or-install-script' }, null, 2)}\n`, + 'utf8', +) +// 같은 win-unpacked 트리로 나중에 NSIS 설치자(--prepackaged)를 만들 수 있으므로, 아카이브를 만든 뒤에는 +// 표식을 지운다. (런타임은 NSIS 제거 프로그램을 먼저 보므로 표식이 섞여도 설치본 업데이트는 꺼지지 않는다.) +process.on('exit', () => { + rmSync(portableMarkerPath, { force: true }) +}) + // 이전 볼륨 정리 (같은 버전 재드 시 잔여 볼륨이 섞이지 않도록) for (const name of readdirSync(releaseDir)) { if (name.startsWith(ARCHIVE_BASE)) {