import { check } from '@tauri-apps/plugin-updater'; import { relaunch } from '@tauri-apps/plugin-process'; import { getVersion } from '@tauri-apps/api/app'; const UPDATE_LOOP_GUARD_KEY = 'goldenchart:startup-update-guard'; const UPDATE_LOOP_MAX_ATTEMPTS = 2; const UPDATE_LOOP_WINDOW_MS = 15 * 60 * 1000; function cmpSemver(a: string, b: string): number { const pa = a.split('.').map(Number); const pb = b.split('.').map(Number); for (let i = 0; i < 3; i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d !== 0) return d; } return 0; } interface UpdateLoopGuard { targetVersion: string; fromVersion: string; attempts: number; lastAt: number; } function readUpdateLoopGuard(): UpdateLoopGuard | null { try { const raw = localStorage.getItem(UPDATE_LOOP_GUARD_KEY); if (!raw) return null; return JSON.parse(raw) as UpdateLoopGuard; } catch { return null; } } function writeUpdateLoopGuard(guard: UpdateLoopGuard) { try { localStorage.setItem(UPDATE_LOOP_GUARD_KEY, JSON.stringify(guard)); } catch { /* ignore */ } } export function clearUpdateLoopGuard() { try { localStorage.removeItem(UPDATE_LOOP_GUARD_KEY); } catch { /* ignore */ } } export interface DesktopUpdateOptions { /** 수동 업데이트·재시도 시 반복 실패 가드 무시 */ bypassLoopGuard?: boolean; } const RELAUNCH_TIMEOUT_MS = 12_000; async function relaunchWithTimeout(): Promise { try { await Promise.race([ relaunch(), new Promise((_, reject) => { window.setTimeout(() => reject(new Error('relaunch timeout')), RELAUNCH_TIMEOUT_MS); }), ]); return true; } catch { return false; } } function shouldBlockUpdateLoop(currentVersion: string, targetVersion: string): boolean { const guard = readUpdateLoopGuard(); if (!guard) return false; if (guard.targetVersion !== targetVersion) return false; if (currentVersion === targetVersion) return false; if (Date.now() - guard.lastAt > UPDATE_LOOP_WINDOW_MS) return false; return guard.attempts >= UPDATE_LOOP_MAX_ATTEMPTS && guard.fromVersion === currentVersion; } function recordUpdateLoopAttempt(currentVersion: string, targetVersion: string) { const guard = readUpdateLoopGuard(); const now = Date.now(); if ( guard && guard.targetVersion === targetVersion && guard.fromVersion === currentVersion && now - guard.lastAt < UPDATE_LOOP_WINDOW_MS ) { writeUpdateLoopGuard({ ...guard, attempts: guard.attempts + 1, lastAt: now }); return; } writeUpdateLoopGuard({ targetVersion, fromVersion: currentVersion, attempts: 1, lastAt: now, }); } export type DesktopUpdatePhase = | 'checking' | 'uptodate' | 'available' | 'downloading' | 'installing' | 'relaunching' | 'error'; export interface DesktopUpdateProgress { phase: DesktopUpdatePhase; message: string; currentVersion?: string; newVersion?: string; downloadPercent?: number; downloadedBytes?: number; totalBytes?: number; } export interface DesktopUpdateResult { ok: boolean; phase: DesktopUpdatePhase; message: string; currentVersion?: string; newVersion?: string; } function emit(onProgress: ((p: DesktopUpdateProgress) => void) | undefined, p: DesktopUpdateProgress) { onProgress?.(p); } function isMacOs(): boolean { if (typeof navigator !== 'undefined' && /mac/i.test(navigator.userAgent)) return true; return false; } function isWindowsOs(): boolean { if (typeof navigator !== 'undefined' && /win/i.test(navigator.userAgent)) return true; return false; } async function formatUpdateError(e: unknown): Promise { const msg = e instanceof Error ? e.message : String(e ?? ''); const lower = msg.toLowerCase(); const onMac = isMacOs(); const onWin = isWindowsOs(); if (!msg.trim()) return '업데이트 확인에 실패했습니다.'; if (/unexpected end of json|eof|empty|content-length.*0|^$/.test(lower) && msg.trim().length < 80) { return onMac ? '서버 latest.json 이 비어 있습니다. PC 프로그램 탭에서 dmg를 받아 다시 설치해 주세요. (관리자: Desktop Jenkins publish 필요)' : '서버 latest.json 이 비어 있습니다. PC 프로그램 탭에서 exe를 받아 다시 설치해 주세요. (관리자: Desktop Jenkins publish 필요)'; } if (/could not fetch a valid release json|release not found/.test(lower)) { return onMac ? '업데이트 서버 응답을 처리하지 못했습니다. PC 프로그램 탭에서 dmg를 새로 설치해 주세요.' : '업데이트 서버 응답을 처리하지 못했습니다. PC 프로그램 탭에서 exe를 새로 설치해 주세요.'; } if (/json|parse|unexpected end|eof|empty|deserialize|invalid value for/.test(lower)) { return onMac ? '서버 업데이트 정보(latest.json) 형식 오류입니다. PC 프로그램 탭에서 dmg를 받아 주세요.' : '서버 업데이트 정보(latest.json) 형식 오류입니다. PC 프로그램 탭에서 exe를 받아 주세요.'; } if (/insecuretransport|insecure transport|http url|https/.test(lower)) { return '업데이트 URL 보안(HTTPS) 설정 오류입니다. 잠시 후 다시 시도해 주세요.'; } if (/signature|pubkey|verify|minisign|invalid key/.test(lower)) { return onMac ? '업데이트 서명 검증에 실패했습니다. PC 프로그램 탭에서 dmg를 다시 받아 주세요.' : '업데이트 서명 검증에 실패했습니다. PC 프로그램 탭에서 exe를 다시 받아 주세요.'; } if (/invalid symbol|offset \d+|replace_with/.test(lower)) { if (onWin) { return '이 PC에 updater 공개키가 포함되지 않은 빌드입니다(로컬 빌드 시 키 미적용). 웹 PC 프로그램 탭에서 최신 exe를 받아 다시 설치하면 이후 자동 업데이트가 됩니다.'; } return '이 PC에 updater 공개키가 포함되지 않은 빌드입니다(로컬 build-desktop.sh 시 키 미적용). 웹 PC 프로그램 탭에서 최신 dmg를 Applications에 설치하면 이후 자동 업데이트가 됩니다.'; } if (/404|not found/.test(lower)) { return onMac ? 'Mac용 업데이트 파일을 찾을 수 없습니다. PC 프로그램 탭에서 dmg를 직접 다운로드해 주세요.' : 'Windows용 업데이트 파일을 찾을 수 없습니다. PC 프로그램 탭에서 exe를 직접 다운로드해 주세요.'; } if (/network|fetch|connect|timeout|dns|offline|failed to send/.test(lower)) { return `네트워크 오류로 업데이트를 확인하지 못했습니다. (${msg})`; } if (/darwin-x86_64|x86_64.*darwin|intel mac/.test(lower) && onMac) { return 'Intel Mac용 업데이트 패키지(darwin-x86_64)가 서버에 없습니다. PC 프로그램 탭에서 dmg를 받아 다시 설치해 주세요.'; } if (/darwin-aarch64|aarch64.*darwin/.test(lower) && onMac) { return 'Apple Silicon Mac용 업데이트 패키지(darwin-aarch64)가 서버에 없습니다. PC 프로그램 탭에서 dmg를 받아 다시 설치해 주세요.'; } if (/windows-x86_64/.test(lower) && onWin) { return 'Windows용 업데이트 패키지가 서버에 없습니다. PC 프로그램 탭에서 exe 설치 파일을 받아 다시 설치해 주세요.'; } if (/fallback platforms|platforms object|platform.*not found|missing platform/.test(lower)) { if (onMac) { const hint = /darwin-x86_64|x86_64/.test(lower) ? 'Intel Mac(darwin-x86_64)' : 'Apple Silicon Mac(darwin-aarch64)'; return `Mac용 업데이트 패키지가 서버에 없습니다 (${hint}). PC 프로그램 탭에서 dmg를 받아 다시 설치해 주세요.`; } if (onWin) { return 'Windows용 업데이트 패키지가 서버에 없습니다. PC 프로그램 탭에서 exe 설치 파일을 받아 다시 설치해 주세요.'; } } if (/platform|darwin|arch|target/.test(lower)) { return onMac ? 'Mac용 업데이트 패키지가 없습니다. PC 프로그램 탭에서 dmg를 받아 다시 설치해 주세요.' : '이 PC용 업데이트 패키지가 없습니다. PC 프로그램 탭에서 설치 파일을 다시 받아 주세요.'; } return msg; } export async function getAppVersion(): Promise { return getVersion(); } /** 서버에 새 버전이 있는지만 확인 (다운로드·설치 없음) */ export async function checkForUpdates(): Promise<{ available: boolean; version?: string; message?: string; }> { try { const currentVersion = await getVersion(); const update = await check(); if (!update) { return { available: false, message: `최신 버전입니다. (v${currentVersion})` }; } return { available: true, version: update.version, message: `새 버전 v${update.version}을(를) 사용할 수 있습니다. (현재 v${currentVersion})`, }; } catch (e) { return { available: false, message: await formatUpdateError(e) }; } } export interface StartupAutoUpdateResult { /** false면 relaunch 중 — 메인 UI를 띄우지 않음 */ shouldContinue: boolean; result: DesktopUpdateResult; } /** 앱 시작 시 — 새 버전이 있으면 자동 다운로드·설치·재시작 */ export async function runStartupAutoUpdate( onProgress?: (p: DesktopUpdateProgress) => void, ): Promise { const result = await runDesktopUpdate(onProgress); return { shouldContinue: result.phase !== 'relaunching', result, }; } export async function runDesktopUpdate( onProgress?: (p: DesktopUpdateProgress) => void, options?: DesktopUpdateOptions, ): Promise { let currentVersion = ''; try { currentVersion = await getVersion(); } catch { currentVersion = '—'; } emit(onProgress, { phase: 'checking', message: '서버에서 새 버전을 확인하는 중…', currentVersion, }); try { const update = await check(); if (!update) { clearUpdateLoopGuard(); const message = `최신 버전입니다. (v${currentVersion})`; emit(onProgress, { phase: 'uptodate', message, currentVersion }); return { ok: true, phase: 'uptodate', message, currentVersion }; } if (cmpSemver(update.version, currentVersion) <= 0) { clearUpdateLoopGuard(); const message = `최신 버전입니다. (v${currentVersion})`; emit(onProgress, { phase: 'uptodate', message, currentVersion }); return { ok: true, phase: 'uptodate', message, currentVersion }; } if (!options?.bypassLoopGuard && shouldBlockUpdateLoop(currentVersion, update.version)) { const message = `v${update.version} 업데이트가 반복 실패했습니다. 서버 패키지와 버전이 맞지 않을 수 있습니다. ` + 'PC 프로그램 탭에서 설치 파일을 받아 다시 설치해 주세요.'; emit(onProgress, { phase: 'error', message, currentVersion, newVersion: update.version }); return { ok: false, phase: 'error', message, currentVersion, newVersion: update.version }; } if (options?.bypassLoopGuard) { clearUpdateLoopGuard(); } recordUpdateLoopAttempt(currentVersion, update.version); emit(onProgress, { phase: 'available', message: `새 버전 v${update.version}을(를) 받습니다. (현재 v${currentVersion})`, currentVersion, newVersion: update.version, }); let downloaded = 0; let total: number | undefined; await update.downloadAndInstall(event => { if (event.event === 'Started') { total = event.data.contentLength; downloaded = 0; emit(onProgress, { phase: 'downloading', message: total ? '다운로드 중… (0%)' : '다운로드 중…', currentVersion, newVersion: update.version, downloadPercent: 0, downloadedBytes: 0, totalBytes: total, }); } else if (event.event === 'Progress') { downloaded += event.data.chunkLength; const pct = total && total > 0 ? Math.min(99, Math.round((downloaded / total) * 100)) : undefined; emit(onProgress, { phase: 'downloading', message: pct != null ? `다운로드 중… (${pct}%)` : '다운로드 중…', currentVersion, newVersion: update.version, downloadPercent: pct, downloadedBytes: downloaded, totalBytes: total, }); } else if (event.event === 'Finished') { emit(onProgress, { phase: 'installing', message: '업데이트 패키지 설치 중…', currentVersion, newVersion: update.version, }); } }); emit(onProgress, { phase: 'relaunching', message: '설치 완료. 앱을 다시 시작합니다…', currentVersion, newVersion: update.version, }); const relaunched = await relaunchWithTimeout(); if (!relaunched) { clearUpdateLoopGuard(); const message = '업데이트는 설치됐지만 앱을 자동으로 다시 시작하지 못했습니다. GoldenChart를 직접 종료한 뒤 다시 실행해 주세요.'; emit(onProgress, { phase: 'error', message, currentVersion, newVersion: update.version }); return { ok: false, phase: 'error', message, currentVersion, newVersion: update.version }; } return { ok: true, phase: 'relaunching', message: '재시작 중…', currentVersion, newVersion: update.version, }; } catch (e) { const message = await formatUpdateError(e); emit(onProgress, { phase: 'error', message, currentVersion }); return { ok: false, phase: 'error', message, currentVersion }; } }