/** * 모바일·PC 프로그램 설치 — 탭 UI (모바일 QR / PC dmg·exe + Jenkins 빌드) */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import QRCode from 'qrcode'; import DraggableModalFrame from './DraggableModalFrame'; import { loadDesktopAppBuildStatus, loadDesktopAppReleaseInfo, loadMobileAppReleaseInfo, triggerDesktopAppBuild, type DesktopAppBuildStatusDto, type DesktopAppPlatformReleaseDto, type DesktopAppReleaseInfoDto, type MobileAppReleaseInfoDto, } from '../utils/backendApi'; interface Props { onClose: () => void; /** ADMIN — Jenkins 데스크톱 빌드 트리거 */ canBuildDesktop?: boolean; } type TabId = 'mobile' | 'desktop'; function formatBytes(n?: number): string { if (n == null || !Number.isFinite(n)) return '—'; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } function resolveDownloadUrl(info: MobileAppReleaseInfoDto | null): string { if (!info?.available) return ''; return info.downloadUrl?.trim() ?? ''; } function triggerDownload(url: string, fileName: string) { const a = document.createElement('a'); a.href = url; a.download = fileName; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove(); } const IcDesktopBuild = () => ( ); const MobileTab: React.FC = () => { const [info, setInfo] = useState(null); const [loading, setLoading] = useState(true); const [qrDataUrl, setQrDataUrl] = useState(null); const [error, setError] = useState(null); const downloadUrl = useMemo(() => resolveDownloadUrl(info), [info]); useEffect(() => { let cancelled = false; void loadMobileAppReleaseInfo() .then(data => { if (!cancelled) setInfo(data); }) .catch(() => { if (!cancelled) setError('앱 정보를 불러오지 못했습니다.'); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, []); useEffect(() => { if (!downloadUrl) { setQrDataUrl(null); return; } let cancelled = false; void QRCode.toDataURL(downloadUrl, { width: 220, margin: 2, color: { dark: '#111827', light: '#ffffff' }, }).then(url => { if (!cancelled) setQrDataUrl(url); }) .catch(() => { if (!cancelled) setQrDataUrl(null); }); return () => { cancelled = true; }; }, [downloadUrl]); if (loading) return

앱 정보 불러오는 중…

; if (error) return

{error}

; if (!info?.available) { return (
📱

설치 파일 준비 중

Android APK가 아직 서버에 업로드되지 않았습니다.
data/mobile-releases/goldenchart-android.apk 경로에 빌드 파일을 배치하세요.

); } return ( <>
GC

GoldenChart Android

{info.version && v{info.version}} {info.sizeBytes != null && {formatBytes(info.sizeBytes)}} {info.updatedAt && {info.updatedAt}}

휴대폰·태블릿(패드) 동일 APK

{qrDataUrl ? ( 앱 설치 QR 코드 ) : (
QR 생성 중…
)}
휴대폰·태블릿 카메라로 QR 스캔

Android에서 APK 다운로드 페이지가 열립니다. 다운로드 후 설치하세요.

{downloadUrl}

설치 방법 (Android · 패드·폰)
  1. QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.
  2. 「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.
  3. 다운로드 완료 후 APK 파일을 탭하여 설치합니다.
  4. 앱 실행 후 설정 → 「업데이트 확인」으로 새 버전을 받을 수 있습니다.
); }; const PlatformCard: React.FC<{ platform: 'mac' | 'windows' | 'android'; release?: DesktopAppPlatformReleaseDto; 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 }, } as const; const { label, icon, ext } = configs[platform]; if (platform === 'android') { if (!mobile?.available || !mobile.downloadUrl) { return (
{icon}
{label}

설치 파일 준비 중 ({ext})

); } return (
{icon}
{label}

{mobile.version && v{mobile.version}} {mobile.sizeBytes != null && {formatBytes(mobile.sizeBytes)}} {mobile.updatedAt && {mobile.updatedAt}}

{mobile.fileName ?? `goldenchart-android${ext}`}

); } if (!release?.available || !release.downloadUrl) { return (
{icon}
{label}

설치 파일 준비 중 ({ext})

); } return (
{icon}
{label}

{release.version && v{release.version}} {release.sizeBytes != null && {formatBytes(release.sizeBytes)}} {release.updatedAt && {release.updatedAt}}

{release.fileName}

); }; const BUILD_POLL_MS = 4000; function formatElapsed(startedAtMs?: number): string { if (startedAtMs == null || !Number.isFinite(startedAtMs)) return '—'; const sec = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); const m = Math.floor(sec / 60); const s = sec % 60; return m > 0 ? `${m}분 ${s.toString().padStart(2, '0')}초` : `${s}초`; } function buildStatusLabel(status?: string, building?: boolean): string { if (building) return '빌드 진행 중'; switch (status) { case 'building': return '빌드 진행 중'; case 'queued': return 'Jenkins 큐 대기'; case 'success': return '빌드 성공'; case 'failure': return '빌드 실패'; case 'aborted': return '빌드 중단'; case 'error': return '상태 조회 오류'; case 'disabled': return 'Jenkins 비활성'; case 'ready': return '빌드 준비됨'; case 'idle': return '대기 중'; default: return status ?? '—'; } } function isTerminalBuildStatus(status?: string, building?: boolean): boolean { if (building) return false; return status === 'success' || status === 'failure' || status === 'aborted'; } const DesktopBuildProgressPanel: React.FC<{ status: DesktopAppBuildStatusDto | null; hint: string | null; busy: boolean; tracking: boolean; tick: number; }> = ({ status, hint, busy, tracking, tick }) => { void tick; const building = Boolean(status?.building || status?.status === 'queued'); const active = busy || building || tracking; const terminal = isTerminalBuildStatus(status?.status, status?.building); const failed = !building && (status?.status === 'failure' || status?.status === 'error'); const succeeded = !building && status?.status === 'success'; if (!active && !terminal && !hint) return null; const steps = [ { id: 'queue', label: 'Jenkins 빌드 시작', done: Boolean(status?.buildNumber) || building || terminal }, { id: 'compile', label: 'macOS dmg · Windows exe 빌드', done: succeeded || (building && Boolean(status?.buildNumber)), active: building && Boolean(status?.buildNumber), }, { id: 'deploy', label: '설치 파일 배포', done: succeeded, active: false }, ]; return (
{(active || building) && } {buildStatusLabel(status?.status, status?.building)} {status?.buildNumber != null && ( #{status.buildNumber} )}
{(building || status?.startedAtMs) && ( 경과 {formatElapsed(status?.startedAtMs)} )}
{(active || building) && (
)}
    {steps.map(step => (
  1. {step.label}
  2. ))}
{(hint || status?.message) && (

{hint ?? status?.message}

)} {succeeded && (

아래에서 macOS·Windows 설치 파일을 다운로드할 수 있습니다.

)}
); }; const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) => { const [info, setInfo] = useState(null); const [mobileInfo, setMobileInfo] = useState(null); const [buildStatus, setBuildStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [buildBusy, setBuildBusy] = useState(false); const [buildHint, setBuildHint] = useState(null); const [buildTracking, setBuildTracking] = useState(false); const [elapsedTick, setElapsedTick] = useState(0); const reloadRelease = useCallback(async () => { const [desktop, mobile] = await Promise.all([ loadDesktopAppReleaseInfo(), loadMobileAppReleaseInfo(), ]); setInfo(desktop); setMobileInfo(mobile); return desktop; }, []); const reloadBuildStatus = useCallback(async () => { const st = await loadDesktopAppBuildStatus(); setBuildStatus(st); return st; }, []); useEffect(() => { let cancelled = false; void Promise.all([reloadRelease(), reloadBuildStatus()]) .catch(() => { if (!cancelled) setError('PC 프로그램 정보를 불러오지 못했습니다.'); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [reloadRelease, reloadBuildStatus]); const isBuilding = Boolean(buildStatus?.building || buildStatus?.status === 'queued'); const showBuildProgress = Boolean( buildBusy || buildTracking || isBuilding || (buildStatus?.buildNumber && buildStatus.status !== 'idle' && buildStatus.status !== 'ready'), ); useEffect(() => { if (!showBuildProgress || buildStatus?.startedAtMs == null) return; const timer = window.setInterval(() => setElapsedTick(t => t + 1), 1000); return () => window.clearInterval(timer); }, [showBuildProgress, buildStatus?.startedAtMs]); useEffect(() => { if (!buildTracking && !isBuilding && !buildBusy) return; const poll = () => { void reloadBuildStatus().then(st => { if (!st) return; if (isTerminalBuildStatus(st.status, st.building)) { setBuildTracking(false); void reloadRelease(); } else if (!st.building && st.status !== 'queued') { void reloadRelease(); } }); }; poll(); const timer = window.setInterval(poll, BUILD_POLL_MS); return () => window.clearInterval(timer); }, [buildTracking, isBuilding, buildBusy, reloadBuildStatus, reloadRelease]); const handleBuild = useCallback(async () => { if (!canBuildDesktop || buildBusy || isBuilding) return; setBuildBusy(true); setBuildTracking(true); setBuildHint('Jenkins 빌드 요청 중…'); try { const res = await triggerDesktopAppBuild(); setBuildHint(res.message ?? (res.accepted ? '빌드 시작됨' : '빌드 요청 거부')); if (!res.accepted) setBuildTracking(false); await reloadBuildStatus(); if (res.accepted) { window.setTimeout(() => { void reloadBuildStatus(); }, 2000); } } catch (e) { setBuildHint(e instanceof Error ? e.message : '빌드 요청 실패'); setBuildTracking(false); } finally { setBuildBusy(false); } }, [canBuildDesktop, buildBusy, isBuilding, reloadBuildStatus]); const hasAny = info?.mac?.available || info?.windows?.available || mobileInfo?.available; const desktopVersionMismatch = Boolean( info?.version && ( (info.mac?.available && info.mac.version && info.mac.version !== info.version) || (info.windows?.available && info.windows.version && info.windows.version !== info.version) ), ); if (loading) return

PC 프로그램 정보 불러오는 중…

; if (error) return

{error}

; const statusMessage = !showBuildProgress ? (buildHint ?? buildStatus?.message) : null; return ( <>
GC

GoldenChart Desktop

{info?.version && v{info.version}} {info?.updatedAt && {info.updatedAt}}

{canBuildDesktop && buildStatus?.jenkinsEnabled !== false && ( )}
{desktopVersionMismatch && (

macOS·Windows 설치 파일 버전이 최신 빌드(v{info?.version})와 다릅니다. 「앱 빌드」를 다시 실행하세요.

)} {showBuildProgress && ( )} {statusMessage && (

{statusMessage}

)} {!canBuildDesktop && (

데스크톱 앱 빌드는 관리자 로그인 후 사용할 수 있습니다.

)} {!hasAny && !isBuilding && (
💻

설치 파일 준비 중

macOS(.dmg), Windows(.exe) 또는 Android(.apk)가 아직 준비되지 않았습니다.
{canBuildDesktop ? '우측 상단 「앱 빌드」 버튼으로 Jenkins 빌드를 시작하세요.' : '관리자에게 Jenkins 빌드를 요청하세요.'}

)} {isBuilding && !hasAny && (

Tauri macOS dmg · Windows NSIS 빌드가 진행 중입니다. 완료되면 아래에 다운로드가 표시됩니다.

)} {(hasAny || isBuilding) && (
)} {(hasAny || isBuilding) && (
설치 방법
  1. macOS: dmg를 열고 GoldenChart.app을 Applications 폴더로 드래그합니다.
  2. macOS — 「손상되었기 때문에 열 수 없습니다」 가 뜨면 앱이 깨진 것이 아니라 macOS Gatekeeper가 미公証 앱을 차단한 것입니다. 먼저 취소를 누른 뒤 Finder → 응용 프로그램에서 GoldenChart를 우클릭 → 열기 하거나, 시스템 설정 → 개인정보 보호 및 보안 → 그래도 열기 를 선택하세요. 그래도 안 되면 터미널에서{' '} xattr -cr /Applications/GoldenChart.app 실행 후 다시 열어보세요.
  3. Windows: setup.exe를 실행하고 안내에 따라 설치합니다.
  4. Android (패드·폰): APK를 다운로드한 뒤 설치합니다. 패드와 폰은 동일 APK입니다.
  5. 설치된 PC 앱은 메뉴 우측 상단 업데이트 버튼으로 자동 업데이트할 수 있습니다.
  6. Android 앱은 시작·복귀 시 또는 설정 → 업데이트 확인으로 새 APK를 안내합니다.
  7. 빌드는 Git main 최신 커밋 기준이며, 완료 후 설치 파일이 자동으로 배포됩니다.
)} ); }; const AppDownloadModal: React.FC = ({ onClose, canBuildDesktop = false }) => { const [tab, setTab] = useState('mobile'); return (
{tab === 'mobile' ? : }
); }; export default AppDownloadModal;