pc 프로그램 다운로드 기능 적용
This commit is contained in:
@@ -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.
@@ -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 @@
|
||||
VITE_API_BASE_URL=https://exdev.co.kr/api
|
||||
VITE_API_BASE_URL=http://exdev.co.kr/api
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+350
-7
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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,38 +66,15 @@ 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 (
|
||||
<DraggableModalFrame
|
||||
onClose={onClose}
|
||||
title="Mobile App"
|
||||
titleKo="GoldenChart 앱 설치"
|
||||
badge="Android"
|
||||
width={440}
|
||||
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>
|
||||
@@ -96,13 +84,15 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
<code>data/mobile-releases/goldenchart-android.apk</code> 경로에 빌드 파일을 배치하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && info?.available && (
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app-download-hero">
|
||||
<div className="app-download-app-icon" aria-hidden>GC</div>
|
||||
<div>
|
||||
<h3 className="app-download-app-name">GoldenChart</h3>
|
||||
<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>}
|
||||
@@ -124,7 +114,11 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="app-download-btn-primary" onClick={handleDownload}>
|
||||
<button
|
||||
type="button"
|
||||
className="app-download-btn-primary"
|
||||
onClick={() => triggerDownload(downloadUrl, info.fileName ?? 'goldenchart-android.apk')}
|
||||
>
|
||||
Android APK 다운로드
|
||||
</button>
|
||||
|
||||
@@ -134,7 +128,6 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
<li>QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.</li>
|
||||
<li>「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.</li>
|
||||
<li>다운로드 완료 후 APK 파일을 탭하여 설치합니다.</li>
|
||||
<li>설치 후 앱에서 API 서버 URL을 설정하세요 (설정 › 네트워크).</li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
@@ -142,7 +135,155 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
|
||||
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="Apps"
|
||||
titleKo="GoldenChart 앱·PC 프로그램"
|
||||
badge={tab === 'mobile' ? 'Mobile' : 'Desktop'}
|
||||
width={480}
|
||||
dialogClassName="sp-modal app-download-modal"
|
||||
>
|
||||
<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-body" role="tabpanel">
|
||||
{tab === 'mobile' ? <MobileTab /> : <DesktopTab />}
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
{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 모바일 앱 다운로드"
|
||||
aria-label="앱 다운로드"
|
||||
title="GoldenChart 앱·PC 프로그램 다운로드"
|
||||
aria-label="앱·PC 프로그램 다운로드"
|
||||
>
|
||||
<IcAppDownload />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{onFullscreen && (
|
||||
<>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
Reference in New Issue
Block a user