수정
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { openWidgetWindow, closeWidgetWindow, focusWidgetWindow } from './widgetWindows';
|
import { openWidgetWindow, closeWidgetWindow, focusWidgetWindow } from './widgetWindows';
|
||||||
import { showNativeNotification } from './notifications';
|
import { showNativeNotification } from './notifications';
|
||||||
import { checkForUpdates, getAppVersion } from './updater';
|
import { checkForUpdates, getAppVersion, runDesktopUpdate } from './updater';
|
||||||
import type { DesktopBridge } from './types';
|
import type { DesktopBridge } from './types';
|
||||||
|
|
||||||
/** frontend App 로드 전 호출 — window.__goldenDesktopBridge 등록 */
|
/** frontend App 로드 전 호출 — window.__goldenDesktopBridge 등록 */
|
||||||
@@ -11,6 +11,7 @@ export function installDesktopBridge(): void {
|
|||||||
focusWidgetWindow,
|
focusWidgetWindow,
|
||||||
showNativeNotification,
|
showNativeNotification,
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
|
runDesktopUpdate,
|
||||||
getAppVersion,
|
getAppVersion,
|
||||||
};
|
};
|
||||||
window.__goldenDesktopBridge = bridge;
|
window.__goldenDesktopBridge = bridge;
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
* 데스크톱(Tauri) 브릿지 타입 — frontend에서 window.__goldenDesktopBridge 로 호출
|
* 데스크톱(Tauri) 브릿지 타입 — frontend에서 window.__goldenDesktopBridge 로 호출
|
||||||
*/
|
*/
|
||||||
import type { FloatingWidgetInstance } from '@frontend/types/floatingWidget';
|
import type { FloatingWidgetInstance } from '@frontend/types/floatingWidget';
|
||||||
|
import type { DesktopUpdateProgress, DesktopUpdateResult } from './updater';
|
||||||
|
|
||||||
|
export type { DesktopUpdateProgress, DesktopUpdateResult, DesktopUpdatePhase } from './updater';
|
||||||
|
|
||||||
export interface DesktopBridge {
|
export interface DesktopBridge {
|
||||||
openWidgetWindow: (instance: FloatingWidgetInstance) => Promise<void>;
|
openWidgetWindow: (instance: FloatingWidgetInstance) => Promise<void>;
|
||||||
@@ -9,6 +12,9 @@ export interface DesktopBridge {
|
|||||||
focusWidgetWindow: (id: string) => Promise<void>;
|
focusWidgetWindow: (id: string) => Promise<void>;
|
||||||
showNativeNotification: (title: string, body: string) => Promise<void>;
|
showNativeNotification: (title: string, body: string) => Promise<void>;
|
||||||
checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>;
|
checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>;
|
||||||
|
runDesktopUpdate: (
|
||||||
|
onProgress?: (progress: DesktopUpdateProgress) => void,
|
||||||
|
) => Promise<DesktopUpdateResult>;
|
||||||
getAppVersion: () => Promise<string>;
|
getAppVersion: () => Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,25 +2,164 @@ import { check } from '@tauri-apps/plugin-updater';
|
|||||||
import { relaunch } from '@tauri-apps/plugin-process';
|
import { relaunch } from '@tauri-apps/plugin-process';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
|
|
||||||
|
export type DesktopUpdatePhase =
|
||||||
|
| 'checking'
|
||||||
|
| 'uptodate'
|
||||||
|
| 'available'
|
||||||
|
| 'downloading'
|
||||||
|
| 'installing'
|
||||||
|
| 'relaunching'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export interface DesktopUpdateProgress {
|
||||||
|
phase: DesktopUpdatePhase;
|
||||||
|
message: string;
|
||||||
|
currentVersion?: string;
|
||||||
|
newVersion?: string;
|
||||||
|
downloadPercent?: number;
|
||||||
|
downloadedBytes?: number;
|
||||||
|
totalBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopUpdateResult {
|
||||||
|
ok: boolean;
|
||||||
|
phase: DesktopUpdatePhase;
|
||||||
|
message: string;
|
||||||
|
currentVersion?: string;
|
||||||
|
newVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(onProgress: ((p: DesktopUpdateProgress) => void) | undefined, p: DesktopUpdateProgress) {
|
||||||
|
onProgress?.(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUpdateError(e: unknown): string {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e ?? '');
|
||||||
|
const lower = msg.toLowerCase();
|
||||||
|
if (!msg.trim()) return '업데이트 확인에 실패했습니다.';
|
||||||
|
if (/json|parse|unexpected end|eof|empty/.test(lower)) {
|
||||||
|
return '서버 업데이트 정보(latest.json)를 읽을 수 없습니다. PC 프로그램 탭에서 새 dmg를 받아 주세요.';
|
||||||
|
}
|
||||||
|
if (/signature|pubkey|verify|minisign|invalid key/.test(lower)) {
|
||||||
|
return '업데이트 서명 검증에 실패했습니다. 웹 PC 프로그램 탭에서 설치 파일을 다시 받아 주세요.';
|
||||||
|
}
|
||||||
|
if (/404|not found/.test(lower)) {
|
||||||
|
return '업데이트 파일을 찾을 수 없습니다. 관리자에게 문의하거나 dmg를 직접 다운로드해 주세요.';
|
||||||
|
}
|
||||||
|
if (/network|fetch|connect|timeout|dns|offline|failed to send/.test(lower)) {
|
||||||
|
return `네트워크 오류로 업데이트를 확인하지 못했습니다. (${msg})`;
|
||||||
|
}
|
||||||
|
if (/platform|darwin|arch|target/.test(lower)) {
|
||||||
|
return `이 PC용 업데이트 패키지가 없습니다. (${msg})`;
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAppVersion(): Promise<string> {
|
export async function getAppVersion(): Promise<string> {
|
||||||
return getVersion();
|
return getVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated runDesktopUpdate 사용 */
|
||||||
export async function checkForUpdates(): Promise<{
|
export async function checkForUpdates(): Promise<{
|
||||||
available: boolean;
|
available: boolean;
|
||||||
version?: string;
|
version?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}> {
|
}> {
|
||||||
|
const result = await runDesktopUpdate();
|
||||||
|
return {
|
||||||
|
available: result.phase === 'relaunching' || result.phase === 'available',
|
||||||
|
version: result.newVersion,
|
||||||
|
message: result.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDesktopUpdate(
|
||||||
|
onProgress?: (p: DesktopUpdateProgress) => void,
|
||||||
|
): Promise<DesktopUpdateResult> {
|
||||||
|
let currentVersion = '';
|
||||||
|
try {
|
||||||
|
currentVersion = await getVersion();
|
||||||
|
} catch {
|
||||||
|
currentVersion = '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(onProgress, {
|
||||||
|
phase: 'checking',
|
||||||
|
message: '서버에서 새 버전을 확인하는 중…',
|
||||||
|
currentVersion,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const update = await check();
|
const update = await check();
|
||||||
if (!update) {
|
if (!update) {
|
||||||
return { available: false, message: '최신 버전입니다.' };
|
const message = `최신 버전입니다. (v${currentVersion})`;
|
||||||
|
emit(onProgress, { phase: 'uptodate', message, currentVersion });
|
||||||
|
return { ok: true, phase: 'uptodate', message, currentVersion };
|
||||||
}
|
}
|
||||||
await update.downloadAndInstall();
|
|
||||||
|
emit(onProgress, {
|
||||||
|
phase: 'available',
|
||||||
|
message: `새 버전 v${update.version}을(를) 받습니다. (현재 v${currentVersion})`,
|
||||||
|
currentVersion,
|
||||||
|
newVersion: update.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
let downloaded = 0;
|
||||||
|
let total: number | undefined;
|
||||||
|
|
||||||
|
await update.downloadAndInstall(event => {
|
||||||
|
if (event.event === 'Started') {
|
||||||
|
total = event.data.contentLength;
|
||||||
|
downloaded = 0;
|
||||||
|
emit(onProgress, {
|
||||||
|
phase: 'downloading',
|
||||||
|
message: total ? '다운로드 중… (0%)' : '다운로드 중…',
|
||||||
|
currentVersion,
|
||||||
|
newVersion: update.version,
|
||||||
|
downloadPercent: 0,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: total,
|
||||||
|
});
|
||||||
|
} else if (event.event === 'Progress') {
|
||||||
|
downloaded += event.data.chunkLength;
|
||||||
|
const pct =
|
||||||
|
total && total > 0 ? Math.min(99, Math.round((downloaded / total) * 100)) : undefined;
|
||||||
|
emit(onProgress, {
|
||||||
|
phase: 'downloading',
|
||||||
|
message: pct != null ? `다운로드 중… (${pct}%)` : '다운로드 중…',
|
||||||
|
currentVersion,
|
||||||
|
newVersion: update.version,
|
||||||
|
downloadPercent: pct,
|
||||||
|
downloadedBytes: downloaded,
|
||||||
|
totalBytes: total,
|
||||||
|
});
|
||||||
|
} else if (event.event === 'Finished') {
|
||||||
|
emit(onProgress, {
|
||||||
|
phase: 'installing',
|
||||||
|
message: '업데이트 패키지 설치 중…',
|
||||||
|
currentVersion,
|
||||||
|
newVersion: update.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
emit(onProgress, {
|
||||||
|
phase: 'relaunching',
|
||||||
|
message: '설치 완료. 앱을 다시 시작합니다…',
|
||||||
|
currentVersion,
|
||||||
|
newVersion: update.version,
|
||||||
|
});
|
||||||
await relaunch();
|
await relaunch();
|
||||||
return { available: true, version: update.version, message: '업데이트 설치 후 재시작합니다.' };
|
return {
|
||||||
|
ok: true,
|
||||||
|
phase: 'relaunching',
|
||||||
|
message: '재시작 중…',
|
||||||
|
currentVersion,
|
||||||
|
newVersion: update.version,
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '업데이트 확인 실패';
|
const message = formatUpdateError(e);
|
||||||
return { available: false, message: msg };
|
emit(onProgress, { phase: 'error', message, currentVersion });
|
||||||
|
return { ok: false, phase: 'error', message, currentVersion };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13288,6 +13288,94 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
|||||||
.app-download-build-done-note {
|
.app-download-build-done-note {
|
||||||
margin: 8px 0 0; font-size: 12px; color: #4ade80; line-height: 1.45;
|
margin: 8px 0 0; font-size: 12px; color: #4ade80; line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
.desktop-update-modal-body { padding: 4px 2px 0; }
|
||||||
|
.desktop-update-version {
|
||||||
|
margin: 0 0 12px; font-size: 13px; color: var(--text2);
|
||||||
|
}
|
||||||
|
.desktop-update-progress {
|
||||||
|
margin: 0 0 14px; padding: 12px 14px; border-radius: 12px;
|
||||||
|
border: 1px solid var(--border); background: var(--bg2);
|
||||||
|
}
|
||||||
|
.desktop-update-progress--active {
|
||||||
|
border-color: color-mix(in srgb, #2196f3 45%, var(--border));
|
||||||
|
background: color-mix(in srgb, #2196f3 8%, var(--bg2));
|
||||||
|
}
|
||||||
|
.desktop-update-progress--success {
|
||||||
|
border-color: color-mix(in srgb, #22c55e 45%, var(--border));
|
||||||
|
background: color-mix(in srgb, #22c55e 8%, var(--bg2));
|
||||||
|
}
|
||||||
|
.desktop-update-progress--failed {
|
||||||
|
border-color: color-mix(in srgb, #ef4444 45%, var(--border));
|
||||||
|
background: color-mix(in srgb, #ef4444 8%, var(--bg2));
|
||||||
|
}
|
||||||
|
.desktop-update-progress-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 10px; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.desktop-update-progress-title {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.desktop-update-spinner {
|
||||||
|
width: 14px; height: 14px; border-radius: 50%;
|
||||||
|
border: 2px solid color-mix(in srgb, #2196f3 25%, transparent);
|
||||||
|
border-top-color: #2196f3;
|
||||||
|
animation: tmb-update-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
.desktop-update-pct {
|
||||||
|
font-size: 11px; color: var(--text3); font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.desktop-update-bar {
|
||||||
|
height: 4px; border-radius: 999px; overflow: hidden;
|
||||||
|
background: color-mix(in srgb, var(--text3) 15%, transparent);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.desktop-update-bar-fill {
|
||||||
|
height: 100%; border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #2196f3, #5eb5ff);
|
||||||
|
}
|
||||||
|
.desktop-update-bar-fill--determinate { transition: width 0.2s ease; }
|
||||||
|
.desktop-update-bar-fill--indeterminate {
|
||||||
|
width: 40%;
|
||||||
|
animation: app-download-build-indeterminate 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.desktop-update-steps {
|
||||||
|
list-style: none; margin: 0; padding: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
}
|
||||||
|
.desktop-update-step {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
font-size: 12px; color: var(--text3); line-height: 1.4;
|
||||||
|
}
|
||||||
|
.desktop-update-step.active { color: #5eb5ff; font-weight: 600; }
|
||||||
|
.desktop-update-step.done { color: var(--text2); }
|
||||||
|
.desktop-update-step-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--text3) 50%, transparent);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.desktop-update-step.active .desktop-update-step-dot {
|
||||||
|
border-color: #2196f3; background: #2196f3;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, #2196f3 25%, transparent);
|
||||||
|
}
|
||||||
|
.desktop-update-step.done .desktop-update-step-dot {
|
||||||
|
border-color: #22c55e; background: #22c55e;
|
||||||
|
}
|
||||||
|
.desktop-update-bytes {
|
||||||
|
margin-left: auto; font-size: 11px; color: var(--text3); font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.desktop-update-message {
|
||||||
|
margin: 10px 0 0; font-size: 12px; color: var(--text2); line-height: 1.45;
|
||||||
|
}
|
||||||
|
.desktop-update-message--error { color: #fca5a5; }
|
||||||
|
.desktop-update-hint {
|
||||||
|
margin: 8px 0 0; font-size: 11px; color: var(--text3); line-height: 1.45;
|
||||||
|
}
|
||||||
|
.desktop-update-done-note {
|
||||||
|
margin: 8px 0 0; font-size: 12px; color: #4ade80; line-height: 1.45;
|
||||||
|
}
|
||||||
|
.desktop-update-actions {
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
}
|
||||||
.app-download-build-note, .app-download-build-wait {
|
.app-download-build-note, .app-download-build-wait {
|
||||||
margin: 0 0 12px; font-size: 12px; line-height: 1.5;
|
margin: 0 0 12px; font-size: 12px; line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { isDesktop } from '../utils/platform';
|
import { isDesktop } from '../utils/platform';
|
||||||
import {
|
import { getDesktopAppVersion } from '../utils/desktopBridge';
|
||||||
checkDesktopUpdates,
|
import DesktopUpdateModal from './DesktopUpdateModal';
|
||||||
getDesktopAppVersion,
|
|
||||||
} from '../utils/desktopBridge';
|
|
||||||
|
|
||||||
/** PC(Tauri) 앱 — 버전 표시 및 업데이트 확인 */
|
/** PC(Tauri) 앱 — 버전 표시 및 업데이트 확인 */
|
||||||
const DesktopUpdatePanel: React.FC = () => {
|
const DesktopUpdatePanel: React.FC = () => {
|
||||||
const [version, setVersion] = useState<string>('—');
|
const [version, setVersion] = useState<string>('—');
|
||||||
const [status, setStatus] = useState<string>('');
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDesktop()) return;
|
if (!isDesktop()) return;
|
||||||
@@ -18,20 +15,14 @@ const DesktopUpdatePanel: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCheck = useCallback(async () => {
|
const handleOpen = useCallback(() => {
|
||||||
setBusy(true);
|
setModalOpen(true);
|
||||||
setStatus('업데이트 확인 중…');
|
|
||||||
try {
|
|
||||||
const res = await checkDesktopUpdates();
|
|
||||||
setStatus(res.message ?? (res.available ? `새 버전 ${res.version}` : '최신 버전입니다.'));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!isDesktop()) return null;
|
if (!isDesktop()) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="stg-section">
|
<div className="stg-section">
|
||||||
<h3 className="stg-section-title">PC 앱 (GoldenChart Desktop)</h3>
|
<h3 className="stg-section-title">PC 앱 (GoldenChart Desktop)</h3>
|
||||||
<div className="stg-row">
|
<div className="stg-row">
|
||||||
@@ -49,13 +40,18 @@ const DesktopUpdatePanel: React.FC = () => {
|
|||||||
<p className="stg-row-desc">서버에서 새 버전을 확인하고 다운로드합니다.</p>
|
<p className="stg-row-desc">서버에서 새 버전을 확인하고 다운로드합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stg-row-control">
|
<div className="stg-row-control">
|
||||||
<button type="button" className="stg-btn-secondary" disabled={busy} onClick={() => { void handleCheck(); }}>
|
<button type="button" className="stg-btn-secondary" onClick={handleOpen}>
|
||||||
{busy ? '확인 중…' : '업데이트 확인'}
|
업데이트 확인
|
||||||
</button>
|
</button>
|
||||||
{status && <span className="stg-hint" style={{ marginLeft: 8 }}>{status}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<DesktopUpdateModal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
autoStart
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { AuthSession } from '../utils/auth';
|
|||||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||||
import { canAccessMenu } from '../utils/permissions';
|
import { canAccessMenu } from '../utils/permissions';
|
||||||
import { isDesktop } from '../utils/platform';
|
import { isDesktop } from '../utils/platform';
|
||||||
import { checkDesktopUpdates } from '../utils/desktopBridge';
|
import DesktopUpdateModal from './DesktopUpdateModal';
|
||||||
|
|
||||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings' | 'widget-dashboard';
|
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'strategy-evaluation' | 'backtest' | 'analysis-report' | 'notifications' | 'settings' | 'widget-dashboard';
|
||||||
|
|
||||||
@@ -266,24 +266,9 @@ export const TopMenuBar = memo(function TopMenuBar({
|
|||||||
menuPermissions,
|
menuPermissions,
|
||||||
}: TopMenuBarProps) {
|
}: TopMenuBarProps) {
|
||||||
const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement);
|
const [isFullscreen, setIsFullscreen] = useState(() => !!document.fullscreenElement);
|
||||||
const [desktopUpdateBusy, setDesktopUpdateBusy] = useState(false);
|
const [desktopUpdateOpen, setDesktopUpdateOpen] = useState(false);
|
||||||
const [desktopUpdateHint, setDesktopUpdateHint] = useState('');
|
|
||||||
const desktopClient = isDesktop();
|
const desktopClient = isDesktop();
|
||||||
|
|
||||||
const handleDesktopUpdate = useCallback(async () => {
|
|
||||||
if (desktopUpdateBusy) return;
|
|
||||||
setDesktopUpdateBusy(true);
|
|
||||||
setDesktopUpdateHint('업데이트 확인 중…');
|
|
||||||
try {
|
|
||||||
const res = await checkDesktopUpdates();
|
|
||||||
setDesktopUpdateHint(res.message ?? (res.available ? `v${res.version ?? ''} 설치` : '최신 버전'));
|
|
||||||
} catch {
|
|
||||||
setDesktopUpdateHint('업데이트 확인 실패');
|
|
||||||
} finally {
|
|
||||||
setDesktopUpdateBusy(false);
|
|
||||||
}
|
|
||||||
}, [desktopUpdateBusy]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sync = () => setIsFullscreen(!!document.fullscreenElement);
|
const sync = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
document.addEventListener('fullscreenchange', sync);
|
document.addEventListener('fullscreenchange', sync);
|
||||||
@@ -295,6 +280,7 @@ export const TopMenuBar = memo(function TopMenuBar({
|
|||||||
? MENU_ITEMS.filter(({ page }) => canAccessMenu(menuPermissions, page))
|
? MENU_ITEMS.filter(({ page }) => canAccessMenu(menuPermissions, page))
|
||||||
: MENU_ITEMS;
|
: MENU_ITEMS;
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<header className="top-menubar">
|
<header className="top-menubar">
|
||||||
{/* 로고 */}
|
{/* 로고 */}
|
||||||
<div className="tmb-logo">
|
<div className="tmb-logo">
|
||||||
@@ -356,10 +342,9 @@ export const TopMenuBar = memo(function TopMenuBar({
|
|||||||
{desktopClient ? (
|
{desktopClient ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`tmb-app-download-btn tmb-desktop-update-btn${desktopUpdateBusy ? ' busy' : ''}`}
|
className="tmb-app-download-btn tmb-desktop-update-btn"
|
||||||
onClick={() => { void handleDesktopUpdate(); }}
|
onClick={() => setDesktopUpdateOpen(true)}
|
||||||
disabled={desktopUpdateBusy}
|
title="PC 프로그램 업데이트"
|
||||||
title={desktopUpdateHint || 'PC 프로그램 업데이트'}
|
|
||||||
aria-label="PC 프로그램 업데이트"
|
aria-label="PC 프로그램 업데이트"
|
||||||
>
|
>
|
||||||
<IcDesktopUpdate />
|
<IcDesktopUpdate />
|
||||||
@@ -430,6 +415,14 @@ export const TopMenuBar = memo(function TopMenuBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{desktopClient && (
|
||||||
|
<DesktopUpdateModal
|
||||||
|
open={desktopUpdateOpen}
|
||||||
|
onClose={() => setDesktopUpdateOpen(false)}
|
||||||
|
autoStart
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
import { isDesktop } from './platform';
|
import { isDesktop } from './platform';
|
||||||
|
|
||||||
|
export type DesktopUpdatePhase =
|
||||||
|
| 'checking'
|
||||||
|
| 'uptodate'
|
||||||
|
| 'available'
|
||||||
|
| 'downloading'
|
||||||
|
| 'installing'
|
||||||
|
| 'relaunching'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export interface DesktopUpdateProgress {
|
||||||
|
phase: DesktopUpdatePhase;
|
||||||
|
message: string;
|
||||||
|
currentVersion?: string;
|
||||||
|
newVersion?: string;
|
||||||
|
downloadPercent?: number;
|
||||||
|
downloadedBytes?: number;
|
||||||
|
totalBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopUpdateResult {
|
||||||
|
ok: boolean;
|
||||||
|
phase: DesktopUpdatePhase;
|
||||||
|
message: string;
|
||||||
|
currentVersion?: string;
|
||||||
|
newVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopBridge {
|
export interface DesktopBridge {
|
||||||
openWidgetWindow: (instance: import('../types/floatingWidget').FloatingWidgetInstance) => Promise<void>;
|
openWidgetWindow: (instance: import('../types/floatingWidget').FloatingWidgetInstance) => Promise<void>;
|
||||||
closeWidgetWindow: (id: string) => Promise<void>;
|
closeWidgetWindow: (id: string) => Promise<void>;
|
||||||
focusWidgetWindow: (id: string) => Promise<void>;
|
focusWidgetWindow: (id: string) => Promise<void>;
|
||||||
showNativeNotification: (title: string, body: string) => Promise<void>;
|
showNativeNotification: (title: string, body: string) => Promise<void>;
|
||||||
checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>;
|
checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>;
|
||||||
|
runDesktopUpdate: (
|
||||||
|
onProgress?: (progress: DesktopUpdateProgress) => void,
|
||||||
|
) => Promise<DesktopUpdateResult>;
|
||||||
getAppVersion: () => Promise<string>;
|
getAppVersion: () => Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +66,16 @@ export async function checkDesktopUpdates(): Promise<{ available: boolean; versi
|
|||||||
return bridge.checkForUpdates();
|
return bridge.checkForUpdates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runDesktopUpdate(
|
||||||
|
onProgress?: (progress: DesktopUpdateProgress) => void,
|
||||||
|
): Promise<DesktopUpdateResult> {
|
||||||
|
const bridge = getDesktopBridge();
|
||||||
|
if (!bridge?.runDesktopUpdate) {
|
||||||
|
return { ok: false, phase: 'error', message: '데스크톱 앱에서만 사용 가능합니다.' };
|
||||||
|
}
|
||||||
|
return bridge.runDesktopUpdate(onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDesktopAppVersion(): Promise<string | null> {
|
export async function getDesktopAppVersion(): Promise<string | null> {
|
||||||
const bridge = getDesktopBridge();
|
const bridge = getDesktopBridge();
|
||||||
if (!bridge) return null;
|
if (!bridge) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user