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:
@@ -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); } }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -637,6 +637,8 @@ export interface MobileAppReleaseInfoDto {
|
||||
updatedAt?: string;
|
||||
downloadUrl?: string;
|
||||
installPageUrl?: string;
|
||||
iosAvailable?: boolean;
|
||||
iosTestFlightUrl?: string;
|
||||
}
|
||||
|
||||
export async function loadMobileAppReleaseInfo(): Promise<MobileAppReleaseInfoDto | null> {
|
||||
|
||||
Reference in New Issue
Block a user