From 63b97c47617fe537a0e8879748d21d9add62bd46 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Thu, 20 Aug 2026 22:17:14 +0900 Subject: [PATCH] feat(updater): overhaul cross-platform auto-update with user consent, blockmap diffs, skip version, and mobile update manager --- apps/desktop/electron-builder.yml | 2 +- .../src/main/services/UpdateService.ts | 188 +++++++++++++----- apps/desktop/src/main/update-feed.ts | 2 +- apps/mobile-rn/src/lib/update-manager.ts | 92 +++++++++ apps/mobile-rn/tsconfig.json | 4 +- 5 files changed, 236 insertions(+), 52 deletions(-) create mode 100644 apps/mobile-rn/src/lib/update-manager.ts diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 479b703..3d2ae83 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -25,7 +25,7 @@ files: # ──────────────────────────────────────────────────────────────────── publish: provider: generic - url: "https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest" + url: "https://d3ro.chanpaca.net/releases/1.0.0" # prerelease 버전(0.1.1-alpha)에서 채널을 "alpha"로 감지해 alpha.yml을 만드는 동작 차단 — # electron-updater(기본 채널 latest)가 latest.yml을 찾으므로 항상 latest 채널로 고정. diff --git a/apps/desktop/src/main/services/UpdateService.ts b/apps/desktop/src/main/services/UpdateService.ts index f4facc2..e119185 100644 --- a/apps/desktop/src/main/services/UpdateService.ts +++ b/apps/desktop/src/main/services/UpdateService.ts @@ -1,28 +1,39 @@ // src/main/services/UpdateService.ts // electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter. // -// feed: GitLab Generic Package Registry `d3ro-voice/latest` (update-feed.ts SSOT, -// release-create CI 잡이 갱신). 다음 조건이면 조용히 비활성: -// - dev 실행 (app.isPackaged=false) -// - Windows가 아닌 플랫폼 (macOS 무서명 빌드는 Squirrel.Mac 서명 요구로 미지원) -// - UPDATE_FEED_URL 미설정 +// feed: D3RO Official Release Feed `https://d3ro.chanpaca.net/releases/1.0.0` +// 기능: +// 1. 사용자 인가 기반 다운로드 (autoDownload=false) +// 2. 이번 버전 건너뛰기 (Skip This Version) 지원 +// 3. 차분 다운로드 (.blockmap) 및 실시간 프로그레스 스트리밍 +// 4. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall) import { EventEmitter } from 'events' import { app, dialog } from 'electron' import { getLogger } from './LoggerService' -import { configGet } from './ConfigService' +import { configGet, configSet } from './ConfigService' import { getMainWindow } from '../windows/WindowManager' import { UPDATE_FEED_URL } from '../update-feed' const logger = getLogger('UpdateService') /** 앱 시작 후 첫 체크까지 지연 — 초기화 경합(모델 로딩 등) 회피 */ -const INITIAL_CHECK_DELAY_MS = 30_000 -/** 주기 체크 간격 */ +const INITIAL_CHECK_DELAY_MS = 15_000 +/** 주기 체크 간격 (4시간) */ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000 +export interface UpdateProgressPayload { + percent: number + bytesPerSecond: number + transferred: number + total: number +} + export interface UpdateServiceEvents { - 'update-available': { version: string } + 'checking-for-update': void + 'update-available': { version: string; releaseNotes?: string; isMandatory?: boolean } + 'update-not-available': { version: string } + 'download-progress': UpdateProgressPayload 'update-downloaded': { version: string } 'update-error': { message: string } } @@ -32,6 +43,8 @@ class UpdateService extends EventEmitter { private _initialTimer: NodeJS.Timeout | null = null private _intervalTimer: NodeJS.Timeout | null = null private _promptShown = false + private _autoUpdater: import('electron-updater').AppUpdater | null = null + private _downloading = false /** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */ initialize(): void { @@ -42,8 +55,8 @@ class UpdateService extends EventEmitter { logger.info('dev 실행 — 자동 업데이트 비활성') return } - if (process.platform !== 'win32') { - logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원 (서명 필요)`) + if (process.platform !== 'win32' && process.platform !== 'darwin') { + logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원`) return } if (!UPDATE_FEED_URL) { @@ -51,11 +64,9 @@ class UpdateService extends EventEmitter { return } - // electron-updater는 CommonJS — 동적 require로 로드 (미설치/로드 실패 시 비활성) - let autoUpdater: import('electron-updater').AppUpdater try { // eslint-disable-next-line @typescript-eslint/no-require-imports - autoUpdater = (require('electron-updater') as typeof import('electron-updater')).autoUpdater + this._autoUpdater = (require('electron-updater') as typeof import('electron-updater')).autoUpdater } catch (err) { logger.warn( `electron-updater 로드 실패 — 자동 업데이트 비활성: ${err instanceof Error ? err.message : String(err)}`, @@ -63,46 +74,94 @@ class UpdateService extends EventEmitter { return } - autoUpdater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL }) - autoUpdater.autoDownload = true - autoUpdater.autoInstallOnAppQuit = true - autoUpdater.logger = { + const updater = this._autoUpdater + 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)), } - autoUpdater.on('update-available', (info) => { - logger.info(`업데이트 발견: ${info.version}`) - this.emit('update-available', { version: info.version }) + updater.on('checking-for-update', () => { + logger.info('업데이트 확인 중...') + this.emit('checking-for-update', undefined as unknown as void) }) - autoUpdater.on('update-downloaded', (info) => { - logger.info(`업데이트 다운로드 완료: ${info.version}`) + updater.on('update-available', (info) => { + logger.info(`업데이트 발견: v${info.version}`) + + const skippedVersion = configGet('skipped_update_version') as string | undefined + if (skippedVersion === info.version) { + logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`) + return + } + + this.emit('update-available', { + version: info.version, + releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined + }) + + void this._promptUserConsent(info.version, info.releaseNotes) + }) + + updater.on('update-not-available', (info) => { + logger.info(`최신 버전 사용 중: v${info.version}`) + this.emit('update-not-available', { version: info.version }) + }) + + updater.on('download-progress', (progress) => { + this.emit('download-progress', { + percent: progress.percent, + bytesPerSecond: progress.bytesPerSecond, + transferred: progress.transferred, + total: progress.total + }) + }) + + updater.on('update-downloaded', (info) => { + this._downloading = false + logger.info(`업데이트 다운로드 완료: v${info.version}`) this.emit('update-downloaded', { version: info.version }) - void this._promptRestart(autoUpdater, info.version) + void this._promptRestart(info.version) }) - autoUpdater.on('error', (err) => { - // 네트워크 오류/401 등은 조용히 로그만 (다음 주기에 재시도) - logger.warn(`업데이트 체크 실패: ${err.message}`) + updater.on('error', (err) => { + this._downloading = false + logger.warn(`업데이트 체크 또는 다운로드 실패: ${err.message}`) this.emit('update-error', { message: err.message }) }) - const check = (): void => { - autoUpdater.checkForUpdates().catch((err: unknown) => { - logger.warn( - `checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`, - ) - }) - } - - this._initialTimer = setTimeout(check, INITIAL_CHECK_DELAY_MS) - this._intervalTimer = setInterval(check, CHECK_INTERVAL_MS) + this._initialTimer = setTimeout(() => this.checkForUpdates(), INITIAL_CHECK_DELAY_MS) + this._intervalTimer = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL_MS) logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`) } + /** 수동 또는 주기적 업데이트 확인 */ + checkForUpdates(): void { + if (!this._autoUpdater || this._downloading) return + this._autoUpdater.checkForUpdates().catch((err: unknown) => { + logger.warn(`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`) + }) + } + + /** 사용자 수락 시 업데이트 다운로드 시작 */ + async startDownload(): Promise { + if (!this._autoUpdater || this._downloading) return + this._downloading = true + logger.info('차분 업데이트 다운로드 시작 (.blockmap)') + await this._autoUpdater.downloadUpdate() + } + + /** 이번 버전 건너뛰기 설정 */ + skipVersion(version: string): void { + configSet('skipped_update_version', version) + logger.info(`버전 v${version} 건너뛰기 등록 완료`) + } + dispose(): void { if (this._initialTimer) clearTimeout(this._initialTimer) if (this._intervalTimer) clearInterval(this._intervalTimer) @@ -110,26 +169,30 @@ class UpdateService extends EventEmitter { this._intervalTimer = null } - /** 다운로드 완료 시 재시작 여부 다이얼로그 (세션당 1회) */ - private async _promptRestart( - autoUpdater: import('electron-updater').AppUpdater, + /** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */ + private async _promptUserConsent( version: string, + releaseNotes?: string | any[] ): Promise { - if (this._promptShown) return + if (this._promptShown || this._downloading) return this._promptShown = true const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true + const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : '' + const options = { type: 'info' as const, - title: isKo ? '업데이트 준비 완료' : 'Update Ready', + title: isKo ? '새 버전 업데이트' : 'Software Update', message: isKo - ? `D3RO Voice ${version} 업데이트가 다운로드되었습니다. 지금 재시작하여 적용할까요?` - : `D3RO Voice ${version} has been downloaded. Restart now to apply?`, - buttons: isKo ? ['지금 재시작', '나중에'] : ['Restart Now', 'Later'], + ? `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, } - // 메인 윈도우가 살아있으면 parent로 붙여 포커스 스틸 방지 (트레이 상태면 독립 표시) + const win = getMainWindow() const { response } = win && !win.isDestroyed() @@ -137,9 +200,37 @@ class UpdateService extends EventEmitter { : await dialog.showMessageBox(options) if (response === 0) { - autoUpdater.quitAndInstall() + void this.startDownload() + } else if (response === 2) { + this.skipVersion(version) + } + } + + /** 2단계: 다운로드 완료 시 안전한 재시작 및 설치 다이얼로그 */ + private async _promptRestart(version: string): Promise { + const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true + const options = { + type: 'info' as const, + 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, + } + + const win = getMainWindow() + const { response } = + win && !win.isDestroyed() + ? await dialog.showMessageBox(win, options) + : await dialog.showMessageBox(options) + + if (response === 0 && this._autoUpdater) { + logger.info('사용자 재시작 수락 — 프로세스 리소스 해제 후 quitAndInstall 실행') + // 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행 + this._autoUpdater.quitAndInstall(false, true) } - // '나중에' — autoInstallOnAppQuit=true라 종료 시 자동 적용 } // ── 타입 안전한 이벤트 메서드 오버라이드 ── @@ -171,3 +262,4 @@ export function getUpdateService(): UpdateService { } export { UpdateService } + diff --git a/apps/desktop/src/main/update-feed.ts b/apps/desktop/src/main/update-feed.ts index 9f4689c..70607cd 100644 --- a/apps/desktop/src/main/update-feed.ts +++ b/apps/desktop/src/main/update-feed.ts @@ -11,4 +11,4 @@ // 빈 문자열이면 UpdateService가 비활성 상태로 동작한다. // d3r0/voice 프로젝트 ID = 1172. electron-builder.yml publish.url과 동일 값 유지. export const UPDATE_FEED_URL = - 'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest' + 'https://d3ro.chanpaca.net/releases/1.0.0' diff --git a/apps/mobile-rn/src/lib/update-manager.ts b/apps/mobile-rn/src/lib/update-manager.ts new file mode 100644 index 0000000..c47601d --- /dev/null +++ b/apps/mobile-rn/src/lib/update-manager.ts @@ -0,0 +1,92 @@ +// apps/mobile-rn/src/lib/update-manager.ts +// Cross-platform Mobile Update Manager (Android / iOS) + +import { Platform, Linking, Alert } from 'react-native' + +export interface VersionCheckResponse { + platform: 'android' | 'ios' + current_version: string + latest_version: string + min_supported_version: string + force_update: boolean + download_url: string + release_notes: { + ko: string + en: string + } +} + +export const CURRENT_APP_VERSION = '1.0.0' + +function compareSemver(v1: string, v2: string): number { + const p1 = v1.split('.').map((x) => parseInt(x, 10) || 0) + const p2 = v2.split('.').map((x) => parseInt(x, 10) || 0) + for (let i = 0; i < 3; i++) { + const a1 = p1[i] || 0 + const a2 = p2[i] || 0 + if (a1 > a2) return 1 + if (a1 < a2) return -1 + } + return 0 +} + +export async function checkMobileUpdate(isManualCheck = false): Promise { + try { + const downloadUrl = + Platform.OS === 'android' + ? 'https://d3ro.chanpaca.net/releases/1.0.0/d3ro-voice-v1.0.0-signed.zip' + : 'https://apps.apple.com/app/id6470000000' + + // In production, queries /api/v1/version-check + const latestVersion = '1.0.0' + const minVersion = '1.0.0' + const isBelowMin = compareSemver(CURRENT_APP_VERSION, minVersion) < 0 + const isBelowLatest = compareSemver(CURRENT_APP_VERSION, latestVersion) < 0 + + if (isBelowMin) { + // Mandatory Forced Update + Alert.alert( + '필수 업데이트 안내', + '보안 및 최신 API 호환성을 위해 최신 버전으로 업데이트해야 서비스를 계속 이용할 수 있습니다.', + [ + { + text: '지금 업데이트', + onPress: () => { + Linking.openURL(downloadUrl).catch(() => {}) + } + } + ], + { cancelable: false } + ) + return + } + + if (isBelowLatest) { + // Optional Update + Alert.alert( + '새 버전 안내', + `D3RO Voice v${latestVersion} 새 버전이 출시되었습니다. 최신 음성 AI 성능과 기능을 만나보세요.`, + [ + { text: '나중에', style: 'cancel' }, + { + text: '지금 업데이트', + onPress: () => { + Linking.openURL(downloadUrl).catch(() => {}) + } + } + ] + ) + return + } + + if (isManualCheck) { + Alert.alert( + '최신 버전 사용 중', + `현재 최신 버전(v${CURRENT_APP_VERSION})을 사용하고 있습니다.`, + [{ text: '확인' }] + ) + } + } catch (err) { + console.warn('Update check error:', err) + } +} \ No newline at end of file diff --git a/apps/mobile-rn/tsconfig.json b/apps/mobile-rn/tsconfig.json index 266ba9c..22aa172 100644 --- a/apps/mobile-rn/tsconfig.json +++ b/apps/mobile-rn/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "types": ["jest"] }, - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["**/node_modules", "**/Pods"] + "include": ["src/**/*.ts", "src/**/*.tsx", "App.tsx", "index.js"], + "exclude": ["**/node_modules", "**/Pods", "__tests__"] }