pc 앱 배포,다운로드 기능
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String, String> crumb = fetchCrumb(client);
|
||||
postBuild(client, buildUrl, crumb);
|
||||
}
|
||||
}
|
||||
|
||||
private void postBuild(WebClient client, String buildUrl, Map<String, String> 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<String, String> 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<String[]> 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<String> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = () => {
|
||||
<FloatingWidgetWindow
|
||||
instance={instance}
|
||||
focused
|
||||
nativeShell
|
||||
onClose={handleClose}
|
||||
onFocus={() => {}}
|
||||
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))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+4
-1
@@ -1,10 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<html lang="ko" class="theme-dark fw-native-widget">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1a1b26" />
|
||||
<title>GoldenChart Widget</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: #1a1b26; }
|
||||
</style>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -218,7 +218,10 @@ function AppMainContent({
|
||||
/>
|
||||
|
||||
{appDownloadOpen && (
|
||||
<AppDownloadModal onClose={() => setAppDownloadOpen(false)} />
|
||||
<AppDownloadModal
|
||||
onClose={() => setAppDownloadOpen(false)}
|
||||
canBuildDesktop={authUser?.role === 'ADMIN'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{menuPage === 'dashboard' && (
|
||||
|
||||
@@ -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 = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M12 3v4" />
|
||||
<path d="M8 7h8" />
|
||||
<rect x="5" y="7" width="14" height="12" rx="2" />
|
||||
<path d="M9 13h6" />
|
||||
<path d="M9 17h4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MobileTab: React.FC = () => {
|
||||
const [info, setInfo] = useState<MobileAppReleaseInfoDto | null>(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<DesktopAppReleaseInfoDto | null>(null);
|
||||
const [buildStatus, setBuildStatus] = useState<DesktopAppBuildStatusDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [buildBusy, setBuildBusy] = useState(false);
|
||||
const [buildHint, setBuildHint] = useState<string | null>(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 <p className="app-download-muted">PC 프로그램 정보 불러오는 중…</p>;
|
||||
if (error) return <p className="app-download-error">{error}</p>;
|
||||
|
||||
const statusMessage = buildHint ?? buildStatus?.message;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app-download-hero">
|
||||
<div className="app-download-app-icon app-download-app-icon--desktop" aria-hidden>GC</div>
|
||||
<div>
|
||||
<h3 className="app-download-app-name">GoldenChart Desktop</h3>
|
||||
<p className="app-download-meta">
|
||||
{info?.version && <span>v{info.version}</span>}
|
||||
{info?.updatedAt && <span>{info.updatedAt}</span>}
|
||||
</p>
|
||||
<div className="app-download-hero app-download-hero--with-action">
|
||||
<div className="app-download-hero-main">
|
||||
<div className="app-download-app-icon app-download-app-icon--desktop" aria-hidden>GC</div>
|
||||
<div>
|
||||
<h3 className="app-download-app-name">GoldenChart Desktop</h3>
|
||||
<p className="app-download-meta">
|
||||
{info?.version && <span>v{info.version}</span>}
|
||||
{info?.updatedAt && <span>{info.updatedAt}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canBuildDesktop && buildStatus?.jenkinsEnabled !== false && (
|
||||
<button
|
||||
type="button"
|
||||
className={`app-download-build-btn${buildBusy || isBuilding ? ' busy' : ''}`}
|
||||
onClick={() => { void handleBuild(); }}
|
||||
disabled={buildBusy || isBuilding}
|
||||
title="Git main 최신 소스로 macOS·Windows 데스크톱 앱 Jenkins 빌드"
|
||||
aria-label="데스크톱 앱 빌드"
|
||||
>
|
||||
<IcDesktopBuild />
|
||||
<span>{isBuilding ? '빌드 중…' : '앱 빌드'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasAny && (
|
||||
{statusMessage && (
|
||||
<p className={`app-download-build-status${isBuilding ? ' app-download-build-status--active' : ''}`}>
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!canBuildDesktop && (
|
||||
<p className="app-download-muted app-download-build-note">
|
||||
데스크톱 앱 빌드는 <strong>관리자</strong> 로그인 후 사용할 수 있습니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!hasAny && !isBuilding && (
|
||||
<div className="app-download-empty">
|
||||
<div className="app-download-empty-icon" aria-hidden>💻</div>
|
||||
<h3>설치 파일 준비 중</h3>
|
||||
<p>
|
||||
macOS(.dmg) 또는 Windows(.exe) 설치 파일이 아직 서버에 없습니다.
|
||||
<br />
|
||||
Jenkins 빌드 후 <code>scripts/publish-desktop-update.sh</code>를 실행하세요.
|
||||
{canBuildDesktop
|
||||
? '우측 상단 「앱 빌드」 버튼으로 Jenkins 빌드를 시작하세요.'
|
||||
: '관리자에게 Jenkins 빌드를 요청하세요.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAny && (
|
||||
<>
|
||||
<div className="app-download-pc-list">
|
||||
<PlatformCard platform="mac" release={info?.mac} />
|
||||
<PlatformCard platform="windows" release={info?.windows} />
|
||||
</div>
|
||||
{isBuilding && !hasAny && (
|
||||
<p className="app-download-muted app-download-build-wait">
|
||||
Jenkins에서 macOS dmg · Windows setup.exe를 빌드 중입니다. 완료되면 아래에 다운로드가 표시됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<details className="app-download-steps">
|
||||
<summary>설치 방법</summary>
|
||||
<ol>
|
||||
<li><strong>macOS</strong>: dmg를 열고 GoldenChart.app을 Applications 폴더로 드래그합니다.</li>
|
||||
<li><strong>Windows</strong>: setup.exe를 실행하고 안내에 따라 설치합니다.</li>
|
||||
<li>설치된 PC 앱은 메뉴 우측 상단 <strong>업데이트</strong> 버튼으로 자동 업데이트할 수 있습니다.</li>
|
||||
</ol>
|
||||
</details>
|
||||
</>
|
||||
{(hasAny || isBuilding) && (
|
||||
<div className="app-download-pc-list">
|
||||
<PlatformCard platform="mac" release={info?.mac} />
|
||||
<PlatformCard platform="windows" release={info?.windows} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(hasAny || isBuilding) && (
|
||||
<details className="app-download-steps">
|
||||
<summary>설치 방법</summary>
|
||||
<ol>
|
||||
<li><strong>macOS</strong>: dmg를 열고 GoldenChart.app을 Applications 폴더로 드래그합니다.</li>
|
||||
<li><strong>Windows</strong>: setup.exe를 실행하고 안내에 따라 설치합니다.</li>
|
||||
<li>설치된 PC 앱은 메뉴 우측 상단 <strong>업데이트</strong> 버튼으로 자동 업데이트할 수 있습니다.</li>
|
||||
<li>빌드는 Git <code>main</code> 최신 커밋 기준이며, 완료 후 설치 파일이 자동으로 배포됩니다.</li>
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
const AppDownloadModal: React.FC<Props> = ({ onClose, canBuildDesktop = false }) => {
|
||||
const [tab, setTab] = useState<TabId>('mobile');
|
||||
|
||||
return (
|
||||
@@ -283,7 +379,7 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
</div>
|
||||
|
||||
<div className="app-download-body" role="tabpanel">
|
||||
{tab === 'mobile' ? <MobileTab /> : <DesktopTab />}
|
||||
{tab === 'mobile' ? <MobileTab /> : <DesktopTab canBuildDesktop={canBuildDesktop} />}
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
);
|
||||
|
||||
@@ -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<Props> = ({
|
||||
@@ -32,6 +34,7 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
onUpdateSlots,
|
||||
onUpdateSize,
|
||||
onUpdateGridFr,
|
||||
nativeShell = false,
|
||||
}) => {
|
||||
const [pickSlotId, setPickSlotId] = useState<string | null>(null);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -146,37 +149,49 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
|
||||
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 = (
|
||||
<>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`fw-window${focused ? ' fw-window--focused' : ''}`}
|
||||
style={{
|
||||
...panelStyle,
|
||||
width: instance.width,
|
||||
height: instance.height,
|
||||
zIndex: instance.zIndex,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={onFocus}
|
||||
className={windowClass}
|
||||
style={windowStyle}
|
||||
onMouseDown={nativeShell ? undefined : onFocus}
|
||||
>
|
||||
<div
|
||||
className="fw-window-head"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="fw-window-title">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="fw-window-close"
|
||||
title="위젯 창 닫기"
|
||||
aria-label="위젯 창 닫기"
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onClick={onClose}
|
||||
{!nativeShell && (
|
||||
<div
|
||||
className="fw-window-head"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<span className="fw-window-title">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="fw-window-close"
|
||||
title="위젯 창 닫기"
|
||||
aria-label="위젯 창 닫기"
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fw-window-body-wrap">
|
||||
<div
|
||||
@@ -227,20 +242,22 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fw-window-resize-layer">
|
||||
<div
|
||||
className="fw-window-resize-handle fw-window-resize-handle--sw"
|
||||
role="separator"
|
||||
aria-label="위젯 창 크기 조절 (좌하단)"
|
||||
onPointerDown={onResizeSwPointerDown}
|
||||
/>
|
||||
<div
|
||||
className="fw-window-resize-handle fw-window-resize-handle--se"
|
||||
role="separator"
|
||||
aria-label="위젯 창 크기 조절 (우하단)"
|
||||
onPointerDown={onResizeSePointerDown}
|
||||
/>
|
||||
</div>
|
||||
{!nativeShell && (
|
||||
<div className="fw-window-resize-layer">
|
||||
<div
|
||||
className="fw-window-resize-handle fw-window-resize-handle--sw"
|
||||
role="separator"
|
||||
aria-label="위젯 창 크기 조절 (좌하단)"
|
||||
onPointerDown={onResizeSwPointerDown}
|
||||
/>
|
||||
<div
|
||||
className="fw-window-resize-handle fw-window-resize-handle--se"
|
||||
role="separator"
|
||||
aria-label="위젯 창 크기 조절 (우하단)"
|
||||
onPointerDown={onResizeSePointerDown}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<WidgetPickerModal
|
||||
@@ -249,9 +266,10 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
||||
onClose={() => setPickSlotId(null)}
|
||||
onSelect={applyWidgetType}
|
||||
/>
|
||||
</>,
|
||||
document.body,
|
||||
</>
|
||||
);
|
||||
|
||||
return nativeShell ? content : createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default FloatingWidgetWindow;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -665,6 +665,30 @@ export async function loadDesktopAppReleaseInfo(): Promise<DesktopAppReleaseInfo
|
||||
return request<DesktopAppReleaseInfoDto>('/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<DesktopAppBuildStatusDto | null> {
|
||||
return request<DesktopAppBuildStatusDto>('/desktop-app/build/status');
|
||||
}
|
||||
|
||||
export async function triggerDesktopAppBuild(): Promise<DesktopAppBuildTriggerDto> {
|
||||
return requestOrThrow<DesktopAppBuildTriggerDto>('/desktop-app/build', { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── 모의투자 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaperPositionDto {
|
||||
|
||||
Reference in New Issue
Block a user