pc 앱 배포,다운로드 기능

This commit is contained in:
Macbook
2026-06-14 10:33:53 +09:00
parent 92f67580e3
commit 407e746822
14 changed files with 640 additions and 86 deletions
+129 -33
View File
@@ -1,12 +1,15 @@
/**
* 모바일·PC 프로그램 설치 — 탭 UI (모바일 QR / PC dmg·exe)
* 모바일·PC 프로그램 설치 — 탭 UI (모바일 QR / PC dmg·exe + Jenkins 빌드)
*/
import React, { useEffect, useMemo, useState } from 'react';
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,
@@ -14,6 +17,8 @@ import {
interface Props {
onClose: () => void;
/** ADMIN — Jenkins 데스크톱 빌드 트리거 */
canBuildDesktop?: boolean;
}
type TabId = 'mobile' | 'desktop';
@@ -39,6 +44,16 @@ function triggerDownload(url: string, fileName: string) {
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);
@@ -184,72 +199,153 @@ const PlatformCard: React.FC<{
);
};
const DesktopTab: React.FC = () => {
const DesktopTab: React.FC<{ canBuildDesktop: boolean }> = ({ canBuildDesktop }) => {
const [info, setInfo] = useState<DesktopAppReleaseInfoDto | 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 reloadRelease = useCallback(async () => {
const data = await loadDesktopAppReleaseInfo();
setInfo(data);
return data;
}, []);
const reloadBuildStatus = useCallback(async () => {
const st = await loadDesktopAppBuildStatus();
setBuildStatus(st);
return st;
}, []);
useEffect(() => {
let cancelled = false;
void loadDesktopAppReleaseInfo()
.then(data => { if (!cancelled) setInfo(data); })
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');
useEffect(() => {
if (!isBuilding) return;
const timer = window.setInterval(() => {
void reloadBuildStatus().then(st => {
if (st && !st.building && st.status !== 'queued') {
void reloadRelease();
}
});
}, 8000);
return () => window.clearInterval(timer);
}, [isBuilding, reloadBuildStatus, reloadRelease]);
const handleBuild = useCallback(async () => {
if (!canBuildDesktop || buildBusy || isBuilding) return;
setBuildBusy(true);
setBuildHint('Jenkins 빌드 요청 중…');
try {
const res = await triggerDesktopAppBuild();
setBuildHint(res.message ?? (res.accepted ? '빌드 시작됨' : '빌드 요청 거부'));
await reloadBuildStatus();
} catch (e) {
setBuildHint(e instanceof Error ? e.message : '빌드 요청 실패');
} finally {
setBuildBusy(false);
}
}, [canBuildDesktop, buildBusy, isBuilding, reloadBuildStatus]);
const hasAny = info?.mac?.available || info?.windows?.available;
if (loading) return <p className="app-download-muted">PC </p>;
if (error) return <p className="app-download-error">{error}</p>;
const statusMessage = buildHint ?? buildStatus?.message;
return (
<>
<div className="app-download-hero">
<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 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>
{!hasAny && (
{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) .
<br />
Jenkins <code>scripts/publish-desktop-update.sh</code> .
{canBuildDesktop
? '우측 상단 「앱 빌드」 버튼으로 Jenkins 빌드를 시작하세요.'
: '관리자에게 Jenkins 빌드를 요청하세요.'}
</p>
</div>
)}
{hasAny && (
<>
<div className="app-download-pc-list">
<PlatformCard platform="mac" release={info?.mac} />
<PlatformCard platform="windows" release={info?.windows} />
</div>
{isBuilding && !hasAny && (
<p className="app-download-muted app-download-build-wait">
Jenkins에서 macOS dmg · Windows setup.exe를 . .
</p>
)}
<details className="app-download-steps">
<summary> </summary>
<ol>
<li><strong>macOS</strong>: dmg를 GoldenChart.app을 Applications .</li>
<li><strong>Windows</strong>: setup.exe를 .</li>
<li> PC <strong></strong> .</li>
</ol>
</details>
</>
{(hasAny || isBuilding) && (
<div className="app-download-pc-list">
<PlatformCard platform="mac" release={info?.mac} />
<PlatformCard platform="windows" release={info?.windows} />
</div>
)}
{(hasAny || isBuilding) && (
<details className="app-download-steps">
<summary> </summary>
<ol>
<li><strong>macOS</strong>: dmg를 GoldenChart.app을 Applications .</li>
<li><strong>Windows</strong>: setup.exe를 .</li>
<li> PC <strong></strong> .</li>
<li> Git <code>main</code> , .</li>
</ol>
</details>
)}
</>
);
};
const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
const AppDownloadModal: React.FC<Props> = ({ onClose, canBuildDesktop = false }) => {
const [tab, setTab] = useState<TabId>('mobile');
return (
@@ -283,7 +379,7 @@ const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
</div>
<div className="app-download-body" role="tabpanel">
{tab === 'mobile' ? <MobileTab /> : <DesktopTab />}
{tab === 'mobile' ? <MobileTab /> : <DesktopTab canBuildDesktop={canBuildDesktop} />}
</div>
</DraggableModalFrame>
);
@@ -22,6 +22,8 @@ interface Props {
onUpdateSlots: (slots: WidgetSlot[]) => void;
onUpdateSize: (width: number, height: number) => void;
onUpdateGridFr: (rowFr: number[], colFr: number[]) => void;
/** Tauri OS 창 — 네이티브 타이틀·리사이즈 사용, 내부 플로팅 크롬 숨김 */
nativeShell?: boolean;
}
const FloatingWidgetWindow: React.FC<Props> = ({
@@ -32,6 +34,7 @@ const FloatingWidgetWindow: React.FC<Props> = ({
onUpdateSlots,
onUpdateSize,
onUpdateGridFr,
nativeShell = false,
}) => {
const [pickSlotId, setPickSlotId] = useState<string | null>(null);
const bodyRef = useRef<HTMLDivElement>(null);
@@ -146,37 +149,49 @@ const FloatingWidgetWindow: React.FC<Props> = ({
const title = `위젯 ${instance.rows}×${instance.cols}`;
return createPortal(
const windowClass = [
'fw-window',
focused ? 'fw-window--focused' : '',
nativeShell ? 'fw-window--native' : '',
].filter(Boolean).join(' ');
const windowStyle = nativeShell
? undefined
: {
...panelStyle,
width: instance.width,
height: instance.height,
zIndex: instance.zIndex,
cursor: dragging ? 'grabbing' : undefined,
};
const content = (
<>
<div
ref={panelRef}
className={`fw-window${focused ? ' fw-window--focused' : ''}`}
style={{
...panelStyle,
width: instance.width,
height: instance.height,
zIndex: instance.zIndex,
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={onFocus}
className={windowClass}
style={windowStyle}
onMouseDown={nativeShell ? undefined : onFocus}
>
<div
className="fw-window-head"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="fw-window-title">{title}</span>
<button
type="button"
className="fw-window-close"
title="위젯 창 닫기"
aria-label="위젯 창 닫기"
onPointerDown={e => e.stopPropagation()}
onClick={onClose}
{!nativeShell && (
<div
className="fw-window-head"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
</button>
</div>
<span className="fw-window-title">{title}</span>
<button
type="button"
className="fw-window-close"
title="위젯 창 닫기"
aria-label="위젯 창 닫기"
onPointerDown={e => e.stopPropagation()}
onClick={onClose}
>
</button>
</div>
)}
<div className="fw-window-body-wrap">
<div
@@ -227,20 +242,22 @@ const FloatingWidgetWindow: React.FC<Props> = ({
)}
</div>
<div className="fw-window-resize-layer">
<div
className="fw-window-resize-handle fw-window-resize-handle--sw"
role="separator"
aria-label="위젯 창 크기 조절 (좌하단)"
onPointerDown={onResizeSwPointerDown}
/>
<div
className="fw-window-resize-handle fw-window-resize-handle--se"
role="separator"
aria-label="위젯 창 크기 조절 (우하단)"
onPointerDown={onResizeSePointerDown}
/>
</div>
{!nativeShell && (
<div className="fw-window-resize-layer">
<div
className="fw-window-resize-handle fw-window-resize-handle--sw"
role="separator"
aria-label="위젯 창 크기 조절 (좌하단)"
onPointerDown={onResizeSwPointerDown}
/>
<div
className="fw-window-resize-handle fw-window-resize-handle--se"
role="separator"
aria-label="위젯 창 크기 조절 (우하단)"
onPointerDown={onResizeSePointerDown}
/>
</div>
)}
</div>
<WidgetPickerModal
@@ -249,9 +266,10 @@ const FloatingWidgetWindow: React.FC<Props> = ({
onClose={() => setPickSlotId(null)}
onSelect={applyWidgetType}
/>
</>,
document.body,
</>
);
return nativeShell ? content : createPortal(content, document.body);
};
export default FloatingWidgetWindow;