위젯 추가 팝업 수정
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",
|
||||
"identifier": "default",
|
||||
"description": "GoldenChart desktop main + widget windows",
|
||||
"windows": ["main", "widget-*"],
|
||||
"windows": ["main", "widget-*", "widget-picker-*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-create",
|
||||
@@ -11,7 +11,15 @@
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"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-set-always-on-top",
|
||||
"core:event:allow-emit",
|
||||
"core:event:allow-listen",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:webview:allow-webview-close",
|
||||
"notification:default",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import DesktopUpdateModal from '@frontend/components/DesktopUpdateModal';
|
||||
import {
|
||||
runStartupAutoUpdate,
|
||||
checkForUpdates,
|
||||
getAppVersion,
|
||||
type DesktopUpdatePhase,
|
||||
type DesktopUpdateProgress,
|
||||
} from './bridge/updater';
|
||||
@@ -27,9 +29,8 @@ function formatBytes(n?: number): string {
|
||||
|
||||
function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | null }) {
|
||||
const phase = progress?.phase ?? 'checking';
|
||||
const isActive = phase === 'checking' || phase === 'downloading' || phase === 'installing' || phase === 'relaunching';
|
||||
const showBar = phase === 'available' || phase === 'downloading' || phase === 'installing';
|
||||
const barPct = phase === 'installing' ? 100 : progress?.downloadPercent;
|
||||
const isActive = phase === 'checking';
|
||||
const showBar = false;
|
||||
|
||||
const panelClass = phase === 'error'
|
||||
? 'desktop-update-progress--failed'
|
||||
@@ -54,9 +55,6 @@ function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | n
|
||||
<h1 className="desktop-startup-update-title">GoldenChart</h1>
|
||||
<p className="desktop-startup-update-sub">
|
||||
{progress?.currentVersion ? `현재 v${progress.currentVersion}` : '시작 중…'}
|
||||
{progress?.newVersion && progress.newVersion !== progress.currentVersion && (
|
||||
<> → v{progress.newVersion}</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<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 />}
|
||||
{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 className="desktop-update-bar" role="progressbar" aria-valuemin={0} aria-valuemax={100}>
|
||||
<div className="desktop-update-bar-fill desktop-update-bar-fill--indeterminate" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -94,10 +76,7 @@ function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | n
|
||||
<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' : ''
|
||||
}`}>
|
||||
<li className="desktop-update-step">
|
||||
<span className="desktop-update-step-dot" aria-hidden />
|
||||
업데이트 다운로드
|
||||
{progress?.downloadedBytes && progress.totalBytes ? (
|
||||
@@ -106,9 +85,7 @@ function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | n
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
<li className={`desktop-update-step${
|
||||
phase === 'installing' ? ' active' : phase === 'relaunching' || phase === 'uptodate' ? ' done' : ''
|
||||
}`}>
|
||||
<li className="desktop-update-step">
|
||||
<span className="desktop-update-step-dot" aria-hidden />
|
||||
설치 및 재시작
|
||||
</li>
|
||||
@@ -125,44 +102,56 @@ function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | n
|
||||
);
|
||||
}
|
||||
|
||||
/** StrictMode 이중 실행 방지 — 시작 업데이트는 한 번만 */
|
||||
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(메인 앱) 렌더 */
|
||||
/** 시작 시 버전 확인 후 children(메인 앱) 렌더 — 새 버전 있으면 업데이트 모달 표시 */
|
||||
export function StartupUpdateGate({ children }: { children: React.ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [startupUpdateOpen, setStartupUpdateOpen] = useState(false);
|
||||
const [progress, setProgress] = useState<DesktopUpdateProgress | null>({
|
||||
phase: 'checking',
|
||||
message: '서버에서 새 버전을 확인하는 중…',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (p: DesktopUpdateProgress) => setProgress(p);
|
||||
startupProgressListeners.add(listener);
|
||||
let cancelled = false;
|
||||
|
||||
void getStartupUpdateTask().then(({ shouldContinue, result }) => {
|
||||
if (!shouldContinue) return;
|
||||
if (result.phase === 'error') {
|
||||
console.warn('[startup-update]', result.message);
|
||||
void (async () => {
|
||||
let currentVersion = '—';
|
||||
try {
|
||||
currentVersion = await getAppVersion();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
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 () => {
|
||||
startupProgressListeners.delete(listener);
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -170,5 +159,14 @@ export function StartupUpdateGate({ children }: { children: React.ReactNode }) {
|
||||
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 로 호출
|
||||
*/
|
||||
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 {
|
||||
openWidgetWindow: (instance: FloatingWidgetInstance) => Promise<void>;
|
||||
@@ -14,6 +14,7 @@ export interface DesktopBridge {
|
||||
checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>;
|
||||
runDesktopUpdate: (
|
||||
onProgress?: (progress: DesktopUpdateProgress) => void,
|
||||
options?: DesktopUpdateOptions,
|
||||
) => Promise<DesktopUpdateResult>;
|
||||
getAppVersion: () => Promise<string>;
|
||||
syncMainWindowTitle: () => Promise<string>;
|
||||
|
||||
@@ -41,7 +41,7 @@ function writeUpdateLoopGuard(guard: UpdateLoopGuard) {
|
||||
}
|
||||
}
|
||||
|
||||
function clearUpdateLoopGuard() {
|
||||
export function clearUpdateLoopGuard() {
|
||||
try {
|
||||
localStorage.removeItem(UPDATE_LOOP_GUARD_KEY);
|
||||
} 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 {
|
||||
const guard = readUpdateLoopGuard();
|
||||
if (!guard) return false;
|
||||
@@ -231,6 +252,7 @@ export async function runStartupAutoUpdate(
|
||||
|
||||
export async function runDesktopUpdate(
|
||||
onProgress?: (p: DesktopUpdateProgress) => void,
|
||||
options?: DesktopUpdateOptions,
|
||||
): Promise<DesktopUpdateResult> {
|
||||
let currentVersion = '';
|
||||
try {
|
||||
@@ -261,7 +283,7 @@ export async function runDesktopUpdate(
|
||||
return { ok: true, phase: 'uptodate', message, currentVersion };
|
||||
}
|
||||
|
||||
if (shouldBlockUpdateLoop(currentVersion, update.version)) {
|
||||
if (!options?.bypassLoopGuard && shouldBlockUpdateLoop(currentVersion, update.version)) {
|
||||
const message =
|
||||
`v${update.version} 업데이트가 반복 실패했습니다. 서버 패키지와 버전이 맞지 않을 수 있습니다. ` +
|
||||
'PC 프로그램 탭에서 설치 파일을 받아 다시 설치해 주세요.';
|
||||
@@ -269,6 +291,10 @@ export async function runDesktopUpdate(
|
||||
return { ok: false, phase: 'error', message, currentVersion, newVersion: update.version };
|
||||
}
|
||||
|
||||
if (options?.bypassLoopGuard) {
|
||||
clearUpdateLoopGuard();
|
||||
}
|
||||
|
||||
recordUpdateLoopAttempt(currentVersion, update.version);
|
||||
|
||||
emit(onProgress, {
|
||||
@@ -323,7 +349,16 @@ export async function runDesktopUpdate(
|
||||
currentVersion,
|
||||
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 {
|
||||
ok: true,
|
||||
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 React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { LogicalSize } from '@tauri-apps/api/dpi';
|
||||
import { initStorage, refreshApiBaseFromStorage } from '@goldenchart/shared';
|
||||
import { ensureDesktopApiBase } from './ensureDesktopApiBase';
|
||||
import { openWidgetPickerWindow } from './bridge/pickerWindow';
|
||||
import FloatingWidgetWindow from '@frontend/components/floatingWidgets/FloatingWidgetWindow';
|
||||
import WidgetPickerModal from '@frontend/components/widgetDashboard/WidgetPickerModal';
|
||||
import {
|
||||
defaultGridFr,
|
||||
type FloatingWidgetInstance,
|
||||
} from '@frontend/types/floatingWidget';
|
||||
import {
|
||||
expandNativeWidgetWindowForPicker,
|
||||
readLogicalInnerSize,
|
||||
restoreNativeWidgetWindowAfterPicker,
|
||||
type LogicalWindowSize,
|
||||
} from './nativeWidgetPickerWindow';
|
||||
} from './bridge/widgetWindowSize';
|
||||
import {
|
||||
WIDGET_PICKER_SELECT_EVENT,
|
||||
type WidgetPickerSelectPayload,
|
||||
} from './widgetPickerEvents';
|
||||
import type { Theme } from '@frontend/types';
|
||||
import type { WidgetSlot } from '@frontend/types/widgetDashboard';
|
||||
import { useMarketTicker } from '@frontend/hooks/useMarketTicker';
|
||||
@@ -33,7 +35,6 @@ import { WidgetDashboardProvider } from '@frontend/widgets/WidgetDashboardContex
|
||||
import { readDesktopSync, subscribeDesktopSync } from '@frontend/utils/desktopSync';
|
||||
import { syncDocumentTheme } from '@frontend/utils/documentTheme';
|
||||
import '@frontend/App.css';
|
||||
import '@frontend/styles/appPopup.css';
|
||||
import '@frontend/styles/paperDashboard.css';
|
||||
import '@frontend/styles/widgetDashboard.css';
|
||||
import '@frontend/styles/floatingWidget.css';
|
||||
@@ -117,18 +118,6 @@ const WidgetApp: React.FC = () => {
|
||||
syncDocumentTheme(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(() => {
|
||||
if (!instance) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
@@ -144,71 +133,41 @@ const WidgetApp: React.FC = () => {
|
||||
}, [instance?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pickSlotId) {
|
||||
document.documentElement.classList.add('fw-native-picker-mode');
|
||||
} 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);
|
||||
if (!instance) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
void (async () => {
|
||||
try {
|
||||
sizeBeforePickerRef.current = await expandNativeWidgetWindowForPicker();
|
||||
if (!cancelled) setPickerReady(true);
|
||||
} catch (err) {
|
||||
console.warn('[widget-main] picker expand failed', err);
|
||||
if (!cancelled) setPickerReady(true);
|
||||
}
|
||||
unlisten = await listen<WidgetPickerSelectPayload>(
|
||||
WIDGET_PICKER_SELECT_EVENT,
|
||||
event => {
|
||||
const payload = event.payload;
|
||||
if (payload.instanceId !== instance.id) return;
|
||||
setInstance(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
slots: prev.slots.map(s => (
|
||||
s.id === payload.slotId
|
||||
? { ...s, widgetType: payload.widgetType, config: {} }
|
||||
: s
|
||||
)),
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pickSlotId]);
|
||||
return () => { unlisten?.(); };
|
||||
}, [instance?.id]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void getCurrentWindow().close();
|
||||
}, []);
|
||||
|
||||
const handleNativePickRequest = useCallback((slotId: string) => {
|
||||
setPickSlotId(slotId);
|
||||
}, []);
|
||||
|
||||
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
|
||||
)),
|
||||
};
|
||||
if (!instance) return;
|
||||
void openWidgetPickerWindow({ instanceId: instance.id, slotId }).catch(err => {
|
||||
console.warn('[widget-main] open picker window failed', err);
|
||||
});
|
||||
setPickSlotId(null);
|
||||
}, [pickSlotId]);
|
||||
}, [instance]);
|
||||
|
||||
if (!instance) {
|
||||
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))}
|
||||
/>
|
||||
|
||||
{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>
|
||||
</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: {
|
||||
main: path.resolve(__dirname, 'index.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;
|
||||
}
|
||||
.desktop-startup-update {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20000;
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px; background: var(--bg, #1a1b26);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
type DesktopUpdatePhase,
|
||||
type DesktopUpdateProgress,
|
||||
} from '../utils/desktopBridge';
|
||||
|
||||
/** 다른 오버레이(플로팅 위젯 13000 등)보다 위에 표시 */
|
||||
const DESKTOP_UPDATE_MODAL_Z = 15000;
|
||||
import { isMacDesktop, isWindowsDesktop } from '../utils/platform';
|
||||
import { PlatformBrandIcon, type PlatformBrandIconId } from './icons/PlatformBrandIcons';
|
||||
|
||||
@@ -52,12 +55,14 @@ const DesktopUpdateModal: React.FC<Props> = ({ open, onClose, autoStart = true }
|
||||
? 'desktop-update-progress--active'
|
||||
: '';
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
const handleRun = useCallback(async (opts?: { bypassLoopGuard?: boolean }) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setProgress({ phase: 'checking', message: '서버에서 새 버전을 확인하는 중…', currentVersion: appVersion });
|
||||
try {
|
||||
await runDesktopUpdate(p => setProgress(p));
|
||||
await runDesktopUpdate(p => setProgress(p), {
|
||||
bypassLoopGuard: opts?.bypassLoopGuard ?? true,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -66,6 +71,8 @@ const DesktopUpdateModal: React.FC<Props> = ({ open, onClose, autoStart = true }
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
startedRef.current = false;
|
||||
setProgress(null);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
void getDesktopAppVersion().then(v => {
|
||||
@@ -73,7 +80,7 @@ const DesktopUpdateModal: React.FC<Props> = ({ open, onClose, autoStart = true }
|
||||
});
|
||||
if (autoStart && !startedRef.current) {
|
||||
startedRef.current = true;
|
||||
void handleRun();
|
||||
void handleRun({ bypassLoopGuard: true });
|
||||
}
|
||||
}, [open, autoStart, handleRun]);
|
||||
|
||||
@@ -97,6 +104,7 @@ const DesktopUpdateModal: React.FC<Props> = ({ open, onClose, autoStart = true }
|
||||
onClose={isActive ? () => {} : onClose}
|
||||
closeOnBackdrop={!isActive}
|
||||
width={440}
|
||||
zIndex={DESKTOP_UPDATE_MODAL_Z}
|
||||
dialogClassName="sp-modal desktop-update-modal"
|
||||
>
|
||||
<div className="desktop-update-modal-body">
|
||||
@@ -200,7 +208,7 @@ const DesktopUpdateModal: React.FC<Props> = ({ open, onClose, autoStart = true }
|
||||
|
||||
<div className="desktop-update-actions">
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -1241,7 +1241,51 @@
|
||||
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 body {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -9,6 +9,11 @@ export type DesktopUpdatePhase =
|
||||
| 'relaunching'
|
||||
| 'error';
|
||||
|
||||
export interface DesktopUpdateOptions {
|
||||
/** 수동 업데이트·재시도 시 반복 실패 가드 무시 */
|
||||
bypassLoopGuard?: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateProgress {
|
||||
phase: DesktopUpdatePhase;
|
||||
message: string;
|
||||
@@ -35,6 +40,7 @@ export interface DesktopBridge {
|
||||
checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>;
|
||||
runDesktopUpdate: (
|
||||
onProgress?: (progress: DesktopUpdateProgress) => void,
|
||||
options?: DesktopUpdateOptions,
|
||||
) => Promise<DesktopUpdateResult>;
|
||||
getAppVersion: () => Promise<string>;
|
||||
syncMainWindowTitle: () => Promise<string>;
|
||||
@@ -69,12 +75,13 @@ export async function checkDesktopUpdates(): Promise<{ available: boolean; versi
|
||||
|
||||
export async function runDesktopUpdate(
|
||||
onProgress?: (progress: DesktopUpdateProgress) => void,
|
||||
options?: DesktopUpdateOptions,
|
||||
): Promise<DesktopUpdateResult> {
|
||||
const bridge = getDesktopBridge();
|
||||
if (!bridge?.runDesktopUpdate) {
|
||||
return { ok: false, phase: 'error', message: '데스크톱 앱에서만 사용 가능합니다.' };
|
||||
}
|
||||
return bridge.runDesktopUpdate(onProgress);
|
||||
return bridge.runDesktopUpdate(onProgress, options);
|
||||
}
|
||||
|
||||
export async function getDesktopAppVersion(): Promise<string | null> {
|
||||
|
||||
Reference in New Issue
Block a user