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> {