pc 앱 배포,다운로드 기능
This commit is contained in:
@@ -1,12 +1,19 @@
|
|||||||
package com.goldenchart.controller;
|
package com.goldenchart.controller;
|
||||||
|
|
||||||
|
import com.goldenchart.dto.DesktopAppBuildStatusDto;
|
||||||
|
import com.goldenchart.dto.DesktopAppBuildTriggerDto;
|
||||||
import com.goldenchart.dto.DesktopAppReleaseInfoDto;
|
import com.goldenchart.dto.DesktopAppReleaseInfoDto;
|
||||||
|
import com.goldenchart.service.DesktopAppBuildService;
|
||||||
import com.goldenchart.service.DesktopAppReleaseService;
|
import com.goldenchart.service.DesktopAppReleaseService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/desktop-app")
|
@RequestMapping("/desktop-app")
|
||||||
@@ -14,12 +21,27 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
public class DesktopAppReleaseController {
|
public class DesktopAppReleaseController {
|
||||||
|
|
||||||
private final DesktopAppReleaseService releaseService;
|
private final DesktopAppReleaseService releaseService;
|
||||||
|
private final DesktopAppBuildService buildService;
|
||||||
|
|
||||||
@GetMapping("/info")
|
@GetMapping("/info")
|
||||||
public DesktopAppReleaseInfoDto info(HttpServletRequest request) {
|
public DesktopAppReleaseInfoDto info(HttpServletRequest request) {
|
||||||
return releaseService.getInfo(resolvePublicBaseUrl(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) {
|
private static String resolvePublicBaseUrl(HttpServletRequest request) {
|
||||||
String forwardedProto = request.getHeader("X-Forwarded-Proto");
|
String forwardedProto = request.getHeader("X-Forwarded-Proto");
|
||||||
String forwardedHost = request.getHeader("X-Forwarded-Host");
|
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}
|
release-dir: ${GC_DESKTOP_APP_RELEASE_DIR:data/desktop-releases}
|
||||||
version: ${GC_DESKTOP_APP_VERSION:}
|
version: ${GC_DESKTOP_APP_VERSION:}
|
||||||
public-base-url: ${GC_DESKTOP_APP_PUBLIC_BASE_URL:http://exdev.co.kr}
|
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:
|
cors:
|
||||||
allowed-origins:
|
allowed-origins:
|
||||||
- http://localhost:5173
|
- http://localhost:5173
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ export async function openWidgetWindow(instance: FloatingWidgetInstance): Promis
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const title = `GoldenChart — 위젯 ${instance.rows}×${instance.cols}`;
|
||||||
|
|
||||||
const win = new WebviewWindow(label, {
|
const win = new WebviewWindow(label, {
|
||||||
url: widgetUrl(instance),
|
url: widgetUrl(instance),
|
||||||
title: 'GoldenChart Widget',
|
title,
|
||||||
width: instance.width,
|
width: instance.width,
|
||||||
height: instance.height,
|
height: instance.height,
|
||||||
minWidth: 320,
|
minWidth: 320,
|
||||||
@@ -38,6 +40,7 @@ export async function openWidgetWindow(instance: FloatingWidgetInstance): Promis
|
|||||||
center: false,
|
center: false,
|
||||||
x: instance.position.x,
|
x: instance.position.x,
|
||||||
y: instance.position.y,
|
y: instance.position.y,
|
||||||
|
backgroundColor: '#1a1b26',
|
||||||
});
|
});
|
||||||
|
|
||||||
widgetWindows.set(instance.id, win);
|
widgetWindows.set(instance.id, win);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import './forceServerApi';
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
|
import { LogicalSize } from '@tauri-apps/api/dpi';
|
||||||
import { initStorage, refreshApiBaseFromStorage } from '@goldenchart/shared';
|
import { initStorage, refreshApiBaseFromStorage } from '@goldenchart/shared';
|
||||||
import { ensureDesktopApiBase } from './ensureDesktopApiBase';
|
import { ensureDesktopApiBase } from './ensureDesktopApiBase';
|
||||||
import FloatingWidgetWindow from '@frontend/components/floatingWidgets/FloatingWidgetWindow';
|
import FloatingWidgetWindow from '@frontend/components/floatingWidgets/FloatingWidgetWindow';
|
||||||
@@ -23,10 +24,15 @@ import {
|
|||||||
import { PAPER_TRADES_CHANGED_EVENT } from '@frontend/utils/paperTradeEvents';
|
import { PAPER_TRADES_CHANGED_EVENT } from '@frontend/utils/paperTradeEvents';
|
||||||
import { WidgetDashboardProvider } from '@frontend/widgets/WidgetDashboardContext';
|
import { WidgetDashboardProvider } from '@frontend/widgets/WidgetDashboardContext';
|
||||||
import { readDesktopSync, subscribeDesktopSync } from '@frontend/utils/desktopSync';
|
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/paperDashboard.css';
|
||||||
import '@frontend/styles/widgetDashboard.css';
|
import '@frontend/styles/widgetDashboard.css';
|
||||||
import '@frontend/styles/floatingWidget.css';
|
import '@frontend/styles/floatingWidget.css';
|
||||||
|
|
||||||
|
document.documentElement.classList.add('fw-native-widget');
|
||||||
|
|
||||||
function parseInstanceFromUrl(): FloatingWidgetInstance | null {
|
function parseInstanceFromUrl(): FloatingWidgetInstance | null {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const id = params.get('id');
|
const id = params.get('id');
|
||||||
@@ -101,9 +107,29 @@ const WidgetApp: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
syncDocumentTheme(theme);
|
||||||
}, [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(() => {
|
const handleClose = useCallback(() => {
|
||||||
void getCurrentWindow().close();
|
void getCurrentWindow().close();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -132,10 +158,14 @@ const WidgetApp: React.FC = () => {
|
|||||||
<FloatingWidgetWindow
|
<FloatingWidgetWindow
|
||||||
instance={instance}
|
instance={instance}
|
||||||
focused
|
focused
|
||||||
|
nativeShell
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onFocus={() => {}}
|
onFocus={() => {}}
|
||||||
onUpdateSlots={slots => setInstance(prev => (prev ? { ...prev, slots } : prev))}
|
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))}
|
onUpdateGridFr={(rowFr, colFr) => setInstance(prev => (prev ? { ...prev, rowFr, colFr } : prev))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-1
@@ -1,10 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko" class="theme-dark fw-native-widget">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#1a1b26" />
|
<meta name="theme-color" content="#1a1b26" />
|
||||||
<title>GoldenChart Widget</title>
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<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;
|
padding: 12px 14px; margin-bottom: 16px;
|
||||||
background: var(--bg3); border-radius: 12px; border: 1px solid var(--border);
|
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 {
|
.app-download-app-icon {
|
||||||
width: 52px; height: 52px; border-radius: 14px;
|
width: 52px; height: 52px; border-radius: 14px;
|
||||||
background: linear-gradient(135deg, #8b5cf6, #2196f3);
|
background: linear-gradient(135deg, #8b5cf6, #2196f3);
|
||||||
|
|||||||
@@ -218,7 +218,10 @@ function AppMainContent({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{appDownloadOpen && (
|
{appDownloadOpen && (
|
||||||
<AppDownloadModal onClose={() => setAppDownloadOpen(false)} />
|
<AppDownloadModal
|
||||||
|
onClose={() => setAppDownloadOpen(false)}
|
||||||
|
canBuildDesktop={authUser?.role === 'ADMIN'}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{menuPage === 'dashboard' && (
|
{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 QRCode from 'qrcode';
|
||||||
import DraggableModalFrame from './DraggableModalFrame';
|
import DraggableModalFrame from './DraggableModalFrame';
|
||||||
import {
|
import {
|
||||||
|
loadDesktopAppBuildStatus,
|
||||||
loadDesktopAppReleaseInfo,
|
loadDesktopAppReleaseInfo,
|
||||||
loadMobileAppReleaseInfo,
|
loadMobileAppReleaseInfo,
|
||||||
|
triggerDesktopAppBuild,
|
||||||
|
type DesktopAppBuildStatusDto,
|
||||||
type DesktopAppPlatformReleaseDto,
|
type DesktopAppPlatformReleaseDto,
|
||||||
type DesktopAppReleaseInfoDto,
|
type DesktopAppReleaseInfoDto,
|
||||||
type MobileAppReleaseInfoDto,
|
type MobileAppReleaseInfoDto,
|
||||||
@@ -14,6 +17,8 @@ import {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** ADMIN — Jenkins 데스크톱 빌드 트리거 */
|
||||||
|
canBuildDesktop?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabId = 'mobile' | 'desktop';
|
type TabId = 'mobile' | 'desktop';
|
||||||
@@ -39,6 +44,16 @@ function triggerDownload(url: string, fileName: string) {
|
|||||||
a.remove();
|
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 MobileTab: React.FC = () => {
|
||||||
const [info, setInfo] = useState<MobileAppReleaseInfoDto | null>(null);
|
const [info, setInfo] = useState<MobileAppReleaseInfoDto | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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 [info, setInfo] = useState<DesktopAppReleaseInfoDto | null>(null);
|
||||||
|
const [buildStatus, setBuildStatus] = useState<DesktopAppBuildStatusDto | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void loadDesktopAppReleaseInfo()
|
void Promise.all([reloadRelease(), reloadBuildStatus()])
|
||||||
.then(data => { if (!cancelled) setInfo(data); })
|
|
||||||
.catch(() => { if (!cancelled) setError('PC 프로그램 정보를 불러오지 못했습니다.'); })
|
.catch(() => { if (!cancelled) setError('PC 프로그램 정보를 불러오지 못했습니다.'); })
|
||||||
.finally(() => { if (!cancelled) setLoading(false); });
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
return () => { cancelled = true; };
|
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;
|
const hasAny = info?.mac?.available || info?.windows?.available;
|
||||||
|
|
||||||
if (loading) return <p className="app-download-muted">PC 프로그램 정보 불러오는 중…</p>;
|
if (loading) return <p className="app-download-muted">PC 프로그램 정보 불러오는 중…</p>;
|
||||||
if (error) return <p className="app-download-error">{error}</p>;
|
if (error) return <p className="app-download-error">{error}</p>;
|
||||||
|
|
||||||
|
const statusMessage = buildHint ?? buildStatus?.message;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="app-download-hero">
|
<div className="app-download-hero app-download-hero--with-action">
|
||||||
<div className="app-download-app-icon app-download-app-icon--desktop" aria-hidden>GC</div>
|
<div className="app-download-hero-main">
|
||||||
<div>
|
<div className="app-download-app-icon app-download-app-icon--desktop" aria-hidden>GC</div>
|
||||||
<h3 className="app-download-app-name">GoldenChart Desktop</h3>
|
<div>
|
||||||
<p className="app-download-meta">
|
<h3 className="app-download-app-name">GoldenChart Desktop</h3>
|
||||||
{info?.version && <span>v{info.version}</span>}
|
<p className="app-download-meta">
|
||||||
{info?.updatedAt && <span>{info.updatedAt}</span>}
|
{info?.version && <span>v{info.version}</span>}
|
||||||
</p>
|
{info?.updatedAt && <span>{info.updatedAt}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</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>
|
</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">
|
||||||
<div className="app-download-empty-icon" aria-hidden>💻</div>
|
<div className="app-download-empty-icon" aria-hidden>💻</div>
|
||||||
<h3>설치 파일 준비 중</h3>
|
<h3>설치 파일 준비 중</h3>
|
||||||
<p>
|
<p>
|
||||||
macOS(.dmg) 또는 Windows(.exe) 설치 파일이 아직 서버에 없습니다.
|
macOS(.dmg) 또는 Windows(.exe) 설치 파일이 아직 서버에 없습니다.
|
||||||
<br />
|
<br />
|
||||||
Jenkins 빌드 후 <code>scripts/publish-desktop-update.sh</code>를 실행하세요.
|
{canBuildDesktop
|
||||||
|
? '우측 상단 「앱 빌드」 버튼으로 Jenkins 빌드를 시작하세요.'
|
||||||
|
: '관리자에게 Jenkins 빌드를 요청하세요.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasAny && (
|
{isBuilding && !hasAny && (
|
||||||
<>
|
<p className="app-download-muted app-download-build-wait">
|
||||||
<div className="app-download-pc-list">
|
Jenkins에서 macOS dmg · Windows setup.exe를 빌드 중입니다. 완료되면 아래에 다운로드가 표시됩니다.
|
||||||
<PlatformCard platform="mac" release={info?.mac} />
|
</p>
|
||||||
<PlatformCard platform="windows" release={info?.windows} />
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<details className="app-download-steps">
|
{(hasAny || isBuilding) && (
|
||||||
<summary>설치 방법</summary>
|
<div className="app-download-pc-list">
|
||||||
<ol>
|
<PlatformCard platform="mac" release={info?.mac} />
|
||||||
<li><strong>macOS</strong>: dmg를 열고 GoldenChart.app을 Applications 폴더로 드래그합니다.</li>
|
<PlatformCard platform="windows" release={info?.windows} />
|
||||||
<li><strong>Windows</strong>: setup.exe를 실행하고 안내에 따라 설치합니다.</li>
|
</div>
|
||||||
<li>설치된 PC 앱은 메뉴 우측 상단 <strong>업데이트</strong> 버튼으로 자동 업데이트할 수 있습니다.</li>
|
)}
|
||||||
</ol>
|
|
||||||
</details>
|
{(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');
|
const [tab, setTab] = useState<TabId>('mobile');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -283,7 +379,7 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="app-download-body" role="tabpanel">
|
<div className="app-download-body" role="tabpanel">
|
||||||
{tab === 'mobile' ? <MobileTab /> : <DesktopTab />}
|
{tab === 'mobile' ? <MobileTab /> : <DesktopTab canBuildDesktop={canBuildDesktop} />}
|
||||||
</div>
|
</div>
|
||||||
</DraggableModalFrame>
|
</DraggableModalFrame>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface Props {
|
|||||||
onUpdateSlots: (slots: WidgetSlot[]) => void;
|
onUpdateSlots: (slots: WidgetSlot[]) => void;
|
||||||
onUpdateSize: (width: number, height: number) => void;
|
onUpdateSize: (width: number, height: number) => void;
|
||||||
onUpdateGridFr: (rowFr: number[], colFr: number[]) => void;
|
onUpdateGridFr: (rowFr: number[], colFr: number[]) => void;
|
||||||
|
/** Tauri OS 창 — 네이티브 타이틀·리사이즈 사용, 내부 플로팅 크롬 숨김 */
|
||||||
|
nativeShell?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FloatingWidgetWindow: React.FC<Props> = ({
|
const FloatingWidgetWindow: React.FC<Props> = ({
|
||||||
@@ -32,6 +34,7 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
|||||||
onUpdateSlots,
|
onUpdateSlots,
|
||||||
onUpdateSize,
|
onUpdateSize,
|
||||||
onUpdateGridFr,
|
onUpdateGridFr,
|
||||||
|
nativeShell = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [pickSlotId, setPickSlotId] = useState<string | null>(null);
|
const [pickSlotId, setPickSlotId] = useState<string | null>(null);
|
||||||
const bodyRef = useRef<HTMLDivElement>(null);
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -146,37 +149,49 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
|||||||
|
|
||||||
const title = `위젯 ${instance.rows}×${instance.cols}`;
|
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
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className={`fw-window${focused ? ' fw-window--focused' : ''}`}
|
className={windowClass}
|
||||||
style={{
|
style={windowStyle}
|
||||||
...panelStyle,
|
onMouseDown={nativeShell ? undefined : onFocus}
|
||||||
width: instance.width,
|
|
||||||
height: instance.height,
|
|
||||||
zIndex: instance.zIndex,
|
|
||||||
cursor: dragging ? 'grabbing' : undefined,
|
|
||||||
}}
|
|
||||||
onMouseDown={onFocus}
|
|
||||||
>
|
>
|
||||||
<div
|
{!nativeShell && (
|
||||||
className="fw-window-head"
|
<div
|
||||||
onPointerDown={onHeaderPointerDown}
|
className="fw-window-head"
|
||||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
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}
|
|
||||||
>
|
>
|
||||||
✕
|
<span className="fw-window-title">{title}</span>
|
||||||
</button>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
className="fw-window-close"
|
||||||
|
title="위젯 창 닫기"
|
||||||
|
aria-label="위젯 창 닫기"
|
||||||
|
onPointerDown={e => e.stopPropagation()}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="fw-window-body-wrap">
|
<div className="fw-window-body-wrap">
|
||||||
<div
|
<div
|
||||||
@@ -227,20 +242,22 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="fw-window-resize-layer">
|
{!nativeShell && (
|
||||||
<div
|
<div className="fw-window-resize-layer">
|
||||||
className="fw-window-resize-handle fw-window-resize-handle--sw"
|
<div
|
||||||
role="separator"
|
className="fw-window-resize-handle fw-window-resize-handle--sw"
|
||||||
aria-label="위젯 창 크기 조절 (좌하단)"
|
role="separator"
|
||||||
onPointerDown={onResizeSwPointerDown}
|
aria-label="위젯 창 크기 조절 (좌하단)"
|
||||||
/>
|
onPointerDown={onResizeSwPointerDown}
|
||||||
<div
|
/>
|
||||||
className="fw-window-resize-handle fw-window-resize-handle--se"
|
<div
|
||||||
role="separator"
|
className="fw-window-resize-handle fw-window-resize-handle--se"
|
||||||
aria-label="위젯 창 크기 조절 (우하단)"
|
role="separator"
|
||||||
onPointerDown={onResizeSePointerDown}
|
aria-label="위젯 창 크기 조절 (우하단)"
|
||||||
/>
|
onPointerDown={onResizeSePointerDown}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WidgetPickerModal
|
<WidgetPickerModal
|
||||||
@@ -249,9 +266,10 @@ const FloatingWidgetWindow: React.FC<Props> = ({
|
|||||||
onClose={() => setPickSlotId(null)}
|
onClose={() => setPickSlotId(null)}
|
||||||
onSelect={applyWidgetType}
|
onSelect={applyWidgetType}
|
||||||
/>
|
/>
|
||||||
</>,
|
</>
|
||||||
document.body,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return nativeShell ? content : createPortal(content, document.body);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FloatingWidgetWindow;
|
export default FloatingWidgetWindow;
|
||||||
|
|||||||
@@ -595,24 +595,61 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Tauri 네이티브 위젯 창 */
|
/* 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 {
|
.fw-layer--picker-only {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fw-widget-native-root {
|
.fw-widget-native-root {
|
||||||
width: 100vw;
|
flex: 1;
|
||||||
height: 100vh;
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: var(--bg, #1a1b26);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fw-widget-native-root .fw-window {
|
.fw-widget-native-root .fw-window,
|
||||||
position: static !important;
|
.fw-widget-native-root .fw-window--native {
|
||||||
|
position: relative !important;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
max-width: none !important;
|
max-width: none !important;
|
||||||
max-height: none !important;
|
max-height: none !important;
|
||||||
box-shadow: none;
|
box-shadow: none !important;
|
||||||
border: none;
|
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');
|
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 {
|
export interface PaperPositionDto {
|
||||||
|
|||||||
Reference in New Issue
Block a user