pc 앱 배포,다운로드 기능

This commit is contained in:
Macbook
2026-06-14 10:33:53 +09:00
parent 92f67580e3
commit 407e746822
14 changed files with 640 additions and 86 deletions
@@ -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
+4 -1
View File
@@ -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);
+32 -2
View File
@@ -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
View File
@@ -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" />
+24
View File
@@ -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);
+4 -1
View File
@@ -218,7 +218,10 @@ function AppMainContent({
/>
{appDownloadOpen && (
<AppDownloadModal onClose={() => setAppDownloadOpen(false)} />
<AppDownloadModal
onClose={() => setAppDownloadOpen(false)}
canBuildDesktop={authUser?.role === 'ADMIN'}
/>
)}
{menuPage === 'dashboard' && (
+110 -14
View File
@@ -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,28 +199,74 @@ 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-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>
@@ -215,41 +276,76 @@ const DesktopTab: React.FC = () => {
</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 && (
<>
{isBuilding && !hasAny && (
<p className="app-download-muted app-download-build-wait">
Jenkins에서 macOS dmg · Windows setup.exe를 . .
</p>
)}
{(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,20 +149,31 @@ const FloatingWidgetWindow: React.FC<Props> = ({
const title = `위젯 ${instance.rows}×${instance.cols}`;
return createPortal(
<>
<div
ref={panelRef}
className={`fw-window${focused ? ' fw-window--focused' : ''}`}
style={{
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,
}}
onMouseDown={onFocus}
};
const content = (
<>
<div
ref={panelRef}
className={windowClass}
style={windowStyle}
onMouseDown={nativeShell ? undefined : onFocus}
>
{!nativeShell && (
<div
className="fw-window-head"
onPointerDown={onHeaderPointerDown}
@@ -177,6 +191,7 @@ const FloatingWidgetWindow: React.FC<Props> = ({
</button>
</div>
)}
<div className="fw-window-body-wrap">
<div
@@ -227,6 +242,7 @@ const FloatingWidgetWindow: React.FC<Props> = ({
)}
</div>
{!nativeShell && (
<div className="fw-window-resize-layer">
<div
className="fw-window-resize-handle fw-window-resize-handle--sw"
@@ -241,6 +257,7 @@ const FloatingWidgetWindow: React.FC<Props> = ({
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;
+43 -6
View File
@@ -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%;
}
+24
View File
@@ -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 {