From c105ae209b695128b26d7f26ff6b3d8eebd454c1 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 14 Jun 2026 19:33:01 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=B1=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8=20=EA=B0=B1=EC=8B=A0=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/useStompChartData.ts | 10 ++------- frontend/src/utils/backendApi.ts | 27 ++++++++++++++++++++++++- frontend/src/utils/upbitWsBroker.ts | 7 +++++++ packages/shared/src/api/backendApi.ts | 19 +++++++++++++++-- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/frontend/src/hooks/useStompChartData.ts b/frontend/src/hooks/useStompChartData.ts index 9596ef8..4bd4f4e 100644 --- a/frontend/src/hooks/useStompChartData.ts +++ b/frontend/src/hooks/useStompChartData.ts @@ -10,7 +10,7 @@ import { getCandleStart, TF_SECONDS } from '../utils/upbitApi'; import { DISPLAY_COUNT, WARMUP_COUNT, OBV_DEEP_HISTORY } from './useUpbitData'; import type { WsStatus } from './useUpbitData'; import { fetchBacktestHistoryCandles } from '../utils/backtestWarmup'; -import { API_BASE } from '../utils/backendApi'; +import { pinCandleWatch } from '../utils/backendApi'; /** 백엔드 BarBuilder 는 틱마다 1m 만 STOMP 발행 — 차트 분봉은 클라이언트에서 집계 */ const STOMP_TICK_CANDLE_TYPE = '1m'; @@ -28,13 +28,7 @@ function resolveChartBarTime( } async function pinChartWatch(market: string, candleType: string): Promise { - try { - await fetch(`${API_BASE}/candles/watch?market=${encodeURIComponent(market)}&type=${encodeURIComponent(candleType)}`, { - method: 'POST', - }); - } catch { - /* watch 실패해도 STOMP 구독은 계속 */ - } + await pinCandleWatch(market, candleType); } interface StompCandlePayload { diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 5a3f15c..3677ced 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -22,16 +22,41 @@ export function getApiBase(): string { return API_BASE.replace(/\/api$/, ''); } +function isDesktopRuntime(): boolean { + if (typeof window === 'undefined') return false; + if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) return true; + return document.documentElement.classList.contains('desktop-client'); +} + +/** secure WebView(https) — ws:// mixed content 회피 */ +export function getSecureApiOrigin(): string { + const origin = getApiBase().replace(/\/$/, ''); + const pageSecure = + typeof window !== 'undefined' + && (window.location.protocol === 'https:' || isDesktopRuntime()); + if (pageSecure && /^http:\/\//i.test(origin)) { + return origin.replace(/^http:/i, 'https:'); + } + return origin; +} + /** * STOMP SockJS 엔드포인트. * Spring Boot context-path가 /api 이므로 반드시 /api/ws/trading 경로를 사용한다. */ export function getStompSockJsUrl(): string { + if (isDesktopRuntime()) { + return `${getSecureApiOrigin()}/api/ws/trading`; + } const base = API_BASE.replace(/\/$/, ''); const path = base.endsWith('/api') ? `${base}/ws/trading` : `${base}/api/ws/trading`; // SockJS: 배포 환경에서 상대 경로만 쓰면 프록시/오리진 불일치 시 연결 실패가 난다. if (typeof window !== 'undefined' && window.location?.origin) { - return `${window.location.origin}${path.startsWith('/') ? path : `/${path}`}`; + const rel = path.startsWith('/') ? path : `/${path}`; + if (window.location.protocol === 'https:' && /^http:\/\//i.test(path)) { + return `${getSecureApiOrigin()}/api/ws/trading`; + } + return `${window.location.origin}${rel}`; } return path; } diff --git a/frontend/src/utils/upbitWsBroker.ts b/frontend/src/utils/upbitWsBroker.ts index 8b44ca4..13ad4d3 100644 --- a/frontend/src/utils/upbitWsBroker.ts +++ b/frontend/src/utils/upbitWsBroker.ts @@ -3,6 +3,8 @@ * 훅·차트마다 WebSocket 을 열면 모바일에서 429·재연결 폭주가 발생한다. */ import type { UpbitMessage } from './upbitApi'; +import { isDesktop } from './platform'; +import { getSecureApiOrigin } from './backendApi'; export type UpbitWsChannel = 'ticker' | 'orderbook' | 'trade'; @@ -36,6 +38,11 @@ let closingIntentionally = false; let wsFailureLogged = false; function getWsUrl(): string { + if (isDesktop()) { + const origin = getSecureApiOrigin().replace(/^http:\/\//i, 'https://'); + const host = origin.replace(/^https:\/\//i, ''); + return `wss://${host}/upbit-ws`; + } const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; return `${proto}://${window.location.host}/upbit-ws`; } diff --git a/packages/shared/src/api/backendApi.ts b/packages/shared/src/api/backendApi.ts index ff3167e..30ab3f8 100644 --- a/packages/shared/src/api/backendApi.ts +++ b/packages/shared/src/api/backendApi.ts @@ -77,13 +77,28 @@ export function getApiBase(): string { return API_BASE.replace(/\/api$/, ''); } +/** + * Desktop Tauri WebView(https 커스텀 스킴)에서는 ws://·http API XHR 이 mixed content 로 차단된다. + * exdev REST 는 tauri HTTP 로 우회하지만 SockJS/WebSocket 은 페이지 오리진 정책을 따른다. + */ +export function getSecureApiOrigin(): string { + const origin = getApiBase().replace(/\/$/, ''); + const pageSecure = + typeof window !== 'undefined' + && (window.location.protocol === 'https:' || isDesktopClient()); + if (pageSecure && /^http:\/\//i.test(origin)) { + return origin.replace(/^http:/i, 'https:'); + } + return origin; +} + /** * STOMP SockJS 엔드포인트. * Spring Boot context-path가 /api 이므로 반드시 /api/ws/trading 경로를 사용한다. */ export function getStompSockJsUrl(): string { - const base = API_BASE.replace(/\/$/, ''); - return base.endsWith('/api') ? `${base}/ws/trading` : `${base}/api/ws/trading`; + const secureOrigin = getSecureApiOrigin(); + return `${secureOrigin}/api/ws/trading`; } // UUID 생성 — HTTPS/localhost: crypto.randomUUID() 사용, HTTP: 폴리필