desktop 앱 적용

This commit is contained in:
Macbook
2026-06-14 10:15:54 +09:00
parent 7a2f65088c
commit 86b99d8c10
81 changed files with 8164 additions and 37 deletions
+7
View File
@@ -141,6 +141,13 @@ server {
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# GoldenChart Desktop updater manifest + bundles
location /desktop/updates/ {
alias /usr/share/nginx/html/desktop/updates/;
add_header Cache-Control "no-cache, no-store, must-revalidate";
default_type application/json;
}
# gzip 압축
gzip on;
gzip_vary on;
@@ -0,0 +1,6 @@
{
"version": "0.1.0",
"notes": "Initial desktop release placeholder",
"pub_date": "2026-06-11T00:00:00Z",
"platforms": {}
}
+7
View File
@@ -35,6 +35,8 @@ import { clearAdminUnlock } from './utils/adminUnlock';
import type { LoginResponse } from './utils/backendApi';
import { invalidateAppSettingsCache } from './hooks/useAppSettings';
import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings';
import { isDesktop } from './utils/platform';
import { writeDesktopSync } from './utils/desktopSync';
const LAST_MENU_KEY = 'gc_last_menu';
const CHART_ONLY_INITIAL: ReadonlySet<MenuPage> = new Set(['dashboard', 'settings', 'verification-board']);
@@ -171,6 +173,11 @@ function AppMainContent({
const [floatingWidgetCount, setFloatingWidgetCount] = useState(0);
const showFloatingWidgets = canMenu('widget-dashboard');
useEffect(() => {
if (!isDesktop()) return;
writeDesktopSync({ theme, selectedMarket: symbol });
}, [theme, symbol]);
const handleFullscreen = () => {
if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {});
else document.exitFullscreen().catch(() => {});
@@ -0,0 +1,62 @@
import React, { useCallback, useEffect, useState } from 'react';
import { isDesktop } from '../utils/platform';
import {
checkDesktopUpdates,
getDesktopAppVersion,
} from '../utils/desktopBridge';
/** PC(Tauri) 앱 — 버전 표시 및 업데이트 확인 */
const DesktopUpdatePanel: React.FC = () => {
const [version, setVersion] = useState<string>('—');
const [status, setStatus] = useState<string>('');
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!isDesktop()) return;
void getDesktopAppVersion().then(v => {
if (v) setVersion(v);
});
}, []);
const handleCheck = useCallback(async () => {
setBusy(true);
setStatus('업데이트 확인 중…');
try {
const res = await checkDesktopUpdates();
setStatus(res.message ?? (res.available ? `새 버전 ${res.version}` : '최신 버전입니다.'));
} finally {
setBusy(false);
}
}, []);
if (!isDesktop()) return null;
return (
<div className="stg-section">
<h3 className="stg-section-title">PC (GoldenChart Desktop)</h3>
<div className="stg-row">
<div className="stg-row-label">
<strong> </strong>
<p className="stg-row-desc"> .</p>
</div>
<div className="stg-row-control">
<span>{version}</span>
</div>
</div>
<div className="stg-row">
<div className="stg-row-label">
<strong></strong>
<p className="stg-row-desc"> .</p>
</div>
<div className="stg-row-control">
<button type="button" className="stg-btn-secondary" disabled={busy} onClick={() => { void handleCheck(); }}>
{busy ? '확인 중…' : '업데이트 확인'}
</button>
{status && <span className="stg-hint" style={{ marginLeft: 8 }}>{status}</span>}
</div>
</div>
</div>
);
};
export default DesktopUpdatePanel;
+3
View File
@@ -17,6 +17,7 @@ import type { IndicatorConfig } from '../types';
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
import ChartTimeFormatPicker from './ChartTimeFormatPicker';
import DesktopUpdatePanel from './DesktopUpdatePanel';
import {
TRADE_ALERT_SOUND_OPTIONS,
normalizeTradeAlertSoundId,
@@ -911,6 +912,8 @@ const GeneralPanel: React.FC<{
</label>
</SettingRow>
</SettingSection>
<DesktopUpdatePanel />
</>
);
};
@@ -1,6 +1,3 @@
/**
* 플로팅 위젯 레이어 — 메뉴바에서 다중 팝업 위젯 실행
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { Theme } from '../../types';
@@ -24,6 +21,9 @@ import {
import type { WidgetSlot } from '../../types/widgetDashboard';
import FloatingWidgetLayoutPicker from './FloatingWidgetLayoutPicker';
import FloatingWidgetWindow from './FloatingWidgetWindow';
import { isDesktop } from '../../utils/platform';
import { getDesktopBridge } from '../../utils/desktopBridge';
import { writeDesktopSync } from '../../utils/desktopSync';
import '../../styles/paperDashboard.css';
import '../../styles/widgetDashboard.css';
import '../../styles/floatingWidget.css';
@@ -40,6 +40,7 @@ interface Props {
onPaperOrderFilled?: () => void;
chartRealtimeSource?: ChartRealtimeSource;
onInstancesChange?: (count: number) => void;
selectedStrategyId?: number | null;
}
const FloatingWidgetLayer: React.FC<Props> = ({
@@ -52,7 +53,9 @@ const FloatingWidgetLayer: React.FC<Props> = ({
onPaperOrderFilled,
chartRealtimeSource = 'BACKEND_STOMP',
onInstancesChange,
selectedStrategyId = null,
}) => {
const desktopMode = isDesktop();
const [instances, setInstances] = useState<FloatingWidgetInstance[]>([]);
const [focusedId, setFocusedId] = useState<string | null>(null);
const zCounterRef = useRef(BASE_Z + 100);
@@ -83,14 +86,28 @@ const FloatingWidgetLayer: React.FC<Props> = ({
return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onChanged);
}, [refreshPaperData]);
useEffect(() => {
if (!desktopMode) return;
writeDesktopSync({
selectedMarket: defaultMarket,
selectedStrategyId,
theme,
});
}, [desktopMode, defaultMarket, selectedStrategyId, theme]);
const bringToFront = useCallback((id: string) => {
if (desktopMode) {
void getDesktopBridge()?.focusWidgetWindow(id);
setFocusedId(id);
return;
}
zCounterRef.current += 1;
const nextZ = zCounterRef.current;
setFocusedId(id);
setInstances(prev => prev.map(inst =>
inst.id === id ? { ...inst, zIndex: nextZ } : inst,
));
}, []);
}, [desktopMode]);
const handleLayoutSelect = useCallback((preset: FloatingWidgetLayoutPreset) => {
zCounterRef.current += 1;
@@ -102,14 +119,25 @@ const FloatingWidgetLayer: React.FC<Props> = ({
zCounterRef.current,
);
inst.zIndex = zCounterRef.current;
if (desktopMode) {
void getDesktopBridge()?.openWidgetWindow(inst);
setInstances(prev => [...prev, inst]);
setFocusedId(inst.id);
return;
}
setInstances(prev => [...prev, inst]);
setFocusedId(inst.id);
}, [instances.length]);
}, [instances.length, desktopMode]);
const handleClose = useCallback((id: string) => {
if (desktopMode) {
void getDesktopBridge()?.closeWidgetWindow(id);
}
setInstances(prev => prev.filter(i => i.id !== id));
setFocusedId(prev => (prev === id ? null : prev));
}, []);
}, [desktopMode]);
const handleUpdateSlots = useCallback((id: string, slots: WidgetSlot[]) => {
setInstances(prev => prev.map(inst => (inst.id === id ? { ...inst, slots } : inst)));
@@ -122,9 +150,9 @@ const FloatingWidgetLayer: React.FC<Props> = ({
}, []);
const handleUpdateGridFr = useCallback((id: string, rowFr: number[], colFr: number[]) => {
setInstances(prev => prev.map(inst => (
inst.id === id ? { ...inst, rowFr, colFr } : inst
)));
setInstances(prev => prev.map(inst =>
inst.id === id ? { ...inst, rowFr, colFr } : inst,
));
}, []);
useEffect(() => {
@@ -133,6 +161,42 @@ const FloatingWidgetLayer: React.FC<Props> = ({
if (instances.length === 0 && !layoutPickerOpen) return null;
const picker = (
<FloatingWidgetLayoutPicker
open={layoutPickerOpen}
onClose={() => onLayoutPickerOpenChange(false)}
onSelect={handleLayoutSelect}
/>
);
if (desktopMode) {
return (
<WidgetDashboardProvider
theme={theme}
tickers={tickers}
marketInfos={marketInfos}
marketLoading={marketLoading}
usdRate={usdRate}
defaultMarket={defaultMarket}
strategies={strategies}
summary={summary}
trades={trades}
refreshPaperData={() => { void refreshPaperData(); }}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
chartRealtimeSource={chartRealtimeSource}
>
{layoutPickerOpen && createPortal(
<div className="fw-layer fw-layer--picker-only" aria-label="위젯 레이아웃 선택">
{picker}
</div>,
document.body,
)}
</WidgetDashboardProvider>
);
}
return (
<WidgetDashboardProvider
theme={theme}
@@ -165,11 +229,7 @@ const FloatingWidgetLayer: React.FC<Props> = ({
/>
))}
<FloatingWidgetLayoutPicker
open={layoutPickerOpen}
onClose={() => onLayoutPickerOpenChange(false)}
onSelect={handleLayoutSelect}
/>
{picker}
</div>,
document.body,
)}
@@ -27,6 +27,8 @@ import {
type TradeAlertSoundId,
} from '../utils/tradeAlertSound';
import { normalizeStrategyId } from '../utils/resolveNotificationStrategy';
import { showDesktopNativeNotification } from '../utils/desktopBridge';
import { getKoreanName } from '../utils/marketNameCache';
import { getUiPreferences, patchUiPreferences } from '../utils/uiPreferencesDb';
import { useAppSettings } from '../hooks/useAppSettings';
@@ -428,6 +430,13 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
const rest = prev.filter(n => n.id !== id);
return [toastItem, ...rest].slice(0, MAX_TOAST_QUEUE);
});
const sideLabel = signal.signalType === 'BUY' ? '매수' : '매도';
const marketLabel = getKoreanName(signal.market);
void showDesktopNativeNotification(
`GoldenChart ${sideLabel} 시그널`,
`${marketLabel} · ₩${Math.round(signal.price ?? 0).toLocaleString()}`,
);
}, [popupEnabled, soundEnabled, alertSoundId]);
const dismissToast = useCallback((id: string) => {
+23
View File
@@ -593,3 +593,26 @@
position: relative;
display: inline-flex;
}
/* Tauri 네이티브 위젯 창 */
.fw-layer--picker-only {
pointer-events: auto;
}
.fw-widget-native-root {
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.fw-widget-native-root .fw-window {
position: static !important;
width: 100% !important;
height: 100% !important;
max-width: none !important;
max-height: none !important;
box-shadow: none;
border: none;
}
+47
View File
@@ -0,0 +1,47 @@
import { isDesktop } from './platform';
export interface DesktopBridge {
openWidgetWindow: (instance: import('../types/floatingWidget').FloatingWidgetInstance) => Promise<void>;
closeWidgetWindow: (id: string) => Promise<void>;
focusWidgetWindow: (id: string) => Promise<void>;
showNativeNotification: (title: string, body: string) => Promise<void>;
checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>;
getAppVersion: () => Promise<string>;
}
declare global {
interface Window {
__goldenDesktopBridge?: DesktopBridge;
}
}
export function getDesktopBridge(): DesktopBridge | null {
if (!isDesktop()) return null;
return window.__goldenDesktopBridge ?? null;
}
export async function showDesktopNativeNotification(title: string, body: string): Promise<void> {
const bridge = getDesktopBridge();
if (!bridge) return;
try {
await bridge.showNativeNotification(title, body);
} catch {
/* ignore */
}
}
export async function checkDesktopUpdates(): Promise<{ available: boolean; version?: string; message?: string }> {
const bridge = getDesktopBridge();
if (!bridge) return { available: false, message: '데스크톱 앱에서만 사용 가능합니다.' };
return bridge.checkForUpdates();
}
export async function getDesktopAppVersion(): Promise<string | null> {
const bridge = getDesktopBridge();
if (!bridge) return null;
try {
return await bridge.getAppVersion();
} catch {
return null;
}
}
+43
View File
@@ -0,0 +1,43 @@
/** 데스크톱 메인↔위젯 창 상태 동기화 (localStorage) */
export const DESKTOP_SYNC_KEY = 'gc_desktop_sync';
export interface DesktopSyncState {
selectedMarket?: string;
selectedStrategyId?: number | null;
theme?: string;
updatedAt: number;
}
export function readDesktopSync(): DesktopSyncState | null {
try {
const raw = localStorage.getItem(DESKTOP_SYNC_KEY);
if (!raw) return null;
return JSON.parse(raw) as DesktopSyncState;
} catch {
return null;
}
}
export function writeDesktopSync(partial: Omit<DesktopSyncState, 'updatedAt'>): void {
const prev = readDesktopSync() ?? { updatedAt: 0 };
const next: DesktopSyncState = {
...prev,
...partial,
updatedAt: Date.now(),
};
try {
localStorage.setItem(DESKTOP_SYNC_KEY, JSON.stringify(next));
} catch {
/* ignore */
}
}
export function subscribeDesktopSync(cb: (state: DesktopSyncState) => void): () => void {
const onStorage = (e: StorageEvent) => {
if (e.key !== DESKTOP_SYNC_KEY) return;
const state = readDesktopSync();
if (state) cb(state);
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}
+26
View File
@@ -0,0 +1,26 @@
/** 런타임 플랫폼 — web · desktop(Tauri) · mobile */
export type RuntimePlatform = 'web' | 'desktop' | 'mobile';
export function isDesktop(): boolean {
return typeof window !== 'undefined' && '__TAURI__' in window;
}
export function isMobileCapacitor(): boolean {
if (typeof window === 'undefined') return false;
try {
const cap = (window as Window & { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
return cap?.isNativePlatform?.() === true;
} catch {
return false;
}
}
export function getRuntimePlatform(): RuntimePlatform {
if (isDesktop()) return 'desktop';
if (isMobileCapacitor()) return 'mobile';
return 'web';
}
export function isWeb(): boolean {
return getRuntimePlatform() === 'web';
}