This commit is contained in:
Macbook
2026-06-14 11:35:54 +09:00
parent 117a588df8
commit 78cda775ad
8 changed files with 519 additions and 63 deletions
@@ -0,0 +1,193 @@
/**
* PC(Tauri) 앱 — 업데이트 확인·다운로드·설치 진행 모달
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import DraggableModalFrame from './DraggableModalFrame';
import {
getDesktopAppVersion,
runDesktopUpdate,
type DesktopUpdatePhase,
type DesktopUpdateProgress,
} from '../utils/desktopBridge';
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 () => {
if (busy) return;
setBusy(true);
setProgress({ phase: 'checking', message: '서버에서 새 버전을 확인하는 중…', currentVersion: appVersion });
try {
await runDesktopUpdate(p => setProgress(p));
} finally {
setBusy(false);
}
}, [appVersion, busy]);
useEffect(() => {
if (!open) {
startedRef.current = false;
return;
}
void getDesktopAppVersion().then(v => {
if (v) setAppVersion(v);
});
if (autoStart && !startedRef.current) {
startedRef.current = true;
void handleRun();
}
}, [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;
return (
<DraggableModalFrame
title="PC 프로그램 업데이트"
onClose={isActive ? () => {} : onClose}
closeOnBackdrop={!isActive}
width={440}
dialogClassName="sp-modal desktop-update-modal"
>
<div className="desktop-update-modal-body">
<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">
. <strong>PC </strong> dmg를 .
</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(); }}>
</button>
)}
<button
type="button"
className="stg-btn-secondary"
disabled={isActive}
onClick={onClose}
>
{isActive ? '진행 중…' : '닫기'}
</button>
</div>
</div>
</DraggableModalFrame>
);
};
export default DesktopUpdateModal;