위젯 추가 팝업 수정
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko" class="theme-dark fw-native-picker-window">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#1a1b26" />
|
||||||
|
<title>GoldenChart — 위젯 선택</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; background: #1a1b26; }
|
||||||
|
</style>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>var global = globalThis;</script>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/picker-main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "GoldenChart desktop main + widget windows",
|
"description": "GoldenChart desktop main + widget windows",
|
||||||
"windows": ["main", "widget-*"],
|
"windows": ["main", "widget-*", "widget-picker-*"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-create",
|
"core:window:allow-create",
|
||||||
@@ -11,7 +11,15 @@
|
|||||||
"core:window:allow-show",
|
"core:window:allow-show",
|
||||||
"core:window:allow-set-focus",
|
"core:window:allow-set-focus",
|
||||||
"core:window:allow-set-title",
|
"core:window:allow-set-title",
|
||||||
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-set-min-size",
|
||||||
|
"core:window:allow-inner-size",
|
||||||
|
"core:window:allow-scale-factor",
|
||||||
|
"core:window:allow-center",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-set-always-on-top",
|
||||||
|
"core:event:allow-emit",
|
||||||
|
"core:event:allow-listen",
|
||||||
"core:webview:allow-create-webview-window",
|
"core:webview:allow-create-webview-window",
|
||||||
"core:webview:allow-webview-close",
|
"core:webview:allow-webview-close",
|
||||||
"notification:default",
|
"notification:default",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import DesktopUpdateModal from '@frontend/components/DesktopUpdateModal';
|
||||||
import {
|
import {
|
||||||
runStartupAutoUpdate,
|
checkForUpdates,
|
||||||
|
getAppVersion,
|
||||||
type DesktopUpdatePhase,
|
type DesktopUpdatePhase,
|
||||||
type DesktopUpdateProgress,
|
type DesktopUpdateProgress,
|
||||||
} from './bridge/updater';
|
} from './bridge/updater';
|
||||||
@@ -27,9 +29,8 @@ function formatBytes(n?: number): string {
|
|||||||
|
|
||||||
function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | null }) {
|
function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | null }) {
|
||||||
const phase = progress?.phase ?? 'checking';
|
const phase = progress?.phase ?? 'checking';
|
||||||
const isActive = phase === 'checking' || phase === 'downloading' || phase === 'installing' || phase === 'relaunching';
|
const isActive = phase === 'checking';
|
||||||
const showBar = phase === 'available' || phase === 'downloading' || phase === 'installing';
|
const showBar = false;
|
||||||
const barPct = phase === 'installing' ? 100 : progress?.downloadPercent;
|
|
||||||
|
|
||||||
const panelClass = phase === 'error'
|
const panelClass = phase === 'error'
|
||||||
? 'desktop-update-progress--failed'
|
? 'desktop-update-progress--failed'
|
||||||
@@ -54,9 +55,6 @@ function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | n
|
|||||||
<h1 className="desktop-startup-update-title">GoldenChart</h1>
|
<h1 className="desktop-startup-update-title">GoldenChart</h1>
|
||||||
<p className="desktop-startup-update-sub">
|
<p className="desktop-startup-update-sub">
|
||||||
{progress?.currentVersion ? `현재 v${progress.currentVersion}` : '시작 중…'}
|
{progress?.currentVersion ? `현재 v${progress.currentVersion}` : '시작 중…'}
|
||||||
{progress?.newVersion && progress.newVersion !== progress.currentVersion && (
|
|
||||||
<> → v{progress.newVersion}</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={`desktop-update-progress ${panelClass}`.trim()}>
|
<div className={`desktop-update-progress ${panelClass}`.trim()}>
|
||||||
@@ -65,27 +63,11 @@ function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | n
|
|||||||
{isActive && <span className="desktop-update-spinner" aria-hidden />}
|
{isActive && <span className="desktop-update-spinner" aria-hidden />}
|
||||||
{PHASE_LABEL[phase]}
|
{PHASE_LABEL[phase]}
|
||||||
</span>
|
</span>
|
||||||
{phase === 'downloading' && progress?.downloadPercent != null && (
|
|
||||||
<span className="desktop-update-pct">{progress.downloadPercent}%</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showBar && (
|
{showBar && (
|
||||||
<div
|
<div className="desktop-update-bar" role="progressbar" aria-valuemin={0} aria-valuemax={100}>
|
||||||
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 className="desktop-update-bar-fill desktop-update-bar-fill--indeterminate" />
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -94,10 +76,7 @@ function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | n
|
|||||||
<span className="desktop-update-step-dot" aria-hidden />
|
<span className="desktop-update-step-dot" aria-hidden />
|
||||||
서버에서 버전 확인
|
서버에서 버전 확인
|
||||||
</li>
|
</li>
|
||||||
<li className={`desktop-update-step${
|
<li className="desktop-update-step">
|
||||||
phase === 'available' || phase === 'downloading' ? ' active'
|
|
||||||
: ['installing', 'relaunching', 'uptodate'].includes(phase) ? ' done' : ''
|
|
||||||
}`}>
|
|
||||||
<span className="desktop-update-step-dot" aria-hidden />
|
<span className="desktop-update-step-dot" aria-hidden />
|
||||||
업데이트 다운로드
|
업데이트 다운로드
|
||||||
{progress?.downloadedBytes && progress.totalBytes ? (
|
{progress?.downloadedBytes && progress.totalBytes ? (
|
||||||
@@ -106,9 +85,7 @@ function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | n
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
<li className={`desktop-update-step${
|
<li className="desktop-update-step">
|
||||||
phase === 'installing' ? ' active' : phase === 'relaunching' || phase === 'uptodate' ? ' done' : ''
|
|
||||||
}`}>
|
|
||||||
<span className="desktop-update-step-dot" aria-hidden />
|
<span className="desktop-update-step-dot" aria-hidden />
|
||||||
설치 및 재시작
|
설치 및 재시작
|
||||||
</li>
|
</li>
|
||||||
@@ -125,44 +102,56 @@ function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | n
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** StrictMode 이중 실행 방지 — 시작 업데이트는 한 번만 */
|
/** 시작 시 버전 확인 후 children(메인 앱) 렌더 — 새 버전 있으면 업데이트 모달 표시 */
|
||||||
let startupUpdateTask: ReturnType<typeof runStartupAutoUpdate> | null = null;
|
|
||||||
const startupProgressListeners = new Set<(p: DesktopUpdateProgress) => void>();
|
|
||||||
|
|
||||||
function emitStartupProgress(p: DesktopUpdateProgress) {
|
|
||||||
for (const listener of startupProgressListeners) listener(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStartupUpdateTask() {
|
|
||||||
if (!startupUpdateTask) {
|
|
||||||
startupUpdateTask = runStartupAutoUpdate(emitStartupProgress);
|
|
||||||
}
|
|
||||||
return startupUpdateTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 시작 시 자동 업데이트 후 children(메인 앱) 렌더 */
|
|
||||||
export function StartupUpdateGate({ children }: { children: React.ReactNode }) {
|
export function StartupUpdateGate({ children }: { children: React.ReactNode }) {
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const [startupUpdateOpen, setStartupUpdateOpen] = useState(false);
|
||||||
const [progress, setProgress] = useState<DesktopUpdateProgress | null>({
|
const [progress, setProgress] = useState<DesktopUpdateProgress | null>({
|
||||||
phase: 'checking',
|
phase: 'checking',
|
||||||
message: '서버에서 새 버전을 확인하는 중…',
|
message: '서버에서 새 버전을 확인하는 중…',
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (p: DesktopUpdateProgress) => setProgress(p);
|
let cancelled = false;
|
||||||
startupProgressListeners.add(listener);
|
|
||||||
|
|
||||||
void getStartupUpdateTask().then(({ shouldContinue, result }) => {
|
void (async () => {
|
||||||
if (!shouldContinue) return;
|
let currentVersion = '—';
|
||||||
if (result.phase === 'error') {
|
try {
|
||||||
console.warn('[startup-update]', result.message);
|
currentVersion = await getAppVersion();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
}
|
}
|
||||||
scheduleMainWindowTitleSync();
|
if (cancelled) return;
|
||||||
setReady(true);
|
|
||||||
|
setProgress({
|
||||||
|
phase: 'checking',
|
||||||
|
message: '서버에서 새 버전을 확인하는 중…',
|
||||||
|
currentVersion,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const result = await checkForUpdates();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (result.available) {
|
||||||
|
setProgress({
|
||||||
|
phase: 'available',
|
||||||
|
message: result.message ?? `새 버전 v${result.version}을(를) 사용할 수 있습니다.`,
|
||||||
|
currentVersion,
|
||||||
|
newVersion: result.version,
|
||||||
|
});
|
||||||
|
} else if (result.message?.includes('실패') || result.message?.includes('오류')) {
|
||||||
|
setProgress({ phase: 'error', message: result.message, currentVersion });
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleMainWindowTitleSync();
|
||||||
|
setReady(true);
|
||||||
|
if (result.available) {
|
||||||
|
setStartupUpdateOpen(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
startupProgressListeners.delete(listener);
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -170,5 +159,14 @@ export function StartupUpdateGate({ children }: { children: React.ReactNode }) {
|
|||||||
return <StartupUpdateSplash progress={progress} />;
|
return <StartupUpdateSplash progress={progress} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<DesktopUpdateModal
|
||||||
|
open={startupUpdateOpen}
|
||||||
|
onClose={() => setStartupUpdateOpen(false)}
|
||||||
|
autoStart
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||||
|
import { LogicalSize } from '@tauri-apps/api/dpi';
|
||||||
|
import { NATIVE_WIDGET_PICKER_WINDOW } from '@frontend/types/floatingWidget';
|
||||||
|
|
||||||
|
const pickerWindows = new Map<string, WebviewWindow>();
|
||||||
|
|
||||||
|
function pickerLabel(instanceId: string): string {
|
||||||
|
return `widget-picker-${instanceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerUrl(instanceId: string, slotId: string): string {
|
||||||
|
const params = new URLSearchParams({ instanceId, slotId });
|
||||||
|
return `picker.html?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 서버 frontend AppPopup(720×860)과 동일한 전용 OS 창 */
|
||||||
|
export async function openWidgetPickerWindow(params: {
|
||||||
|
instanceId: string;
|
||||||
|
slotId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const label = pickerLabel(params.instanceId);
|
||||||
|
const existing = pickerWindows.get(params.instanceId)
|
||||||
|
?? (await WebviewWindow.getByLabel(label));
|
||||||
|
if (existing) {
|
||||||
|
await existing.setFocus();
|
||||||
|
pickerWindows.set(params.instanceId, existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = NATIVE_WIDGET_PICKER_WINDOW.width;
|
||||||
|
const height = NATIVE_WIDGET_PICKER_WINDOW.height;
|
||||||
|
|
||||||
|
const win = new WebviewWindow(label, {
|
||||||
|
url: pickerUrl(params.instanceId, params.slotId),
|
||||||
|
title: 'GoldenChart — 위젯 선택 (Widget)',
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
minWidth: Math.min(width, 520),
|
||||||
|
minHeight: Math.min(height, 560),
|
||||||
|
resizable: true,
|
||||||
|
center: true,
|
||||||
|
decorations: true,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
backgroundColor: '#1a1b26',
|
||||||
|
});
|
||||||
|
|
||||||
|
pickerWindows.set(params.instanceId, win);
|
||||||
|
|
||||||
|
void win.once('tauri://destroyed', () => {
|
||||||
|
pickerWindows.delete(params.instanceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeWidgetPickerWindow(instanceId: string): Promise<void> {
|
||||||
|
const label = pickerLabel(instanceId);
|
||||||
|
const win = pickerWindows.get(instanceId) ?? (await WebviewWindow.getByLabel(label));
|
||||||
|
if (win) {
|
||||||
|
await win.close();
|
||||||
|
pickerWindows.delete(instanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 화면이 작을 때 picker 창 논리 크기 (생성 직후 보정) */
|
||||||
|
export function computePickerWindowLogicalSize(): { width: number; height: number } {
|
||||||
|
const marginW = 32;
|
||||||
|
const marginH = 40;
|
||||||
|
const screenW = typeof window !== 'undefined' ? (window.screen?.availWidth ?? 1280) : 1280;
|
||||||
|
const screenH = typeof window !== 'undefined' ? (window.screen?.availHeight ?? 900) : 900;
|
||||||
|
return {
|
||||||
|
width: Math.min(
|
||||||
|
NATIVE_WIDGET_PICKER_WINDOW.width,
|
||||||
|
Math.max(520, screenW - marginW),
|
||||||
|
),
|
||||||
|
height: Math.min(
|
||||||
|
NATIVE_WIDGET_PICKER_WINDOW.height,
|
||||||
|
Math.max(560, screenH - marginH),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fitPickerWindowToScreen(): Promise<void> {
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
const target = computePickerWindowLogicalSize();
|
||||||
|
await win.setSize(new LogicalSize(target.width, target.height));
|
||||||
|
await win.center();
|
||||||
|
}
|
||||||
@@ -2,9 +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';
|
import type { DesktopUpdateProgress, DesktopUpdateResult, DesktopUpdateOptions } from './updater';
|
||||||
|
|
||||||
export type { DesktopUpdateProgress, DesktopUpdateResult, DesktopUpdatePhase } from './updater';
|
export type { DesktopUpdateProgress, DesktopUpdateResult, DesktopUpdatePhase, DesktopUpdateOptions } from './updater';
|
||||||
|
|
||||||
export interface DesktopBridge {
|
export interface DesktopBridge {
|
||||||
openWidgetWindow: (instance: FloatingWidgetInstance) => Promise<void>;
|
openWidgetWindow: (instance: FloatingWidgetInstance) => Promise<void>;
|
||||||
@@ -14,6 +14,7 @@ export interface DesktopBridge {
|
|||||||
checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>;
|
checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>;
|
||||||
runDesktopUpdate: (
|
runDesktopUpdate: (
|
||||||
onProgress?: (progress: DesktopUpdateProgress) => void,
|
onProgress?: (progress: DesktopUpdateProgress) => void,
|
||||||
|
options?: DesktopUpdateOptions,
|
||||||
) => Promise<DesktopUpdateResult>;
|
) => Promise<DesktopUpdateResult>;
|
||||||
getAppVersion: () => Promise<string>;
|
getAppVersion: () => Promise<string>;
|
||||||
syncMainWindowTitle: () => Promise<string>;
|
syncMainWindowTitle: () => Promise<string>;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function writeUpdateLoopGuard(guard: UpdateLoopGuard) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearUpdateLoopGuard() {
|
export function clearUpdateLoopGuard() {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(UPDATE_LOOP_GUARD_KEY);
|
localStorage.removeItem(UPDATE_LOOP_GUARD_KEY);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -49,6 +49,27 @@ function clearUpdateLoopGuard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopUpdateOptions {
|
||||||
|
/** 수동 업데이트·재시도 시 반복 실패 가드 무시 */
|
||||||
|
bypassLoopGuard?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RELAUNCH_TIMEOUT_MS = 12_000;
|
||||||
|
|
||||||
|
async function relaunchWithTimeout(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
relaunch(),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
window.setTimeout(() => reject(new Error('relaunch timeout')), RELAUNCH_TIMEOUT_MS);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function shouldBlockUpdateLoop(currentVersion: string, targetVersion: string): boolean {
|
function shouldBlockUpdateLoop(currentVersion: string, targetVersion: string): boolean {
|
||||||
const guard = readUpdateLoopGuard();
|
const guard = readUpdateLoopGuard();
|
||||||
if (!guard) return false;
|
if (!guard) return false;
|
||||||
@@ -231,6 +252,7 @@ export async function runStartupAutoUpdate(
|
|||||||
|
|
||||||
export async function runDesktopUpdate(
|
export async function runDesktopUpdate(
|
||||||
onProgress?: (p: DesktopUpdateProgress) => void,
|
onProgress?: (p: DesktopUpdateProgress) => void,
|
||||||
|
options?: DesktopUpdateOptions,
|
||||||
): Promise<DesktopUpdateResult> {
|
): Promise<DesktopUpdateResult> {
|
||||||
let currentVersion = '';
|
let currentVersion = '';
|
||||||
try {
|
try {
|
||||||
@@ -261,7 +283,7 @@ export async function runDesktopUpdate(
|
|||||||
return { ok: true, phase: 'uptodate', message, currentVersion };
|
return { ok: true, phase: 'uptodate', message, currentVersion };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldBlockUpdateLoop(currentVersion, update.version)) {
|
if (!options?.bypassLoopGuard && shouldBlockUpdateLoop(currentVersion, update.version)) {
|
||||||
const message =
|
const message =
|
||||||
`v${update.version} 업데이트가 반복 실패했습니다. 서버 패키지와 버전이 맞지 않을 수 있습니다. ` +
|
`v${update.version} 업데이트가 반복 실패했습니다. 서버 패키지와 버전이 맞지 않을 수 있습니다. ` +
|
||||||
'PC 프로그램 탭에서 설치 파일을 받아 다시 설치해 주세요.';
|
'PC 프로그램 탭에서 설치 파일을 받아 다시 설치해 주세요.';
|
||||||
@@ -269,6 +291,10 @@ export async function runDesktopUpdate(
|
|||||||
return { ok: false, phase: 'error', message, currentVersion, newVersion: update.version };
|
return { ok: false, phase: 'error', message, currentVersion, newVersion: update.version };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.bypassLoopGuard) {
|
||||||
|
clearUpdateLoopGuard();
|
||||||
|
}
|
||||||
|
|
||||||
recordUpdateLoopAttempt(currentVersion, update.version);
|
recordUpdateLoopAttempt(currentVersion, update.version);
|
||||||
|
|
||||||
emit(onProgress, {
|
emit(onProgress, {
|
||||||
@@ -323,7 +349,16 @@ export async function runDesktopUpdate(
|
|||||||
currentVersion,
|
currentVersion,
|
||||||
newVersion: update.version,
|
newVersion: update.version,
|
||||||
});
|
});
|
||||||
await relaunch();
|
|
||||||
|
const relaunched = await relaunchWithTimeout();
|
||||||
|
if (!relaunched) {
|
||||||
|
clearUpdateLoopGuard();
|
||||||
|
const message =
|
||||||
|
'업데이트는 설치됐지만 앱을 자동으로 다시 시작하지 못했습니다. GoldenChart를 직접 종료한 뒤 다시 실행해 주세요.';
|
||||||
|
emit(onProgress, { phase: 'error', message, currentVersion, newVersion: update.version });
|
||||||
|
return { ok: false, phase: 'error', message, currentVersion, newVersion: update.version };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
phase: 'relaunching',
|
phase: 'relaunching',
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { getCurrentWindow, type Window } from '@tauri-apps/api/window';
|
||||||
|
import { type PhysicalSize } from '@tauri-apps/api/dpi';
|
||||||
|
|
||||||
|
export type LogicalWindowSize = { width: number; height: number };
|
||||||
|
|
||||||
|
export async function readLogicalInnerSize(win: Window = getCurrentWindow()): Promise<LogicalWindowSize> {
|
||||||
|
const physical: PhysicalSize = await win.innerSize();
|
||||||
|
const factor = await win.scaleFactor();
|
||||||
|
const logical = physical.toLogical(factor);
|
||||||
|
return { width: logical.width, height: logical.height };
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { getCurrentWindow, type Window } from '@tauri-apps/api/window';
|
|
||||||
import { LogicalSize, type PhysicalSize } from '@tauri-apps/api/dpi';
|
|
||||||
|
|
||||||
/** 서버 frontend AppPopup 위젯 선택 팝업과 동일 (720×860) */
|
|
||||||
export const WEB_WIDGET_PICKER_WINDOW = { width: 720, height: 860 } as const;
|
|
||||||
|
|
||||||
export type LogicalWindowSize = { width: number; height: number };
|
|
||||||
|
|
||||||
const WIDGET_MIN = { width: 320, height: 240 } as const;
|
|
||||||
|
|
||||||
export async function readLogicalInnerSize(win: Window = getCurrentWindow()): Promise<LogicalWindowSize> {
|
|
||||||
const physical: PhysicalSize = await win.innerSize();
|
|
||||||
const factor = await win.scaleFactor();
|
|
||||||
const logical = physical.toLogical(factor);
|
|
||||||
return { width: logical.width, height: logical.height };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** OS 창 목표 크기 — 현재 창 크기(innerWidth)가 아닌 화면 기준 */
|
|
||||||
export function computeNativePickerLogicalSize(): LogicalWindowSize {
|
|
||||||
const marginW = 32;
|
|
||||||
const marginH = 40;
|
|
||||||
const screenW = window.screen?.availWidth ?? 1280;
|
|
||||||
const screenH = window.screen?.availHeight ?? 900;
|
|
||||||
return {
|
|
||||||
width: Math.min(WEB_WIDGET_PICKER_WINDOW.width, Math.max(520, screenW - marginW)),
|
|
||||||
height: Math.min(WEB_WIDGET_PICKER_WINDOW.height, Math.max(560, screenH - marginH)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForLogicalSize(
|
|
||||||
win: Window,
|
|
||||||
target: LogicalWindowSize,
|
|
||||||
timeoutMs = 1500,
|
|
||||||
): Promise<void> {
|
|
||||||
const start = Date.now();
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
|
||||||
const current = await readLogicalInnerSize(win);
|
|
||||||
if (
|
|
||||||
Math.abs(current.width - target.width) < 16
|
|
||||||
&& Math.abs(current.height - target.height) < 16
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise<void>(resolve => { window.setTimeout(resolve, 50); });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expandNativeWidgetWindowForPicker(
|
|
||||||
win: Window = getCurrentWindow(),
|
|
||||||
): Promise<LogicalWindowSize> {
|
|
||||||
const before = await readLogicalInnerSize(win);
|
|
||||||
const target = computeNativePickerLogicalSize();
|
|
||||||
|
|
||||||
await win.setMinSize(new LogicalSize(
|
|
||||||
Math.min(target.width, WEB_WIDGET_PICKER_WINDOW.width),
|
|
||||||
Math.min(target.height, 520),
|
|
||||||
));
|
|
||||||
await win.setSize(new LogicalSize(target.width, target.height));
|
|
||||||
await win.center();
|
|
||||||
await win.setFocus();
|
|
||||||
await waitForLogicalSize(win, target);
|
|
||||||
|
|
||||||
const after = await readLogicalInnerSize(win);
|
|
||||||
if (after.width < target.width - 24 || after.height < target.height - 24) {
|
|
||||||
await win.setSize(new LogicalSize(target.width, target.height));
|
|
||||||
await waitForLogicalSize(win, target, 800);
|
|
||||||
}
|
|
||||||
|
|
||||||
return before;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function restoreNativeWidgetWindowAfterPicker(
|
|
||||||
before: LogicalWindowSize,
|
|
||||||
win: Window = getCurrentWindow(),
|
|
||||||
): Promise<void> {
|
|
||||||
await win.setMinSize(new LogicalSize(WIDGET_MIN.width, WIDGET_MIN.height));
|
|
||||||
await win.setSize(new LogicalSize(before.width, before.height));
|
|
||||||
await waitForLogicalSize(win, before, 800);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import './forceServerApi';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { emit } from '@tauri-apps/api/event';
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
|
import { initStorage, refreshApiBaseFromStorage } from '@goldenchart/shared';
|
||||||
|
import { ensureDesktopApiBase } from './ensureDesktopApiBase';
|
||||||
|
import { fitPickerWindowToScreen } from './bridge/pickerWindow';
|
||||||
|
import WidgetPickerModal from '@frontend/components/widgetDashboard/WidgetPickerModal';
|
||||||
|
import {
|
||||||
|
WIDGET_PICKER_SELECT_EVENT,
|
||||||
|
type WidgetPickerSelectPayload,
|
||||||
|
} from './widgetPickerEvents';
|
||||||
|
import { readDesktopSync, subscribeDesktopSync } from '@frontend/utils/desktopSync';
|
||||||
|
import { syncDocumentTheme } from '@frontend/utils/documentTheme';
|
||||||
|
import type { Theme } from '@frontend/types';
|
||||||
|
import '@frontend/App.css';
|
||||||
|
import '@frontend/styles/appPopup.css';
|
||||||
|
import '@frontend/styles/widgetDashboard.css';
|
||||||
|
|
||||||
|
document.documentElement.classList.add('fw-native-picker-window');
|
||||||
|
|
||||||
|
function parseParams(): { instanceId: string; slotId: string } {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return {
|
||||||
|
instanceId: params.get('instanceId') ?? '',
|
||||||
|
slotId: params.get('slotId') ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PickerApp: React.FC = () => {
|
||||||
|
const { instanceId, slotId } = useMemo(() => parseParams(), []);
|
||||||
|
const sync = readDesktopSync();
|
||||||
|
const [theme, setTheme] = useState<Theme>((sync?.theme as Theme | undefined) ?? 'dark');
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncDocumentTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeDesktopSync(state => {
|
||||||
|
if (state.theme) setTheme(state.theme as Theme);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fitPickerWindowToScreen()
|
||||||
|
.catch(err => console.warn('[picker-main] window fit failed', err))
|
||||||
|
.finally(() => setReady(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
void getCurrentWindow().close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(async (widgetType: string) => {
|
||||||
|
if (!instanceId || !slotId) {
|
||||||
|
await getCurrentWindow().close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload: WidgetPickerSelectPayload = { instanceId, slotId, widgetType };
|
||||||
|
await emit(WIDGET_PICKER_SELECT_EVENT, payload);
|
||||||
|
await getCurrentWindow().close();
|
||||||
|
}, [instanceId, slotId]);
|
||||||
|
|
||||||
|
if (!instanceId || !slotId) {
|
||||||
|
return <p className="wd-widget-empty">위젯 선택 정보가 없습니다.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="wd-picker-native-loading" aria-live="polite">
|
||||||
|
위젯 목록 준비 중…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetPickerModal
|
||||||
|
open
|
||||||
|
category="all"
|
||||||
|
onClose={handleClose}
|
||||||
|
onSelect={widgetType => { void handleSelect(widgetType); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
await initStorage();
|
||||||
|
ensureDesktopApiBase();
|
||||||
|
refreshApiBaseFromStorage();
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<PickerApp />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
+34
-90
@@ -1,22 +1,24 @@
|
|||||||
import './forceServerApi';
|
import './forceServerApi';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { LogicalSize } from '@tauri-apps/api/dpi';
|
import { LogicalSize } from '@tauri-apps/api/dpi';
|
||||||
import { initStorage, refreshApiBaseFromStorage } from '@goldenchart/shared';
|
import { initStorage, refreshApiBaseFromStorage } from '@goldenchart/shared';
|
||||||
import { ensureDesktopApiBase } from './ensureDesktopApiBase';
|
import { ensureDesktopApiBase } from './ensureDesktopApiBase';
|
||||||
|
import { openWidgetPickerWindow } from './bridge/pickerWindow';
|
||||||
import FloatingWidgetWindow from '@frontend/components/floatingWidgets/FloatingWidgetWindow';
|
import FloatingWidgetWindow from '@frontend/components/floatingWidgets/FloatingWidgetWindow';
|
||||||
import WidgetPickerModal from '@frontend/components/widgetDashboard/WidgetPickerModal';
|
|
||||||
import {
|
import {
|
||||||
defaultGridFr,
|
defaultGridFr,
|
||||||
type FloatingWidgetInstance,
|
type FloatingWidgetInstance,
|
||||||
} from '@frontend/types/floatingWidget';
|
} from '@frontend/types/floatingWidget';
|
||||||
import {
|
import {
|
||||||
expandNativeWidgetWindowForPicker,
|
|
||||||
readLogicalInnerSize,
|
readLogicalInnerSize,
|
||||||
restoreNativeWidgetWindowAfterPicker,
|
} from './bridge/widgetWindowSize';
|
||||||
type LogicalWindowSize,
|
import {
|
||||||
} from './nativeWidgetPickerWindow';
|
WIDGET_PICKER_SELECT_EVENT,
|
||||||
|
type WidgetPickerSelectPayload,
|
||||||
|
} from './widgetPickerEvents';
|
||||||
import type { Theme } from '@frontend/types';
|
import type { Theme } from '@frontend/types';
|
||||||
import type { WidgetSlot } from '@frontend/types/widgetDashboard';
|
import type { WidgetSlot } from '@frontend/types/widgetDashboard';
|
||||||
import { useMarketTicker } from '@frontend/hooks/useMarketTicker';
|
import { useMarketTicker } from '@frontend/hooks/useMarketTicker';
|
||||||
@@ -33,7 +35,6 @@ import { WidgetDashboardProvider } from '@frontend/widgets/WidgetDashboardContex
|
|||||||
import { readDesktopSync, subscribeDesktopSync } from '@frontend/utils/desktopSync';
|
import { readDesktopSync, subscribeDesktopSync } from '@frontend/utils/desktopSync';
|
||||||
import { syncDocumentTheme } from '@frontend/utils/documentTheme';
|
import { syncDocumentTheme } from '@frontend/utils/documentTheme';
|
||||||
import '@frontend/App.css';
|
import '@frontend/App.css';
|
||||||
import '@frontend/styles/appPopup.css';
|
|
||||||
import '@frontend/styles/paperDashboard.css';
|
import '@frontend/styles/paperDashboard.css';
|
||||||
import '@frontend/styles/widgetDashboard.css';
|
import '@frontend/styles/widgetDashboard.css';
|
||||||
import '@frontend/styles/floatingWidget.css';
|
import '@frontend/styles/floatingWidget.css';
|
||||||
@@ -117,18 +118,6 @@ const WidgetApp: React.FC = () => {
|
|||||||
syncDocumentTheme(theme);
|
syncDocumentTheme(theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const [pickSlotId, setPickSlotId] = useState<string | null>(null);
|
|
||||||
const [pickerReady, setPickerReady] = useState(false);
|
|
||||||
const sizeBeforePickerRef = useRef<LogicalWindowSize | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!instance) return;
|
|
||||||
const title = pickSlotId
|
|
||||||
? 'GoldenChart — 위젯 선택'
|
|
||||||
: `GoldenChart — 위젯 ${instance.rows}×${instance.cols}`;
|
|
||||||
void getCurrentWindow().setTitle(title);
|
|
||||||
}, [instance?.rows, instance?.cols, instance, pickSlotId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instance) return;
|
if (!instance) return;
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
@@ -144,71 +133,41 @@ const WidgetApp: React.FC = () => {
|
|||||||
}, [instance?.id]);
|
}, [instance?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pickSlotId) {
|
if (!instance) return;
|
||||||
document.documentElement.classList.add('fw-native-picker-mode');
|
let unlisten: (() => void) | undefined;
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('fw-native-picker-mode');
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.documentElement.classList.remove('fw-native-picker-mode');
|
|
||||||
};
|
|
||||||
}, [pickSlotId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pickSlotId) {
|
|
||||||
setPickerReady(false);
|
|
||||||
const prev = sizeBeforePickerRef.current;
|
|
||||||
if (prev) {
|
|
||||||
sizeBeforePickerRef.current = null;
|
|
||||||
void restoreNativeWidgetWindowAfterPicker(prev).catch(err => {
|
|
||||||
console.warn('[widget-main] picker restore failed', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
setPickerReady(false);
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
unlisten = await listen<WidgetPickerSelectPayload>(
|
||||||
sizeBeforePickerRef.current = await expandNativeWidgetWindowForPicker();
|
WIDGET_PICKER_SELECT_EVENT,
|
||||||
if (!cancelled) setPickerReady(true);
|
event => {
|
||||||
} catch (err) {
|
const payload = event.payload;
|
||||||
console.warn('[widget-main] picker expand failed', err);
|
if (payload.instanceId !== instance.id) return;
|
||||||
if (!cancelled) setPickerReady(true);
|
setInstance(prev => {
|
||||||
}
|
if (!prev) return prev;
|
||||||
})();
|
return {
|
||||||
|
...prev,
|
||||||
return () => {
|
slots: prev.slots.map(s => (
|
||||||
cancelled = true;
|
s.id === payload.slotId
|
||||||
|
? { ...s, widgetType: payload.widgetType, config: {} }
|
||||||
|
: s
|
||||||
|
)),
|
||||||
};
|
};
|
||||||
}, [pickSlotId]);
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
return () => { unlisten?.(); };
|
||||||
|
}, [instance?.id]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
void getCurrentWindow().close();
|
void getCurrentWindow().close();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNativePickRequest = useCallback((slotId: string) => {
|
const handleNativePickRequest = useCallback((slotId: string) => {
|
||||||
setPickSlotId(slotId);
|
if (!instance) return;
|
||||||
}, []);
|
void openWidgetPickerWindow({ instanceId: instance.id, slotId }).catch(err => {
|
||||||
|
console.warn('[widget-main] open picker window failed', err);
|
||||||
const handleNativePickClose = useCallback(() => {
|
|
||||||
setPickSlotId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNativePickSelect = useCallback((widgetType: string) => {
|
|
||||||
if (!pickSlotId) return;
|
|
||||||
setInstance(prev => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
slots: prev.slots.map(s => (
|
|
||||||
s.id === pickSlotId ? { ...s, widgetType, config: {} } : s
|
|
||||||
)),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
setPickSlotId(null);
|
}, [instance]);
|
||||||
}, [pickSlotId]);
|
|
||||||
|
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
return <p className="wd-widget-empty">위젯 정보를 불러올 수 없습니다.</p>;
|
return <p className="wd-widget-empty">위젯 정보를 불러올 수 없습니다.</p>;
|
||||||
@@ -245,21 +204,6 @@ const WidgetApp: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onUpdateGridFr={(rowFr, colFr) => setInstance(prev => (prev ? { ...prev, rowFr, colFr } : prev))}
|
onUpdateGridFr={(rowFr, colFr) => setInstance(prev => (prev ? { ...prev, rowFr, colFr } : prev))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{pickSlotId != null && !pickerReady && (
|
|
||||||
<div className="wd-picker-native-loading" aria-live="polite">
|
|
||||||
위젯 목록 준비 중…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pickSlotId != null && pickerReady && (
|
|
||||||
<WidgetPickerModal
|
|
||||||
open
|
|
||||||
category="all"
|
|
||||||
onClose={handleNativePickClose}
|
|
||||||
onSelect={handleNativePickSelect}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</WidgetDashboardProvider>
|
</WidgetDashboardProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** Tauri 위젯 ↔ 위젯 선택 창 이벤트 */
|
||||||
|
export const WIDGET_PICKER_SELECT_EVENT = 'gc-widget-picker-select';
|
||||||
|
|
||||||
|
export interface WidgetPickerSelectPayload {
|
||||||
|
instanceId: string;
|
||||||
|
slotId: string;
|
||||||
|
widgetType: string;
|
||||||
|
}
|
||||||
@@ -80,6 +80,7 @@ export default defineConfig({
|
|||||||
input: {
|
input: {
|
||||||
main: path.resolve(__dirname, 'index.html'),
|
main: path.resolve(__dirname, 'index.html'),
|
||||||
widget: path.resolve(__dirname, 'widget.html'),
|
widget: path.resolve(__dirname, 'widget.html'),
|
||||||
|
picker: path.resolve(__dirname, 'picker.html'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13525,6 +13525,9 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
|||||||
display: flex; justify-content: flex-end; gap: 8px;
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
}
|
}
|
||||||
.desktop-startup-update {
|
.desktop-startup-update {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20000;
|
||||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||||
padding: 24px; background: var(--bg, #1a1b26);
|
padding: 24px; background: var(--bg, #1a1b26);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
type DesktopUpdatePhase,
|
type DesktopUpdatePhase,
|
||||||
type DesktopUpdateProgress,
|
type DesktopUpdateProgress,
|
||||||
} from '../utils/desktopBridge';
|
} from '../utils/desktopBridge';
|
||||||
|
|
||||||
|
/** 다른 오버레이(플로팅 위젯 13000 등)보다 위에 표시 */
|
||||||
|
const DESKTOP_UPDATE_MODAL_Z = 15000;
|
||||||
import { isMacDesktop, isWindowsDesktop } from '../utils/platform';
|
import { isMacDesktop, isWindowsDesktop } from '../utils/platform';
|
||||||
import { PlatformBrandIcon, type PlatformBrandIconId } from './icons/PlatformBrandIcons';
|
import { PlatformBrandIcon, type PlatformBrandIconId } from './icons/PlatformBrandIcons';
|
||||||
|
|
||||||
@@ -52,12 +55,14 @@ const DesktopUpdateModal: React.FC<Props> = ({ open, onClose, autoStart = true }
|
|||||||
? 'desktop-update-progress--active'
|
? 'desktop-update-progress--active'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const handleRun = useCallback(async () => {
|
const handleRun = useCallback(async (opts?: { bypassLoopGuard?: boolean }) => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setProgress({ phase: 'checking', message: '서버에서 새 버전을 확인하는 중…', currentVersion: appVersion });
|
setProgress({ phase: 'checking', message: '서버에서 새 버전을 확인하는 중…', currentVersion: appVersion });
|
||||||
try {
|
try {
|
||||||
await runDesktopUpdate(p => setProgress(p));
|
await runDesktopUpdate(p => setProgress(p), {
|
||||||
|
bypassLoopGuard: opts?.bypassLoopGuard ?? true,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -66,6 +71,8 @@ const DesktopUpdateModal: React.FC<Props> = ({ open, onClose, autoStart = true }
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
startedRef.current = false;
|
startedRef.current = false;
|
||||||
|
setProgress(null);
|
||||||
|
setBusy(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void getDesktopAppVersion().then(v => {
|
void getDesktopAppVersion().then(v => {
|
||||||
@@ -73,7 +80,7 @@ const DesktopUpdateModal: React.FC<Props> = ({ open, onClose, autoStart = true }
|
|||||||
});
|
});
|
||||||
if (autoStart && !startedRef.current) {
|
if (autoStart && !startedRef.current) {
|
||||||
startedRef.current = true;
|
startedRef.current = true;
|
||||||
void handleRun();
|
void handleRun({ bypassLoopGuard: true });
|
||||||
}
|
}
|
||||||
}, [open, autoStart, handleRun]);
|
}, [open, autoStart, handleRun]);
|
||||||
|
|
||||||
@@ -97,6 +104,7 @@ const DesktopUpdateModal: React.FC<Props> = ({ open, onClose, autoStart = true }
|
|||||||
onClose={isActive ? () => {} : onClose}
|
onClose={isActive ? () => {} : onClose}
|
||||||
closeOnBackdrop={!isActive}
|
closeOnBackdrop={!isActive}
|
||||||
width={440}
|
width={440}
|
||||||
|
zIndex={DESKTOP_UPDATE_MODAL_Z}
|
||||||
dialogClassName="sp-modal desktop-update-modal"
|
dialogClassName="sp-modal desktop-update-modal"
|
||||||
>
|
>
|
||||||
<div className="desktop-update-modal-body">
|
<div className="desktop-update-modal-body">
|
||||||
@@ -200,7 +208,7 @@ const DesktopUpdateModal: React.FC<Props> = ({ open, onClose, autoStart = true }
|
|||||||
|
|
||||||
<div className="desktop-update-actions">
|
<div className="desktop-update-actions">
|
||||||
{phase === 'error' && (
|
{phase === 'error' && (
|
||||||
<button type="button" className="stg-btn-secondary" disabled={busy} onClick={() => { void handleRun(); }}>
|
<button type="button" className="stg-btn-secondary" disabled={busy} onClick={() => { void handleRun({ bypassLoopGuard: true }); }}>
|
||||||
다시 시도
|
다시 시도
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1241,7 +1241,51 @@
|
|||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tauri 네이티브 위젯 창 — 서버와 동일 AppPopup (720×860 OS 창) */
|
/* Tauri 위젯 선택 전용 OS 창 (720×860) — 서버 AppPopup과 동일 */
|
||||||
|
html.fw-native-picker-window,
|
||||||
|
html.fw-native-picker-window body {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg, #1a1b26);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.fw-native-picker-window #root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.fw-native-picker-window .wd-picker-native-loading {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 13199;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg, #1a1b26);
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.fw-native-picker-window .app-popup-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 13200;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.fw-native-picker-window .app-popup-shell--widget-picker {
|
||||||
|
width: min(720px, calc(100vw - 16px)) !important;
|
||||||
|
max-height: min(860px, calc(100vh - 16px));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.fw-native-picker-window .app-popup-shell--widget-picker .wd-picker-popup-body {
|
||||||
|
max-height: min(760px, calc(100vh - 120px));
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* (legacy) Tauri 네이티브 위젯 창 — in-window picker */
|
||||||
html.fw-native-widget.fw-native-picker-mode,
|
html.fw-native-widget.fw-native-picker-mode,
|
||||||
html.fw-native-widget.fw-native-picker-mode body {
|
html.fw-native-widget.fw-native-picker-mode body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export type DesktopUpdatePhase =
|
|||||||
| 'relaunching'
|
| 'relaunching'
|
||||||
| 'error';
|
| 'error';
|
||||||
|
|
||||||
|
export interface DesktopUpdateOptions {
|
||||||
|
/** 수동 업데이트·재시도 시 반복 실패 가드 무시 */
|
||||||
|
bypassLoopGuard?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopUpdateProgress {
|
export interface DesktopUpdateProgress {
|
||||||
phase: DesktopUpdatePhase;
|
phase: DesktopUpdatePhase;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -35,6 +40,7 @@ export interface DesktopBridge {
|
|||||||
checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>;
|
checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>;
|
||||||
runDesktopUpdate: (
|
runDesktopUpdate: (
|
||||||
onProgress?: (progress: DesktopUpdateProgress) => void,
|
onProgress?: (progress: DesktopUpdateProgress) => void,
|
||||||
|
options?: DesktopUpdateOptions,
|
||||||
) => Promise<DesktopUpdateResult>;
|
) => Promise<DesktopUpdateResult>;
|
||||||
getAppVersion: () => Promise<string>;
|
getAppVersion: () => Promise<string>;
|
||||||
syncMainWindowTitle: () => Promise<string>;
|
syncMainWindowTitle: () => Promise<string>;
|
||||||
@@ -69,12 +75,13 @@ export async function checkDesktopUpdates(): Promise<{ available: boolean; versi
|
|||||||
|
|
||||||
export async function runDesktopUpdate(
|
export async function runDesktopUpdate(
|
||||||
onProgress?: (progress: DesktopUpdateProgress) => void,
|
onProgress?: (progress: DesktopUpdateProgress) => void,
|
||||||
|
options?: DesktopUpdateOptions,
|
||||||
): Promise<DesktopUpdateResult> {
|
): Promise<DesktopUpdateResult> {
|
||||||
const bridge = getDesktopBridge();
|
const bridge = getDesktopBridge();
|
||||||
if (!bridge?.runDesktopUpdate) {
|
if (!bridge?.runDesktopUpdate) {
|
||||||
return { ok: false, phase: 'error', message: '데스크톱 앱에서만 사용 가능합니다.' };
|
return { ok: false, phase: 'error', message: '데스크톱 앱에서만 사용 가능합니다.' };
|
||||||
}
|
}
|
||||||
return bridge.runDesktopUpdate(onProgress);
|
return bridge.runDesktopUpdate(onProgress, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDesktopAppVersion(): Promise<string | null> {
|
export async function getDesktopAppVersion(): Promise<string | null> {
|
||||||
|
|||||||
Reference in New Issue
Block a user