From 407e746822a097085f271cb87102586497602f90 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 14 Jun 2026 10:33:53 +0900 Subject: [PATCH] =?UTF-8?q?pc=20=EC=95=B1=20=EB=B0=B0=ED=8F=AC,=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DesktopAppReleaseController.java | 22 ++ .../dto/DesktopAppBuildStatusDto.java | 19 ++ .../dto/DesktopAppBuildTriggerDto.java | 12 + .../service/DesktopAppBuildService.java | 255 ++++++++++++++++++ backend/src/main/resources/application.yml | 8 + desktop/src/bridge/widgetWindows.ts | 5 +- desktop/src/widget-main.tsx | 34 ++- desktop/widget.html | 5 +- frontend/src/App.css | 24 ++ frontend/src/App.tsx | 5 +- frontend/src/components/AppDownloadModal.tsx | 162 ++++++++--- .../floatingWidgets/FloatingWidgetWindow.tsx | 102 ++++--- frontend/src/styles/floatingWidget.css | 49 +++- frontend/src/utils/backendApi.ts | 24 ++ 14 files changed, 640 insertions(+), 86 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/dto/DesktopAppBuildStatusDto.java create mode 100644 backend/src/main/java/com/goldenchart/dto/DesktopAppBuildTriggerDto.java create mode 100644 backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java diff --git a/backend/src/main/java/com/goldenchart/controller/DesktopAppReleaseController.java b/backend/src/main/java/com/goldenchart/controller/DesktopAppReleaseController.java index 3fe7ec4..d0f68d0 100644 --- a/backend/src/main/java/com/goldenchart/controller/DesktopAppReleaseController.java +++ b/backend/src/main/java/com/goldenchart/controller/DesktopAppReleaseController.java @@ -1,12 +1,19 @@ package com.goldenchart.controller; +import com.goldenchart.dto.DesktopAppBuildStatusDto; +import com.goldenchart.dto.DesktopAppBuildTriggerDto; import com.goldenchart.dto.DesktopAppReleaseInfoDto; +import com.goldenchart.service.DesktopAppBuildService; import com.goldenchart.service.DesktopAppReleaseService; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/desktop-app") @@ -14,12 +21,27 @@ import org.springframework.web.bind.annotation.RestController; public class DesktopAppReleaseController { private final DesktopAppReleaseService releaseService; + private final DesktopAppBuildService buildService; @GetMapping("/info") public DesktopAppReleaseInfoDto info(HttpServletRequest request) { return releaseService.getInfo(resolvePublicBaseUrl(request)); } + @GetMapping("/build/status") + public DesktopAppBuildStatusDto buildStatus() { + return buildService.getStatus(); + } + + @PostMapping("/build") + public DesktopAppBuildTriggerDto triggerBuild( + @RequestHeader(value = "X-User-Id", required = false) Long userId) { + if (userId == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "관리자 로그인이 필요합니다."); + } + return buildService.triggerBuild(userId); + } + private static String resolvePublicBaseUrl(HttpServletRequest request) { String forwardedProto = request.getHeader("X-Forwarded-Proto"); String forwardedHost = request.getHeader("X-Forwarded-Host"); diff --git a/backend/src/main/java/com/goldenchart/dto/DesktopAppBuildStatusDto.java b/backend/src/main/java/com/goldenchart/dto/DesktopAppBuildStatusDto.java new file mode 100644 index 0000000..f69b94e --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/DesktopAppBuildStatusDto.java @@ -0,0 +1,19 @@ +package com.goldenchart.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class DesktopAppBuildStatusDto { + /** Jenkins 연동 활성 여부 */ + private boolean jenkinsEnabled; + /** 현재 빌드 진행 중 */ + private boolean building; + /** idle | queued | building | success | failure | disabled | error */ + private String status; + private Integer buildNumber; + private String message; + private String buildUrl; + private Long startedAtMs; +} diff --git a/backend/src/main/java/com/goldenchart/dto/DesktopAppBuildTriggerDto.java b/backend/src/main/java/com/goldenchart/dto/DesktopAppBuildTriggerDto.java new file mode 100644 index 0000000..2cc0446 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/DesktopAppBuildTriggerDto.java @@ -0,0 +1,12 @@ +package com.goldenchart.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class DesktopAppBuildTriggerDto { + private boolean accepted; + private Integer buildNumber; + private String message; +} diff --git a/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java b/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java new file mode 100644 index 0000000..6debe9f --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java @@ -0,0 +1,255 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.goldenchart.dto.DesktopAppBuildStatusDto; +import com.goldenchart.dto.DesktopAppBuildTriggerDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.util.UriUtils; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +@Slf4j +@RequiredArgsConstructor +public class DesktopAppBuildService { + + private final WebClient.Builder webClientBuilder; + private final RolePermissionService rolePermissionService; + + @Value("${goldenchart.desktop-app.jenkins.enabled:true}") + private boolean jenkinsEnabled; + + @Value("${goldenchart.desktop-app.jenkins.url:http://127.0.0.1:8090}") + private String jenkinsUrl; + + @Value("${goldenchart.desktop-app.jenkins.job:goldenChart-Desktop-Pipeline}") + private String jenkinsJob; + + @Value("${goldenchart.desktop-app.jenkins.user:}") + private String jenkinsUser; + + @Value("${goldenchart.desktop-app.jenkins.token:}") + private String jenkinsToken; + + @Value("${goldenchart.desktop-app.git-bare-dir:}") + private String gitBareDir; + + @Value("${goldenchart.desktop-app.work-tree:}") + private String workTree; + + public DesktopAppBuildStatusDto getStatus() { + if (!jenkinsEnabled) { + return DesktopAppBuildStatusDto.builder() + .jenkinsEnabled(false) + .building(false) + .status("disabled") + .message("Jenkins 연동이 비활성화되어 있습니다.") + .build(); + } + try { + return fetchLastBuildStatus(); + } catch (Exception e) { + log.warn("[desktop-build] status failed: {}", e.getMessage()); + return DesktopAppBuildStatusDto.builder() + .jenkinsEnabled(true) + .building(false) + .status("error") + .message("Jenkins 상태 조회 실패: " + e.getMessage()) + .build(); + } + } + + public DesktopAppBuildTriggerDto triggerBuild(Long userId) { + rolePermissionService.requireAdmin(userId); + if (!jenkinsEnabled) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jenkins 연동이 비활성화되어 있습니다."); + } + + syncGitLatest(); + + DesktopAppBuildStatusDto current = fetchLastBuildStatus(); + if (current.isBuilding()) { + return DesktopAppBuildTriggerDto.builder() + .accepted(false) + .buildNumber(current.getBuildNumber()) + .message("이미 빌드가 진행 중입니다 (#" + current.getBuildNumber() + ").") + .build(); + } + + triggerJenkinsBuild(); + DesktopAppBuildStatusDto after = fetchLastBuildStatus(); + return DesktopAppBuildTriggerDto.builder() + .accepted(true) + .buildNumber(after.getBuildNumber()) + .message("Jenkins에서 macOS·Windows 데스크톱 앱 빌드를 시작했습니다. 완료 후 설치 파일이 자동으로 반영됩니다.") + .build(); + } + + private DesktopAppBuildStatusDto fetchLastBuildStatus() { + WebClient client = jenkinsClient(); + String jobPath = jobApiPath(); + JsonNode job = client.get() + .uri(jobPath + "/api/json?tree=lastBuild[number,building,result,url,timestamp]") + .retrieve() + .bodyToMono(JsonNode.class) + .block(Duration.ofSeconds(15)); + + if (job == null || job.path("lastBuild").isMissingNode() || job.path("lastBuild").isNull()) { + return DesktopAppBuildStatusDto.builder() + .jenkinsEnabled(true) + .building(false) + .status("idle") + .message("아직 빌드 이력이 없습니다.") + .build(); + } + + JsonNode last = job.path("lastBuild"); + boolean building = last.path("building").asBoolean(false); + String result = textOrNull(last.path("result")); + int number = last.path("number").asInt(); + String url = textOrNull(last.path("url")); + Long startedAt = last.path("timestamp").isNumber() ? last.path("timestamp").asLong() : null; + + String status; + String message; + if (building) { + status = "building"; + message = "빌드 진행 중… (#" + number + ")"; + } else if (result == null) { + status = "queued"; + message = "빌드 대기 중… (#" + number + ")"; + } else { + status = result.toLowerCase(Locale.ROOT); + message = switch (status) { + case "success" -> "최근 빌드 성공 (#" + number + ")"; + case "failure" -> "최근 빌드 실패 (#" + number + ")"; + case "aborted" -> "최근 빌드 중단 (#" + number + ")"; + default -> "최근 빌드: " + result + " (#" + number + ")"; + }; + } + + return DesktopAppBuildStatusDto.builder() + .jenkinsEnabled(true) + .building(building) + .status(status) + .buildNumber(number) + .message(message) + .buildUrl(url) + .startedAtMs(startedAt) + .build(); + } + + private void triggerJenkinsBuild() { + WebClient client = jenkinsClient(); + String buildUrl = jobApiPath() + "/build"; + try { + postBuild(client, buildUrl, null); + } catch (WebClientResponseException.Forbidden e) { + Map crumb = fetchCrumb(client); + postBuild(client, buildUrl, crumb); + } + } + + private void postBuild(WebClient client, String buildUrl, Map crumb) { + var spec = client.post().uri(buildUrl); + if (crumb != null) { + spec = spec.header(crumb.get("field"), crumb.get("value")); + } + spec.retrieve() + .toBodilessEntity() + .block(Duration.ofSeconds(20)); + } + + private Map fetchCrumb(WebClient client) { + JsonNode node = client.get() + .uri("/crumbIssuer/api/json") + .retrieve() + .bodyToMono(JsonNode.class) + .block(Duration.ofSeconds(10)); + if (node == null) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Jenkins CSRF crumb 조회 실패"); + } + return Map.of( + "field", node.path("crumbRequestField").asText("Jenkins-Crumb"), + "value", node.path("crumb").asText() + ); + } + + private WebClient jenkinsClient() { + WebClient.Builder builder = webClientBuilder + .baseUrl(normalizeBase(jenkinsUrl)) + .defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE); + if (StringUtils.hasText(jenkinsUser) && StringUtils.hasText(jenkinsToken)) { + builder.defaultHeaders(h -> h.setBasicAuth(jenkinsUser.trim(), jenkinsToken.trim())); + } + return builder.build(); + } + + private String jobApiPath() { + String encoded = UriUtils.encodePathSegment(jenkinsJob.trim(), StandardCharsets.UTF_8); + return "/job/" + encoded; + } + + private static String normalizeBase(String url) { + if (!StringUtils.hasText(url)) return "http://127.0.0.1:8090"; + return url.trim().replaceAll("/+$", ""); + } + + private static String textOrNull(JsonNode node) { + if (node == null || node.isNull() || node.isMissingNode()) return null; + String t = node.asText(null); + return t == null || t.isBlank() ? null : t; + } + + private void syncGitLatest() { + if (!StringUtils.hasText(gitBareDir)) { + log.debug("[desktop-build] git-bare-dir 미설정 — Jenkins SCM checkout에 위임"); + return; + } + List commands = new ArrayList<>(); + commands.add(new String[]{"fetch", "origin", "main:main"}); + if (StringUtils.hasText(workTree)) { + commands.add(new String[]{"checkout", "-f", "main"}); + } + for (String[] args : commands) { + runGit(args); + } + } + + private void runGit(String[] args) { + List cmd = new ArrayList<>(); + cmd.add("git"); + cmd.add("--git-dir=" + gitBareDir.trim()); + if (StringUtils.hasText(workTree)) { + cmd.add("--work-tree=" + workTree.trim()); + } + for (String a : args) cmd.add(a); + try { + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + Process p = pb.start(); + int code = p.waitFor(); + if (code != 0) { + log.warn("[desktop-build] git {} → exit {}", String.join(" ", args), code); + } else { + log.info("[desktop-build] git {} OK", String.join(" ", args)); + } + } catch (Exception e) { + log.warn("[desktop-build] git {} failed: {}", String.join(" ", args), e.getMessage()); + } + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index c999321..28f7b69 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -116,6 +116,14 @@ goldenchart: release-dir: ${GC_DESKTOP_APP_RELEASE_DIR:data/desktop-releases} version: ${GC_DESKTOP_APP_VERSION:} public-base-url: ${GC_DESKTOP_APP_PUBLIC_BASE_URL:http://exdev.co.kr} + git-bare-dir: ${GC_GIT_BARE_DIR:} + work-tree: ${GC_WORK_TREE:} + jenkins: + enabled: ${GC_DESKTOP_JENKINS_ENABLED:true} + url: ${GC_DESKTOP_JENKINS_URL:http://127.0.0.1:8090} + job: ${GC_DESKTOP_JENKINS_JOB:goldenChart-Desktop-Pipeline} + user: ${GC_DESKTOP_JENKINS_USER:} + token: ${GC_DESKTOP_JENKINS_TOKEN:} cors: allowed-origins: - http://localhost:5173 diff --git a/desktop/src/bridge/widgetWindows.ts b/desktop/src/bridge/widgetWindows.ts index 7ae04a0..d35b8ad 100644 --- a/desktop/src/bridge/widgetWindows.ts +++ b/desktop/src/bridge/widgetWindows.ts @@ -26,9 +26,11 @@ export async function openWidgetWindow(instance: FloatingWidgetInstance): Promis return; } + const title = `GoldenChart — 위젯 ${instance.rows}×${instance.cols}`; + const win = new WebviewWindow(label, { url: widgetUrl(instance), - title: 'GoldenChart Widget', + title, width: instance.width, height: instance.height, minWidth: 320, @@ -38,6 +40,7 @@ export async function openWidgetWindow(instance: FloatingWidgetInstance): Promis center: false, x: instance.position.x, y: instance.position.y, + backgroundColor: '#1a1b26', }); widgetWindows.set(instance.id, win); diff --git a/desktop/src/widget-main.tsx b/desktop/src/widget-main.tsx index 22a1ec6..268c9ec 100644 --- a/desktop/src/widget-main.tsx +++ b/desktop/src/widget-main.tsx @@ -2,6 +2,7 @@ import './forceServerApi'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import ReactDOM from 'react-dom/client'; import { getCurrentWindow } from '@tauri-apps/api/window'; +import { LogicalSize } from '@tauri-apps/api/dpi'; import { initStorage, refreshApiBaseFromStorage } from '@goldenchart/shared'; import { ensureDesktopApiBase } from './ensureDesktopApiBase'; import FloatingWidgetWindow from '@frontend/components/floatingWidgets/FloatingWidgetWindow'; @@ -23,10 +24,15 @@ import { import { PAPER_TRADES_CHANGED_EVENT } from '@frontend/utils/paperTradeEvents'; import { WidgetDashboardProvider } from '@frontend/widgets/WidgetDashboardContext'; import { readDesktopSync, subscribeDesktopSync } from '@frontend/utils/desktopSync'; +import { syncDocumentTheme } from '@frontend/utils/documentTheme'; +import '@frontend/App.css'; +import '@frontend/styles/appPopup.css'; import '@frontend/styles/paperDashboard.css'; import '@frontend/styles/widgetDashboard.css'; import '@frontend/styles/floatingWidget.css'; +document.documentElement.classList.add('fw-native-widget'); + function parseInstanceFromUrl(): FloatingWidgetInstance | null { const params = new URLSearchParams(window.location.search); const id = params.get('id'); @@ -101,9 +107,29 @@ const WidgetApp: React.FC = () => { }, []); useEffect(() => { - document.documentElement.setAttribute('data-theme', theme); + syncDocumentTheme(theme); }, [theme]); + useEffect(() => { + if (!instance) return; + const title = `GoldenChart — 위젯 ${instance.rows}×${instance.cols}`; + void getCurrentWindow().setTitle(title); + }, [instance?.rows, instance?.cols, instance]); + + useEffect(() => { + if (!instance) return; + let unlisten: (() => void) | undefined; + void (async () => { + unlisten = await getCurrentWindow().onResized(async () => { + const size = await getCurrentWindow().innerSize(); + setInstance(prev => ( + prev ? { ...prev, width: size.width, height: size.height } : prev + )); + }); + })(); + return () => { unlisten?.(); }; + }, [instance?.id]); + const handleClose = useCallback(() => { void getCurrentWindow().close(); }, []); @@ -132,10 +158,14 @@ const WidgetApp: React.FC = () => { {}} onUpdateSlots={slots => setInstance(prev => (prev ? { ...prev, slots } : prev))} - onUpdateSize={(width, height) => setInstance(prev => (prev ? { ...prev, width, height } : prev))} + onUpdateSize={(width, height) => { + setInstance(prev => (prev ? { ...prev, width, height } : prev)); + void getCurrentWindow().setSize(new LogicalSize(width, height)); + }} onUpdateGridFr={(rowFr, colFr) => setInstance(prev => (prev ? { ...prev, rowFr, colFr } : prev))} /> diff --git a/desktop/widget.html b/desktop/widget.html index 8eabebc..8c9325b 100644 --- a/desktop/widget.html +++ b/desktop/widget.html @@ -1,10 +1,13 @@ - + GoldenChart Widget + diff --git a/frontend/src/App.css b/frontend/src/App.css index 0daf4e8..c87a311 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -13190,6 +13190,30 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } padding: 12px 14px; margin-bottom: 16px; background: var(--bg3); border-radius: 12px; border: 1px solid var(--border); } +.app-download-hero--with-action { + justify-content: space-between; + flex-wrap: wrap; + gap: 10px 14px; +} +.app-download-hero-main { + display: flex; align-items: center; gap: 14px; min-width: 0; +} +.app-download-build-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 12px; border-radius: 10px; border: 1px solid color-mix(in srgb, #2196f3 55%, var(--border)); + background: linear-gradient(135deg, color-mix(in srgb, #2196f3 18%, var(--bg2)), var(--bg2)); + color: var(--text); font-size: 12px; font-weight: 700; cursor: pointer; flex-shrink: 0; +} +.app-download-build-btn:hover:not(:disabled) { filter: brightness(1.08); } +.app-download-build-btn:disabled, .app-download-build-btn.busy { opacity: 0.65; cursor: wait; } +.app-download-build-btn.busy svg { animation: tmb-update-spin 0.9s linear infinite; } +.app-download-build-status { + margin: -8px 0 12px; font-size: 12px; color: var(--text2); line-height: 1.45; +} +.app-download-build-status--active { color: #5eb5ff; } +.app-download-build-note, .app-download-build-wait { + margin: 0 0 12px; font-size: 12px; line-height: 1.5; +} .app-download-app-icon { width: 52px; height: 52px; border-radius: 14px; background: linear-gradient(135deg, #8b5cf6, #2196f3); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2416ac3..43ee3cf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -218,7 +218,10 @@ function AppMainContent({ /> {appDownloadOpen && ( - setAppDownloadOpen(false)} /> + setAppDownloadOpen(false)} + canBuildDesktop={authUser?.role === 'ADMIN'} + /> )} {menuPage === 'dashboard' && ( diff --git a/frontend/src/components/AppDownloadModal.tsx b/frontend/src/components/AppDownloadModal.tsx index 0d95ae5..8749b73 100644 --- a/frontend/src/components/AppDownloadModal.tsx +++ b/frontend/src/components/AppDownloadModal.tsx @@ -1,12 +1,15 @@ /** - * 모바일·PC 프로그램 설치 — 탭 UI (모바일 QR / PC dmg·exe) + * 모바일·PC 프로그램 설치 — 탭 UI (모바일 QR / PC dmg·exe + Jenkins 빌드) */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import QRCode from 'qrcode'; import DraggableModalFrame from './DraggableModalFrame'; import { + loadDesktopAppBuildStatus, loadDesktopAppReleaseInfo, loadMobileAppReleaseInfo, + triggerDesktopAppBuild, + type DesktopAppBuildStatusDto, type DesktopAppPlatformReleaseDto, type DesktopAppReleaseInfoDto, type MobileAppReleaseInfoDto, @@ -14,6 +17,8 @@ import { interface Props { onClose: () => void; + /** ADMIN — Jenkins 데스크톱 빌드 트리거 */ + canBuildDesktop?: boolean; } type TabId = 'mobile' | 'desktop'; @@ -39,6 +44,16 @@ function triggerDownload(url: string, fileName: string) { a.remove(); } +const IcDesktopBuild = () => ( + + + + + + + +); + const MobileTab: React.FC = () => { const [info, setInfo] = useState(null); const [loading, setLoading] = useState(true); @@ -184,72 +199,153 @@ const PlatformCard: React.FC<{ ); }; -const DesktopTab: React.FC = () => { +const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) => { const [info, setInfo] = useState(null); + const [buildStatus, setBuildStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [buildBusy, setBuildBusy] = useState(false); + const [buildHint, setBuildHint] = useState(null); + + const reloadRelease = useCallback(async () => { + const data = await loadDesktopAppReleaseInfo(); + setInfo(data); + return data; + }, []); + + const reloadBuildStatus = useCallback(async () => { + const st = await loadDesktopAppBuildStatus(); + setBuildStatus(st); + return st; + }, []); useEffect(() => { let cancelled = false; - void loadDesktopAppReleaseInfo() - .then(data => { if (!cancelled) setInfo(data); }) + void Promise.all([reloadRelease(), reloadBuildStatus()]) .catch(() => { if (!cancelled) setError('PC 프로그램 정보를 불러오지 못했습니다.'); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, []); + }, [reloadRelease, reloadBuildStatus]); + + const isBuilding = Boolean(buildStatus?.building || buildStatus?.status === 'queued'); + + useEffect(() => { + if (!isBuilding) return; + const timer = window.setInterval(() => { + void reloadBuildStatus().then(st => { + if (st && !st.building && st.status !== 'queued') { + void reloadRelease(); + } + }); + }, 8000); + return () => window.clearInterval(timer); + }, [isBuilding, reloadBuildStatus, reloadRelease]); + + const handleBuild = useCallback(async () => { + if (!canBuildDesktop || buildBusy || isBuilding) return; + setBuildBusy(true); + setBuildHint('Jenkins 빌드 요청 중…'); + try { + const res = await triggerDesktopAppBuild(); + setBuildHint(res.message ?? (res.accepted ? '빌드 시작됨' : '빌드 요청 거부')); + await reloadBuildStatus(); + } catch (e) { + setBuildHint(e instanceof Error ? e.message : '빌드 요청 실패'); + } finally { + setBuildBusy(false); + } + }, [canBuildDesktop, buildBusy, isBuilding, reloadBuildStatus]); const hasAny = info?.mac?.available || info?.windows?.available; if (loading) return

PC 프로그램 정보 불러오는 중…

; if (error) return

{error}

; + const statusMessage = buildHint ?? buildStatus?.message; + return ( <> -
-
GC
-
-

GoldenChart Desktop

-

- {info?.version && v{info.version}} - {info?.updatedAt && {info.updatedAt}} -

+
+
+
GC
+
+

GoldenChart Desktop

+

+ {info?.version && v{info.version}} + {info?.updatedAt && {info.updatedAt}} +

+
+ {canBuildDesktop && buildStatus?.jenkinsEnabled !== false && ( + + )}
- {!hasAny && ( + {statusMessage && ( +

+ {statusMessage} +

+ )} + + {!canBuildDesktop && ( +

+ 데스크톱 앱 빌드는 관리자 로그인 후 사용할 수 있습니다. +

+ )} + + {!hasAny && !isBuilding && (
💻

설치 파일 준비 중

macOS(.dmg) 또는 Windows(.exe) 설치 파일이 아직 서버에 없습니다.
- Jenkins 빌드 후 scripts/publish-desktop-update.sh를 실행하세요. + {canBuildDesktop + ? '우측 상단 「앱 빌드」 버튼으로 Jenkins 빌드를 시작하세요.' + : '관리자에게 Jenkins 빌드를 요청하세요.'}

)} - {hasAny && ( - <> -
- - -
+ {isBuilding && !hasAny && ( +

+ Jenkins에서 macOS dmg · Windows setup.exe를 빌드 중입니다. 완료되면 아래에 다운로드가 표시됩니다. +

+ )} -
- 설치 방법 -
    -
  1. macOS: dmg를 열고 GoldenChart.app을 Applications 폴더로 드래그합니다.
  2. -
  3. Windows: setup.exe를 실행하고 안내에 따라 설치합니다.
  4. -
  5. 설치된 PC 앱은 메뉴 우측 상단 업데이트 버튼으로 자동 업데이트할 수 있습니다.
  6. -
-
- + {(hasAny || isBuilding) && ( +
+ + +
+ )} + + {(hasAny || isBuilding) && ( +
+ 설치 방법 +
    +
  1. macOS: dmg를 열고 GoldenChart.app을 Applications 폴더로 드래그합니다.
  2. +
  3. Windows: setup.exe를 실행하고 안내에 따라 설치합니다.
  4. +
  5. 설치된 PC 앱은 메뉴 우측 상단 업데이트 버튼으로 자동 업데이트할 수 있습니다.
  6. +
  7. 빌드는 Git main 최신 커밋 기준이며, 완료 후 설치 파일이 자동으로 배포됩니다.
  8. +
+
)} ); }; -const AppDownloadModal: React.FC = ({ onClose }) => { +const AppDownloadModal: React.FC = ({ onClose, canBuildDesktop = false }) => { const [tab, setTab] = useState('mobile'); return ( @@ -283,7 +379,7 @@ const AppDownloadModal: React.FC = ({ onClose }) => {
- {tab === 'mobile' ? : } + {tab === 'mobile' ? : }
); diff --git a/frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx b/frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx index ba55290..7985019 100644 --- a/frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx +++ b/frontend/src/components/floatingWidgets/FloatingWidgetWindow.tsx @@ -22,6 +22,8 @@ interface Props { onUpdateSlots: (slots: WidgetSlot[]) => void; onUpdateSize: (width: number, height: number) => void; onUpdateGridFr: (rowFr: number[], colFr: number[]) => void; + /** Tauri OS 창 — 네이티브 타이틀·리사이즈 사용, 내부 플로팅 크롬 숨김 */ + nativeShell?: boolean; } const FloatingWidgetWindow: React.FC = ({ @@ -32,6 +34,7 @@ const FloatingWidgetWindow: React.FC = ({ onUpdateSlots, onUpdateSize, onUpdateGridFr, + nativeShell = false, }) => { const [pickSlotId, setPickSlotId] = useState(null); const bodyRef = useRef(null); @@ -146,37 +149,49 @@ const FloatingWidgetWindow: React.FC = ({ const title = `위젯 ${instance.rows}×${instance.cols}`; - return createPortal( + const windowClass = [ + 'fw-window', + focused ? 'fw-window--focused' : '', + nativeShell ? 'fw-window--native' : '', + ].filter(Boolean).join(' '); + + const windowStyle = nativeShell + ? undefined + : { + ...panelStyle, + width: instance.width, + height: instance.height, + zIndex: instance.zIndex, + cursor: dragging ? 'grabbing' : undefined, + }; + + const content = ( <>
-
- {title} - -
+ {title} + +
+ )}
= ({ )}
-
-
-
-
+ {!nativeShell && ( +
+
+
+
+ )}
= ({ onClose={() => setPickSlotId(null)} onSelect={applyWidgetType} /> - , - document.body, + ); + + return nativeShell ? content : createPortal(content, document.body); }; export default FloatingWidgetWindow; diff --git a/frontend/src/styles/floatingWidget.css b/frontend/src/styles/floatingWidget.css index 294ca13..e612839 100644 --- a/frontend/src/styles/floatingWidget.css +++ b/frontend/src/styles/floatingWidget.css @@ -595,24 +595,61 @@ } /* Tauri 네이티브 위젯 창 */ +/* Tauri 네이티브 위젯 창 — OS 창 전체를 콘텐츠 영역으로 사용 */ +html.fw-native-widget, +html.fw-native-widget body { + margin: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--bg, #1a1b26); + color: var(--text, #c0caf5); + font-family: var(--font, 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif); +} + +html.fw-native-widget #root { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + .fw-layer--picker-only { pointer-events: auto; } .fw-widget-native-root { - width: 100vw; - height: 100vh; + flex: 1; + min-height: 0; + width: 100%; + height: 100%; overflow: hidden; display: flex; flex-direction: column; + background: var(--bg, #1a1b26); } -.fw-widget-native-root .fw-window { - position: static !important; +.fw-widget-native-root .fw-window, +.fw-widget-native-root .fw-window--native { + position: relative !important; + flex: 1; + min-height: 0; width: 100% !important; height: 100% !important; max-width: none !important; max-height: none !important; - box-shadow: none; - border: none; + box-shadow: none !important; + border: none !important; + border-radius: 0 !important; +} + +.fw-window--native .fw-window-body-wrap { + flex: 1; + min-height: 0; +} + +.fw-window--native .fw-window-body { + height: 100%; } diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index ddcb077..0436868 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -665,6 +665,30 @@ export async function loadDesktopAppReleaseInfo(): Promise('/desktop-app/info'); } +export interface DesktopAppBuildStatusDto { + jenkinsEnabled: boolean; + building: boolean; + status: string; + buildNumber?: number; + message?: string; + buildUrl?: string; + startedAtMs?: number; +} + +export interface DesktopAppBuildTriggerDto { + accepted: boolean; + buildNumber?: number; + message?: string; +} + +export async function loadDesktopAppBuildStatus(): Promise { + return request('/desktop-app/build/status'); +} + +export async function triggerDesktopAppBuild(): Promise { + return requestOrThrow('/desktop-app/build', { method: 'POST' }); +} + // ── 모의투자 ─────────────────────────────────────────────────────────────────── export interface PaperPositionDto {