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 <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-06-14 11:58:54 +09:00
parent d2b8affc57
commit 9a0f3f554b
5 changed files with 210 additions and 14 deletions
+1 -1
View File
@@ -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)
+159
View File
@@ -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<DesktopUpdatePhase, string> = {
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 (
<div className="desktop-startup-update">
<div className="desktop-startup-update-card">
<h1 className="desktop-startup-update-title">GoldenChart</h1>
<p className="desktop-startup-update-sub">
{progress?.currentVersion ? `현재 v${progress.currentVersion}` : '시작 중…'}
{progress?.newVersion && progress.newVersion !== progress.currentVersion && (
<> v{progress.newVersion}</>
)}
</p>
<div className={`desktop-update-progress ${panelClass}`.trim()}>
<div className="desktop-update-progress-head">
<span className="desktop-update-progress-title">
{isActive && <span className="desktop-update-spinner" aria-hidden />}
{PHASE_LABEL[phase]}
</span>
{phase === 'downloading' && progress?.downloadPercent != null && (
<span className="desktop-update-pct">{progress.downloadPercent}%</span>
)}
</div>
{showBar && (
<div
className="desktop-update-bar"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={barPct ?? undefined}
>
{barPct != null ? (
<div
className="desktop-update-bar-fill desktop-update-bar-fill--determinate"
style={{ width: `${barPct}%` }}
/>
) : (
<div className="desktop-update-bar-fill desktop-update-bar-fill--indeterminate" />
)}
</div>
)}
<ul className="desktop-update-steps">
<li className={`desktop-update-step${phase === 'checking' ? ' active' : phase !== 'error' ? ' done' : ''}`}>
<span className="desktop-update-step-dot" aria-hidden />
</li>
<li className={`desktop-update-step${
phase === 'available' || phase === 'downloading' ? ' active'
: ['installing', 'relaunching', 'uptodate'].includes(phase) ? ' done' : ''
}`}>
<span className="desktop-update-step-dot" aria-hidden />
{progress?.downloadedBytes && progress.totalBytes ? (
<span className="desktop-update-bytes">
{formatBytes(progress.downloadedBytes)} / {formatBytes(progress.totalBytes)}
</span>
) : null}
</li>
<li className={`desktop-update-step${
phase === 'installing' ? ' active' : phase === 'relaunching' || phase === 'uptodate' ? ' done' : ''
}`}>
<span className="desktop-update-step-dot" aria-hidden />
</li>
</ul>
{progress?.message && (
<p className={`desktop-update-message${phase === 'error' ? ' desktop-update-message--error' : ''}`}>
{progress.message}
</p>
)}
</div>
</div>
</div>
);
}
/** StrictMode 이중 실행 방지 — 시작 업데이트는 한 번만 */
let startupUpdateTask: ReturnType<typeof runStartupAutoUpdate> | 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<DesktopUpdateProgress | null>({
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 <StartupUpdateSplash progress={progress} />;
}
return <>{children}</>;
}
+30 -5
View File
@@ -65,17 +65,42 @@ export async function getAppVersion(): Promise<string> {
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: result.phase === 'relaunching' || result.phase === 'available',
version: result.newVersion,
message: result.message,
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<StartupAutoUpdateResult> {
const result = await runDesktopUpdate(onProgress);
return {
shouldContinue: result.phase !== 'relaunching',
result,
};
}
+3 -7
View File
@@ -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(
<React.StrictMode>
<StartupUpdateGate>
<App />
</StartupUpdateGate>
</React.StrictMode>,
);
}
+16
View File
@@ -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;
}