feat: Phase 1 mobile tablet — Jenkins APK, PC tab, in-app update

Capacitor 확장으로 git push 시 Mobile Jenkins 빌드, PC 탭 Android/iPad 다운로드,
앱 내 APK 업데이트, iOS TestFlight API 및 Capacitor ios 프로젝트를 추가한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-06-14 16:31:55 +09:00
parent 36c06b55ea
commit f34104b721
38 changed files with 1468 additions and 15 deletions
+4
View File
@@ -13467,6 +13467,10 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
color: #fff; font-size: 13px; font-weight: 700; cursor: pointer;
}
.app-download-pc-btn:hover { filter: brightness(1.06); }
.app-download-pc-btn--link {
display: inline-flex; align-items: center; justify-content: center;
text-decoration: none; box-sizing: border-box;
}
.tmb-desktop-update-btn.busy { opacity: 0.65; cursor: wait; }
.tmb-desktop-update-btn.busy svg { animation: tmb-update-spin 0.9s linear infinite; }
@keyframes tmb-update-spin { to { transform: rotate(360deg); } }
+101 -12
View File
@@ -147,20 +147,98 @@ const MobileTab: React.FC = () => {
</details>
<p className="app-download-ios-note">
iOS는 App Store / TestFlight 배포가 필요합니다. 현재 QR·APK 설치는 Android 전용입니다.
iPad는 PC 프로그램 탭의 TestFlight 링크를 이용하세요. 이 탭의 QR·APK는 Android 전용입니다.
</p>
</>
);
};
const PlatformCard: React.FC<{
platform: 'mac' | 'windows';
platform: 'mac' | 'windows' | 'android' | 'ipad';
release?: DesktopAppPlatformReleaseDto;
}> = ({ platform, release }) => {
const isMac = platform === 'mac';
const label = isMac ? 'macOS (Apple Silicon · Intel)' : 'Windows (64-bit)';
const icon = isMac ? '🍎' : '🪟';
const ext = isMac ? '.dmg' : '.exe';
mobile?: MobileAppReleaseInfoDto | null;
}> = ({ platform, release, mobile }) => {
const configs = {
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];
if (platform === 'android') {
if (!mobile?.available || !mobile.downloadUrl) {
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">설치 파일 준비 중 ({ext})</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">
{mobile.version && <span>v{mobile.version}</span>}
{mobile.sizeBytes != null && <span>{formatBytes(mobile.sizeBytes)}</span>}
{mobile.updatedAt && <span>{mobile.updatedAt}</span>}
</p>
<p className="app-download-muted app-download-url" title={mobile.downloadUrl}>
{mobile.fileName ?? `goldenchart-android${ext}`}
</p>
</div>
<button
type="button"
className="app-download-pc-btn"
onClick={() => triggerDownload(mobile.downloadUrl!, mobile.fileName ?? `goldenchart-android${ext}`)}
>
다운로드
</button>
</div>
);
}
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 (
@@ -309,6 +387,7 @@ const DesktopBuildProgressPanel: React.FC<{
const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) => {
const [info, setInfo] = useState<DesktopAppReleaseInfoDto | null>(null);
const [mobileInfo, setMobileInfo] = useState<MobileAppReleaseInfoDto | null>(null);
const [buildStatus, setBuildStatus] = useState<DesktopAppBuildStatusDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -318,9 +397,13 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
const [elapsedTick, setElapsedTick] = useState(0);
const reloadRelease = useCallback(async () => {
const data = await loadDesktopAppReleaseInfo();
setInfo(data);
return data;
const [desktop, mobile] = await Promise.all([
loadDesktopAppReleaseInfo(),
loadMobileAppReleaseInfo(),
]);
setInfo(desktop);
setMobileInfo(mobile);
return desktop;
}, []);
const reloadBuildStatus = useCallback(async () => {
@@ -388,7 +471,8 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
}
}, [canBuildDesktop, buildBusy, isBuilding, reloadBuildStatus]);
const hasAny = info?.mac?.available || info?.windows?.available;
const hasAny = info?.mac?.available || info?.windows?.available
|| mobileInfo?.available || mobileInfo?.iosAvailable;
if (loading) return <p className="app-download-muted">PC 프로그램 정보 불러오는 중…</p>;
if (error) return <p className="app-download-error">{error}</p>;
@@ -450,7 +534,7 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
<div className="app-download-empty-icon" aria-hidden>💻</div>
<h3>설치 파일 준비 중</h3>
<p>
macOS(.dmg) 또는 Windows(.exe) 설치 파일이 아직 서버에 없습니다.
macOS(.dmg), Windows(.exe), Android(.apk) 또는 iPad TestFlight 링크가 아직 준비되지 않았습니다.
<br />
{canBuildDesktop
? '우측 상단 「앱 빌드」 버튼으로 Jenkins 빌드를 시작하세요.'
@@ -469,6 +553,8 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
<div className="app-download-pc-list">
<PlatformCard platform="mac" release={info?.mac} />
<PlatformCard platform="windows" release={info?.windows} />
<PlatformCard platform="android" mobile={mobileInfo} />
<PlatformCard platform="ipad" mobile={mobileInfo} />
</div>
)}
@@ -486,7 +572,10 @@ const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop })
<code>xattr -cr /Applications/GoldenChart.app</code> 실행 후 다시 열어보세요.
</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>빌드는 Git <code>main</code> 최신 커밋 기준이며, 완료 후 설치 파일이 자동으로 배포됩니다.</li>
</ol>
</details>
+2
View File
@@ -637,6 +637,8 @@ export interface MobileAppReleaseInfoDto {
updatedAt?: string;
downloadUrl?: string;
installPageUrl?: string;
iosAvailable?: boolean;
iosTestFlightUrl?: string;
}
export async function loadMobileAppReleaseInfo(): Promise<MobileAppReleaseInfoDto | null> {