pc 프로그램 다운로드 기능 적용

This commit is contained in:
Macbook
2026-06-14 10:25:22 +09:00
parent 86b99d8c10
commit 92f67580e3
25 changed files with 1031 additions and 125 deletions
@@ -0,0 +1,36 @@
package com.goldenchart.controller;
import com.goldenchart.dto.DesktopAppReleaseInfoDto;
import com.goldenchart.service.DesktopAppReleaseService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/desktop-app")
@RequiredArgsConstructor
public class DesktopAppReleaseController {
private final DesktopAppReleaseService releaseService;
@GetMapping("/info")
public DesktopAppReleaseInfoDto info(HttpServletRequest request) {
return releaseService.getInfo(resolvePublicBaseUrl(request));
}
private static String resolvePublicBaseUrl(HttpServletRequest request) {
String forwardedProto = request.getHeader("X-Forwarded-Proto");
String forwardedHost = request.getHeader("X-Forwarded-Host");
if (forwardedHost != null && !forwardedHost.isBlank()) {
String scheme = forwardedProto != null && !forwardedProto.isBlank() ? forwardedProto : "https";
return scheme + "://" + forwardedHost.split(",")[0].trim();
}
String scheme = request.getScheme();
int port = request.getServerPort();
String host = request.getServerName();
boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
return defaultPort ? scheme + "://" + host : scheme + "://" + host + ":" + port;
}
}
@@ -0,0 +1,16 @@
package com.goldenchart.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class DesktopAppPlatformReleaseDto {
private boolean available;
private String fileName;
private String version;
private Long sizeBytes;
private String updatedAt;
/** 브라우저 다운로드용 절대 URL */
private String downloadUrl;
}
@@ -0,0 +1,13 @@
package com.goldenchart.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class DesktopAppReleaseInfoDto {
private String version;
private String updatedAt;
private DesktopAppPlatformReleaseDto mac;
private DesktopAppPlatformReleaseDto windows;
}
@@ -0,0 +1,199 @@
package com.goldenchart.service;
import com.goldenchart.dto.DesktopAppPlatformReleaseDto;
import com.goldenchart.dto.DesktopAppReleaseInfoDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.*;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.Locale;
import java.util.Optional;
import java.util.stream.Stream;
@Service
@Slf4j
public class DesktopAppReleaseService {
private static final DateTimeFormatter DT_FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.of("Asia/Seoul"));
@Value("${goldenchart.desktop-app.release-dir:data/desktop-releases}")
private String releaseDir;
@Value("${goldenchart.desktop-app.version:}")
private String configuredVersion;
@Value("${goldenchart.desktop-app.public-base-url:}")
private String configuredPublicBaseUrl;
public DesktopAppReleaseInfoDto getInfo(String requestBaseUrl) {
String publicBaseUrl = resolvePublicBaseUrl(requestBaseUrl);
String base = normalizeBase(publicBaseUrl);
String staticBase = base + "/desktop/updates";
Optional<Path> macFile = resolveMacInstaller();
Optional<Path> winFile = resolveWindowsInstaller();
String version = configuredVersion != null && !configuredVersion.isBlank()
? configuredVersion.trim()
: resolveVersion(macFile, winFile);
Instant latestUpdated = Stream.of(macFile, winFile)
.flatMap(Optional::stream)
.map(this::lastModified)
.max(Comparator.naturalOrder())
.orElse(Instant.EPOCH);
return DesktopAppReleaseInfoDto.builder()
.version(version)
.updatedAt(latestUpdated.equals(Instant.EPOCH) ? null : DT_FMT.format(latestUpdated))
.mac(buildPlatform(macFile, staticBase, version))
.windows(buildPlatform(winFile, staticBase, version))
.build();
}
private DesktopAppPlatformReleaseDto buildPlatform(
Optional<Path> file,
String staticBase,
String version
) {
if (file.isEmpty()) {
return DesktopAppPlatformReleaseDto.builder().available(false).build();
}
Path path = file.get();
try {
long size = Files.size(path);
Instant updated = Files.getLastModifiedTime(path).toInstant();
String fileName = path.getFileName().toString();
return DesktopAppPlatformReleaseDto.builder()
.available(true)
.fileName(fileName)
.version(version)
.sizeBytes(size)
.updatedAt(DT_FMT.format(updated))
.downloadUrl(staticBase + "/" + encodeFileName(fileName))
.build();
} catch (IOException e) {
log.warn("[desktop-app] stat failed: {}", e.getMessage());
return DesktopAppPlatformReleaseDto.builder().available(false).build();
}
}
private Instant lastModified(Path path) {
try {
return Files.getLastModifiedTime(path).toInstant();
} catch (IOException e) {
return Instant.EPOCH;
}
}
private String resolveVersion(Optional<Path> macFile, Optional<Path> winFile) {
return Stream.of(macFile, winFile)
.flatMap(Optional::stream)
.map(p -> p.getFileName().toString())
.map(this::extractVersionFromFileName)
.filter(v -> v != null && !v.isBlank())
.findFirst()
.orElse(null);
}
private String extractVersionFromFileName(String fileName) {
// GoldenChart_0.1.0_aarch64.dmg, GoldenChart_0.1.0_x64-setup.exe
var m = java.util.regex.Pattern
.compile("GoldenChart[_-](\\d+\\.\\d+\\.\\d+)", java.util.regex.Pattern.CASE_INSENSITIVE)
.matcher(fileName);
return m.find() ? m.group(1) : null;
}
private Optional<Path> resolveMacInstaller() {
Path dir = releaseDirectory();
if (dir == null) return Optional.empty();
try (Stream<Path> stream = Files.list(dir)) {
return stream
.filter(p -> Files.isRegularFile(p) && p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".dmg"))
.max(macComparator());
} catch (IOException e) {
log.warn("[desktop-app] list dir failed: {}", e.getMessage());
return Optional.empty();
}
}
private Optional<Path> resolveWindowsInstaller() {
Path dir = releaseDirectory();
if (dir == null) return Optional.empty();
try (Stream<Path> stream = Files.list(dir)) {
return stream
.filter(p -> {
String n = p.getFileName().toString().toLowerCase(Locale.ROOT);
return Files.isRegularFile(p) && n.endsWith(".exe")
&& (n.contains("setup") || n.contains("nsis") || n.startsWith("goldenchart"));
})
.max(windowsComparator());
} catch (IOException e) {
log.warn("[desktop-app] list dir failed: {}", e.getMessage());
return Optional.empty();
}
}
private Comparator<Path> macComparator() {
return Comparator
.comparingInt((Path p) -> scoreMac(p.getFileName().toString()))
.thenComparing(p -> lastModified(p).toEpochMilli());
}
private Comparator<Path> windowsComparator() {
return Comparator
.comparingInt((Path p) -> scoreWindows(p.getFileName().toString()))
.thenComparing(p -> lastModified(p).toEpochMilli());
}
private static int scoreMac(String name) {
String lower = name.toLowerCase(Locale.ROOT);
if (lower.contains("aarch64") || lower.contains("arm64")) return 3;
if (lower.contains("universal")) return 2;
if (lower.contains("x64") || lower.contains("x86_64")) return 1;
return 0;
}
private static int scoreWindows(String name) {
String lower = name.toLowerCase(Locale.ROOT);
if (lower.contains("setup")) return 2;
return 1;
}
private Path releaseDirectory() {
Path dir = Paths.get(releaseDir).toAbsolutePath().normalize();
if (!Files.isDirectory(dir)) {
log.debug("[desktop-app] release dir missing: {}", dir);
return null;
}
return dir;
}
private static String encodeFileName(String fileName) {
return fileName.replace(" ", "%20");
}
private String resolvePublicBaseUrl(String requestBaseUrl) {
if (configuredPublicBaseUrl != null && !configuredPublicBaseUrl.isBlank()) {
return configuredPublicBaseUrl.trim();
}
if (requestBaseUrl != null && !requestBaseUrl.isBlank()) {
return requestBaseUrl.trim();
}
return "http://exdev.co.kr";
}
private static String normalizeBase(String publicBaseUrl) {
if (publicBaseUrl == null || publicBaseUrl.isBlank()) {
return "http://exdev.co.kr";
}
return publicBaseUrl.replaceAll("/+$", "");
}
}
@@ -112,6 +112,10 @@ goldenchart:
version: ${GC_MOBILE_APP_VERSION:}
# QR·APK 다운로드 절대 URL (로컬 개발 시에도 exdev 서버 APK 사용)
public-base-url: ${GC_MOBILE_APP_PUBLIC_BASE_URL:http://exdev.co.kr}
desktop-app:
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}
cors:
allowed-origins:
- http://localhost:5173
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
# GoldenChart Desktop 설치 파일
Jenkins 빌드 후 `scripts/publish-desktop-update.sh`가 이 디렉터리와
`frontend/public/desktop/updates/`에 설치 파일을 복사합니다.
| 파일 | 플랫폼 |
|------|--------|
| `GoldenChart_*_aarch64.dmg` | macOS |
| `GoldenChart_*-setup.exe` | Windows |
- API: `GET /api/desktop-app/info`
- 다운로드 URL: `http://exdev.co.kr/desktop/updates/<파일명>`
+1 -1
View File
@@ -1 +1 @@
VITE_API_BASE_URL=https://exdev.co.kr/api
VITE_API_BASE_URL=http://exdev.co.kr/api
+1
View File
@@ -19,6 +19,7 @@
"@stomp/stompjs": "^7.3.0",
"@tanstack/react-virtual": "^3.14.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2",
+350 -7
View File
@@ -462,6 +462,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.45"
@@ -499,10 +505,39 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -526,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"libc",
@@ -539,7 +574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -659,6 +694,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "dbus"
version = "0.9.11"
@@ -788,6 +829,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -874,6 +924,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1254,8 +1313,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1265,9 +1326,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1387,6 +1450,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-http",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-process",
@@ -1445,6 +1509,25 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "h2"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1549,6 +1632,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1572,6 +1656,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -1592,9 +1677,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2026,6 +2113,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -2041,6 +2134,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac-notification-sys"
version = "0.6.14"
@@ -2775,6 +2874,22 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
@@ -2793,6 +2908,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -2918,6 +3088,49 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.4"
@@ -3031,6 +3244,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
@@ -3040,7 +3254,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
@@ -3078,6 +3292,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3160,7 +3380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -3300,6 +3520,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.21.0"
@@ -3582,6 +3814,27 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3603,7 +3856,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -3693,7 +3946,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -3792,6 +4045,54 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-http"
version = "2.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067"
dependencies = [
"bytes",
"cookie_store",
"data-url",
"http",
"regex",
"reqwest 0.12.28",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"tokio",
"url",
"urlpattern",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
@@ -3859,7 +4160,7 @@ dependencies = [
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"reqwest 0.13.4",
"rustls",
"semver",
"serde",
@@ -4117,9 +4418,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -4691,6 +5004,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
@@ -4756,6 +5079,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4941,6 +5273,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
+1
View File
@@ -18,5 +18,6 @@ tauri-plugin-notification = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-opener = "2"
tauri-plugin-http = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+11 -1
View File
@@ -22,6 +22,16 @@
"updater:allow-download-and-install",
"process:allow-restart",
"process:allow-exit",
"opener:default"
"opener:default",
{
"identifier": "http:default",
"allow": [
{ "url": "http://exdev.co.kr/**" },
{ "url": "http://localhost:8080/**" },
{ "url": "http://127.0.0.1:8080/**" },
{ "url": "https://exdev.co.kr/**" },
{ "url": "https://api.upbit.com/**" }
]
}
]
}
+1
View File
@@ -16,6 +16,7 @@ fn show_main_window(app: &AppHandle) {
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init())
+1 -1
View File
@@ -1,6 +1,6 @@
import { DESKTOP_API_BASE, setApiBase } from '@goldenchart/shared';
/** Tauri desktop — 항상 https exdev (localhost·http 금지) */
/** Tauri desktop — 항상 exdev 서버 (localhost 금지) */
export function ensureDesktopApiBase(): void {
setApiBase(DESKTOP_API_BASE);
}
+3 -1
View File
@@ -1,4 +1,6 @@
import { ensureDesktopApiBase } from './ensureDesktopApiBase';
import { installTauriHttp } from './installTauriHttp';
/** App·backendApi import 전 API base 고정 (localhost 금지) */
/** App·backendApi import 전 API base 고정 + 네이티브 HTTP */
ensureDesktopApiBase();
installTauriHttp();
+7
View File
@@ -0,0 +1,7 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { setHttpFetch } from '@goldenchart/shared';
/** WebView fetch(ATS/CORS) 대신 Rust reqwest — exdev HTTP API */
export function installTauriHttp(): void {
setHttpFetch(tauriFetch);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
define: {
global: 'globalThis',
/** desktop은 항상 exdev 서버 (localhost 금지) */
'import.meta.env.VITE_API_BASE_URL': JSON.stringify('https://exdev.co.kr/api'),
'import.meta.env.VITE_API_BASE_URL': JSON.stringify('http://exdev.co.kr/api'),
__DESKTOP_CLIENT__: 'true',
},
envDir: __dirname,
+35
View File
@@ -13231,6 +13231,41 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
margin-top: 12px; font-size: 11px; color: var(--text3); line-height: 1.45;
}
.app-download-tabs {
display: flex; gap: 6px; margin-bottom: 14px;
padding: 4px; border-radius: 12px; background: var(--bg3); border: 1px solid var(--border);
}
.app-download-tab {
flex: 1; padding: 10px 12px; border: none; border-radius: 9px;
background: transparent; color: var(--text2); font-size: 13px; font-weight: 600; cursor: pointer;
}
.app-download-tab.active {
background: var(--bg); color: var(--text);
box-shadow: 0 1px 4px rgba(0,0,0,0.12);
}
.app-download-app-icon--desktop {
background: linear-gradient(135deg, #2196f3, #0ea5e9);
}
.app-download-pc-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
.app-download-pc-card {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px; border-radius: 12px;
background: var(--bg2); border: 1px solid var(--border);
}
.app-download-pc-card--empty { opacity: 0.75; }
.app-download-pc-icon { font-size: 28px; flex-shrink: 0; width: 36px; text-align: center; }
.app-download-pc-body { flex: 1; min-width: 0; }
.app-download-pc-body strong { display: block; font-size: 14px; margin-bottom: 4px; }
.app-download-pc-btn {
flex-shrink: 0; padding: 8px 14px; border: none; border-radius: 10px;
background: linear-gradient(135deg, #2196f3, #0284c7);
color: #fff; font-size: 13px; font-weight: 700; cursor: pointer;
}
.app-download-pc-btn:hover { filter: brightness(1.06); }
.tmb-desktop-update-btn.busy { opacity: 0.65; cursor: wait; }
.tmb-desktop-update-btn.busy svg { animation: tmb-update-spin 0.9s linear infinite; }
@keyframes tmb-update-spin { to { transform: rotate(360deg); } }
/* ── 정식 로그인 유도 (게스트·매매 기능) ─────────────────────────────────── */
.formal-login-gate {
display: flex;
+232 -91
View File
@@ -1,28 +1,45 @@
/**
* 모바일 앱 설치 — APK 다운로드 URL·QR 코드
* 모바일·PC 프로그램 설치 — 탭 UI (모바일 QR / PC dmg·exe)
*/
import React, { useEffect, useMemo, useState } from 'react';
import QRCode from 'qrcode';
import DraggableModalFrame from './DraggableModalFrame';
import { loadMobileAppReleaseInfo, type MobileAppReleaseInfoDto } from '../utils/backendApi';
import {
loadDesktopAppReleaseInfo,
loadMobileAppReleaseInfo,
type DesktopAppPlatformReleaseDto,
type DesktopAppReleaseInfoDto,
type MobileAppReleaseInfoDto,
} from '../utils/backendApi';
interface Props {
onClose: () => void;
}
type TabId = 'mobile' | 'desktop';
function formatBytes(n?: number): string {
if (n == null || !Number.isFinite(n)) return '—';
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** APK·QR은 항상 서버가 내려준 절대 URL 사용 (exdev.co.kr, localhost 아님) */
function resolveDownloadUrl(info: MobileAppReleaseInfoDto | null): string {
if (!info?.available) return '';
return info.downloadUrl?.trim() ?? '';
}
const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
function triggerDownload(url: string, fileName: string) {
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
const MobileTab: React.FC = () => {
const [info, setInfo] = useState<MobileAppReleaseInfoDto | null>(null);
const [loading, setLoading] = useState(true);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
@@ -33,15 +50,9 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
useEffect(() => {
let cancelled = false;
void loadMobileAppReleaseInfo()
.then(data => {
if (!cancelled) setInfo(data);
})
.catch(() => {
if (!cancelled) setError('앱 정보를 불러오지 못했습니다.');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
.then(data => { if (!cancelled) setInfo(data); })
.catch(() => { if (!cancelled) setError('앱 정보를 불러오지 못했습니다.'); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
@@ -55,94 +66,224 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
width: 220,
margin: 2,
color: { dark: '#111827', light: '#ffffff' },
}).then(url => {
if (!cancelled) setQrDataUrl(url);
}).catch(() => {
if (!cancelled) setQrDataUrl(null);
});
}).then(url => { if (!cancelled) setQrDataUrl(url); })
.catch(() => { if (!cancelled) setQrDataUrl(null); });
return () => { cancelled = true; };
}, [downloadUrl]);
const handleDownload = () => {
if (!downloadUrl) return;
const a = document.createElement('a');
a.href = downloadUrl;
a.download = info?.fileName ?? 'goldenchart-android.apk';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
};
if (loading) return <p className="app-download-muted"> </p>;
if (error) return <p className="app-download-error">{error}</p>;
if (!info?.available) {
return (
<div className="app-download-empty">
<div className="app-download-empty-icon" aria-hidden>📱</div>
<h3> </h3>
<p>
Android APK가 .
<br />
<code>data/mobile-releases/goldenchart-android.apk</code> .
</p>
</div>
);
}
return (
<>
<div className="app-download-hero">
<div className="app-download-app-icon" aria-hidden>GC</div>
<div>
<h3 className="app-download-app-name">GoldenChart Mobile</h3>
<p className="app-download-meta">
{info.version && <span>v{info.version}</span>}
{info.sizeBytes != null && <span>{formatBytes(info.sizeBytes)}</span>}
{info.updatedAt && <span>{info.updatedAt}</span>}
</p>
</div>
</div>
<div className="app-download-qr-block">
{qrDataUrl ? (
<img src={qrDataUrl} alt="앱 설치 QR 코드" className="app-download-qr" width={220} height={220} />
) : (
<div className="app-download-qr app-download-qr--placeholder">QR </div>
)}
<div className="app-download-qr-help">
<strong> QR </strong>
<p>Android에서 APK . .</p>
<p className="app-download-muted app-download-url" title={downloadUrl}>{downloadUrl}</p>
</div>
</div>
<button
type="button"
className="app-download-btn-primary"
onClick={() => triggerDownload(downloadUrl, info.fileName ?? 'goldenchart-android.apk')}
>
Android APK
</button>
<details className="app-download-steps">
<summary> (Android)</summary>
<ol>
<li>QR APK를 .</li>
<li> .</li>
<li> APK .</li>
</ol>
</details>
<p className="app-download-ios-note">
iOS는 App Store / TestFlight . QR·APK Android .
</p>
</>
);
};
const PlatformCard: React.FC<{
platform: 'mac' | 'windows';
release?: DesktopAppPlatformReleaseDto;
}> = ({ platform, release }) => {
const isMac = platform === 'mac';
const label = isMac ? 'macOS (Apple Silicon · Intel)' : 'Windows (64-bit)';
const icon = isMac ? '🍎' : '🪟';
const ext = isMac ? '.dmg' : '.exe';
if (!release?.available || !release.downloadUrl) {
return (
<div className="app-download-pc-card app-download-pc-card--empty">
<div className="app-download-pc-icon" aria-hidden>{icon}</div>
<div>
<strong>{label}</strong>
<p className="app-download-muted"> ({ext})</p>
</div>
</div>
);
}
return (
<div className="app-download-pc-card">
<div className="app-download-pc-icon" aria-hidden>{icon}</div>
<div className="app-download-pc-body">
<strong>{label}</strong>
<p className="app-download-meta">
{release.version && <span>v{release.version}</span>}
{release.sizeBytes != null && <span>{formatBytes(release.sizeBytes)}</span>}
{release.updatedAt && <span>{release.updatedAt}</span>}
</p>
<p className="app-download-muted app-download-url" title={release.downloadUrl}>
{release.fileName}
</p>
</div>
<button
type="button"
className="app-download-pc-btn"
onClick={() => triggerDownload(release.downloadUrl!, release.fileName ?? `GoldenChart${ext}`)}
>
</button>
</div>
);
};
const DesktopTab: React.FC = () => {
const [info, setInfo] = useState<DesktopAppReleaseInfoDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void loadDesktopAppReleaseInfo()
.then(data => { if (!cancelled) setInfo(data); })
.catch(() => { if (!cancelled) setError('PC 프로그램 정보를 불러오지 못했습니다.'); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
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>;
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>
</div>
{!hasAny && (
<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> .
</p>
</div>
)}
{hasAny && (
<>
<div className="app-download-pc-list">
<PlatformCard platform="mac" release={info?.mac} />
<PlatformCard platform="windows" release={info?.windows} />
</div>
<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>
</>
)}
</>
);
};
const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
const [tab, setTab] = useState<TabId>('mobile');
return (
<DraggableModalFrame
onClose={onClose}
title="Mobile App"
titleKo="GoldenChart 앱 설치"
badge="Android"
width={440}
title="Apps"
titleKo="GoldenChart 앱·PC 프로그램"
badge={tab === 'mobile' ? 'Mobile' : 'Desktop'}
width={480}
dialogClassName="sp-modal app-download-modal"
>
<div className="app-download-body">
{loading && <p className="app-download-muted"> </p>}
{!loading && error && <p className="app-download-error">{error}</p>}
{!loading && !error && !info?.available && (
<div className="app-download-empty">
<div className="app-download-empty-icon" aria-hidden>📱</div>
<h3> </h3>
<p>
Android APK가 .
<br />
<code>data/mobile-releases/goldenchart-android.apk</code> .
</p>
</div>
)}
{!loading && info?.available && (
<>
<div className="app-download-hero">
<div className="app-download-app-icon" aria-hidden>GC</div>
<div>
<h3 className="app-download-app-name">GoldenChart</h3>
<p className="app-download-meta">
{info.version && <span>v{info.version}</span>}
{info.sizeBytes != null && <span>{formatBytes(info.sizeBytes)}</span>}
{info.updatedAt && <span>{info.updatedAt}</span>}
</p>
</div>
</div>
<div className="app-download-tabs" role="tablist" aria-label="다운로드 종류">
<button
type="button"
role="tab"
aria-selected={tab === 'mobile'}
className={`app-download-tab${tab === 'mobile' ? ' active' : ''}`}
onClick={() => setTab('mobile')}
>
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'desktop'}
className={`app-download-tab${tab === 'desktop' ? ' active' : ''}`}
onClick={() => setTab('desktop')}
>
PC
</button>
</div>
<div className="app-download-qr-block">
{qrDataUrl ? (
<img src={qrDataUrl} alt="앱 설치 QR 코드" className="app-download-qr" width={220} height={220} />
) : (
<div className="app-download-qr app-download-qr--placeholder">QR </div>
)}
<div className="app-download-qr-help">
<strong> QR </strong>
<p>Android에서 APK . .</p>
<p className="app-download-muted app-download-url" title={downloadUrl}>{downloadUrl}</p>
</div>
</div>
<button type="button" className="app-download-btn-primary" onClick={handleDownload}>
Android APK
</button>
<details className="app-download-steps">
<summary> (Android)</summary>
<ol>
<li>QR APK를 .</li>
<li> .</li>
<li> APK .</li>
<li> API URL을 ( ).</li>
</ol>
</details>
<p className="app-download-ios-note">
iOS는 App Store / TestFlight . QR·APK Android .
</p>
</>
)}
<div className="app-download-body" role="tabpanel">
{tab === 'mobile' ? <MobileTab /> : <DesktopTab />}
</div>
</DraggableModalFrame>
);
+50 -14
View File
@@ -4,11 +4,13 @@
* 레이아웃:
* [로고] | [대시보드] [실시간차트] [전략편집기] … | [테마토글] [로그인]
*/
import React, { memo, useEffect, useState } from 'react';
import React, { memo, useCallback, useEffect, useState } from 'react';
import type { Theme } from '../types';
import type { AuthSession } from '../utils/auth';
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
import { canAccessMenu } from '../utils/permissions';
import { isDesktop } from '../utils/platform';
import { checkDesktopUpdates } from '../utils/desktopBridge';
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings' | 'widget-dashboard';
@@ -105,6 +107,13 @@ const IcAppDownload = () => (
</svg>
);
const IcDesktopUpdate = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/>
<polyline points="13.5,2.5 13.5,5.5 10.5,5.5"/>
</svg>
);
const IcNotify = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 1.5a4.5 4.5 0 0 0-4.5 4.5c0 3.5-1 4.5-1.5 4.5h12c-.5 0-1.5-1-1.5-4.5A4.5 4.5 0 0 0 8 1.5z"/>
@@ -257,6 +266,24 @@ export const TopMenuBar = memo(function TopMenuBar({
menuPermissions,
}: TopMenuBarProps) {
const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement);
const [desktopUpdateBusy, setDesktopUpdateBusy] = useState(false);
const [desktopUpdateHint, setDesktopUpdateHint] = useState('');
const desktopClient = isDesktop();
const handleDesktopUpdate = useCallback(async () => {
if (desktopUpdateBusy) return;
setDesktopUpdateBusy(true);
setDesktopUpdateHint('업데이트 확인 중…');
try {
const res = await checkDesktopUpdates();
setDesktopUpdateHint(res.message ?? (res.available ? `v${res.version ?? ''} 설치` : '최신 버전'));
} catch {
setDesktopUpdateHint('업데이트 확인 실패');
} finally {
setDesktopUpdateBusy(false);
}
}, [desktopUpdateBusy]);
useEffect(() => {
const sync = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', sync);
@@ -326,19 +353,28 @@ export const TopMenuBar = memo(function TopMenuBar({
</div>
)}
{onOpenAppDownload && (
<>
<button
type="button"
className="tmb-app-download-btn"
onClick={onOpenAppDownload}
title="GoldenChart 모바일 앱 다운로드"
aria-label="앱 다운로드"
>
<IcAppDownload />
</button>
</>
)}
{desktopClient ? (
<button
type="button"
className={`tmb-app-download-btn tmb-desktop-update-btn${desktopUpdateBusy ? ' busy' : ''}`}
onClick={() => { void handleDesktopUpdate(); }}
disabled={desktopUpdateBusy}
title={desktopUpdateHint || 'PC 프로그램 업데이트'}
aria-label="PC 프로그램 업데이트"
>
<IcDesktopUpdate />
</button>
) : onOpenAppDownload ? (
<button
type="button"
className="tmb-app-download-btn"
onClick={onOpenAppDownload}
title="GoldenChart 앱·PC 프로그램 다운로드"
aria-label="앱·PC 프로그램 다운로드"
>
<IcAppDownload />
</button>
) : null}
{onFullscreen && (
<>
+22
View File
@@ -643,6 +643,28 @@ export async function loadMobileAppReleaseInfo(): Promise<MobileAppReleaseInfoDt
return request<MobileAppReleaseInfoDto>('/mobile-app/info');
}
// ── PC(데스크톱) 앱 배포 ───────────────────────────────────────────────────────
export interface DesktopAppPlatformReleaseDto {
available: boolean;
fileName?: string;
version?: string;
sizeBytes?: number;
updatedAt?: string;
downloadUrl?: string;
}
export interface DesktopAppReleaseInfoDto {
version?: string;
updatedAt?: string;
mac?: DesktopAppPlatformReleaseDto;
windows?: DesktopAppPlatformReleaseDto;
}
export async function loadDesktopAppReleaseInfo(): Promise<DesktopAppReleaseInfoDto | null> {
return request<DesktopAppReleaseInfoDto>('/desktop-app/info');
}
// ── 모의투자 ───────────────────────────────────────────────────────────────────
export interface PaperPositionDto {
+10
View File
@@ -50,6 +50,7 @@
"@stomp/stompjs": "^7.3.0",
"@tanstack/react-virtual": "^3.14.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2",
@@ -1792,6 +1793,15 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
"integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
+17 -6
View File
@@ -14,8 +14,8 @@ export type StrategyFlowLayoutStore = Record<string, unknown>;
/** 배포 APK·exdev (모바일 APK — http) */
export const PRODUCTION_API_BASE = 'http://exdev.co.kr/api';
/** Tauri desktop — macOS ATS 호환 (https 필수) */
export const DESKTOP_API_BASE = 'https://exdev.co.kr/api';
/** Tauri desktop — exdev HTTP (HTTPS /api는 Caddy에서 빈 응답) */
export const DESKTOP_API_BASE = 'http://exdev.co.kr/api';
function viteEnv(): Record<string, string> | undefined {
return typeof import.meta !== 'undefined'
@@ -43,7 +43,6 @@ function isDesktopClient(): boolean {
}
function resolveApiBase(): string {
// Desktop(Tauri): https exdev (macOS ATS — http 차단)
if (isDesktopClient()) {
return DESKTOP_API_BASE;
}
@@ -128,6 +127,18 @@ const DEFAULT_FETCH_TIMEOUT_MS = 45_000;
/** exdev 첫 로그인 응답이 60초 이상 걸리는 경우 있음 */
const LOGIN_FETCH_TIMEOUT_MS = 180_000;
type HttpFetchFn = typeof fetch;
let httpFetchImpl: HttpFetchFn | null = null;
/** Tauri 등 WebView fetch 대신 네이티브 HTTP 사용 (desktop bootstrap에서 호출) */
export function setHttpFetch(fn: HttpFetchFn): void {
httpFetchImpl = fn;
}
function httpFetch(): HttpFetchFn {
return httpFetchImpl ?? fetch;
}
function wrapNetworkError(e: unknown): Error {
if (e instanceof TypeError || (e instanceof Error && /failed to fetch|load failed/i.test(e.message))) {
return new Error(
@@ -145,7 +156,7 @@ async function fetchWithTimeout(
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
return await httpFetch()(url, {
...init,
signal: controller.signal,
headers: { ...authHeaders(), ...(init?.headers ?? {}) },
@@ -978,7 +989,7 @@ export async function loadStrategy(id: number): Promise<StrategyDto | null> {
/** 전략 저장 (id 없으면 신규, 있으면 수정) — 실패 시 Error throw */
export async function saveStrategy(dto: StrategyDto): Promise<StrategyDto> {
const res = await fetch(`${API_BASE}/strategies`, {
const res = await httpFetch()(`${API_BASE}/strategies`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(dto),
@@ -1337,7 +1348,7 @@ export async function repairStrategyTimeframes(strategyId: number): Promise<void
/** 차트 실시간 파이프라인 pin (STOMP warm-up) */
export async function pinCandleWatch(market: string, candleType: string): Promise<void> {
try {
await fetch(
await httpFetch()(
`${API_BASE}/candles/watch?market=${encodeURIComponent(market)}&type=${encodeURIComponent(candleType)}`,
{ method: 'POST' },
);
+8 -2
View File
@@ -8,6 +8,7 @@ UPDATES_SRC="${UPDATES_SRC:-$ARTIFACT_DIR/updates}"
UPDATES_DIR="${UPDATES_DIR:-${WORK_TREE:-$ROOT}/frontend/public/desktop/updates}"
WEB_STATIC_DIR="${WEB_STATIC_DIR:-$UPDATES_DIR}"
BASE_URL="${DESKTOP_UPDATE_BASE_URL:-http://exdev.co.kr/desktop/updates}"
DESKTOP_RELEASE_DIR="${GC_DESKTOP_APP_RELEASE_DIR:-$ROOT/data/desktop-releases}"
KEY_PATH="${TAURI_SIGNER_KEY:-$HOME/.tauri/goldenchart.key}"
DESKTOP_DIR="$ROOT/desktop"
CONF="$DESKTOP_DIR/src-tauri/tauri.conf.json"
@@ -27,9 +28,14 @@ log "Web static: $WEB_STATIC_DIR"
mkdir -p "$WEB_STATIC_DIR"
# 설치 파일(dist-desktop/*.dmg, *setup.exe) 복사
# 설치 파일(dist-desktop/*.dmg, *setup.exe) 복사 — nginx static + backend API 디렉터리
if [[ -d "$ARTIFACT_DIR" ]]; then
find "$ARTIFACT_DIR" -maxdepth 1 -type f \( -name '*.dmg' -o -name '*setup.exe' \) -exec cp -f {} "$WEB_STATIC_DIR/" \;
mkdir -p "$DESKTOP_RELEASE_DIR"
find "$ARTIFACT_DIR" -maxdepth 1 -type f \( -name '*.dmg' -o -name '*setup.exe' \) | while read -r f; do
cp -f "$f" "$WEB_STATIC_DIR/"
cp -f "$f" "$DESKTOP_RELEASE_DIR/"
log "Installer: $(basename "$f")"
done
fi
PUBLISH_UPDATES="$WEB_STATIC_DIR"