diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 3d3f59e..42a834f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -10,7 +10,9 @@ RUN npm ci # Docker 환경에서 /api 는 nginx 가 backend:8080 으로 프록시하므로 그대로 사용 COPY . . ARG VITE_API_BASE_URL=/api +ARG VITE_APP_VERSION= ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} +ENV VITE_APP_VERSION=${VITE_APP_VERSION} RUN npm run build # ── 2단계: 서비스 ───────────────────────────────────────────────────────────── diff --git a/frontend/src/App.css b/frontend/src/App.css index eeba2fe..6eb6557 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -206,6 +206,14 @@ html.theme-blue { white-space: nowrap; } +.tmb-logo-version { + font-size: 12px; + font-weight: 500; + color: var(--text3); + letter-spacing: 0; + white-space: nowrap; +} + html.desktop-client .tmb-logo-text { font-size: 13px; font-weight: 600; @@ -213,10 +221,6 @@ html.desktop-client .tmb-logo-text { html.desktop-client .tmb-logo-version { font-size: 12px; - font-weight: 500; - color: var(--text3); - letter-spacing: 0; - white-space: nowrap; } /* 구분선 */ diff --git a/frontend/src/components/TopMenuBar.tsx b/frontend/src/components/TopMenuBar.tsx index 8ae64fb..c51ca82 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 { useDesktopAppTitle } from '../hooks/useDesktopAppTitle'; +import { useAppTitleBarVersion } from '../hooks/useDesktopAppTitle'; import DesktopUpdateModal from './DesktopUpdateModal'; import TopMenuBarNavGroups from './TopMenuBarNavGroups'; @@ -298,7 +298,7 @@ export const TopMenuBar = memo(function TopMenuBar({ const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement); const [desktopUpdateOpen, setDesktopUpdateOpen] = useState(false); const desktopClient = isDesktop(); - const desktopAppVersion = useDesktopAppTitle(); + const appVersion = useAppTitleBarVersion(); useEffect(() => { const sync = () => setIsFullscreen(!!document.fullscreenElement); @@ -320,8 +320,8 @@ export const TopMenuBar = memo(function TopMenuBar({ GoldenChart - {desktopClient && desktopAppVersion ? ( - (v{desktopAppVersion}) + {appVersion ? ( + (v{appVersion}) ) : null} diff --git a/frontend/src/hooks/useDesktopAppTitle.ts b/frontend/src/hooks/useDesktopAppTitle.ts index fd50dde..30cbb15 100644 --- a/frontend/src/hooks/useDesktopAppTitle.ts +++ b/frontend/src/hooks/useDesktopAppTitle.ts @@ -1,26 +1,47 @@ import { useEffect, useState } from 'react'; +import { fetchPublishedAppVersion, readBakedAppVersion } from '../utils/appVersion'; import { isDesktop } from '../utils/platform'; import { getDesktopAppVersion, syncDesktopMainWindowTitle } from '../utils/desktopBridge'; -/** Desktop — Tauri getVersion() 기준 (설치된 앱 버전). baked 0.1.4 등 git 고정값 사용 안 함 */ -export function useDesktopAppTitle(): string | null { - const [version, setVersion] = useState(null); +/** 타이틀바 GoldenChart 옆 (vX.Y.Z) — Desktop: Tauri, Web: latest.json */ +export function useAppTitleBarVersion(): string | null { + const desktop = isDesktop(); + const [version, setVersion] = useState(() => readBakedAppVersion()); useEffect(() => { - if (!isDesktop()) return; let cancelled = false; + + if (desktop) { + void (async () => { + const v = await getDesktopAppVersion(); + if (cancelled) return; + if (v?.trim()) { + setVersion(v.trim()); + await syncDesktopMainWindowTitle(); + return; + } + setVersion(readBakedAppVersion()); + })(); + return () => { cancelled = true; }; + } + void (async () => { - const v = await getDesktopAppVersion(); - if (cancelled || !v?.trim()) return; - setVersion(v.trim()); - await syncDesktopMainWindowTitle(); + const published = await fetchPublishedAppVersion(); + if (cancelled) return; + setVersion(published ?? readBakedAppVersion()); })(); + return () => { cancelled = true; }; - }, []); + }, [desktop]); return version; } +/** @deprecated useAppTitleBarVersion 사용 */ +export function useDesktopAppTitle(): string | null { + return useAppTitleBarVersion(); +} + export function formatDesktopAppLabel(version: string | null | undefined): string { if (!version?.trim()) return 'GoldenChart'; return `GoldenChart (v${version.trim()})`; diff --git a/frontend/src/utils/appVersion.ts b/frontend/src/utils/appVersion.ts new file mode 100644 index 0000000..add9b91 --- /dev/null +++ b/frontend/src/utils/appVersion.ts @@ -0,0 +1,23 @@ +import { readBakedDesktopAppVersion } from './desktopAppVersion'; + +const LATEST_JSON_PATH = '/desktop/updates/latest.json'; + +/** Vite 빌드·desktop 번들에 주입된 앱 버전 */ +export function readBakedAppVersion(): string | null { + const fromVite = import.meta.env.VITE_APP_VERSION?.trim(); + if (fromVite) return fromVite; + return readBakedDesktopAppVersion(); +} + +/** 서버에 배포된 desktop latest.json — 웹 타이틀바 런타임 버전 */ +export async function fetchPublishedAppVersion(): Promise { + try { + const res = await fetch(LATEST_JSON_PATH, { cache: 'no-store' }); + if (!res.ok) return null; + const data = (await res.json()) as { version?: string }; + const v = data.version?.trim(); + return v || null; + } catch { + return null; + } +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 11f02fe..303cf69 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1 +1,10 @@ /// + +interface ImportMetaEnv { + readonly VITE_APP_VERSION?: string; + readonly VITE_API_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4be7bb6..2ae4cc7 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,39 @@ import { defineConfig, createLogger } from 'vite'; import react from '@vitejs/plugin-react'; import { visualizer } from 'rollup-plugin-visualizer'; +import { readFileSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); + +function readAppVersionForBuild(): string { + const fromEnv = + process.env.VITE_APP_VERSION?.trim() + || process.env.FRONTEND_APP_VERSION?.trim() + || process.env.DESKTOP_APP_VERSION?.trim(); + if (fromEnv) return fromEnv; + + try { + const latestPath = path.resolve(__dirname, 'public/desktop/updates/latest.json'); + const latest = JSON.parse(readFileSync(latestPath, 'utf8')) as { version?: string }; + const fromLatest = latest.version?.trim(); + if (fromLatest) return fromLatest; + } catch { + /* optional */ + } + + try { + const confPath = path.resolve(repoRoot, 'desktop/src-tauri/tauri.conf.json'); + const conf = JSON.parse(readFileSync(confPath, 'utf8')) as { version?: string }; + return String(conf.version ?? '').trim(); + } catch { + return ''; + } +} + +const appVersion = readAppVersionForBuild(); // WebSocket proxy 재연결/종료 시 발생하는 ECONNRESET·EPIPE 는 정상 TCP 이벤트이므로 로그 억제 const SUPPRESS = ['ECONNRESET', 'EPIPE', 'ws proxy socket error']; @@ -13,6 +46,9 @@ logger.error = (msg, opts) => { export default defineConfig({ customLogger: logger, + define: { + 'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion), + }, plugins: [ react(), visualizer({ filename: 'dist/stats.html', open: false, gzipSize: true, brotliSize: true }),