diff --git a/desktop/src/bridge/installDesktopBridge.ts b/desktop/src/bridge/installDesktopBridge.ts index d3efc6b..94af28e 100644 --- a/desktop/src/bridge/installDesktopBridge.ts +++ b/desktop/src/bridge/installDesktopBridge.ts @@ -1,6 +1,6 @@ import { openWidgetWindow, closeWidgetWindow, focusWidgetWindow } from './widgetWindows'; import { showNativeNotification } from './notifications'; -import { checkForUpdates, getAppVersion } from './updater'; +import { checkForUpdates, getAppVersion, runDesktopUpdate } from './updater'; import type { DesktopBridge } from './types'; /** frontend App 로드 전 호출 — window.__goldenDesktopBridge 등록 */ @@ -11,6 +11,7 @@ export function installDesktopBridge(): void { focusWidgetWindow, showNativeNotification, checkForUpdates, + runDesktopUpdate, getAppVersion, }; window.__goldenDesktopBridge = bridge; diff --git a/desktop/src/bridge/types.ts b/desktop/src/bridge/types.ts index de38ccd..6bf98cb 100644 --- a/desktop/src/bridge/types.ts +++ b/desktop/src/bridge/types.ts @@ -2,6 +2,9 @@ * 데스크톱(Tauri) 브릿지 타입 — frontend에서 window.__goldenDesktopBridge 로 호출 */ import type { FloatingWidgetInstance } from '@frontend/types/floatingWidget'; +import type { DesktopUpdateProgress, DesktopUpdateResult } from './updater'; + +export type { DesktopUpdateProgress, DesktopUpdateResult, DesktopUpdatePhase } from './updater'; export interface DesktopBridge { openWidgetWindow: (instance: FloatingWidgetInstance) => Promise; @@ -9,6 +12,9 @@ export interface DesktopBridge { focusWidgetWindow: (id: string) => Promise; showNativeNotification: (title: string, body: string) => Promise; checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>; + runDesktopUpdate: ( + onProgress?: (progress: DesktopUpdateProgress) => void, + ) => Promise; getAppVersion: () => Promise; } diff --git a/desktop/src/bridge/updater.ts b/desktop/src/bridge/updater.ts index 796b878..4cc48d4 100644 --- a/desktop/src/bridge/updater.ts +++ b/desktop/src/bridge/updater.ts @@ -2,25 +2,164 @@ import { check } from '@tauri-apps/plugin-updater'; import { relaunch } from '@tauri-apps/plugin-process'; import { getVersion } from '@tauri-apps/api/app'; +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 formatUpdateError(e: unknown): string { + const msg = e instanceof Error ? e.message : String(e ?? ''); + const lower = msg.toLowerCase(); + if (!msg.trim()) return '업데이트 확인에 실패했습니다.'; + if (/json|parse|unexpected end|eof|empty/.test(lower)) { + return '서버 업데이트 정보(latest.json)를 읽을 수 없습니다. PC 프로그램 탭에서 새 dmg를 받아 주세요.'; + } + if (/signature|pubkey|verify|minisign|invalid key/.test(lower)) { + return '업데이트 서명 검증에 실패했습니다. 웹 PC 프로그램 탭에서 설치 파일을 다시 받아 주세요.'; + } + if (/404|not found/.test(lower)) { + return '업데이트 파일을 찾을 수 없습니다. 관리자에게 문의하거나 dmg를 직접 다운로드해 주세요.'; + } + if (/network|fetch|connect|timeout|dns|offline|failed to send/.test(lower)) { + return `네트워크 오류로 업데이트를 확인하지 못했습니다. (${msg})`; + } + if (/platform|darwin|arch|target/.test(lower)) { + return `이 PC용 업데이트 패키지가 없습니다. (${msg})`; + } + return msg; +} + export async function getAppVersion(): Promise { return getVersion(); } +/** @deprecated runDesktopUpdate 사용 */ export async function checkForUpdates(): Promise<{ available: boolean; version?: string; message?: string; }> { + const result = await runDesktopUpdate(); + return { + available: result.phase === 'relaunching' || result.phase === 'available', + version: result.newVersion, + message: result.message, + }; +} + +export async function runDesktopUpdate( + onProgress?: (p: DesktopUpdateProgress) => void, +): Promise { + let currentVersion = ''; + try { + currentVersion = await getVersion(); + } catch { + currentVersion = '—'; + } + + emit(onProgress, { + phase: 'checking', + message: '서버에서 새 버전을 확인하는 중…', + currentVersion, + }); + try { const update = await check(); if (!update) { - return { available: false, message: '최신 버전입니다.' }; + const message = `최신 버전입니다. (v${currentVersion})`; + emit(onProgress, { phase: 'uptodate', message, currentVersion }); + return { ok: true, phase: 'uptodate', message, currentVersion }; } - await update.downloadAndInstall(); + + 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, + }); await relaunch(); - return { available: true, version: update.version, message: '업데이트 설치 후 재시작합니다.' }; + return { + ok: true, + phase: 'relaunching', + message: '재시작 중…', + currentVersion, + newVersion: update.version, + }; } catch (e) { - const msg = e instanceof Error ? e.message : '업데이트 확인 실패'; - return { available: false, message: msg }; + const message = formatUpdateError(e); + emit(onProgress, { phase: 'error', message, currentVersion }); + return { ok: false, phase: 'error', message, currentVersion }; } } diff --git a/frontend/src/App.css b/frontend/src/App.css index fc6773c..7ccc342 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -13288,6 +13288,94 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } .app-download-build-done-note { margin: 8px 0 0; font-size: 12px; color: #4ade80; line-height: 1.45; } +.desktop-update-modal-body { padding: 4px 2px 0; } +.desktop-update-version { + margin: 0 0 12px; font-size: 13px; color: var(--text2); +} +.desktop-update-progress { + margin: 0 0 14px; padding: 12px 14px; border-radius: 12px; + border: 1px solid var(--border); background: var(--bg2); +} +.desktop-update-progress--active { + border-color: color-mix(in srgb, #2196f3 45%, var(--border)); + background: color-mix(in srgb, #2196f3 8%, var(--bg2)); +} +.desktop-update-progress--success { + border-color: color-mix(in srgb, #22c55e 45%, var(--border)); + background: color-mix(in srgb, #22c55e 8%, var(--bg2)); +} +.desktop-update-progress--failed { + border-color: color-mix(in srgb, #ef4444 45%, var(--border)); + background: color-mix(in srgb, #ef4444 8%, var(--bg2)); +} +.desktop-update-progress-head { + display: flex; align-items: center; justify-content: space-between; + gap: 10px; margin-bottom: 10px; +} +.desktop-update-progress-title { + display: inline-flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; +} +.desktop-update-spinner { + width: 14px; height: 14px; border-radius: 50%; + border: 2px solid color-mix(in srgb, #2196f3 25%, transparent); + border-top-color: #2196f3; + animation: tmb-update-spin 0.8s linear infinite; +} +.desktop-update-pct { + font-size: 11px; color: var(--text3); font-variant-numeric: tabular-nums; +} +.desktop-update-bar { + height: 4px; border-radius: 999px; overflow: hidden; + background: color-mix(in srgb, var(--text3) 15%, transparent); + margin-bottom: 12px; +} +.desktop-update-bar-fill { + height: 100%; border-radius: inherit; + background: linear-gradient(90deg, #2196f3, #5eb5ff); +} +.desktop-update-bar-fill--determinate { transition: width 0.2s ease; } +.desktop-update-bar-fill--indeterminate { + width: 40%; + animation: app-download-build-indeterminate 1.4s ease-in-out infinite; +} +.desktop-update-steps { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 8px; +} +.desktop-update-step { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + font-size: 12px; color: var(--text3); line-height: 1.4; +} +.desktop-update-step.active { color: #5eb5ff; font-weight: 600; } +.desktop-update-step.done { color: var(--text2); } +.desktop-update-step-dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; + border: 2px solid color-mix(in srgb, var(--text3) 50%, transparent); + background: transparent; +} +.desktop-update-step.active .desktop-update-step-dot { + border-color: #2196f3; background: #2196f3; + box-shadow: 0 0 0 3px color-mix(in srgb, #2196f3 25%, transparent); +} +.desktop-update-step.done .desktop-update-step-dot { + border-color: #22c55e; background: #22c55e; +} +.desktop-update-bytes { + margin-left: auto; font-size: 11px; color: var(--text3); font-variant-numeric: tabular-nums; +} +.desktop-update-message { + margin: 10px 0 0; font-size: 12px; color: var(--text2); line-height: 1.45; +} +.desktop-update-message--error { color: #fca5a5; } +.desktop-update-hint { + margin: 8px 0 0; font-size: 11px; color: var(--text3); line-height: 1.45; +} +.desktop-update-done-note { + margin: 8px 0 0; font-size: 12px; color: #4ade80; line-height: 1.45; +} +.desktop-update-actions { + display: flex; justify-content: flex-end; gap: 8px; +} .app-download-build-note, .app-download-build-wait { margin: 0 0 12px; font-size: 12px; line-height: 1.5; } diff --git a/frontend/src/components/DesktopUpdateModal.tsx b/frontend/src/components/DesktopUpdateModal.tsx new file mode 100644 index 0000000..28265a0 --- /dev/null +++ b/frontend/src/components/DesktopUpdateModal.tsx @@ -0,0 +1,193 @@ +/** + * PC(Tauri) 앱 — 업데이트 확인·다운로드·설치 진행 모달 + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import DraggableModalFrame from './DraggableModalFrame'; +import { + getDesktopAppVersion, + runDesktopUpdate, + type DesktopUpdatePhase, + type DesktopUpdateProgress, +} from '../utils/desktopBridge'; + +interface Props { + open: boolean; + onClose: () => void; + /** 열릴 때 자동으로 업데이트 확인 시작 */ + autoStart?: boolean; +} + +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`; +} + +const DesktopUpdateModal: React.FC = ({ open, onClose, autoStart = true }) => { + const [appVersion, setAppVersion] = useState('—'); + const [progress, setProgress] = useState(null); + const [busy, setBusy] = useState(false); + const startedRef = useRef(false); + + const isActive = busy || progress?.phase === 'checking' || progress?.phase === 'downloading' + || progress?.phase === 'installing' || progress?.phase === 'relaunching'; + + const panelClass = progress?.phase === 'error' + ? 'desktop-update-progress--failed' + : progress?.phase === 'uptodate' + ? 'desktop-update-progress--success' + : isActive + ? 'desktop-update-progress--active' + : ''; + + const handleRun = useCallback(async () => { + if (busy) return; + setBusy(true); + setProgress({ phase: 'checking', message: '서버에서 새 버전을 확인하는 중…', currentVersion: appVersion }); + try { + await runDesktopUpdate(p => setProgress(p)); + } finally { + setBusy(false); + } + }, [appVersion, busy]); + + useEffect(() => { + if (!open) { + startedRef.current = false; + return; + } + void getDesktopAppVersion().then(v => { + if (v) setAppVersion(v); + }); + if (autoStart && !startedRef.current) { + startedRef.current = true; + void handleRun(); + } + }, [open, autoStart, handleRun]); + + if (!open) return null; + + const phase = progress?.phase ?? 'checking'; + const showBar = phase === 'downloading' || phase === 'installing' || phase === 'available'; + const barPct = phase === 'installing' + ? 100 + : progress?.downloadPercent; + + return ( + {} : onClose} + closeOnBackdrop={!isActive} + width={440} + dialogClassName="sp-modal desktop-update-modal" + > +
+

+ 현재 버전 v{progress?.currentVersion ?? appVersion} + {progress?.newVersion && progress.newVersion !== progress.currentVersion && ( + <> → v{progress.newVersion} + )} +

+ +
+
+ + {(phase === 'checking' || phase === 'downloading' || phase === 'installing' || phase === 'relaunching') && ( + + )} + {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} +

+ )} + + {phase === 'error' && ( +

+ 자동 업데이트가 설정되지 않았을 수 있습니다. 웹 PC 프로그램 탭에서 dmg를 받아 다시 설치해 주세요. +

+ )} + + {phase === 'uptodate' && ( +

현재 설치된 버전이 서버와 같습니다.

+ )} +
+ +
+ {phase === 'error' && ( + + )} + +
+
+ + ); +}; + +export default DesktopUpdateModal; diff --git a/frontend/src/components/DesktopUpdatePanel.tsx b/frontend/src/components/DesktopUpdatePanel.tsx index e71f517..fb6d0f8 100644 --- a/frontend/src/components/DesktopUpdatePanel.tsx +++ b/frontend/src/components/DesktopUpdatePanel.tsx @@ -1,15 +1,12 @@ import React, { useCallback, useEffect, useState } from 'react'; import { isDesktop } from '../utils/platform'; -import { - checkDesktopUpdates, - getDesktopAppVersion, -} from '../utils/desktopBridge'; +import { getDesktopAppVersion } from '../utils/desktopBridge'; +import DesktopUpdateModal from './DesktopUpdateModal'; /** PC(Tauri) 앱 — 버전 표시 및 업데이트 확인 */ const DesktopUpdatePanel: React.FC = () => { const [version, setVersion] = useState('—'); - const [status, setStatus] = useState(''); - const [busy, setBusy] = useState(false); + const [modalOpen, setModalOpen] = useState(false); useEffect(() => { if (!isDesktop()) return; @@ -18,44 +15,43 @@ const DesktopUpdatePanel: React.FC = () => { }); }, []); - const handleCheck = useCallback(async () => { - setBusy(true); - setStatus('업데이트 확인 중…'); - try { - const res = await checkDesktopUpdates(); - setStatus(res.message ?? (res.available ? `새 버전 ${res.version}` : '최신 버전입니다.')); - } finally { - setBusy(false); - } + const handleOpen = useCallback(() => { + setModalOpen(true); }, []); if (!isDesktop()) return null; return ( -
-

PC 앱 (GoldenChart Desktop)

-
-
- 현재 버전 -

설치된 데스크톱 클라이언트 버전입니다.

+ <> +
+

PC 앱 (GoldenChart Desktop)

+
+
+ 현재 버전 +

설치된 데스크톱 클라이언트 버전입니다.

+
+
+ {version} +
-
- {version} +
+
+ 업데이트 +

서버에서 새 버전을 확인하고 다운로드합니다.

+
+
+ +
-
-
- 업데이트 -

서버에서 새 버전을 확인하고 다운로드합니다.

-
-
- - {status && {status}} -
-
-
+ setModalOpen(false)} + autoStart + /> + ); }; diff --git a/frontend/src/components/TopMenuBar.tsx b/frontend/src/components/TopMenuBar.tsx index 213f1b9..c4b528a 100644 --- a/frontend/src/components/TopMenuBar.tsx +++ b/frontend/src/components/TopMenuBar.tsx @@ -10,7 +10,7 @@ import type { AuthSession } from '../utils/auth'; import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout'; import { canAccessMenu } from '../utils/permissions'; import { isDesktop } from '../utils/platform'; -import { checkDesktopUpdates } from '../utils/desktopBridge'; +import DesktopUpdateModal from './DesktopUpdateModal'; export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings' | 'widget-dashboard'; @@ -266,24 +266,9 @@ export const TopMenuBar = memo(function TopMenuBar({ menuPermissions, }: TopMenuBarProps) { const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement); - const [desktopUpdateBusy, setDesktopUpdateBusy] = useState(false); - const [desktopUpdateHint, setDesktopUpdateHint] = useState(''); + const [desktopUpdateOpen, setDesktopUpdateOpen] = useState(false); const desktopClient = isDesktop(); - const handleDesktopUpdate = useCallback(async () => { - if (desktopUpdateBusy) return; - setDesktopUpdateBusy(true); - setDesktopUpdateHint('업데이트 확인 중…'); - try { - const res = await checkDesktopUpdates(); - setDesktopUpdateHint(res.message ?? (res.available ? `v${res.version ?? ''} 설치` : '최신 버전')); - } catch { - setDesktopUpdateHint('업데이트 확인 실패'); - } finally { - setDesktopUpdateBusy(false); - } - }, [desktopUpdateBusy]); - useEffect(() => { const sync = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', sync); @@ -295,6 +280,7 @@ export const TopMenuBar = memo(function TopMenuBar({ ? MENU_ITEMS.filter(({ page }) => canAccessMenu(menuPermissions, page)) : MENU_ITEMS; return ( + <>
{/* 로고 */}
@@ -356,10 +342,9 @@ export const TopMenuBar = memo(function TopMenuBar({ {desktopClient ? (
+ {desktopClient && ( + setDesktopUpdateOpen(false)} + autoStart + /> + )} + ); }); diff --git a/frontend/src/utils/desktopBridge.ts b/frontend/src/utils/desktopBridge.ts index 83f0b3a..c06a2b3 100644 --- a/frontend/src/utils/desktopBridge.ts +++ b/frontend/src/utils/desktopBridge.ts @@ -1,11 +1,41 @@ import { isDesktop } from './platform'; +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; +} + export interface DesktopBridge { openWidgetWindow: (instance: import('../types/floatingWidget').FloatingWidgetInstance) => Promise; closeWidgetWindow: (id: string) => Promise; focusWidgetWindow: (id: string) => Promise; showNativeNotification: (title: string, body: string) => Promise; checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>; + runDesktopUpdate: ( + onProgress?: (progress: DesktopUpdateProgress) => void, + ) => Promise; getAppVersion: () => Promise; } @@ -36,6 +66,16 @@ export async function checkDesktopUpdates(): Promise<{ available: boolean; versi return bridge.checkForUpdates(); } +export async function runDesktopUpdate( + onProgress?: (progress: DesktopUpdateProgress) => void, +): Promise { + const bridge = getDesktopBridge(); + if (!bridge?.runDesktopUpdate) { + return { ok: false, phase: 'error', message: '데스크톱 앱에서만 사용 가능합니다.' }; + } + return bridge.runDesktopUpdate(onProgress); +} + export async function getDesktopAppVersion(): Promise { const bridge = getDesktopBridge(); if (!bridge) return null;