위젯 추가 팝업 수정

This commit is contained in:
Macbook
2026-06-15 17:17:06 +09:00
parent 271392bc49
commit 51fdcf40e5
16 changed files with 435 additions and 238 deletions
+20
View File
@@ -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>
+9 -1
View File
@@ -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",
+55 -57
View File
@@ -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" <div className="desktop-update-bar-fill desktop-update-bar-fill--indeterminate" />
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> </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 */
} }
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(); scheduleMainWindowTitleSync();
setReady(true); 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
/>
</>
);
} }
+87
View File
@@ -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();
}
+3 -2
View File
@@ -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>;
+38 -3
View File
@@ -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',
+11
View File
@@ -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 };
}
-79
View File
@@ -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);
}
+101
View File
@@ -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
View File
@@ -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,
slots: prev.slots.map(s => (
s.id === payload.slotId
? { ...s, widgetType: payload.widgetType, config: {} }
: s
)),
};
});
},
);
})(); })();
return () => { unlisten?.(); };
return () => { }, [instance?.id]);
cancelled = true;
};
}, [pickSlotId]);
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>
); );
+8
View File
@@ -0,0 +1,8 @@
/** Tauri 위젯 ↔ 위젯 선택 창 이벤트 */
export const WIDGET_PICKER_SELECT_EVENT = 'gc-widget-picker-select';
export interface WidgetPickerSelectPayload {
instanceId: string;
slotId: string;
widgetType: string;
}
+1
View File
@@ -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'),
}, },
}, },
}, },
+3
View File
@@ -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);
} }
+12 -4
View File
@@ -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>
)} )}
+45 -1
View File
@@ -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;
+8 -1
View File
@@ -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> {