From 9a0f3f554b5eb8a3fe0a004be5d2d61c037e33c7 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 14 Jun 2026 11:58:54 +0900 Subject: [PATCH] feat: desktop startup auto-update before main app launch Check server version on launch, download/install/relaunch if newer, then show the main UI. Manual update check no longer triggers full install. Co-authored-by: Cursor --- desktop/README.md | 2 +- desktop/src/StartupUpdateGate.tsx | 159 ++++++++++++++++++++++++++++++ desktop/src/bridge/updater.ts | 35 ++++++- desktop/src/main.tsx | 12 +-- frontend/src/App.css | 16 +++ 5 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 desktop/src/StartupUpdateGate.tsx diff --git a/desktop/README.md b/desktop/README.md index c990d54..1c3f11e 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -75,7 +75,7 @@ export APPLE_TEAM_ID="..." - **위젯 OS 창**: `WebviewWindow` + `widget.html` - **트레이 상주**: 닫기 → 숨김, STOMP 백그라운드 유지 - **OS 알림**: 매매 시그널 → `tauri-plugin-notification` -- **업데이트**: 설정 → PC 앱 → 업데이트 확인 (`tauri-plugin-updater`) +- **업데이트**: 앱 시작 시 서버에 새 버전이 있으면 자동 다운로드·설치·재시작. 수동 확인은 설정 → PC 앱 또는 상단 업데이트 버튼. 자세한 배포·파일럿: [docs/desktop-pilot.md](../docs/desktop-pilot.md) diff --git a/desktop/src/StartupUpdateGate.tsx b/desktop/src/StartupUpdateGate.tsx new file mode 100644 index 0000000..d51c0d4 --- /dev/null +++ b/desktop/src/StartupUpdateGate.tsx @@ -0,0 +1,159 @@ +import React, { useEffect, useState } from 'react'; +import { + runStartupAutoUpdate, + type DesktopUpdatePhase, + type DesktopUpdateProgress, +} from './bridge/updater'; +import '@frontend/App.css'; + +const PHASE_LABEL: Record = { + checking: '업데이트 확인 중', + uptodate: '최신 버전', + available: '새 버전 발견', + downloading: '다운로드 중', + installing: '설치 중', + relaunching: '재시작 중', + error: '업데이트 실패', +}; + +function formatBytes(n?: number): string { + if (n == null || !Number.isFinite(n) || n <= 0) return ''; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | null }) { + const phase = progress?.phase ?? 'checking'; + const isActive = phase === 'checking' || phase === 'downloading' || phase === 'installing' || phase === 'relaunching'; + const showBar = phase === 'available' || phase === 'downloading' || phase === 'installing'; + const barPct = phase === 'installing' ? 100 : progress?.downloadPercent; + + const panelClass = phase === 'error' + ? 'desktop-update-progress--failed' + : isActive + ? 'desktop-update-progress--active' + : ''; + + return ( +
+
+

GoldenChart

+

+ {progress?.currentVersion ? `현재 v${progress.currentVersion}` : '시작 중…'} + {progress?.newVersion && progress.newVersion !== progress.currentVersion && ( + <> → v{progress.newVersion} + )} +

+ +
+
+ + {isActive && } + {PHASE_LABEL[phase]} + + {phase === 'downloading' && progress?.downloadPercent != null && ( + {progress.downloadPercent}% + )} +
+ + {showBar && ( +
+ {barPct != null ? ( +
+ ) : ( +
+ )} +
+ )} + +
    +
  • + + 서버에서 버전 확인 +
  • +
  • + + 업데이트 다운로드 + {progress?.downloadedBytes && progress.totalBytes ? ( + + {formatBytes(progress.downloadedBytes)} / {formatBytes(progress.totalBytes)} + + ) : null} +
  • +
  • + + 설치 및 재시작 +
  • +
+ + {progress?.message && ( +

+ {progress.message} +

+ )} +
+
+
+ ); +} + +/** StrictMode 이중 실행 방지 — 시작 업데이트는 한 번만 */ +let startupUpdateTask: ReturnType | null = null; +const startupProgressListeners = new Set<(p: DesktopUpdateProgress) => void>(); + +function emitStartupProgress(p: DesktopUpdateProgress) { + for (const listener of startupProgressListeners) listener(p); +} + +function getStartupUpdateTask() { + if (!startupUpdateTask) { + startupUpdateTask = runStartupAutoUpdate(emitStartupProgress); + } + return startupUpdateTask; +} + +/** 시작 시 자동 업데이트 후 children(메인 앱) 렌더 */ +export function StartupUpdateGate({ children }: { children: React.ReactNode }) { + const [ready, setReady] = useState(false); + const [progress, setProgress] = useState({ + phase: 'checking', + message: '서버에서 새 버전을 확인하는 중…', + }); + + useEffect(() => { + const listener = (p: DesktopUpdateProgress) => setProgress(p); + startupProgressListeners.add(listener); + + void getStartupUpdateTask().then(({ shouldContinue, result }) => { + if (!shouldContinue) return; + if (result.phase === 'error') { + console.warn('[startup-update]', result.message); + } + setReady(true); + }); + + return () => { + startupProgressListeners.delete(listener); + }; + }, []); + + if (!ready) { + return ; + } + + return <>{children}; +} diff --git a/desktop/src/bridge/updater.ts b/desktop/src/bridge/updater.ts index 121731d..fb45aee 100644 --- a/desktop/src/bridge/updater.ts +++ b/desktop/src/bridge/updater.ts @@ -65,17 +65,42 @@ export async function getAppVersion(): Promise { return getVersion(); } -/** @deprecated runDesktopUpdate 사용 */ +/** 서버에 새 버전이 있는지만 확인 (다운로드·설치 없음) */ export async function checkForUpdates(): Promise<{ available: boolean; version?: string; message?: string; }> { - const result = await runDesktopUpdate(); + 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: 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 { - available: result.phase === 'relaunching' || result.phase === 'available', - version: result.newVersion, - message: result.message, + shouldContinue: result.phase !== 'relaunching', + result, }; } diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 4f4d354..672370c 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -4,17 +4,11 @@ import ReactDOM from 'react-dom/client'; import { initStorage, refreshApiBaseFromStorage } from '@goldenchart/shared'; import { installDesktopBridge } from './bridge/installDesktopBridge'; import { ensureDesktopApiBase } from './ensureDesktopApiBase'; +import { StartupUpdateGate } from './StartupUpdateGate'; import App from '@frontend/App'; installDesktopBridge(); -/** 시작 시 백그라운드 업데이트 확인 (실패 무시) */ -void import('./bridge/updater').then(({ checkForUpdates }) => { - window.setTimeout(() => { - void checkForUpdates().catch(() => { /* offline 등 */ }); - }, 8000); -}); - async function bootstrap() { await initStorage(); ensureDesktopApiBase(); @@ -22,7 +16,9 @@ async function bootstrap() { ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ); } diff --git a/frontend/src/App.css b/frontend/src/App.css index 7ccc342..0f8f8d2 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -13376,6 +13376,22 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } .desktop-update-actions { display: flex; justify-content: flex-end; gap: 8px; } +.desktop-startup-update { + min-height: 100vh; display: flex; align-items: center; justify-content: center; + padding: 24px; background: var(--bg, #1a1b26); +} +.desktop-startup-update-card { + width: min(440px, 100%); padding: 28px 24px 24px; + border-radius: 16px; border: 1px solid var(--border); + background: var(--bg2, #24283b); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); +} +.desktop-startup-update-title { + margin: 0 0 6px; font-size: 22px; font-weight: 700; text-align: center; +} +.desktop-startup-update-sub { + margin: 0 0 18px; font-size: 13px; color: var(--text2); text-align: center; +} .app-download-build-note, .app-download-build-wait { margin: 0 0 12px; font-size: 12px; line-height: 1.5; }