feat: Android 폰·태블릿 다운로드·앱 내 업데이트 완료
CI에서 APK 버전 자동 bump, 앱 복귀 시 업데이트 확인·시스템 다운로드, 프론트 Android 전용 UI 정리(iPad는 향후). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,50 +1,52 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { App } from '@capacitor/app';
|
||||||
import { Capacitor } from '@capacitor/core';
|
import { Capacitor } from '@capacitor/core';
|
||||||
import { promptAppUpdateIfNeeded } from '../services/appUpdate';
|
import { promptAppUpdate } from '../services/appUpdate';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Android 네이티브 — 시작 시 서버 APK 버전 확인 (무음 스킵, 이후 설정에서 수동 확인) */
|
const isAndroidNative = () =>
|
||||||
|
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||||
|
|
||||||
|
/** Android — 시작·복귀 시 서버 APK 버전 확인 */
|
||||||
export default function AppUpdateGate({ children }: Props) {
|
export default function AppUpdateGate({ children }: Props) {
|
||||||
const [ready, setReady] = useState(() =>
|
const [ready, setReady] = useState(() => !isAndroidNative());
|
||||||
!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android',
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ready) return;
|
if (!isAndroidNative()) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
|
||||||
|
const runCheck = async () => {
|
||||||
try {
|
try {
|
||||||
const { checkForAppUpdate, openApkDownload } = await import('../services/appUpdate');
|
await promptAppUpdate({ skipIfDismissed: true });
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} 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) {
|
if (!ready) {
|
||||||
return <div className="loading-center">업데이트 확인…</div>;
|
return <div className="loading-center">업데이트 확인…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface AppUpdateCheckResult {
|
|||||||
release?: MobileAppReleaseInfoDto | null;
|
release?: MobileAppReleaseInfoDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const skipKey = (remote: string) => `gc_skip_update_${remote}`;
|
||||||
|
|
||||||
export async function getCurrentAppVersion(): Promise<string> {
|
export async function getCurrentAppVersion(): Promise<string> {
|
||||||
if (!Capacitor.isNativePlatform()) {
|
if (!Capacitor.isNativePlatform()) {
|
||||||
return import.meta.env.VITE_APP_VERSION ?? '0.0.0';
|
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 };
|
return { updateAvailable, currentVersion, remoteVersion, release };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Android — 시스템 브라우저/다운로드 매니저로 APK 받기 */
|
||||||
export async function openApkDownload(release?: MobileAppReleaseInfoDto | null): Promise<boolean> {
|
export async function openApkDownload(release?: MobileAppReleaseInfoDto | null): Promise<boolean> {
|
||||||
const info = release ?? await loadMobileAppReleaseInfo();
|
const info = release ?? await loadMobileAppReleaseInfo();
|
||||||
const url = info?.downloadUrl?.trim();
|
const url = info?.downloadUrl?.trim();
|
||||||
if (!url) return false;
|
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;
|
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') {
|
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -59,22 +96,26 @@ export async function promptAppUpdateIfNeeded(silent = false): Promise<boolean>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (silent) {
|
if (options?.skipIfDismissed && isUpdateDismissed(check.remoteVersion)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = window.confirm(
|
const ok = window.confirm(
|
||||||
`새 버전(v${check.remoteVersion})이 있습니다.\n`
|
`GoldenChart v${check.remoteVersion} 업데이트가 있습니다.\n`
|
||||||
+ `현재: v${check.currentVersion}\n\n`
|
+ `(현재 v${check.currentVersion})\n\n`
|
||||||
+ 'APK를 다운로드하여 설치하시겠습니까?',
|
+ 'APK를 다운로드하여 설치하시겠습니까?\n'
|
||||||
|
+ '(다운로드 후 알림에서 설치를 완료하세요.)',
|
||||||
);
|
);
|
||||||
if (!ok) return false;
|
if (!ok) {
|
||||||
|
dismissUpdate(check.remoteVersion);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
await openApkDownload(check.release);
|
await openApkDownload(check.release);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 설정 화면 등 — 수동 업데이트 확인 */
|
/** 설정 화면 — 수동 업데이트 확인 */
|
||||||
export async function checkAndPromptAppUpdate(): Promise<AppUpdateCheckResult> {
|
export async function checkAndPromptAppUpdate(): Promise<AppUpdateCheckResult> {
|
||||||
const result = await checkForAppUpdate();
|
const result = await checkForAppUpdate();
|
||||||
if (!result.updateAvailable) {
|
if (!result.updateAvailable) {
|
||||||
@@ -82,7 +123,8 @@ export async function checkAndPromptAppUpdate(): Promise<AppUpdateCheckResult> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
const ok = window.confirm(
|
const ok = window.confirm(
|
||||||
`새 버전 v${result.remoteVersion} 사용 가능.\nAPK를 다운로드하시겠습니까?`,
|
`새 버전 v${result.remoteVersion} 사용 가능.\n`
|
||||||
|
+ `현재 v${result.currentVersion}\n\nAPK를 다운로드하시겠습니까?`,
|
||||||
);
|
);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
await openApkDownload(result.release);
|
await openApkDownload(result.release);
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
# 모바일·태블릿 Phase 1 — Capacitor 확장 (A)
|
# Android 모바일·태블릿 (Phase 1)
|
||||||
|
|
||||||
## 스택 결정
|
## 범위
|
||||||
|
|
||||||
| 항목 | 선택 |
|
| 항목 | 지원 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 단기 경로 | **1단계 A — Capacitor 확장** (`app/`) |
|
| Android 휴대폰 | ✅ 동일 APK |
|
||||||
| 장기(선택) | 2단계 B — Tauri Mobile 이전 (미착수) |
|
| Android 태블릿(패드) | ✅ 동일 APK (반응형 UI) |
|
||||||
| Android 패드 | 동일 APK (반응형 UI) |
|
| iPad | ⏸ 향후 (TestFlight) |
|
||||||
| iPad | TestFlight 링크 (ipa 직접 다운로드·앱 내 updater 불가) |
|
|
||||||
|
|
||||||
Phase 1 목표: 데스크톱과 동일한 **git push → CI 빌드 → 다운로드 허브** 흐름을 Android에 맞추고, PC 프로그램 탭에서 Android·iPad를 함께 제공한다.
|
|
||||||
|
|
||||||
## 파이프라인
|
## 파이프라인
|
||||||
|
|
||||||
@@ -18,44 +15,53 @@ git push main
|
|||||||
→ post-receive (deploy.sh)
|
→ post-receive (deploy.sh)
|
||||||
→ trigger-mobile-jenkins-build.sh
|
→ trigger-mobile-jenkins-build.sh
|
||||||
→ jenkins-mobile-pipeline.sh
|
→ jenkins-mobile-pipeline.sh
|
||||||
|
→ bump-android-version.sh (CI patch + versionCode++)
|
||||||
→ build-mobile-apk.sh
|
→ build-mobile-apk.sh
|
||||||
→ data/mobile-releases/goldenchart-android.apk
|
→ data/mobile-releases/goldenchart-android.apk
|
||||||
→ GC_MOBILE_APP_VERSION (.env)
|
→ GC_MOBILE_APP_VERSION (.env)
|
||||||
|
→ backend 재시작
|
||||||
```
|
```
|
||||||
|
|
||||||
## 다운로드 UI
|
## 다운로드 UI
|
||||||
|
|
||||||
**PC 프로그램** 탭 (`AppDownloadModal`):
|
| 위치 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| **모바일** 탭 | QR + APK 다운로드 (Android 전용) |
|
||||||
|
| **PC 프로그램** 탭 | Android (패드·폰) 카드 |
|
||||||
|
| `/install.html` | QR 설치 페이지 |
|
||||||
|
|
||||||
- macOS (.dmg) — 기존
|
## 앱 내 자동 업데이트 (Android)
|
||||||
- Windows (.exe) — 기존
|
|
||||||
- Android 패드/폰 (.apk) — `/api/mobile-app/info`
|
|
||||||
- iPad (TestFlight) — `GC_MOBILE_APP_IOS_TESTFLIGHT_URL` 링크
|
|
||||||
|
|
||||||
## 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`
|
## Jenkins (서버 1회)
|
||||||
- 새 버전이면 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회)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/server-install-mobile-jenkins.sh
|
./scripts/server-install-mobile-jenkins.sh
|
||||||
# .env 예시
|
./scripts/server-setup-mobile-phase1.sh # hook·env·검증
|
||||||
# GC_MOBILE_JENKINS_TRIGGER_TOKEN=goldenchart-mobile-build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 환경 변수
|
## 환경 변수
|
||||||
|
|
||||||
| 변수 | 설명 |
|
| 변수 | 설명 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `GC_MOBILE_APP_VERSION` | 웹·API 표시 버전 |
|
| `GC_MOBILE_APP_VERSION` | API·앱 업데이트 비교용 버전 |
|
||||||
| `GC_MOBILE_APP_IOS_TESTFLIGHT_URL` | iPad TestFlight 초대 URL |
|
| `GC_MOBILE_JENKINS_*` | Jenkins mobile job |
|
||||||
| `GC_MOBILE_JENKINS_*` | Jenkins mobile job (desktop과 동일 패턴) |
|
| `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`
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="logo">GC</div>
|
<div class="logo">GC</div>
|
||||||
<h1>GoldenChart 앱 설치</h1>
|
<h1>GoldenChart 앱 설치</h1>
|
||||||
<p class="muted">가상매매·전략편집기·푸시 알림을 모바일에서 사용하세요.</p>
|
<p class="muted">Android 휴대폰·태블릿에서 가상매매·전략·푸시 알림을 사용하세요.</p>
|
||||||
<div class="card" id="content">
|
<div class="card" id="content">
|
||||||
<p class="muted">로딩 중…</p>
|
<p class="muted">로딩 중…</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,8 @@
|
|||||||
'<a class="btn" href="' + url + '" download>APK 다운로드</a>' +
|
'<a class="btn" href="' + url + '" download>APK 다운로드</a>' +
|
||||||
'<ol style="margin-top:20px"><li>다운로드 후 APK 파일을 열어 설치</li>' +
|
'<ol style="margin-top:20px"><li>다운로드 후 APK 파일을 열어 설치</li>' +
|
||||||
'<li>「알 수 없는 출처」 허용 필요 시 설정에서 활성화</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 });
|
QRCode.toCanvas(document.getElementById('qr'), url, { width: 200, margin: 1 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = '<p class="err">앱 정보를 불러오지 못했습니다.</p>';
|
el.innerHTML = '<p class="err">앱 정보를 불러오지 못했습니다.</p>';
|
||||||
|
|||||||
@@ -107,12 +107,15 @@ const MobileTab: React.FC = () => {
|
|||||||
<div className="app-download-hero">
|
<div className="app-download-hero">
|
||||||
<div className="app-download-app-icon" aria-hidden>GC</div>
|
<div className="app-download-app-icon" aria-hidden>GC</div>
|
||||||
<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">
|
<p className="app-download-meta">
|
||||||
{info.version && <span>v{info.version}</span>}
|
{info.version && <span>v{info.version}</span>}
|
||||||
{info.sizeBytes != null && <span>{formatBytes(info.sizeBytes)}</span>}
|
{info.sizeBytes != null && <span>{formatBytes(info.sizeBytes)}</span>}
|
||||||
{info.updatedAt && <span>{info.updatedAt}</span>}
|
{info.updatedAt && <span>{info.updatedAt}</span>}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="app-download-muted" style={{ marginTop: 4, fontSize: 12 }}>
|
||||||
|
휴대폰·태블릿(패드) 동일 APK
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 app-download-qr--placeholder">QR 생성 중…</div>
|
||||||
)}
|
)}
|
||||||
<div className="app-download-qr-help">
|
<div className="app-download-qr-help">
|
||||||
<strong>휴대폰 카메라로 QR 스캔</strong>
|
<strong>휴대폰·태블릿 카메라로 QR 스캔</strong>
|
||||||
<p>Android에서 APK 다운로드 페이지가 열립니다. 다운로드 후 설치하세요.</p>
|
<p>Android에서 APK 다운로드 페이지가 열립니다. 다운로드 후 설치하세요.</p>
|
||||||
<p className="app-download-muted app-download-url" title={downloadUrl}>{downloadUrl}</p>
|
<p className="app-download-muted app-download-url" title={downloadUrl}>{downloadUrl}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,23 +141,20 @@ const MobileTab: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<details className="app-download-steps">
|
<details className="app-download-steps">
|
||||||
<summary>설치 방법 (Android)</summary>
|
<summary>설치 방법 (Android · 패드·폰)</summary>
|
||||||
<ol>
|
<ol>
|
||||||
<li>QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.</li>
|
<li>QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.</li>
|
||||||
<li>「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.</li>
|
<li>「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.</li>
|
||||||
<li>다운로드 완료 후 APK 파일을 탭하여 설치합니다.</li>
|
<li>다운로드 완료 후 APK 파일을 탭하여 설치합니다.</li>
|
||||||
|
<li>앱 실행 후 설정 → 「업데이트 확인」으로 새 버전을 받을 수 있습니다.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<p className="app-download-ios-note">
|
|
||||||
iPad는 PC 프로그램 탭의 TestFlight 링크를 이용하세요. 이 탭의 QR·APK는 Android 전용입니다.
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PlatformCard: React.FC<{
|
const PlatformCard: React.FC<{
|
||||||
platform: 'mac' | 'windows' | 'android' | 'ipad';
|
platform: 'mac' | 'windows' | 'android';
|
||||||
release?: DesktopAppPlatformReleaseDto;
|
release?: DesktopAppPlatformReleaseDto;
|
||||||
mobile?: MobileAppReleaseInfoDto | null;
|
mobile?: MobileAppReleaseInfoDto | null;
|
||||||
}> = ({ platform, release, mobile }) => {
|
}> = ({ platform, release, mobile }) => {
|
||||||
@@ -162,7 +162,6 @@ const PlatformCard: React.FC<{
|
|||||||
mac: { label: 'macOS (Apple Silicon · Intel)', icon: '🍎', ext: '.dmg', isLink: false },
|
mac: { label: 'macOS (Apple Silicon · Intel)', icon: '🍎', ext: '.dmg', isLink: false },
|
||||||
windows: { label: 'Windows (64-bit)', icon: '🪟', ext: '.exe', isLink: false },
|
windows: { label: 'Windows (64-bit)', icon: '🪟', ext: '.exe', isLink: false },
|
||||||
android: { label: 'Android (패드 · 폰)', icon: '🤖', ext: '.apk', isLink: false },
|
android: { label: 'Android (패드 · 폰)', icon: '🤖', ext: '.apk', isLink: false },
|
||||||
ipad: { label: 'iPad (TestFlight)', icon: '📱', ext: '', isLink: true },
|
|
||||||
} as const;
|
} as const;
|
||||||
const { label, icon, ext } = configs[platform];
|
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) {
|
if (!release?.available || !release.downloadUrl) {
|
||||||
return (
|
return (
|
||||||
<div className="app-download-pc-card app-download-pc-card--empty">
|
<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]);
|
}, [canBuildDesktop, buildBusy, isBuilding, reloadBuildStatus]);
|
||||||
|
|
||||||
const hasAny = info?.mac?.available || info?.windows?.available
|
const hasAny = info?.mac?.available || info?.windows?.available || mobileInfo?.available;
|
||||||
|| mobileInfo?.available || mobileInfo?.iosAvailable;
|
|
||||||
|
|
||||||
if (loading) return <p className="app-download-muted">PC 프로그램 정보 불러오는 중…</p>;
|
if (loading) return <p className="app-download-muted">PC 프로그램 정보 불러오는 중…</p>;
|
||||||
if (error) return <p className="app-download-error">{error}</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>
|
<div className="app-download-empty-icon" aria-hidden>💻</div>
|
||||||
<h3>설치 파일 준비 중</h3>
|
<h3>설치 파일 준비 중</h3>
|
||||||
<p>
|
<p>
|
||||||
macOS(.dmg), Windows(.exe), Android(.apk) 또는 iPad TestFlight 링크가 아직 준비되지 않았습니다.
|
macOS(.dmg), Windows(.exe) 또는 Android(.apk)가 아직 준비되지 않았습니다.
|
||||||
<br />
|
<br />
|
||||||
{canBuildDesktop
|
{canBuildDesktop
|
||||||
? '우측 상단 「앱 빌드」 버튼으로 Jenkins 빌드를 시작하세요.'
|
? '우측 상단 「앱 빌드」 버튼으로 Jenkins 빌드를 시작하세요.'
|
||||||
@@ -554,7 +515,6 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
|
|||||||
<PlatformCard platform="mac" release={info?.mac} />
|
<PlatformCard platform="mac" release={info?.mac} />
|
||||||
<PlatformCard platform="windows" release={info?.windows} />
|
<PlatformCard platform="windows" release={info?.windows} />
|
||||||
<PlatformCard platform="android" mobile={mobileInfo} />
|
<PlatformCard platform="android" mobile={mobileInfo} />
|
||||||
<PlatformCard platform="ipad" mobile={mobileInfo} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -573,9 +533,8 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
|
|||||||
</li>
|
</li>
|
||||||
<li><strong>Windows</strong>: setup.exe를 실행하고 안내에 따라 설치합니다.</li>
|
<li><strong>Windows</strong>: setup.exe를 실행하고 안내에 따라 설치합니다.</li>
|
||||||
<li><strong>Android (패드·폰)</strong>: APK를 다운로드한 뒤 설치합니다. 패드와 폰은 동일 APK입니다.</li>
|
<li><strong>Android (패드·폰)</strong>: APK를 다운로드한 뒤 설치합니다. 패드와 폰은 동일 APK입니다.</li>
|
||||||
<li><strong>iPad</strong>: TestFlight 링크로 베타 앱을 설치합니다 (App Store 정책상 ipa 직접 다운로드 불가).</li>
|
|
||||||
<li>설치된 PC 앱은 메뉴 우측 상단 <strong>업데이트</strong> 버튼으로 자동 업데이트할 수 있습니다.</li>
|
<li>설치된 PC 앱은 메뉴 우측 상단 <strong>업데이트</strong> 버튼으로 자동 업데이트할 수 있습니다.</li>
|
||||||
<li>Android 앱은 시작 시 또는 설정에서 새 APK 버전을 안내합니다.</li>
|
<li>Android 앱은 시작·복귀 시 또는 설정 → <strong>업데이트 확인</strong>으로 새 APK를 안내합니다.</li>
|
||||||
<li>빌드는 Git <code>main</code> 최신 커밋 기준이며, 완료 후 설치 파일이 자동으로 배포됩니다.</li>
|
<li>빌드는 Git <code>main</code> 최신 커밋 기준이며, 완료 후 설치 파일이 자동으로 배포됩니다.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
Executable
+47
@@ -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"
|
||||||
@@ -30,6 +30,19 @@ fi
|
|||||||
|
|
||||||
chmod +x "$ROOT/scripts/"*.sh 2>/dev/null || true
|
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"
|
"$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)"
|
VERSION_NAME="$(grep -E 'versionName[[:space:]]+"' "$ROOT/app/android/app/build.gradle" | head -1 | sed -E 's/.*versionName[[:space:]]+"([^"]+)".*/\1/' || true)"
|
||||||
|
|||||||
@@ -59,16 +59,9 @@ export GC_MOBILE_JENKINS_USER="${DESKTOP_USER:-}"
|
|||||||
export GC_MOBILE_JENKINS_TOKEN="${DESKTOP_TOKEN:-}"
|
export GC_MOBILE_JENKINS_TOKEN="${DESKTOP_TOKEN:-}"
|
||||||
"$WORK_TREE/scripts/trigger-mobile-jenkins-build.sh" || log "[WARN] Mobile Jenkins 트리거 실패"
|
"$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 확인"
|
log "API 확인"
|
||||||
sleep 4
|
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://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
|
curl -fsSL http://exdev.co.kr/api/mobile-app/info | python3 -m json.tool 2>/dev/null || true
|
||||||
|
|
||||||
log "완료"
|
log "완료 (Android APK · iPad는 향후 TestFlight)"
|
||||||
|
|||||||
Reference in New Issue
Block a user