pc 앱 배포,다운로드 기능
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user