230 lines
8.3 KiB
TypeScript
230 lines
8.3 KiB
TypeScript
/**
|
|
* PC(Tauri) 앱 — 업데이트 확인·다운로드·설치 진행 모달
|
|
*/
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import DraggableModalFrame from './DraggableModalFrame';
|
|
import {
|
|
getDesktopAppVersion,
|
|
runDesktopUpdate,
|
|
type DesktopUpdatePhase,
|
|
type DesktopUpdateProgress,
|
|
} from '../utils/desktopBridge';
|
|
|
|
/** 다른 오버레이(플로팅 위젯 13000 등)보다 위에 표시 */
|
|
const DESKTOP_UPDATE_MODAL_Z = 15000;
|
|
import { isMacDesktop, isWindowsDesktop } from '../utils/platform';
|
|
import { PlatformBrandIcon, type PlatformBrandIconId } from './icons/PlatformBrandIcons';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
/** 열릴 때 자동으로 업데이트 확인 시작 */
|
|
autoStart?: boolean;
|
|
}
|
|
|
|
const PHASE_LABEL: Record<DesktopUpdatePhase, string> = {
|
|
checking: '업데이트 확인 중',
|
|
uptodate: '최신 버전',
|
|
available: '새 버전 발견',
|
|
downloading: '다운로드 중',
|
|
installing: '설치 중',
|
|
relaunching: '재시작 중',
|
|
error: '업데이트 실패',
|
|
};
|
|
|
|
function formatBytes(n?: number): string {
|
|
if (n == null || !Number.isFinite(n) || n <= 0) return '';
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
const DesktopUpdateModal: React.FC<Props> = ({ open, onClose, autoStart = true }) => {
|
|
const [appVersion, setAppVersion] = useState('—');
|
|
const [progress, setProgress] = useState<DesktopUpdateProgress | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const startedRef = useRef(false);
|
|
|
|
const isActive = busy || progress?.phase === 'checking' || progress?.phase === 'downloading'
|
|
|| progress?.phase === 'installing' || progress?.phase === 'relaunching';
|
|
|
|
const panelClass = progress?.phase === 'error'
|
|
? 'desktop-update-progress--failed'
|
|
: progress?.phase === 'uptodate'
|
|
? 'desktop-update-progress--success'
|
|
: isActive
|
|
? 'desktop-update-progress--active'
|
|
: '';
|
|
|
|
const handleRun = useCallback(async (opts?: { bypassLoopGuard?: boolean }) => {
|
|
if (busy) return;
|
|
setBusy(true);
|
|
setProgress({ phase: 'checking', message: '서버에서 새 버전을 확인하는 중…', currentVersion: appVersion });
|
|
try {
|
|
await runDesktopUpdate(p => setProgress(p), {
|
|
bypassLoopGuard: opts?.bypassLoopGuard ?? true,
|
|
});
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}, [appVersion, busy]);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
startedRef.current = false;
|
|
setProgress(null);
|
|
setBusy(false);
|
|
return;
|
|
}
|
|
void getDesktopAppVersion().then(v => {
|
|
if (v) setAppVersion(v);
|
|
});
|
|
if (autoStart && !startedRef.current) {
|
|
startedRef.current = true;
|
|
void handleRun({ bypassLoopGuard: true });
|
|
}
|
|
}, [open, autoStart, handleRun]);
|
|
|
|
if (!open) return null;
|
|
|
|
const phase = progress?.phase ?? 'checking';
|
|
const showBar = phase === 'downloading' || phase === 'installing' || phase === 'available';
|
|
const barPct = phase === 'installing'
|
|
? 100
|
|
: progress?.downloadPercent;
|
|
|
|
const platformBrand: PlatformBrandIconId | null = isMacDesktop()
|
|
? 'mac'
|
|
: isWindowsDesktop()
|
|
? 'windows'
|
|
: null;
|
|
|
|
return (
|
|
<DraggableModalFrame
|
|
title="PC 프로그램 업데이트"
|
|
onClose={isActive ? () => {} : onClose}
|
|
closeOnBackdrop={!isActive}
|
|
width={440}
|
|
zIndex={DESKTOP_UPDATE_MODAL_Z}
|
|
dialogClassName="sp-modal desktop-update-modal"
|
|
>
|
|
<div className="desktop-update-modal-body">
|
|
{platformBrand && (
|
|
<div className="desktop-update-platform-brand">
|
|
<PlatformBrandIcon platform={platformBrand} />
|
|
</div>
|
|
)}
|
|
<p className="desktop-update-version">
|
|
현재 버전 <strong>v{progress?.currentVersion ?? appVersion}</strong>
|
|
{progress?.newVersion && progress.newVersion !== progress.currentVersion && (
|
|
<> → <strong>v{progress.newVersion}</strong></>
|
|
)}
|
|
</p>
|
|
|
|
<div className={`desktop-update-progress ${panelClass}`.trim()}>
|
|
<div className="desktop-update-progress-head">
|
|
<span className="desktop-update-progress-title">
|
|
{(phase === 'checking' || phase === 'downloading' || phase === 'installing' || phase === 'relaunching') && (
|
|
<span className="desktop-update-spinner" aria-hidden />
|
|
)}
|
|
{PHASE_LABEL[phase]}
|
|
</span>
|
|
{phase === 'downloading' && progress?.downloadPercent != null && (
|
|
<span className="desktop-update-pct">{progress.downloadPercent}%</span>
|
|
)}
|
|
</div>
|
|
|
|
{showBar && (
|
|
<div
|
|
className="desktop-update-bar"
|
|
role="progressbar"
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={barPct ?? undefined}
|
|
>
|
|
{barPct != null ? (
|
|
<div className="desktop-update-bar-fill desktop-update-bar-fill--determinate" style={{ width: `${barPct}%` }} />
|
|
) : (
|
|
<div className="desktop-update-bar-fill desktop-update-bar-fill--indeterminate" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<ul className="desktop-update-steps">
|
|
<li className={`desktop-update-step${phase === 'checking' ? ' active' : phase !== 'error' ? ' done' : ''}`}>
|
|
<span className="desktop-update-step-dot" aria-hidden />
|
|
서버에서 버전 확인
|
|
</li>
|
|
<li className={`desktop-update-step${
|
|
phase === 'available' || phase === 'downloading' ? ' active'
|
|
: ['installing', 'relaunching', 'uptodate'].includes(phase) ? ' done' : ''
|
|
}`}>
|
|
<span className="desktop-update-step-dot" aria-hidden />
|
|
업데이트 다운로드
|
|
{progress?.downloadedBytes && progress.totalBytes ? (
|
|
<span className="desktop-update-bytes">
|
|
{formatBytes(progress.downloadedBytes)} / {formatBytes(progress.totalBytes)}
|
|
</span>
|
|
) : null}
|
|
</li>
|
|
<li className={`desktop-update-step${
|
|
phase === 'installing' ? ' active' : phase === 'relaunching' || phase === 'uptodate' ? ' done' : ''
|
|
}`}>
|
|
<span className="desktop-update-step-dot" aria-hidden />
|
|
설치 및 재시작
|
|
</li>
|
|
</ul>
|
|
|
|
{progress?.message && (
|
|
<p className={`desktop-update-message${phase === 'error' ? ' desktop-update-message--error' : ''}`}>
|
|
{progress.message}
|
|
</p>
|
|
)}
|
|
|
|
{phase === 'error' && (
|
|
<p className="desktop-update-hint">
|
|
{isWindowsDesktop() ? (
|
|
<>
|
|
자동 업데이트가 설정되지 않았을 수 있습니다. 웹 <strong>PC 프로그램</strong> 탭에서
|
|
<strong> exe</strong> 설치 파일을 받아 다시 설치해 주세요.
|
|
</>
|
|
) : isMacDesktop() ? (
|
|
<>
|
|
자동 업데이트가 설정되지 않았을 수 있습니다. 웹 <strong>PC 프로그램</strong> 탭에서
|
|
<strong> dmg</strong>를 받아 Applications에 다시 설치해 주세요.
|
|
</>
|
|
) : (
|
|
<>
|
|
자동 업데이트가 설정되지 않았을 수 있습니다. 웹 <strong>PC 프로그램</strong> 탭에서
|
|
macOS는 dmg, Windows는 exe 설치 파일을 받아 다시 설치해 주세요.
|
|
</>
|
|
)}
|
|
</p>
|
|
)}
|
|
|
|
{phase === 'uptodate' && (
|
|
<p className="desktop-update-done-note">현재 설치된 버전이 서버와 같습니다.</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="desktop-update-actions">
|
|
{phase === 'error' && (
|
|
<button type="button" className="stg-btn-secondary" disabled={busy} onClick={() => { void handleRun({ bypassLoopGuard: true }); }}>
|
|
다시 시도
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="stg-btn-secondary"
|
|
disabled={isActive}
|
|
onClick={onClose}
|
|
>
|
|
{isActive ? '진행 중…' : '닫기'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</DraggableModalFrame>
|
|
);
|
|
};
|
|
|
|
export default DesktopUpdateModal;
|