diff --git a/desktop/picker.html b/desktop/picker.html new file mode 100644 index 0000000..97844ac --- /dev/null +++ b/desktop/picker.html @@ -0,0 +1,20 @@ + + + + + + + GoldenChart — 위젯 선택 + + + + + + + +
+ + + diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index c15512a..2423888 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -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", diff --git a/desktop/src/StartupUpdateGate.tsx b/desktop/src/StartupUpdateGate.tsx index 615ca24..009634c 100644 --- a/desktop/src/StartupUpdateGate.tsx +++ b/desktop/src/StartupUpdateGate.tsx @@ -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

GoldenChart

{progress?.currentVersion ? `현재 v${progress.currentVersion}` : '시작 중…'} - {progress?.newVersion && progress.newVersion !== progress.currentVersion && ( - <> → v{progress.newVersion} - )}

@@ -65,27 +63,11 @@ function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | n {isActive && } {PHASE_LABEL[phase]} - {phase === 'downloading' && progress?.downloadPercent != null && ( - {progress.downloadPercent}% - )}
{showBar && ( -
- {barPct != null ? ( -
- ) : ( -
- )} +
+
)} @@ -94,10 +76,7 @@ function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | n 서버에서 버전 확인 -
  • +
  • 업데이트 다운로드 {progress?.downloadedBytes && progress.totalBytes ? ( @@ -106,9 +85,7 @@ function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | n ) : null}
  • -
  • +
  • 설치 및 재시작
  • @@ -125,44 +102,56 @@ function StartupUpdateSplash({ progress }: { progress: DesktopUpdateProgress | n ); } -/** StrictMode 이중 실행 방지 — 시작 업데이트는 한 번만 */ -let startupUpdateTask: ReturnType | 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({ 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 ; } - return <>{children}; + return ( + <> + {children} + setStartupUpdateOpen(false)} + autoStart + /> + + ); } diff --git a/desktop/src/bridge/pickerWindow.ts b/desktop/src/bridge/pickerWindow.ts new file mode 100644 index 0000000..5a895fa --- /dev/null +++ b/desktop/src/bridge/pickerWindow.ts @@ -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(); + +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 { + 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 { + 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 { + 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(); +} diff --git a/desktop/src/bridge/types.ts b/desktop/src/bridge/types.ts index 9cbd657..a0732ac 100644 --- a/desktop/src/bridge/types.ts +++ b/desktop/src/bridge/types.ts @@ -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; @@ -14,6 +14,7 @@ export interface DesktopBridge { checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>; runDesktopUpdate: ( onProgress?: (progress: DesktopUpdateProgress) => void, + options?: DesktopUpdateOptions, ) => Promise; getAppVersion: () => Promise; syncMainWindowTitle: () => Promise; diff --git a/desktop/src/bridge/updater.ts b/desktop/src/bridge/updater.ts index 9b3d568..372f69c 100644 --- a/desktop/src/bridge/updater.ts +++ b/desktop/src/bridge/updater.ts @@ -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 { + try { + await Promise.race([ + relaunch(), + new Promise((_, 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 { 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', diff --git a/desktop/src/bridge/widgetWindowSize.ts b/desktop/src/bridge/widgetWindowSize.ts new file mode 100644 index 0000000..357ef34 --- /dev/null +++ b/desktop/src/bridge/widgetWindowSize.ts @@ -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 { + const physical: PhysicalSize = await win.innerSize(); + const factor = await win.scaleFactor(); + const logical = physical.toLogical(factor); + return { width: logical.width, height: logical.height }; +} diff --git a/desktop/src/nativeWidgetPickerWindow.ts b/desktop/src/nativeWidgetPickerWindow.ts deleted file mode 100644 index 1c592bd..0000000 --- a/desktop/src/nativeWidgetPickerWindow.ts +++ /dev/null @@ -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 { - 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 { - 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(resolve => { window.setTimeout(resolve, 50); }); - } -} - -export async function expandNativeWidgetWindowForPicker( - win: Window = getCurrentWindow(), -): Promise { - 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 { - 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); -} diff --git a/desktop/src/picker-main.tsx b/desktop/src/picker-main.tsx new file mode 100644 index 0000000..5767fb7 --- /dev/null +++ b/desktop/src/picker-main.tsx @@ -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((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

    위젯 선택 정보가 없습니다.

    ; + } + + if (!ready) { + return ( +
    + 위젯 목록 준비 중… +
    + ); + } + + return ( + { void handleSelect(widgetType); }} + /> + ); +}; + +async function bootstrap() { + await initStorage(); + ensureDesktopApiBase(); + refreshApiBaseFromStorage(); + + ReactDOM.createRoot(document.getElementById('root')!).render( + + + , + ); +} + +void bootstrap(); diff --git a/desktop/src/widget-main.tsx b/desktop/src/widget-main.tsx index 3471b78..7f14015 100644 --- a/desktop/src/widget-main.tsx +++ b/desktop/src/widget-main.tsx @@ -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(null); - const [pickerReady, setPickerReady] = useState(false); - const sizeBeforePickerRef = useRef(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( + 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

    위젯 정보를 불러올 수 없습니다.

    ; @@ -245,21 +204,6 @@ const WidgetApp: React.FC = () => { }} onUpdateGridFr={(rowFr, colFr) => setInstance(prev => (prev ? { ...prev, rowFr, colFr } : prev))} /> - - {pickSlotId != null && !pickerReady && ( -
    - 위젯 목록 준비 중… -
    - )} - - {pickSlotId != null && pickerReady && ( - - )}
    ); diff --git a/desktop/src/widgetPickerEvents.ts b/desktop/src/widgetPickerEvents.ts new file mode 100644 index 0000000..0435ff6 --- /dev/null +++ b/desktop/src/widgetPickerEvents.ts @@ -0,0 +1,8 @@ +/** Tauri 위젯 ↔ 위젯 선택 창 이벤트 */ +export const WIDGET_PICKER_SELECT_EVENT = 'gc-widget-picker-select'; + +export interface WidgetPickerSelectPayload { + instanceId: string; + slotId: string; + widgetType: string; +} diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index d58f36e..6f15e26 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -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'), }, }, }, diff --git a/frontend/src/App.css b/frontend/src/App.css index 89ca154..249657e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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); } diff --git a/frontend/src/components/DesktopUpdateModal.tsx b/frontend/src/components/DesktopUpdateModal.tsx index b93e633..139e61e 100644 --- a/frontend/src/components/DesktopUpdateModal.tsx +++ b/frontend/src/components/DesktopUpdateModal.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ open, onClose, autoStart = true } onClose={isActive ? () => {} : onClose} closeOnBackdrop={!isActive} width={440} + zIndex={DESKTOP_UPDATE_MODAL_Z} dialogClassName="sp-modal desktop-update-modal" >
    @@ -200,7 +208,7 @@ const DesktopUpdateModal: React.FC = ({ open, onClose, autoStart = true }
    {phase === 'error' && ( - )} diff --git a/frontend/src/styles/widgetDashboard.css b/frontend/src/styles/widgetDashboard.css index bcad5d1..532879a 100644 --- a/frontend/src/styles/widgetDashboard.css +++ b/frontend/src/styles/widgetDashboard.css @@ -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; diff --git a/frontend/src/utils/desktopBridge.ts b/frontend/src/utils/desktopBridge.ts index ba7f263..b82bad4 100644 --- a/frontend/src/utils/desktopBridge.ts +++ b/frontend/src/utils/desktopBridge.ts @@ -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; getAppVersion: () => Promise; syncMainWindowTitle: () => Promise; @@ -69,12 +75,13 @@ export async function checkDesktopUpdates(): Promise<{ available: boolean; versi export async function runDesktopUpdate( onProgress?: (progress: DesktopUpdateProgress) => void, + options?: DesktopUpdateOptions, ): Promise { 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 {