diff --git a/backend/src/main/java/com/goldenchart/controller/DesktopAppReleaseController.java b/backend/src/main/java/com/goldenchart/controller/DesktopAppReleaseController.java new file mode 100644 index 0000000..3fe7ec4 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/DesktopAppReleaseController.java @@ -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; + } +} diff --git a/backend/src/main/java/com/goldenchart/dto/DesktopAppPlatformReleaseDto.java b/backend/src/main/java/com/goldenchart/dto/DesktopAppPlatformReleaseDto.java new file mode 100644 index 0000000..9389694 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/DesktopAppPlatformReleaseDto.java @@ -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; +} diff --git a/backend/src/main/java/com/goldenchart/dto/DesktopAppReleaseInfoDto.java b/backend/src/main/java/com/goldenchart/dto/DesktopAppReleaseInfoDto.java new file mode 100644 index 0000000..305f510 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/DesktopAppReleaseInfoDto.java @@ -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; +} diff --git a/backend/src/main/java/com/goldenchart/service/DesktopAppReleaseService.java b/backend/src/main/java/com/goldenchart/service/DesktopAppReleaseService.java new file mode 100644 index 0000000..db0fe24 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/DesktopAppReleaseService.java @@ -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 macFile = resolveMacInstaller(); + Optional 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 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 macFile, Optional 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 resolveMacInstaller() { + Path dir = releaseDirectory(); + if (dir == null) return Optional.empty(); + try (Stream 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 resolveWindowsInstaller() { + Path dir = releaseDirectory(); + if (dir == null) return Optional.empty(); + try (Stream 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 macComparator() { + return Comparator + .comparingInt((Path p) -> scoreMac(p.getFileName().toString())) + .thenComparing(p -> lastModified(p).toEpochMilli()); + } + + private Comparator 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("/+$", ""); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index ce0ba41..c999321 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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 diff --git a/data/desktop-releases/GoldenChart_0.1.0_aarch64.dmg b/data/desktop-releases/GoldenChart_0.1.0_aarch64.dmg new file mode 100644 index 0000000..803699b Binary files /dev/null and b/data/desktop-releases/GoldenChart_0.1.0_aarch64.dmg differ diff --git a/data/desktop-releases/README.md b/data/desktop-releases/README.md new file mode 100644 index 0000000..913e0c3 --- /dev/null +++ b/data/desktop-releases/README.md @@ -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/<파일명>` diff --git a/desktop/.env.example b/desktop/.env.example index f56e006..7d3ff0d 100644 --- a/desktop/.env.example +++ b/desktop/.env.example @@ -1 +1 @@ -VITE_API_BASE_URL=https://exdev.co.kr/api +VITE_API_BASE_URL=http://exdev.co.kr/api diff --git a/desktop/package.json b/desktop/package.json index cb08e32..d6ee4b9 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 3d0910a..293a972 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -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" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 18fa616..30f1fb8 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -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" diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 3794044..7b81515 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -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/**" } + ] + } ] } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 948bb3f..00c58a8 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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()) diff --git a/desktop/src/ensureDesktopApiBase.ts b/desktop/src/ensureDesktopApiBase.ts index 4af53d3..b281533 100644 --- a/desktop/src/ensureDesktopApiBase.ts +++ b/desktop/src/ensureDesktopApiBase.ts @@ -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); } diff --git a/desktop/src/forceServerApi.ts b/desktop/src/forceServerApi.ts index 3d0f2ac..877774c 100644 --- a/desktop/src/forceServerApi.ts +++ b/desktop/src/forceServerApi.ts @@ -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(); diff --git a/desktop/src/installTauriHttp.ts b/desktop/src/installTauriHttp.ts new file mode 100644 index 0000000..eb2bd0f --- /dev/null +++ b/desktop/src/installTauriHttp.ts @@ -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); +} diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index 5a3253d..e8883be 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -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, diff --git a/frontend/public/desktop/updates/GoldenChart_0.1.0_aarch64.dmg b/frontend/public/desktop/updates/GoldenChart_0.1.0_aarch64.dmg new file mode 100644 index 0000000..803699b Binary files /dev/null and b/frontend/public/desktop/updates/GoldenChart_0.1.0_aarch64.dmg differ diff --git a/frontend/src/App.css b/frontend/src/App.css index f083690..0daf4e8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/components/AppDownloadModal.tsx b/frontend/src/components/AppDownloadModal.tsx index c0d9be6..0d95ae5 100644 --- a/frontend/src/components/AppDownloadModal.tsx +++ b/frontend/src/components/AppDownloadModal.tsx @@ -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 = ({ 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(null); const [loading, setLoading] = useState(true); const [qrDataUrl, setQrDataUrl] = useState(null); @@ -33,15 +50,9 @@ const AppDownloadModal: React.FC = ({ 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 = ({ 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

앱 정보 불러오는 중…

; + if (error) return

{error}

; + if (!info?.available) { + return ( +
+
📱
+

설치 파일 준비 중

+

+ Android APK가 아직 서버에 업로드되지 않았습니다. +
+ data/mobile-releases/goldenchart-android.apk 경로에 빌드 파일을 배치하세요. +

+
+ ); + } + + return ( + <> +
+
GC
+
+

GoldenChart Mobile

+

+ {info.version && v{info.version}} + {info.sizeBytes != null && {formatBytes(info.sizeBytes)}} + {info.updatedAt && {info.updatedAt}} +

+
+
+ +
+ {qrDataUrl ? ( + 앱 설치 QR 코드 + ) : ( +
QR 생성 중…
+ )} +
+ 휴대폰 카메라로 QR 스캔 +

Android에서 APK 다운로드 페이지가 열립니다. 다운로드 후 설치하세요.

+

{downloadUrl}

+
+
+ + + +
+ 설치 방법 (Android) +
    +
  1. QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.
  2. +
  3. 「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.
  4. +
  5. 다운로드 완료 후 APK 파일을 탭하여 설치합니다.
  6. +
+
+ +

+ iOS는 App Store / TestFlight 배포가 필요합니다. 현재 QR·APK 설치는 Android 전용입니다. +

+ + ); +}; + +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 ( +
+
{icon}
+
+ {label} +

설치 파일 준비 중 ({ext})

+
+
+ ); + } + + return ( +
+
{icon}
+
+ {label} +

+ {release.version && v{release.version}} + {release.sizeBytes != null && {formatBytes(release.sizeBytes)}} + {release.updatedAt && {release.updatedAt}} +

+

+ {release.fileName} +

+
+ +
+ ); +}; + +const DesktopTab: React.FC = () => { + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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

PC 프로그램 정보 불러오는 중…

; + if (error) return

{error}

; + + return ( + <> +
+
GC
+
+

GoldenChart Desktop

+

+ {info?.version && v{info.version}} + {info?.updatedAt && {info.updatedAt}} +

+
+
+ + {!hasAny && ( +
+
💻
+

설치 파일 준비 중

+

+ macOS(.dmg) 또는 Windows(.exe) 설치 파일이 아직 서버에 없습니다. +
+ Jenkins 빌드 후 scripts/publish-desktop-update.sh를 실행하세요. +

+
+ )} + + {hasAny && ( + <> +
+ + +
+ +
+ 설치 방법 +
    +
  1. macOS: dmg를 열고 GoldenChart.app을 Applications 폴더로 드래그합니다.
  2. +
  3. Windows: setup.exe를 실행하고 안내에 따라 설치합니다.
  4. +
  5. 설치된 PC 앱은 메뉴 우측 상단 업데이트 버튼으로 자동 업데이트할 수 있습니다.
  6. +
+
+ + )} + + ); +}; + +const AppDownloadModal: React.FC = ({ onClose }) => { + const [tab, setTab] = useState('mobile'); return ( -
- {loading &&

앱 정보 불러오는 중…

} - {!loading && error &&

{error}

} - {!loading && !error && !info?.available && ( -
-
📱
-

설치 파일 준비 중

-

- Android APK가 아직 서버에 업로드되지 않았습니다. -
- data/mobile-releases/goldenchart-android.apk 경로에 빌드 파일을 배치하세요. -

-
- )} - {!loading && info?.available && ( - <> -
-
GC
-
-

GoldenChart

-

- {info.version && v{info.version}} - {info.sizeBytes != null && {formatBytes(info.sizeBytes)}} - {info.updatedAt && {info.updatedAt}} -

-
-
+
+ + +
-
- {qrDataUrl ? ( - 앱 설치 QR 코드 - ) : ( -
QR 생성 중…
- )} -
- 휴대폰 카메라로 QR 스캔 -

Android에서 APK 다운로드 페이지가 열립니다. 다운로드 후 설치하세요.

-

{downloadUrl}

-
-
- - - -
- 설치 방법 (Android) -
    -
  1. QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.
  2. -
  3. 「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.
  4. -
  5. 다운로드 완료 후 APK 파일을 탭하여 설치합니다.
  6. -
  7. 설치 후 앱에서 API 서버 URL을 설정하세요 (설정 › 네트워크).
  8. -
-
- -

- iOS는 App Store / TestFlight 배포가 필요합니다. 현재 QR·APK 설치는 Android 전용입니다. -

- - )} +
+ {tab === 'mobile' ? : }
); diff --git a/frontend/src/components/TopMenuBar.tsx b/frontend/src/components/TopMenuBar.tsx index 976959d..213f1b9 100644 --- a/frontend/src/components/TopMenuBar.tsx +++ b/frontend/src/components/TopMenuBar.tsx @@ -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 = () => ( ); +const IcDesktopUpdate = () => ( + + + + +); + const IcNotify = () => ( @@ -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({
)} - {onOpenAppDownload && ( - <> - - - )} + {desktopClient ? ( + + ) : onOpenAppDownload ? ( + + ) : null} {onFullscreen && ( <> diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 9759f42..ddcb077 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -643,6 +643,28 @@ export async function loadMobileAppReleaseInfo(): Promise('/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 { + return request('/desktop-app/info'); +} + // ── 모의투자 ─────────────────────────────────────────────────────────────────── export interface PaperPositionDto { diff --git a/package-lock.json b/package-lock.json index 2550f07..9779d6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/shared/src/api/backendApi.ts b/packages/shared/src/api/backendApi.ts index 842ab0f..68e7a65 100644 --- a/packages/shared/src/api/backendApi.ts +++ b/packages/shared/src/api/backendApi.ts @@ -14,8 +14,8 @@ export type StrategyFlowLayoutStore = Record; /** 배포 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 | 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 { /** 전략 저장 (id 없으면 신규, 있으면 수정) — 실패 시 Error throw */ export async function saveStrategy(dto: StrategyDto): Promise { - 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 { try { - await fetch( + await httpFetch()( `${API_BASE}/candles/watch?market=${encodeURIComponent(market)}&type=${encodeURIComponent(candleType)}`, { method: 'POST' }, ); diff --git a/scripts/publish-desktop-update.sh b/scripts/publish-desktop-update.sh index 0ff025f..ef8dcb7 100755 --- a/scripts/publish-desktop-update.sh +++ b/scripts/publish-desktop-update.sh @@ -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"