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 }),