601 lines
22 KiB
TypeScript
601 lines
22 KiB
TypeScript
/**
|
|
* 모바일·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 = () => (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="M12 3v4" />
|
|
<path d="M8 7h8" />
|
|
<rect x="5" y="7" width="14" height="12" rx="2" />
|
|
<path d="M9 13h6" />
|
|
<path d="M9 17h4" />
|
|
</svg>
|
|
);
|
|
|
|
const MobileTab: React.FC = () => {
|
|
const [info, setInfo] = useState<MobileAppReleaseInfoDto | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(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 <p className="app-download-muted">앱 정보 불러오는 중…</p>;
|
|
if (error) return <p className="app-download-error">{error}</p>;
|
|
if (!info?.available) {
|
|
return (
|
|
<div className="app-download-empty">
|
|
<div className="app-download-empty-icon" aria-hidden>📱</div>
|
|
<h3>설치 파일 준비 중</h3>
|
|
<p>
|
|
Android APK가 아직 서버에 업로드되지 않았습니다.
|
|
<br />
|
|
<code>data/mobile-releases/goldenchart-android.apk</code> 경로에 빌드 파일을 배치하세요.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="app-download-hero">
|
|
<div className="app-download-app-icon" aria-hidden>GC</div>
|
|
<div>
|
|
<h3 className="app-download-app-name">GoldenChart Android</h3>
|
|
<p className="app-download-meta">
|
|
{info.version && <span>v{info.version}</span>}
|
|
{info.sizeBytes != null && <span>{formatBytes(info.sizeBytes)}</span>}
|
|
{info.updatedAt && <span>{info.updatedAt}</span>}
|
|
</p>
|
|
<p className="app-download-muted" style={{ marginTop: 4, fontSize: 12 }}>
|
|
휴대폰·태블릿(패드) 동일 APK
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="app-download-qr-block">
|
|
{qrDataUrl ? (
|
|
<img src={qrDataUrl} alt="앱 설치 QR 코드" className="app-download-qr" width={220} height={220} />
|
|
) : (
|
|
<div className="app-download-qr app-download-qr--placeholder">QR 생성 중…</div>
|
|
)}
|
|
<div className="app-download-qr-help">
|
|
<strong>휴대폰·태블릿 카메라로 QR 스캔</strong>
|
|
<p>Android에서 APK 다운로드 페이지가 열립니다. 다운로드 후 설치하세요.</p>
|
|
<p className="app-download-muted app-download-url" title={downloadUrl}>{downloadUrl}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="app-download-btn-primary"
|
|
onClick={() => triggerDownload(downloadUrl, info.fileName ?? 'goldenchart-android.apk')}
|
|
>
|
|
Android APK 다운로드
|
|
</button>
|
|
|
|
<details className="app-download-steps">
|
|
<summary>설치 방법 (Android · 패드·폰)</summary>
|
|
<ol>
|
|
<li>QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.</li>
|
|
<li>「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.</li>
|
|
<li>다운로드 완료 후 APK 파일을 탭하여 설치합니다.</li>
|
|
<li>앱 실행 후 설정 → 「업데이트 확인」으로 새 버전을 받을 수 있습니다.</li>
|
|
</ol>
|
|
</details>
|
|
</>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<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 (!release?.available || !release.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">
|
|
{release.version && <span>v{release.version}</span>}
|
|
{release.sizeBytes != null && <span>{formatBytes(release.sizeBytes)}</span>}
|
|
{release.updatedAt && <span>{release.updatedAt}</span>}
|
|
</p>
|
|
<p className="app-download-muted app-download-url" title={release.downloadUrl}>
|
|
{release.fileName}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="app-download-pc-btn"
|
|
onClick={() => triggerDownload(release.downloadUrl!, release.fileName ?? `GoldenChart${ext}`)}
|
|
>
|
|
다운로드
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<div
|
|
className={`app-download-build-progress${active ? ' app-download-build-progress--active' : ''}${failed ? ' app-download-build-progress--failed' : ''}${succeeded ? ' app-download-build-progress--success' : ''}`}
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<div className="app-download-build-progress-head">
|
|
<div className="app-download-build-progress-title">
|
|
{(active || building) && <span className="app-download-build-spinner" aria-hidden />}
|
|
<strong>{buildStatusLabel(status?.status, status?.building)}</strong>
|
|
{status?.buildNumber != null && (
|
|
<span className="app-download-build-badge">#{status.buildNumber}</span>
|
|
)}
|
|
</div>
|
|
{(building || status?.startedAtMs) && (
|
|
<span className="app-download-build-elapsed">
|
|
경과 {formatElapsed(status?.startedAtMs)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{(active || building) && (
|
|
<div className="app-download-build-bar" aria-hidden>
|
|
<div className="app-download-build-bar-fill" />
|
|
</div>
|
|
)}
|
|
|
|
<ol className="app-download-build-steps">
|
|
{steps.map(step => (
|
|
<li
|
|
key={step.id}
|
|
className={`app-download-build-step${step.done ? ' done' : ''}${step.active ? ' active' : ''}`}
|
|
>
|
|
<span className="app-download-build-step-dot" aria-hidden />
|
|
{step.label}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
|
|
{(hint || status?.message) && (
|
|
<p className="app-download-build-message">{hint ?? status?.message}</p>
|
|
)}
|
|
|
|
{succeeded && (
|
|
<p className="app-download-build-done-note">아래에서 macOS·Windows 설치 파일을 다운로드할 수 있습니다.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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);
|
|
const [buildBusy, setBuildBusy] = useState(false);
|
|
const [buildHint, setBuildHint] = useState<string | null>(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 <p className="app-download-muted">PC 프로그램 정보 불러오는 중…</p>;
|
|
if (error) return <p className="app-download-error">{error}</p>;
|
|
|
|
const statusMessage = !showBuildProgress ? (buildHint ?? buildStatus?.message) : null;
|
|
|
|
return (
|
|
<>
|
|
<div className="app-download-hero app-download-hero--with-action">
|
|
<div className="app-download-hero-main">
|
|
<div className="app-download-app-icon app-download-app-icon--desktop" aria-hidden>GC</div>
|
|
<div>
|
|
<h3 className="app-download-app-name">GoldenChart Desktop</h3>
|
|
<p className="app-download-meta">
|
|
{info?.version && <span>v{info.version}</span>}
|
|
{info?.updatedAt && <span>{info.updatedAt}</span>}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{canBuildDesktop && buildStatus?.jenkinsEnabled !== false && (
|
|
<button
|
|
type="button"
|
|
className={`app-download-build-btn${buildBusy || isBuilding ? ' busy' : ''}`}
|
|
onClick={() => { void handleBuild(); }}
|
|
disabled={buildBusy || isBuilding}
|
|
title="Git main 최신 소스로 macOS·Windows 데스크톱 앱 Jenkins 빌드"
|
|
aria-label="데스크톱 앱 빌드"
|
|
>
|
|
<IcDesktopBuild />
|
|
<span>{isBuilding ? '빌드 중…' : '앱 빌드'}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{desktopVersionMismatch && (
|
|
<p className="app-download-error" style={{ marginBottom: 8 }}>
|
|
macOS·Windows 설치 파일 버전이 최신 빌드(v{info?.version})와 다릅니다. 「앱 빌드」를 다시 실행하세요.
|
|
</p>
|
|
)}
|
|
|
|
{showBuildProgress && (
|
|
<DesktopBuildProgressPanel
|
|
status={buildStatus}
|
|
hint={buildHint}
|
|
busy={buildBusy}
|
|
tracking={buildTracking}
|
|
tick={elapsedTick}
|
|
/>
|
|
)}
|
|
|
|
{statusMessage && (
|
|
<p className={`app-download-build-status${isBuilding ? ' app-download-build-status--active' : ''}`}>
|
|
{statusMessage}
|
|
</p>
|
|
)}
|
|
|
|
{!canBuildDesktop && (
|
|
<p className="app-download-muted app-download-build-note">
|
|
데스크톱 앱 빌드는 <strong>관리자</strong> 로그인 후 사용할 수 있습니다.
|
|
</p>
|
|
)}
|
|
|
|
{!hasAny && !isBuilding && (
|
|
<div className="app-download-empty">
|
|
<div className="app-download-empty-icon" aria-hidden>💻</div>
|
|
<h3>설치 파일 준비 중</h3>
|
|
<p>
|
|
macOS(.dmg), Windows(.exe) 또는 Android(.apk)가 아직 준비되지 않았습니다.
|
|
<br />
|
|
{canBuildDesktop
|
|
? '우측 상단 「앱 빌드」 버튼으로 Jenkins 빌드를 시작하세요.'
|
|
: '관리자에게 Jenkins 빌드를 요청하세요.'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{isBuilding && !hasAny && (
|
|
<p className="app-download-muted app-download-build-wait">
|
|
Tauri macOS dmg · Windows NSIS 빌드가 진행 중입니다. 완료되면 아래에 다운로드가 표시됩니다.
|
|
</p>
|
|
)}
|
|
|
|
{(hasAny || isBuilding) && (
|
|
<div className="app-download-pc-list">
|
|
<PlatformCard platform="mac" release={info?.mac} />
|
|
<PlatformCard platform="windows" release={info?.windows} />
|
|
<PlatformCard platform="android" mobile={mobileInfo} />
|
|
</div>
|
|
)}
|
|
|
|
{(hasAny || isBuilding) && (
|
|
<details className="app-download-steps">
|
|
<summary>설치 방법</summary>
|
|
<ol>
|
|
<li><strong>macOS</strong>: dmg를 열고 GoldenChart.app을 Applications 폴더로 드래그합니다.</li>
|
|
<li>
|
|
<strong>macOS — 「손상되었기 때문에 열 수 없습니다」</strong> 가 뜨면 앱이 깨진 것이 아니라
|
|
macOS Gatekeeper가 미公証 앱을 차단한 것입니다. 먼저 <strong>취소</strong>를 누른 뒤
|
|
Finder → 응용 프로그램에서 GoldenChart를 <strong>우클릭 → 열기</strong> 하거나,
|
|
시스템 설정 → 개인정보 보호 및 보안 → <strong>그래도 열기</strong> 를 선택하세요.
|
|
그래도 안 되면 터미널에서{' '}
|
|
<code>xattr -cr /Applications/GoldenChart.app</code> 실행 후 다시 열어보세요.
|
|
</li>
|
|
<li><strong>Windows</strong>: setup.exe를 실행하고 안내에 따라 설치합니다.</li>
|
|
<li><strong>Android (패드·폰)</strong>: APK를 다운로드한 뒤 설치합니다. 패드와 폰은 동일 APK입니다.</li>
|
|
<li>설치된 PC 앱은 메뉴 우측 상단 <strong>업데이트</strong> 버튼으로 자동 업데이트할 수 있습니다.</li>
|
|
<li>Android 앱은 시작·복귀 시 또는 설정 → <strong>업데이트 확인</strong>으로 새 APK를 안내합니다.</li>
|
|
<li>빌드는 Git <code>main</code> 최신 커밋 기준이며, 완료 후 설치 파일이 자동으로 배포됩니다.</li>
|
|
</ol>
|
|
</details>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const AppDownloadModal: React.FC<Props> = ({ onClose, canBuildDesktop = false }) => {
|
|
const [tab, setTab] = useState<TabId>('mobile');
|
|
|
|
return (
|
|
<DraggableModalFrame
|
|
onClose={onClose}
|
|
title="Apps"
|
|
titleKo="GoldenChart 앱·PC 프로그램"
|
|
badge={tab === 'mobile' ? 'Mobile' : 'Desktop'}
|
|
width={480}
|
|
dialogClassName="sp-modal app-download-modal"
|
|
>
|
|
<div className="app-download-tabs" role="tablist" aria-label="다운로드 종류">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={tab === 'mobile'}
|
|
className={`app-download-tab${tab === 'mobile' ? ' active' : ''}`}
|
|
onClick={() => setTab('mobile')}
|
|
>
|
|
모바일
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={tab === 'desktop'}
|
|
className={`app-download-tab${tab === 'desktop' ? ' active' : ''}`}
|
|
onClick={() => setTab('desktop')}
|
|
>
|
|
PC 프로그램
|
|
</button>
|
|
</div>
|
|
|
|
<div className="app-download-body" role="tabpanel">
|
|
{tab === 'mobile' ? <MobileTab /> : <DesktopTab canBuildDesktop={canBuildDesktop} />}
|
|
</div>
|
|
</DraggableModalFrame>
|
|
);
|
|
};
|
|
|
|
export default AppDownloadModal;
|