feat: Android 폰·태블릿 다운로드·앱 내 업데이트 완료

CI에서 APK 버전 자동 bump, 앱 복귀 시 업데이트 확인·시스템 다운로드,
프론트 Android 전용 UI 정리(iPad는 향후).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-06-14 17:03:10 +09:00
parent be45247dc9
commit c840e09885
8 changed files with 193 additions and 130 deletions
+30 -28
View File
@@ -1,50 +1,52 @@
import React, { useEffect, useState } from 'react';
import { App } from '@capacitor/app';
import { Capacitor } from '@capacitor/core';
import { promptAppUpdateIfNeeded } from '../services/appUpdate';
import { promptAppUpdate } from '../services/appUpdate';
type Props = {
children: React.ReactNode;
};
/** Android 네이티브 — 시작 시 서버 APK 버전 확인 (무음 스킵, 이후 설정에서 수동 확인) */
const isAndroidNative = () =>
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
/** Android — 시작·복귀 시 서버 APK 버전 확인 */
export default function AppUpdateGate({ children }: Props) {
const [ready, setReady] = useState(() =>
!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android',
);
const [ready, setReady] = useState(() => !isAndroidNative());
useEffect(() => {
if (ready) return;
if (!isAndroidNative()) return;
let cancelled = false;
void (async () => {
const runCheck = async () => {
try {
const { checkForAppUpdate, openApkDownload } = await import('../services/appUpdate');
const check = await checkForAppUpdate();
if (!cancelled && check.updateAvailable && check.remoteVersion) {
const dismissed = sessionStorage.getItem(`gc_skip_update_${check.remoteVersion}`);
if (!dismissed) {
const ok = window.confirm(
`GoldenChart v${check.remoteVersion} 업데이트가 있습니다.\n`
+ `(현재 v${check.currentVersion})\n\n지금 APK를 다운로드하시겠습니까?`,
);
if (ok) {
await openApkDownload(check.release);
} else {
sessionStorage.setItem(`gc_skip_update_${check.remoteVersion}`, '1');
}
}
}
await promptAppUpdate({ skipIfDismissed: true });
} catch {
/* 네트워크 오류 — 앱 진입 허용 */
} finally {
if (!cancelled) setReady(true);
}
};
void (async () => {
await runCheck();
if (!cancelled) setReady(true);
})();
return () => { cancelled = true; };
}, [ready]);
const sub = App.addListener('appStateChange', ({ isActive }) => {
if (isActive && !cancelled) {
void runCheck();
}
});
return () => {
cancelled = true;
void sub.then(h => h.remove());
};
}, []);
if (!ready) {
return <div className="loading-center"> </div>;
}
return <>{children}</>;
}
};
+51 -9
View File
@@ -16,6 +16,8 @@ export interface AppUpdateCheckResult {
release?: MobileAppReleaseInfoDto | null;
}
const skipKey = (remote: string) => `gc_skip_update_${remote}`;
export async function getCurrentAppVersion(): Promise<string> {
if (!Capacitor.isNativePlatform()) {
return import.meta.env.VITE_APP_VERSION ?? '0.0.0';
@@ -40,16 +42,51 @@ export async function checkForAppUpdate(): Promise<AppUpdateCheckResult> {
return { updateAvailable, currentVersion, remoteVersion, release };
}
/** Android — 시스템 브라우저/다운로드 매니저로 APK 받기 */
export async function openApkDownload(release?: MobileAppReleaseInfoDto | null): Promise<boolean> {
const info = release ?? await loadMobileAppReleaseInfo();
const url = info?.downloadUrl?.trim();
if (!url) return false;
window.location.href = url;
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') {
try {
await App.openUrl({ url });
return true;
} catch {
/* fallback */
}
}
const a = document.createElement('a');
a.href = url;
a.rel = 'noopener';
a.download = info?.fileName ?? 'goldenchart-android.apk';
document.body.appendChild(a);
a.click();
a.remove();
return true;
}
export async function promptAppUpdateIfNeeded(silent = false): Promise<boolean> {
export function isUpdateDismissed(remoteVersion: string): boolean {
try {
return sessionStorage.getItem(skipKey(remoteVersion)) === '1';
} catch {
return false;
}
}
export function dismissUpdate(remoteVersion: string): void {
try {
sessionStorage.setItem(skipKey(remoteVersion), '1');
} catch {
/* ignore */
}
}
/** 업데이트 확인 후 confirm → APK 다운로드. skipIfDismissed면 세션 내 거부 버전 생략 */
export async function promptAppUpdate(options?: {
skipIfDismissed?: boolean;
}): Promise<boolean> {
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android') {
return false;
}
@@ -59,22 +96,26 @@ export async function promptAppUpdateIfNeeded(silent = false): Promise<boolean>
return false;
}
if (silent) {
if (options?.skipIfDismissed && isUpdateDismissed(check.remoteVersion)) {
return false;
}
const ok = window.confirm(
`새 버전(v${check.remoteVersion})이 있습니다.\n`
+ `현재: v${check.currentVersion}\n\n`
+ 'APK를 다운로드하여 설치하시겠습니까?',
`GoldenChart v${check.remoteVersion} 업데이트가 있습니다.\n`
+ `(현재 v${check.currentVersion})\n\n`
+ 'APK를 다운로드하여 설치하시겠습니까?\n'
+ '(다운로드 후 알림에서 설치를 완료하세요.)',
);
if (!ok) return false;
if (!ok) {
dismissUpdate(check.remoteVersion);
return false;
}
await openApkDownload(check.release);
return true;
}
/** 설정 화면 — 수동 업데이트 확인 */
/** 설정 화면 — 수동 업데이트 확인 */
export async function checkAndPromptAppUpdate(): Promise<AppUpdateCheckResult> {
const result = await checkForAppUpdate();
if (!result.updateAvailable) {
@@ -82,7 +123,8 @@ export async function checkAndPromptAppUpdate(): Promise<AppUpdateCheckResult> {
return result;
}
const ok = window.confirm(
`새 버전 v${result.remoteVersion} 사용 가능.\nAPK를 다운로드하시겠습니까?`,
`새 버전 v${result.remoteVersion} 사용 가능.\n`
+ `현재 v${result.currentVersion}\n\nAPK를 다운로드하시겠습니까?`,
);
if (ok) {
await openApkDownload(result.release);
+37 -31
View File
@@ -1,15 +1,12 @@
# 모바일·태블릿 Phase 1 — Capacitor 확장 (A)
# Android 모바일·태블릿 (Phase 1)
## 스택 결정
## 범위
| 항목 | 선택 |
| 항목 | 지원 |
|------|------|
| 단기 경로 | **1단계 A — Capacitor 확장** (`app/`) |
| 장기(선택) | 2단계 B — Tauri Mobile 이전 (미착수) |
| Android 패드 | 동일 APK (반응형 UI) |
| iPad | TestFlight 링크 (ipa 직접 다운로드·앱 내 updater 불가) |
Phase 1 목표: 데스크톱과 동일한 **git push → CI 빌드 → 다운로드 허브** 흐름을 Android에 맞추고, PC 프로그램 탭에서 Android·iPad를 함께 제공한다.
| Android 휴대폰 | ✅ 동일 APK |
| Android 태블릿(패드) | ✅ 동일 APK (반응형 UI) |
| iPad | ⏸ 향후 (TestFlight) |
## 파이프라인
@@ -18,44 +15,53 @@ git push main
→ post-receive (deploy.sh)
→ trigger-mobile-jenkins-build.sh
→ jenkins-mobile-pipeline.sh
→ bump-android-version.sh (CI patch + versionCode++)
→ build-mobile-apk.sh
→ data/mobile-releases/goldenchart-android.apk
→ GC_MOBILE_APP_VERSION (.env)
→ backend 재시작
```
## 다운로드 UI
**PC 프로그램** 탭 (`AppDownloadModal`):
| 위치 | 내용 |
|------|------|
| **모바일** 탭 | QR + APK 다운로드 (Android 전용) |
| **PC 프로그램** 탭 | Android (패드·폰) 카드 |
| `/install.html` | QR 설치 페이지 |
- macOS (.dmg) — 기존
- Windows (.exe) — 기존
- Android 패드/폰 (.apk) — `/api/mobile-app/info`
- iPad (TestFlight) — `GC_MOBILE_APP_IOS_TESTFLIGHT_URL` 링크
## 앱 내 자동 업데이트 (Android)
## Android 앱 내 업데이트
1. `/api/mobile-app/info``version` vs `@capacitor/app` `versionName`
2. 새 버전이면 시작·앱 복귀 시 confirm → `App.openUrl`로 APK 다운로드
3. 설정 → **업데이트 확인** 수동 트리거
- 시작 시 `/api/mobile-app/info` 버전 vs `@capacitor/app` `version`
- 새 버전이면 APK 다운로드 URL 열기 (재설치)
## iPad / TestFlight
1. Mac + Xcode + Apple Developer Program
2. `cd app && npm run cap:sync && npx cap open ios`
3. Archive → TestFlight 업로드
4. 서버 `.env`: `GC_MOBILE_APP_IOS_TESTFLIGHT_URL=https://testflight.apple.com/join/...`
## Jenkins 설치 (서버 1회)
## Jenkins (서버 1회)
```bash
./scripts/server-install-mobile-jenkins.sh
# .env 예시
# GC_MOBILE_JENKINS_TRIGGER_TOKEN=goldenchart-mobile-build
./scripts/server-setup-mobile-phase1.sh # hook·env·검증
```
## 환경 변수
| 변수 | 설명 |
|------|------|
| `GC_MOBILE_APP_VERSION` | 웹·API 표시 버전 |
| `GC_MOBILE_APP_IOS_TESTFLIGHT_URL` | iPad TestFlight 초대 URL |
| `GC_MOBILE_JENKINS_*` | Jenkins mobile job (desktop과 동일 패턴) |
| `GC_MOBILE_APP_VERSION` | API·앱 업데이트 비교용 버전 |
| `GC_MOBILE_JENKINS_*` | Jenkins mobile job |
| `GC_MOBILE_APP_PUBLIC_BASE_URL` | 다운로드 URL base (기본 `http://exdev.co.kr`) |
## 수동 빌드
```bash
# 버전 bump 없이
BUMP_VERSION=0 ./scripts/jenkins-mobile-pipeline.sh
# bump 포함 (CI와 동일)
./scripts/jenkins-mobile-pipeline.sh
```
## API
- `GET /api/mobile-app/info`
- `GET /api/mobile-app/download/android`
+3 -2
View File
@@ -45,7 +45,7 @@
<div class="wrap">
<div class="logo">GC</div>
<h1>GoldenChart 앱 설치</h1>
<p class="muted">가상매매·전략편집기·푸시 알림을 모바일에서 사용하세요.</p>
<p class="muted">Android 휴대폰·태블릿에서 가상매매·전략·푸시 알림을 사용하세요.</p>
<div class="card" id="content">
<p class="muted">로딩 중…</p>
</div>
@@ -73,7 +73,8 @@
'<a class="btn" href="' + url + '" download>APK 다운로드</a>' +
'<ol style="margin-top:20px"><li>다운로드 후 APK 파일을 열어 설치</li>' +
'<li>「알 수 없는 출처」 허용 필요 시 설정에서 활성화</li>' +
'<li>앱 실행 후 API URL 설정</li></ol>';
'<li>앱은 서버(exdev.co.kr)에 자동 연결됩니다</li>' +
'<li>새 버전은 앱 시작·설정 → 업데이트 확인</li></ol>';
QRCode.toCanvas(document.getElementById('qr'), url, { width: 200, margin: 1 });
} catch (e) {
el.innerHTML = '<p class="err">앱 정보를 불러오지 못했습니다.</p>';
+11 -52
View File
@@ -107,12 +107,15 @@ const MobileTab: React.FC = () => {
<div className="app-download-hero">
<div className="app-download-app-icon" aria-hidden>GC</div>
<div>
<h3 className="app-download-app-name">GoldenChart Mobile</h3>
<h3 className="app-download-app-name">GoldenChart Android</h3>
<p className="app-download-meta">
{info.version && <span>v{info.version}</span>}
{info.sizeBytes != null && <span>{formatBytes(info.sizeBytes)}</span>}
{info.updatedAt && <span>{info.updatedAt}</span>}
</p>
<p className="app-download-muted" style={{ marginTop: 4, fontSize: 12 }}>
·릿() APK
</p>
</div>
</div>
@@ -123,7 +126,7 @@ const MobileTab: React.FC = () => {
<div className="app-download-qr app-download-qr--placeholder">QR </div>
)}
<div className="app-download-qr-help">
<strong> QR </strong>
<strong>·릿 QR </strong>
<p>Android에서 APK . .</p>
<p className="app-download-muted app-download-url" title={downloadUrl}>{downloadUrl}</p>
</div>
@@ -138,23 +141,20 @@ const MobileTab: React.FC = () => {
</button>
<details className="app-download-steps">
<summary> (Android)</summary>
<summary> (Android · ·)</summary>
<ol>
<li>QR APK를 .</li>
<li> .</li>
<li> APK .</li>
<li> .</li>
</ol>
</details>
<p className="app-download-ios-note">
iPad는 PC TestFlight . QR·APK는 Android .
</p>
</>
);
};
const PlatformCard: React.FC<{
platform: 'mac' | 'windows' | 'android' | 'ipad';
platform: 'mac' | 'windows' | 'android';
release?: DesktopAppPlatformReleaseDto;
mobile?: MobileAppReleaseInfoDto | null;
}> = ({ platform, release, mobile }) => {
@@ -162,7 +162,6 @@ const PlatformCard: React.FC<{
mac: { label: 'macOS (Apple Silicon · Intel)', icon: '🍎', ext: '.dmg', isLink: false },
windows: { label: 'Windows (64-bit)', icon: '🪟', ext: '.exe', isLink: false },
android: { label: 'Android (패드 · 폰)', icon: '🤖', ext: '.apk', isLink: false },
ipad: { label: 'iPad (TestFlight)', icon: '📱', ext: '', isLink: true },
} as const;
const { label, icon, ext } = configs[platform];
@@ -203,43 +202,6 @@ const PlatformCard: React.FC<{
);
}
if (platform === 'ipad') {
const testFlightUrl = mobile?.iosTestFlightUrl?.trim();
if (!mobile?.iosAvailable || !testFlightUrl) {
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">TestFlight </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">
<span>TestFlight </span>
</p>
<p className="app-download-muted app-download-url" title={testFlightUrl}>
Apple TestFlight
</p>
</div>
<a
href={testFlightUrl}
target="_blank"
rel="noopener noreferrer"
className="app-download-pc-btn app-download-pc-btn--link"
>
TestFlight
</a>
</div>
);
}
if (!release?.available || !release.downloadUrl) {
return (
<div className="app-download-pc-card app-download-pc-card--empty">
@@ -471,8 +433,7 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
}
}, [canBuildDesktop, buildBusy, isBuilding, reloadBuildStatus]);
const hasAny = info?.mac?.available || info?.windows?.available
|| mobileInfo?.available || mobileInfo?.iosAvailable;
const hasAny = info?.mac?.available || info?.windows?.available || mobileInfo?.available;
if (loading) return <p className="app-download-muted">PC </p>;
if (error) return <p className="app-download-error">{error}</p>;
@@ -534,7 +495,7 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
<div className="app-download-empty-icon" aria-hidden>💻</div>
<h3> </h3>
<p>
macOS(.dmg), Windows(.exe), Android(.apk) iPad TestFlight .
macOS(.dmg), Windows(.exe) Android(.apk) .
<br />
{canBuildDesktop
? '우측 상단 「앱 빌드」 버튼으로 Jenkins 빌드를 시작하세요.'
@@ -554,7 +515,6 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
<PlatformCard platform="mac" release={info?.mac} />
<PlatformCard platform="windows" release={info?.windows} />
<PlatformCard platform="android" mobile={mobileInfo} />
<PlatformCard platform="ipad" mobile={mobileInfo} />
</div>
)}
@@ -573,9 +533,8 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
</li>
<li><strong>Windows</strong>: setup.exe를 .</li>
<li><strong>Android (·)</strong>: APK를 . APK입니다.</li>
<li><strong>iPad</strong>: TestFlight (App Store ipa ).</li>
<li> PC <strong></strong> .</li>
<li>Android APK .</li>
<li>Android · <strong> </strong> APK를 .</li>
<li> Git <code>main</code> , .</li>
</ol>
</details>
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# CI: .env GC_MOBILE_APP_VERSION 또는 build.gradle 기준 patch bump + versionCode++
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK_TREE="${WORK_TREE:-$ROOT}"
GRADLE="${GRADLE:-$ROOT/app/android/app/build.gradle}"
ENV_FILE="${ENV_FILE:-$WORK_TREE/.env}"
if [[ ! -f "$GRADLE" ]]; then
echo "build.gradle 없음: $GRADLE" >&2
exit 1
fi
ENV_VER=""
if [[ -f "$ENV_FILE" ]]; then
ENV_VER="$(grep '^GC_MOBILE_APP_VERSION=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- | tr -d '\r' || true)"
fi
node -e "
const fs = require('fs');
const gradlePath = process.argv[1];
const envVersion = (process.argv[2] || '').trim();
let content = fs.readFileSync(gradlePath, 'utf8');
const vc = content.match(/versionCode\\s+(\\d+)/);
const vn = content.match(/versionName\\s+\"([^\"]+)\"/);
if (!vc || !vn) {
console.error('versionCode/versionName 파싱 실패');
process.exit(1);
}
let versionCode = parseInt(vc[1], 10);
let base = envVersion || vn[1];
const parts = base.split('.').map(n => parseInt(n, 10) || 0);
while (parts.length < 2) parts.push(0);
if (parts.length === 2) {
parts[1] += 1;
} else {
while (parts.length < 3) parts.push(0);
parts[2] += 1;
}
const versionName = parts.join('.');
versionCode += 1;
content = content.replace(/versionCode\\s+\\d+/, 'versionCode ' + versionCode);
content = content.replace(/versionName\\s+\"[^\"]+\"/, 'versionName \"' + versionName + '\"');
fs.writeFileSync(gradlePath, content);
console.log(versionName);
" "$GRADLE" "$ENV_VER"
+13
View File
@@ -30,6 +30,19 @@ fi
chmod +x "$ROOT/scripts/"*.sh 2>/dev/null || true
if [[ "${BUMP_VERSION:-}" == "0" ]]; then
log "version bump 스킵 (BUMP_VERSION=0)"
elif [[ -n "${BUILD_NUMBER:-}" && "${MOBILE_BUMP_ON_CI:-1}" == "1" ]]; then
ENV_FILE="$WORK_TREE/.env"
log "CI Android version bump (.env 기준)..."
NEW_VER="$("$ROOT/scripts/bump-android-version.sh")"
log "Android version → $NEW_VER"
elif [[ -n "${BUMP_VERSION:-}" ]]; then
log "CI Android version bump..."
NEW_VER="$("$ROOT/scripts/bump-android-version.sh")"
log "Android version → $NEW_VER"
fi
"$ROOT/scripts/build-mobile-apk.sh"
VERSION_NAME="$(grep -E 'versionName[[:space:]]+"' "$ROOT/app/android/app/build.gradle" | head -1 | sed -E 's/.*versionName[[:space:]]+"([^"]+)".*/\1/' || true)"
+1 -8
View File
@@ -59,16 +59,9 @@ export GC_MOBILE_JENKINS_USER="${DESKTOP_USER:-}"
export GC_MOBILE_JENKINS_TOKEN="${DESKTOP_TOKEN:-}"
"$WORK_TREE/scripts/trigger-mobile-jenkins-build.sh" || log "[WARN] Mobile Jenkins 트리거 실패"
if command -v pod >/dev/null 2>&1 && [[ -f "$WORK_TREE/app/ios/App/Podfile" ]]; then
log "iOS pod install"
(cd "$WORK_TREE/app/ios/App" && pod install) || log "[WARN] pod install 실패"
else
log "CocoaPods 없음 — iOS 빌드는 brew install cocoapods 후 pod install"
fi
log "API 확인"
sleep 4
curl -fsSL http://127.0.0.1:8080/api/mobile-app/info 2>/dev/null | python3 -m json.tool 2>/dev/null || \
curl -fsSL http://exdev.co.kr/api/mobile-app/info | python3 -m json.tool 2>/dev/null || true
log "완료"
log "완료 (Android APK · iPad는 향후 TestFlight)"