diff --git a/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java b/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java index 432b4a3..dd2a273 100644 --- a/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java +++ b/backend/src/main/java/com/goldenchart/service/DesktopAppBuildService.java @@ -45,6 +45,9 @@ public class DesktopAppBuildService { @Value("${goldenchart.desktop-app.jenkins.token:}") private String jenkinsToken; + @Value("${goldenchart.desktop-app.jenkins.trigger-token:}") + private String jenkinsTriggerToken; + @Value("${goldenchart.desktop-app.git-bare-dir:}") private String gitBareDir; @@ -53,11 +56,22 @@ public class DesktopAppBuildService { public DesktopAppBuildStatusDto getStatus() { if (!jenkinsEnabled) { + return disabledStatus("Jenkins 연동이 비활성화되어 있습니다."); + } + if (!hasBasicAuth()) { + if (hasTriggerToken()) { + return DesktopAppBuildStatusDto.builder() + .jenkinsEnabled(true) + .building(false) + .status("ready") + .message("빌드 트리거 준비됨 (원격 토큰). 상태 조회는 Jenkins API 토큰 설정 시 가능합니다.") + .build(); + } return DesktopAppBuildStatusDto.builder() - .jenkinsEnabled(false) + .jenkinsEnabled(true) .building(false) - .status("disabled") - .message("Jenkins 연동이 비활성화되어 있습니다.") + .status("error") + .message("Jenkins 인증 미설정 — GC_DESKTOP_JENKINS_TRIGGER_TOKEN 또는 USER/TOKEN을 backend에 설정하세요.") .build(); } try { @@ -68,7 +82,7 @@ public class DesktopAppBuildService { .jenkinsEnabled(true) .building(false) .status("error") - .message("Jenkins 상태 조회 실패: " + e.getMessage()) + .message("Jenkins 상태 조회 실패: " + rootMessage(e)) .build(); } } @@ -78,23 +92,40 @@ public class DesktopAppBuildService { if (!jenkinsEnabled) { 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 { syncGitLatest(); - DesktopAppBuildStatusDto current = fetchLastBuildStatus(); - if (current.isBuilding()) { - return DesktopAppBuildTriggerDto.builder() - .accepted(false) - .buildNumber(current.getBuildNumber()) - .message("이미 빌드가 진행 중입니다 (#" + current.getBuildNumber() + ").") - .build(); + if (hasBasicAuth()) { + try { + DesktopAppBuildStatusDto current = fetchLastBuildStatus(); + if (current.isBuilding()) { + return DesktopAppBuildTriggerDto.builder() + .accepted(false) + .buildNumber(current.getBuildNumber()) + .message("이미 빌드가 진행 중입니다 (#" + current.getBuildNumber() + ").") + .build(); + } + } catch (WebClientResponseException e) { + log.warn("[desktop-build] status skip before trigger: HTTP {}", e.getStatusCode().value()); + } } triggerJenkinsBuild(); - DesktopAppBuildStatusDto after = fetchLastBuildStatus(); + + Integer buildNumber = null; + if (hasBasicAuth()) { + try { + buildNumber = fetchLastBuildStatus().getBuildNumber(); + } catch (Exception ignored) { /* optional */ } + } + return DesktopAppBuildTriggerDto.builder() .accepted(true) - .buildNumber(after.getBuildNumber()) + .buildNumber(buildNumber) .message("Jenkins에서 macOS·Windows 데스크톱 앱 빌드를 시작했습니다. 완료 후 설치 파일이 자동으로 반영됩니다.") .build(); } catch (ResponseStatusException e) { @@ -165,7 +196,7 @@ public class DesktopAppBuildService { private void triggerJenkinsBuild() { WebClient client = jenkinsClient(); - String buildUrl = jobApiPath() + "/build"; + String buildUrl = buildTriggerUrl(); Map crumb = fetchCrumbOptional(client); try { 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 fetchCrumbOptional(WebClient client) { try { return fetchCrumb(client); } catch (Exception e) { - log.debug("[desktop-build] crumb optional skip: {}", e.getMessage()); + log.debug("[desktop-build] crumb skip: {}", e.getMessage()); return null; } } @@ -215,17 +254,34 @@ public class DesktopAppBuildService { WebClient.Builder builder = webClientBuilder .baseUrl(normalizeBase(jenkinsUrl)) .defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE); - if (StringUtils.hasText(jenkinsUser) && StringUtils.hasText(jenkinsToken)) { + if (hasBasicAuth()) { builder.defaultHeaders(h -> h.setBasicAuth(jenkinsUser.trim(), jenkinsToken.trim())); } return builder.build(); } + private boolean hasBasicAuth() { + return StringUtils.hasText(jenkinsUser) && StringUtils.hasText(jenkinsToken); + } + + private boolean hasTriggerToken() { + return StringUtils.hasText(jenkinsTriggerToken); + } + private String jobApiPath() { String encoded = UriUtils.encodePathSegment(jenkinsJob.trim(), StandardCharsets.UTF_8); 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) { if (!StringUtils.hasText(url)) return "http://127.0.0.1:8090"; return url.trim().replaceAll("/+$", ""); @@ -240,10 +296,10 @@ public class DesktopAppBuildService { private static String jenkinsHttpError(WebClientResponseException e) { int code = e.getStatusCode().value(); 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) { - return "Jenkins Job을 찾을 수 없습니다 — goldenChart-Desktop-Pipeline Job을 등록하세요."; + return "Jenkins Job을 찾을 수 없습니다 — ./scripts/server-install-desktop-jenkins.sh 로 Job을 등록하세요."; } return "Jenkins HTTP " + code + ": " + e.getStatusText(); } diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml index b2e4b9a..2369f3f 100644 --- a/backend/src/main/resources/application-docker.yml +++ b/backend/src/main/resources/application-docker.yml @@ -18,6 +18,10 @@ spring: baseline-version: 0 validate-on-migrate: false +server: + error: + include-message: always + goldenchart: cors: allowed-origins: @@ -32,6 +36,7 @@ goldenchart: job: ${GC_DESKTOP_JENKINS_JOB:goldenChart-Desktop-Pipeline} user: ${GC_DESKTOP_JENKINS_USER:} token: ${GC_DESKTOP_JENKINS_TOKEN:} + trigger-token: ${GC_DESKTOP_JENKINS_TRIGGER_TOKEN:goldenchart-desktop-build} # FCM — exdev: FIREBASE_ENABLED=true + 서비스 계정 JSON 마운트 firebase: diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 28f7b69..a127efd 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -124,6 +124,7 @@ goldenchart: job: ${GC_DESKTOP_JENKINS_JOB:goldenChart-Desktop-Pipeline} user: ${GC_DESKTOP_JENKINS_USER:} token: ${GC_DESKTOP_JENKINS_TOKEN:} + trigger-token: ${GC_DESKTOP_JENKINS_TRIGGER_TOKEN:} cors: allowed-origins: - http://localhost:5173 diff --git a/desktop/README.md b/desktop/README.md index 7fb8713..9901fe4 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -34,7 +34,31 @@ npm run build:desktop:win # Windows NSIS (macOS에서 cargo-xwin 크로스 빌 2. **Finder → 응용 프로그램 → GoldenChart** 실행 (`goldenChart` 아님, **GoldenChart**) 3. Launchpad에 없으면 Spotlight(`⌘+Space`)에 `GoldenChart` 검색 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 경고 없이 설치됩니다. ## 주요 기능 diff --git a/desktop/src-tauri/Entitlements.plist b/desktop/src-tauri/Entitlements.plist new file mode 100644 index 0000000..c0052ff --- /dev/null +++ b/desktop/src-tauri/Entitlements.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.network.client + + + diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 52a9556..8364b36 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -44,7 +44,9 @@ ], "macOS": { "exceptionDomain": "exdev.co.kr", - "infoPlist": "Info.plist" + "infoPlist": "Info.plist", + "signingIdentity": "-", + "entitlements": "./Entitlements.plist" } }, "plugins": { diff --git a/docker-compose.yml b/docker-compose.yml index 10c943c..9dc944f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -76,6 +76,7 @@ services: GC_DESKTOP_JENKINS_JOB: ${GC_DESKTOP_JENKINS_JOB:-goldenChart-Desktop-Pipeline} GC_DESKTOP_JENKINS_USER: ${GC_DESKTOP_JENKINS_USER:-} 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_SERVICE_ACCOUNT: ${FIREBASE_SERVICE_ACCOUNT:-firebase-service-account.json} ports: diff --git a/frontend/src/components/AppDownloadModal.tsx b/frontend/src/components/AppDownloadModal.tsx index 8749b73..7680968 100644 --- a/frontend/src/components/AppDownloadModal.tsx +++ b/frontend/src/components/AppDownloadModal.tsx @@ -335,6 +335,14 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) 설치 방법
  1. macOS: dmg를 열고 GoldenChart.app을 Applications 폴더로 드래그합니다.
  2. +
  3. + macOS — 「손상되었기 때문에 열 수 없습니다」 가 뜨면 앱이 깨진 것이 아니라 + macOS Gatekeeper가 미公証 앱을 차단한 것입니다. 먼저 취소를 누른 뒤 + Finder → 응용 프로그램에서 GoldenChart를 우클릭 → 열기 하거나, + 시스템 설정 → 개인정보 보호 및 보안 → 그래도 열기 를 선택하세요. + 그래도 안 되면 터미널에서{' '} + xattr -cr /Applications/GoldenChart.app 실행 후 다시 열어보세요. +
  4. Windows: setup.exe를 실행하고 안내에 따라 설치합니다.
  5. 설치된 PC 앱은 메뉴 우측 상단 업데이트 버튼으로 자동 업데이트할 수 있습니다.
  6. 빌드는 Git main 최신 커밋 기준이며, 완료 후 설치 파일이 자동으로 배포됩니다.
  7. diff --git a/scripts/prepare-tauri-build.sh b/scripts/prepare-tauri-build.sh index 08b71b9..d02000c 100644 --- a/scripts/prepare-tauri-build.sh +++ b/scripts/prepare-tauri-build.sh @@ -41,3 +41,15 @@ else unset TAURI_SIGNING_PRIVATE_KEY 2>/dev/null || true echo "[prepare-tauri-build] updater signing skipped (no key)" 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 diff --git a/scripts/server-install-desktop-jenkins.sh b/scripts/server-install-desktop-jenkins.sh index 81710e3..cd8b34f 100755 --- a/scripts/server-install-desktop-jenkins.sh +++ b/scripts/server-install-desktop-jenkins.sh @@ -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_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}" WORK_TREE="${WORK_TREE:-/Users/aidev/apps/goldenChart}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -42,6 +43,7 @@ cat > "${JENKINS_HOME}/jobs/${JENKINS_JOB}/config.xml" << JENKINS_XML false false false + ${JENKINS_TRIGGER_TOKEN} false @@ -78,6 +80,8 @@ if curl -sf -o /dev/null "http://127.0.0.1:8090/login" 2>/dev/null; then fi 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 "Jenkins Credentials (Secret file):" log " ID: tauri-signer-key → \$HOME/.tauri/goldenchart.key" diff --git a/scripts/trigger-desktop-jenkins-build.sh b/scripts/trigger-desktop-jenkins-build.sh index d34a34f..5c7083f 100755 --- a/scripts/trigger-desktop-jenkins-build.sh +++ b/scripts/trigger-desktop-jenkins-build.sh @@ -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_USER="${JENKINS_USER:-${GC_DESKTOP_JENKINS_USER:-}}" 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() { echo "$(date '+%Y-%m-%d %H:%M:%S') $LOG_TAG $*"; } @@ -51,6 +52,12 @@ if [[ -n "$CRUMB_JSON" ]]; then 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 log "빌드 큐 등록 완료" exit 0