desktop 다운로드 수정

This commit is contained in:
Macbook
2026-06-14 10:47:17 +09:00
parent 7654ec99dc
commit 9fe1ad4c51
11 changed files with 154 additions and 20 deletions
@@ -45,6 +45,9 @@ public class DesktopAppBuildService {
@Value("${goldenchart.desktop-app.jenkins.token:}") @Value("${goldenchart.desktop-app.jenkins.token:}")
private String jenkinsToken; private String jenkinsToken;
@Value("${goldenchart.desktop-app.jenkins.trigger-token:}")
private String jenkinsTriggerToken;
@Value("${goldenchart.desktop-app.git-bare-dir:}") @Value("${goldenchart.desktop-app.git-bare-dir:}")
private String gitBareDir; private String gitBareDir;
@@ -53,11 +56,22 @@ public class DesktopAppBuildService {
public DesktopAppBuildStatusDto getStatus() { public DesktopAppBuildStatusDto getStatus() {
if (!jenkinsEnabled) { if (!jenkinsEnabled) {
return disabledStatus("Jenkins 연동이 비활성화되어 있습니다.");
}
if (!hasBasicAuth()) {
if (hasTriggerToken()) {
return DesktopAppBuildStatusDto.builder() return DesktopAppBuildStatusDto.builder()
.jenkinsEnabled(false) .jenkinsEnabled(true)
.building(false) .building(false)
.status("disabled") .status("ready")
.message("Jenkins 연동이 비활성화되어 있습니다.") .message("빌드 트리거 준비됨 (원격 토큰). 상태 조회는 Jenkins API 토큰 설정 시 가능합니다.")
.build();
}
return DesktopAppBuildStatusDto.builder()
.jenkinsEnabled(true)
.building(false)
.status("error")
.message("Jenkins 인증 미설정 — GC_DESKTOP_JENKINS_TRIGGER_TOKEN 또는 USER/TOKEN을 backend에 설정하세요.")
.build(); .build();
} }
try { try {
@@ -68,7 +82,7 @@ public class DesktopAppBuildService {
.jenkinsEnabled(true) .jenkinsEnabled(true)
.building(false) .building(false)
.status("error") .status("error")
.message("Jenkins 상태 조회 실패: " + e.getMessage()) .message("Jenkins 상태 조회 실패: " + rootMessage(e))
.build(); .build();
} }
} }
@@ -78,9 +92,15 @@ public class DesktopAppBuildService {
if (!jenkinsEnabled) { if (!jenkinsEnabled) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jenkins 연동이 비활성화되어 있습니다."); throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jenkins 연동이 비활성화되어 있습니다.");
} }
if (!hasBasicAuth() && !hasTriggerToken()) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Jenkins 연동 설정 필요 — GC_DESKTOP_JENKINS_TRIGGER_TOKEN 또는 GC_DESKTOP_JENKINS_USER·TOKEN을 backend 환경변수에 설정하세요.");
}
try { try {
syncGitLatest(); syncGitLatest();
if (hasBasicAuth()) {
try {
DesktopAppBuildStatusDto current = fetchLastBuildStatus(); DesktopAppBuildStatusDto current = fetchLastBuildStatus();
if (current.isBuilding()) { if (current.isBuilding()) {
return DesktopAppBuildTriggerDto.builder() return DesktopAppBuildTriggerDto.builder()
@@ -89,12 +109,23 @@ public class DesktopAppBuildService {
.message("이미 빌드가 진행 중입니다 (#" + current.getBuildNumber() + ").") .message("이미 빌드가 진행 중입니다 (#" + current.getBuildNumber() + ").")
.build(); .build();
} }
} catch (WebClientResponseException e) {
log.warn("[desktop-build] status skip before trigger: HTTP {}", e.getStatusCode().value());
}
}
triggerJenkinsBuild(); triggerJenkinsBuild();
DesktopAppBuildStatusDto after = fetchLastBuildStatus();
Integer buildNumber = null;
if (hasBasicAuth()) {
try {
buildNumber = fetchLastBuildStatus().getBuildNumber();
} catch (Exception ignored) { /* optional */ }
}
return DesktopAppBuildTriggerDto.builder() return DesktopAppBuildTriggerDto.builder()
.accepted(true) .accepted(true)
.buildNumber(after.getBuildNumber()) .buildNumber(buildNumber)
.message("Jenkins에서 macOS·Windows 데스크톱 앱 빌드를 시작했습니다. 완료 후 설치 파일이 자동으로 반영됩니다.") .message("Jenkins에서 macOS·Windows 데스크톱 앱 빌드를 시작했습니다. 완료 후 설치 파일이 자동으로 반영됩니다.")
.build(); .build();
} catch (ResponseStatusException e) { } catch (ResponseStatusException e) {
@@ -165,7 +196,7 @@ public class DesktopAppBuildService {
private void triggerJenkinsBuild() { private void triggerJenkinsBuild() {
WebClient client = jenkinsClient(); WebClient client = jenkinsClient();
String buildUrl = jobApiPath() + "/build"; String buildUrl = buildTriggerUrl();
Map<String, String> crumb = fetchCrumbOptional(client); Map<String, String> crumb = fetchCrumbOptional(client);
try { try {
postBuild(client, buildUrl, crumb); postBuild(client, buildUrl, crumb);
@@ -175,11 +206,19 @@ public class DesktopAppBuildService {
} }
} }
private String buildTriggerUrl() {
String url = jobApiPath() + "/build";
if (hasTriggerToken()) {
url += "?token=" + UriUtils.encodeQueryParam(jenkinsTriggerToken.trim(), StandardCharsets.UTF_8);
}
return url;
}
private Map<String, String> fetchCrumbOptional(WebClient client) { private Map<String, String> fetchCrumbOptional(WebClient client) {
try { try {
return fetchCrumb(client); return fetchCrumb(client);
} catch (Exception e) { } catch (Exception e) {
log.debug("[desktop-build] crumb optional skip: {}", e.getMessage()); log.debug("[desktop-build] crumb skip: {}", e.getMessage());
return null; return null;
} }
} }
@@ -215,17 +254,34 @@ public class DesktopAppBuildService {
WebClient.Builder builder = webClientBuilder WebClient.Builder builder = webClientBuilder
.baseUrl(normalizeBase(jenkinsUrl)) .baseUrl(normalizeBase(jenkinsUrl))
.defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE); .defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE);
if (StringUtils.hasText(jenkinsUser) && StringUtils.hasText(jenkinsToken)) { if (hasBasicAuth()) {
builder.defaultHeaders(h -> h.setBasicAuth(jenkinsUser.trim(), jenkinsToken.trim())); builder.defaultHeaders(h -> h.setBasicAuth(jenkinsUser.trim(), jenkinsToken.trim()));
} }
return builder.build(); return builder.build();
} }
private boolean hasBasicAuth() {
return StringUtils.hasText(jenkinsUser) && StringUtils.hasText(jenkinsToken);
}
private boolean hasTriggerToken() {
return StringUtils.hasText(jenkinsTriggerToken);
}
private String jobApiPath() { private String jobApiPath() {
String encoded = UriUtils.encodePathSegment(jenkinsJob.trim(), StandardCharsets.UTF_8); String encoded = UriUtils.encodePathSegment(jenkinsJob.trim(), StandardCharsets.UTF_8);
return "/job/" + encoded; return "/job/" + encoded;
} }
private static DesktopAppBuildStatusDto disabledStatus(String message) {
return DesktopAppBuildStatusDto.builder()
.jenkinsEnabled(false)
.building(false)
.status("disabled")
.message(message)
.build();
}
private static String normalizeBase(String url) { private static String normalizeBase(String url) {
if (!StringUtils.hasText(url)) return "http://127.0.0.1:8090"; if (!StringUtils.hasText(url)) return "http://127.0.0.1:8090";
return url.trim().replaceAll("/+$", ""); return url.trim().replaceAll("/+$", "");
@@ -240,10 +296,10 @@ public class DesktopAppBuildService {
private static String jenkinsHttpError(WebClientResponseException e) { private static String jenkinsHttpError(WebClientResponseException e) {
int code = e.getStatusCode().value(); int code = e.getStatusCode().value();
if (code == 401 || code == 403) { if (code == 401 || code == 403) {
return "Jenkins 인증 실패 — GC_DESKTOP_JENKINS_USER·TOKEN을 확인하세요."; return "Jenkins 인증 실패 — GC_DESKTOP_JENKINS_TRIGGER_TOKEN 또는 USER/TOKEN·Job 원격 트리거 토큰을 확인하세요.";
} }
if (code == 404) { if (code == 404) {
return "Jenkins Job을 찾을 수 없습니다 — goldenChart-Desktop-Pipeline Job을 등록하세요."; return "Jenkins Job을 찾을 수 없습니다 — ./scripts/server-install-desktop-jenkins.sh 로 Job을 등록하세요.";
} }
return "Jenkins HTTP " + code + ": " + e.getStatusText(); return "Jenkins HTTP " + code + ": " + e.getStatusText();
} }
@@ -18,6 +18,10 @@ spring:
baseline-version: 0 baseline-version: 0
validate-on-migrate: false validate-on-migrate: false
server:
error:
include-message: always
goldenchart: goldenchart:
cors: cors:
allowed-origins: allowed-origins:
@@ -32,6 +36,7 @@ goldenchart:
job: ${GC_DESKTOP_JENKINS_JOB:goldenChart-Desktop-Pipeline} job: ${GC_DESKTOP_JENKINS_JOB:goldenChart-Desktop-Pipeline}
user: ${GC_DESKTOP_JENKINS_USER:} user: ${GC_DESKTOP_JENKINS_USER:}
token: ${GC_DESKTOP_JENKINS_TOKEN:} token: ${GC_DESKTOP_JENKINS_TOKEN:}
trigger-token: ${GC_DESKTOP_JENKINS_TRIGGER_TOKEN:goldenchart-desktop-build}
# FCM — exdev: FIREBASE_ENABLED=true + 서비스 계정 JSON 마운트 # FCM — exdev: FIREBASE_ENABLED=true + 서비스 계정 JSON 마운트
firebase: firebase:
@@ -124,6 +124,7 @@ goldenchart:
job: ${GC_DESKTOP_JENKINS_JOB:goldenChart-Desktop-Pipeline} job: ${GC_DESKTOP_JENKINS_JOB:goldenChart-Desktop-Pipeline}
user: ${GC_DESKTOP_JENKINS_USER:} user: ${GC_DESKTOP_JENKINS_USER:}
token: ${GC_DESKTOP_JENKINS_TOKEN:} token: ${GC_DESKTOP_JENKINS_TOKEN:}
trigger-token: ${GC_DESKTOP_JENKINS_TRIGGER_TOKEN:}
cors: cors:
allowed-origins: allowed-origins:
- http://localhost:5173 - http://localhost:5173
+25 -1
View File
@@ -34,7 +34,31 @@ npm run build:desktop:win # Windows NSIS (macOS에서 cargo-xwin 크로스 빌
2. **Finder → 응용 프로그램 → GoldenChart** 실행 (`goldenChart` 아님, **GoldenChart**) 2. **Finder → 응용 프로그램 → GoldenChart** 실행 (`goldenChart` 아님, **GoldenChart**)
3. Launchpad에 없으면 Spotlight(`⌘+Space`)에 `GoldenChart` 검색 3. Launchpad에 없으면 Spotlight(`⌘+Space`)에 `GoldenChart` 검색
4. 창을 닫아도 **메뉴 막대 트레이**에 남음 — 트레이 아이콘 클릭으로 다시 열기 4. 창을 닫아도 **메뉴 막대 트레이**에 남음 — 트레이 아이콘 클릭으로 다시 열기
5. 최초 실행: **시스템 설정 → 개인정보 보호 → 확인 없이 열기**
#### 「손상되었기 때문에 열 수 없습니다」 (Gatekeeper)
Chrome 등으로 다운로드한 dmg는 Apple **公証(notarization)** 이 없으면 macOS가 차단합니다. 앱 파일이 실제로 손상된 것은 아닙니다.
1. 경고에서 **취소** → Finder에서 GoldenChart **우클릭 → 열기** (최초 1회)
2. 또는 **시스템 설정 → 개인정보 보호 및 보안 → 그래도 열기**
3. 위 방법이 안 되면 터미널:
```bash
xattr -cr /Applications/GoldenChart.app
```
#### Jenkins / CI 코드 서명
빌드 시 기본 **ad-hoc 서명** (`signingIdentity: "-"`). Apple Developer ID가 있으면 Jenkins에 설정:
```bash
export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (TEAMID)"
export APPLE_ID="..."
export APPLE_PASSWORD="app-specific-password"
export APPLE_TEAM_ID="..."
```
公証까지 완료되면 Gatekeeper 경고 없이 설치됩니다.
## 주요 기능 ## 주요 기능
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
+3 -1
View File
@@ -44,7 +44,9 @@
], ],
"macOS": { "macOS": {
"exceptionDomain": "exdev.co.kr", "exceptionDomain": "exdev.co.kr",
"infoPlist": "Info.plist" "infoPlist": "Info.plist",
"signingIdentity": "-",
"entitlements": "./Entitlements.plist"
} }
}, },
"plugins": { "plugins": {
+1
View File
@@ -76,6 +76,7 @@ services:
GC_DESKTOP_JENKINS_JOB: ${GC_DESKTOP_JENKINS_JOB:-goldenChart-Desktop-Pipeline} GC_DESKTOP_JENKINS_JOB: ${GC_DESKTOP_JENKINS_JOB:-goldenChart-Desktop-Pipeline}
GC_DESKTOP_JENKINS_USER: ${GC_DESKTOP_JENKINS_USER:-} GC_DESKTOP_JENKINS_USER: ${GC_DESKTOP_JENKINS_USER:-}
GC_DESKTOP_JENKINS_TOKEN: ${GC_DESKTOP_JENKINS_TOKEN:-} GC_DESKTOP_JENKINS_TOKEN: ${GC_DESKTOP_JENKINS_TOKEN:-}
GC_DESKTOP_JENKINS_TRIGGER_TOKEN: ${GC_DESKTOP_JENKINS_TRIGGER_TOKEN:-goldenchart-desktop-build}
FIREBASE_ENABLED: ${FIREBASE_ENABLED:-false} FIREBASE_ENABLED: ${FIREBASE_ENABLED:-false}
FIREBASE_SERVICE_ACCOUNT: ${FIREBASE_SERVICE_ACCOUNT:-firebase-service-account.json} FIREBASE_SERVICE_ACCOUNT: ${FIREBASE_SERVICE_ACCOUNT:-firebase-service-account.json}
ports: ports:
@@ -335,6 +335,14 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
<summary> </summary> <summary> </summary>
<ol> <ol>
<li><strong>macOS</strong>: dmg를 GoldenChart.app을 Applications .</li> <li><strong>macOS</strong>: dmg를 GoldenChart.app을 Applications .</li>
<li>
<strong>macOS </strong>
macOS Gatekeeper가 . <strong></strong>
Finder GoldenChart를 <strong> </strong> ,
<strong> </strong> .
{' '}
<code>xattr -cr /Applications/GoldenChart.app</code> .
</li>
<li><strong>Windows</strong>: setup.exe를 .</li> <li><strong>Windows</strong>: setup.exe를 .</li>
<li> PC <strong></strong> .</li> <li> PC <strong></strong> .</li>
<li> Git <code>main</code> , .</li> <li> Git <code>main</code> , .</li>
+12
View File
@@ -41,3 +41,15 @@ else
unset TAURI_SIGNING_PRIVATE_KEY 2>/dev/null || true unset TAURI_SIGNING_PRIVATE_KEY 2>/dev/null || true
echo "[prepare-tauri-build] updater signing skipped (no key)" echo "[prepare-tauri-build] updater signing skipped (no key)"
fi fi
# macOS: ad-hoc codesign by default (Apple Silicon requires signing).
# Override with Developer ID for production: export APPLE_SIGNING_IDENTITY="Developer ID Application: ..."
# Notarization (optional): APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID or APPLE_API_KEY*
if [[ "$(uname -s)" == "Darwin" ]]; then
: "${APPLE_SIGNING_IDENTITY:=-}"
export APPLE_SIGNING_IDENTITY
echo "[prepare-tauri-build] APPLE_SIGNING_IDENTITY=${APPLE_SIGNING_IDENTITY}"
if [[ -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" ]]; then
echo "[prepare-tauri-build] Apple notarization env detected — tauri will notarize if credentials are valid"
fi
fi
@@ -6,6 +6,7 @@ export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:${PATH:-/usr/bi
JENKINS_HOME="${JENKINS_HOME:-$HOME/.jenkins}" JENKINS_HOME="${JENKINS_HOME:-$HOME/.jenkins}"
JENKINS_JOB="goldenChart-Desktop-Pipeline" JENKINS_JOB="goldenChart-Desktop-Pipeline"
JENKINS_TRIGGER_TOKEN="${JENKINS_DESKTOP_TRIGGER_TOKEN:-${GC_DESKTOP_JENKINS_TRIGGER_TOKEN:-goldenchart-desktop-build}}"
GIT_DIR="${GIT_DIR:-/Volumes/ADATA/git/goldenChart.git}" GIT_DIR="${GIT_DIR:-/Volumes/ADATA/git/goldenChart.git}"
WORK_TREE="${WORK_TREE:-/Users/aidev/apps/goldenChart}" WORK_TREE="${WORK_TREE:-/Users/aidev/apps/goldenChart}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -42,6 +43,7 @@ cat > "${JENKINS_HOME}/jobs/${JENKINS_JOB}/config.xml" << JENKINS_XML
<disabled>false</disabled> <disabled>false</disabled>
<blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding> <blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
<blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding> <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
<authToken>${JENKINS_TRIGGER_TOKEN}</authToken>
<triggers/> <triggers/>
<concurrentBuild>false</concurrentBuild> <concurrentBuild>false</concurrentBuild>
<builders> <builders>
@@ -78,6 +80,8 @@ if curl -sf -o /dev/null "http://127.0.0.1:8090/login" 2>/dev/null; then
fi fi
log "완료: http://127.0.0.1:8090/job/${JENKINS_JOB}/" log "완료: http://127.0.0.1:8090/job/${JENKINS_JOB}/"
log "원격 트리거 토큰: $JENKINS_TRIGGER_TOKEN"
log "backend env: GC_DESKTOP_JENKINS_TRIGGER_TOKEN=$JENKINS_TRIGGER_TOKEN"
log "" log ""
log "Jenkins Credentials (Secret file):" log "Jenkins Credentials (Secret file):"
log " ID: tauri-signer-key → \$HOME/.tauri/goldenchart.key" log " ID: tauri-signer-key → \$HOME/.tauri/goldenchart.key"
+7
View File
@@ -6,6 +6,7 @@ JENKINS_URL="${JENKINS_URL:-${GC_DESKTOP_JENKINS_URL:-http://127.0.0.1:8090}}"
JENKINS_JOB="${JENKINS_JOB:-${GC_DESKTOP_JENKINS_JOB:-goldenChart-Desktop-Pipeline}}" JENKINS_JOB="${JENKINS_JOB:-${GC_DESKTOP_JENKINS_JOB:-goldenChart-Desktop-Pipeline}}"
JENKINS_USER="${JENKINS_USER:-${GC_DESKTOP_JENKINS_USER:-}}" JENKINS_USER="${JENKINS_USER:-${GC_DESKTOP_JENKINS_USER:-}}"
JENKINS_TOKEN="${JENKINS_TOKEN:-${GC_DESKTOP_JENKINS_TOKEN:-}}" JENKINS_TOKEN="${JENKINS_TOKEN:-${GC_DESKTOP_JENKINS_TOKEN:-}}"
JENKINS_TRIGGER_TOKEN="${JENKINS_TRIGGER_TOKEN:-${GC_DESKTOP_JENKINS_TRIGGER_TOKEN:-goldenchart-desktop-build}}"
LOG_TAG="[trigger-desktop]" LOG_TAG="[trigger-desktop]"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $LOG_TAG $*"; } log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $LOG_TAG $*"; }
@@ -51,6 +52,12 @@ if [[ -n "$CRUMB_JSON" ]]; then
fi fi
fi fi
if curl_post "${CRUMB_HDR[@]}" -H "Content-Type: application/x-www-form-urlencoded" \
"${JOB_API}/build?token=${JENKINS_TRIGGER_TOKEN}"; then
log "빌드 큐 등록 완료 (token)"
exit 0
fi
if curl_post "${CRUMB_HDR[@]}" -H "Content-Type: application/x-www-form-urlencoded" "${JOB_API}/build"; then if curl_post "${CRUMB_HDR[@]}" -H "Content-Type: application/x-www-form-urlencoded" "${JOB_API}/build"; then
log "빌드 큐 등록 완료" log "빌드 큐 등록 완료"
exit 0 exit 0