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

This commit is contained in:
Macbook
2026-06-14 10:25:22 +09:00
parent 86b99d8c10
commit 92f67580e3
25 changed files with 1031 additions and 125 deletions
@@ -0,0 +1,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("/+$", "");
}
}