feat(updater): overhaul cross-platform auto-update with user consent, blockmap diffs, skip version, and mobile update manager
This commit is contained in:
parent
d4dc498448
commit
63b97c4761
5 changed files with 236 additions and 52 deletions
|
|
@ -25,7 +25,7 @@ files:
|
||||||
# ────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
publish:
|
publish:
|
||||||
provider: generic
|
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을 만드는 동작 차단 —
|
# prerelease 버전(0.1.1-alpha)에서 채널을 "alpha"로 감지해 alpha.yml을 만드는 동작 차단 —
|
||||||
# electron-updater(기본 채널 latest)가 latest.yml을 찾으므로 항상 latest 채널로 고정.
|
# electron-updater(기본 채널 latest)가 latest.yml을 찾으므로 항상 latest 채널로 고정.
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,39 @@
|
||||||
// src/main/services/UpdateService.ts
|
// src/main/services/UpdateService.ts
|
||||||
// electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter.
|
// electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter.
|
||||||
//
|
//
|
||||||
// feed: GitLab Generic Package Registry `d3ro-voice/latest` (update-feed.ts SSOT,
|
// feed: D3RO Official Release Feed `https://d3ro.chanpaca.net/releases/1.0.0`
|
||||||
// release-create CI 잡이 갱신). 다음 조건이면 조용히 비활성:
|
// 기능:
|
||||||
// - dev 실행 (app.isPackaged=false)
|
// 1. 사용자 인가 기반 다운로드 (autoDownload=false)
|
||||||
// - Windows가 아닌 플랫폼 (macOS 무서명 빌드는 Squirrel.Mac 서명 요구로 미지원)
|
// 2. 이번 버전 건너뛰기 (Skip This Version) 지원
|
||||||
// - UPDATE_FEED_URL 미설정
|
// 3. 차분 다운로드 (.blockmap) 및 실시간 프로그레스 스트리밍
|
||||||
|
// 4. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall)
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { app, dialog } from 'electron'
|
import { app, dialog } from 'electron'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { configGet } from './ConfigService'
|
import { configGet, configSet } from './ConfigService'
|
||||||
import { getMainWindow } from '../windows/WindowManager'
|
import { getMainWindow } from '../windows/WindowManager'
|
||||||
import { UPDATE_FEED_URL } from '../update-feed'
|
import { UPDATE_FEED_URL } from '../update-feed'
|
||||||
|
|
||||||
const logger = getLogger('UpdateService')
|
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
|
const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
export interface UpdateProgressPayload {
|
||||||
|
percent: number
|
||||||
|
bytesPerSecond: number
|
||||||
|
transferred: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateServiceEvents {
|
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-downloaded': { version: string }
|
||||||
'update-error': { message: string }
|
'update-error': { message: string }
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +43,8 @@ class UpdateService extends EventEmitter {
|
||||||
private _initialTimer: NodeJS.Timeout | null = null
|
private _initialTimer: NodeJS.Timeout | null = null
|
||||||
private _intervalTimer: NodeJS.Timeout | null = null
|
private _intervalTimer: NodeJS.Timeout | null = null
|
||||||
private _promptShown = false
|
private _promptShown = false
|
||||||
|
private _autoUpdater: import('electron-updater').AppUpdater | null = null
|
||||||
|
private _downloading = false
|
||||||
|
|
||||||
/** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */
|
/** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */
|
||||||
initialize(): void {
|
initialize(): void {
|
||||||
|
|
@ -42,8 +55,8 @@ class UpdateService extends EventEmitter {
|
||||||
logger.info('dev 실행 — 자동 업데이트 비활성')
|
logger.info('dev 실행 — 자동 업데이트 비활성')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||||
logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원 (서명 필요)`)
|
logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!UPDATE_FEED_URL) {
|
if (!UPDATE_FEED_URL) {
|
||||||
|
|
@ -51,11 +64,9 @@ class UpdateService extends EventEmitter {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// electron-updater는 CommonJS — 동적 require로 로드 (미설치/로드 실패 시 비활성)
|
|
||||||
let autoUpdater: import('electron-updater').AppUpdater
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// 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) {
|
} catch (err) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`electron-updater 로드 실패 — 자동 업데이트 비활성: ${err instanceof Error ? err.message : String(err)}`,
|
`electron-updater 로드 실패 — 자동 업데이트 비활성: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
|
@ -63,46 +74,94 @@ class UpdateService extends EventEmitter {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
autoUpdater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL })
|
const updater = this._autoUpdater
|
||||||
autoUpdater.autoDownload = true
|
updater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL })
|
||||||
autoUpdater.autoInstallOnAppQuit = true
|
// 사용자 인가를 위해 자동 다운로드는 비활성화 (동의 시 downloadUpdate 호출)
|
||||||
autoUpdater.logger = {
|
updater.autoDownload = false
|
||||||
|
updater.autoInstallOnAppQuit = true
|
||||||
|
updater.logger = {
|
||||||
info: (msg: unknown) => logger.info(String(msg)),
|
info: (msg: unknown) => logger.info(String(msg)),
|
||||||
warn: (msg: unknown) => logger.warn(String(msg)),
|
warn: (msg: unknown) => logger.warn(String(msg)),
|
||||||
error: (msg: unknown) => logger.error(String(msg)),
|
error: (msg: unknown) => logger.error(String(msg)),
|
||||||
debug: (msg: unknown) => logger.debug(String(msg)),
|
debug: (msg: unknown) => logger.debug(String(msg)),
|
||||||
}
|
}
|
||||||
|
|
||||||
autoUpdater.on('update-available', (info) => {
|
updater.on('checking-for-update', () => {
|
||||||
logger.info(`업데이트 발견: ${info.version}`)
|
logger.info('업데이트 확인 중...')
|
||||||
this.emit('update-available', { version: info.version })
|
this.emit('checking-for-update', undefined as unknown as void)
|
||||||
})
|
})
|
||||||
|
|
||||||
autoUpdater.on('update-downloaded', (info) => {
|
updater.on('update-available', (info) => {
|
||||||
logger.info(`업데이트 다운로드 완료: ${info.version}`)
|
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 })
|
this.emit('update-downloaded', { version: info.version })
|
||||||
void this._promptRestart(autoUpdater, info.version)
|
void this._promptRestart(info.version)
|
||||||
})
|
})
|
||||||
|
|
||||||
autoUpdater.on('error', (err) => {
|
updater.on('error', (err) => {
|
||||||
// 네트워크 오류/401 등은 조용히 로그만 (다음 주기에 재시도)
|
this._downloading = false
|
||||||
logger.warn(`업데이트 체크 실패: ${err.message}`)
|
logger.warn(`업데이트 체크 또는 다운로드 실패: ${err.message}`)
|
||||||
this.emit('update-error', { message: err.message })
|
this.emit('update-error', { message: err.message })
|
||||||
})
|
})
|
||||||
|
|
||||||
const check = (): void => {
|
this._initialTimer = setTimeout(() => this.checkForUpdates(), INITIAL_CHECK_DELAY_MS)
|
||||||
autoUpdater.checkForUpdates().catch((err: unknown) => {
|
this._intervalTimer = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL_MS)
|
||||||
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)
|
|
||||||
logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`)
|
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<void> {
|
||||||
|
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 {
|
dispose(): void {
|
||||||
if (this._initialTimer) clearTimeout(this._initialTimer)
|
if (this._initialTimer) clearTimeout(this._initialTimer)
|
||||||
if (this._intervalTimer) clearInterval(this._intervalTimer)
|
if (this._intervalTimer) clearInterval(this._intervalTimer)
|
||||||
|
|
@ -110,26 +169,30 @@ class UpdateService extends EventEmitter {
|
||||||
this._intervalTimer = null
|
this._intervalTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 다운로드 완료 시 재시작 여부 다이얼로그 (세션당 1회) */
|
/** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */
|
||||||
private async _promptRestart(
|
private async _promptUserConsent(
|
||||||
autoUpdater: import('electron-updater').AppUpdater,
|
|
||||||
version: string,
|
version: string,
|
||||||
|
releaseNotes?: string | any[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this._promptShown) return
|
if (this._promptShown || this._downloading) return
|
||||||
this._promptShown = true
|
this._promptShown = true
|
||||||
|
|
||||||
const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true
|
const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true
|
||||||
|
const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : ''
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
type: 'info' as const,
|
type: 'info' as const,
|
||||||
title: isKo ? '업데이트 준비 완료' : 'Update Ready',
|
title: isKo ? '새 버전 업데이트' : 'Software Update',
|
||||||
message: isKo
|
message: isKo
|
||||||
? `D3RO Voice ${version} 업데이트가 다운로드되었습니다. 지금 재시작하여 적용할까요?`
|
? `D3RO Voice v${version} 새 버전이 출시되었습니다. 지금 다운로드할까요?${notesText}`
|
||||||
: `D3RO Voice ${version} has been downloaded. Restart now to apply?`,
|
: `A new version of D3RO Voice (v${version}) is available. Would you like to download it now?${notesText}`,
|
||||||
buttons: isKo ? ['지금 재시작', '나중에'] : ['Restart Now', 'Later'],
|
buttons: isKo
|
||||||
|
? ['지금 다운로드', '나중에', '이 버전 건너뛰기']
|
||||||
|
: ['Download Now', 'Later', 'Skip This Version'],
|
||||||
defaultId: 0,
|
defaultId: 0,
|
||||||
cancelId: 1,
|
cancelId: 1,
|
||||||
}
|
}
|
||||||
// 메인 윈도우가 살아있으면 parent로 붙여 포커스 스틸 방지 (트레이 상태면 독립 표시)
|
|
||||||
const win = getMainWindow()
|
const win = getMainWindow()
|
||||||
const { response } =
|
const { response } =
|
||||||
win && !win.isDestroyed()
|
win && !win.isDestroyed()
|
||||||
|
|
@ -137,9 +200,37 @@ class UpdateService extends EventEmitter {
|
||||||
: await dialog.showMessageBox(options)
|
: await dialog.showMessageBox(options)
|
||||||
|
|
||||||
if (response === 0) {
|
if (response === 0) {
|
||||||
autoUpdater.quitAndInstall()
|
void this.startDownload()
|
||||||
|
} else if (response === 2) {
|
||||||
|
this.skipVersion(version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2단계: 다운로드 완료 시 안전한 재시작 및 설치 다이얼로그 */
|
||||||
|
private async _promptRestart(version: string): Promise<void> {
|
||||||
|
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 }
|
export { UpdateService }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,4 @@
|
||||||
// 빈 문자열이면 UpdateService가 비활성 상태로 동작한다.
|
// 빈 문자열이면 UpdateService가 비활성 상태로 동작한다.
|
||||||
// d3r0/voice 프로젝트 ID = 1172. electron-builder.yml publish.url과 동일 값 유지.
|
// d3r0/voice 프로젝트 ID = 1172. electron-builder.yml publish.url과 동일 값 유지.
|
||||||
export const UPDATE_FEED_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'
|
||||||
|
|
|
||||||
92
apps/mobile-rn/src/lib/update-manager.ts
Normal file
92
apps/mobile-rn/src/lib/update-manager.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,6 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["jest"]
|
"types": ["jest"]
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "**/*.tsx"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "App.tsx", "index.js"],
|
||||||
"exclude": ["**/node_modules", "**/Pods"]
|
"exclude": ["**/node_modules", "**/Pods", "__tests__"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue