diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts
index a6c310c..c70a3bb 100644
--- a/src/i18n/locales/en-US.ts
+++ b/src/i18n/locales/en-US.ts
@@ -104,6 +104,25 @@ export default {
'Oldest logs will be discarded when exceeding the limit (recommended 500–2000)',
resetWindowLayout: 'Reset Window Layout',
resetWindowLayoutHint: 'Restore window size to default and center the window',
+ logOverlay: 'Log Overlay',
+ logOverlayHint: 'Show a transparent floating log near the connected window. Hidden until connected',
+ logOverlayMode: 'Overlay Mode',
+ logOverlayModeFixed: 'Fixed Position',
+ logOverlayModeFollow: 'Follow Connected Window',
+ logOverlayAnchor: 'Anchor Position',
+ logOverlayAnchorLeftCenter: 'Left Center',
+ logOverlayAnchorRightTop: 'Right Top (1/3)',
+ logOverlayAnchorRightBottom: 'Right Bottom (2/3)',
+ logOverlayAnchorTopCenter: 'Top Center',
+ logOverlayEnable: 'Enable',
+ logOverlayFollowWindow: 'Follow Window',
+ logOverlayFollowWindowNone: 'Not selected',
+ logOverlayFollowWindowAuto: 'Connected window',
+ logOverlayRefreshWindows: 'Refresh window list',
+ logOverlayWaitingLogs: 'Waiting for logs...',
+ logOverlayZOrder: 'Window Layer',
+ logOverlayZOrderTop: 'On Top',
+ logOverlayZOrderAboveTarget: 'Above Connected Window',
},
// Special tasks
diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts
index 9826fad..ca9e98a 100644
--- a/src/i18n/locales/ja-JP.ts
+++ b/src/i18n/locales/ja-JP.ts
@@ -99,6 +99,25 @@ export default {
'手動でアプリを開く際も、上で選択した設定を自動実行します(無効な場合はシステム起動時のみ実行)',
confirmBeforeDelete: '削除操作の前に確認する',
confirmBeforeDeleteHint: '削除/一覧クリア/上書きインポート等の前に確認ダイアログを表示します',
+ logOverlay: 'ログオーバーレイ',
+ logOverlayHint: '接続したウィンドウ付近に半透明のログを表示。未接続時は非表示',
+ logOverlayMode: 'オーバーレイモード',
+ logOverlayModeFixed: '固定位置',
+ logOverlayModeFollow: '接続ウィンドウに追従',
+ logOverlayZOrder: 'ウィンドウの重ね順',
+ logOverlayZOrderTop: '最前面に表示',
+ logOverlayZOrderAboveTarget: '接続ウィンドウの上',
+ logOverlayAnchor: 'アンカー位置',
+ logOverlayAnchorLeftCenter: '左中央',
+ logOverlayAnchorRightTop: '右上(1/3)',
+ logOverlayAnchorRightBottom: '右下(2/3)',
+ logOverlayAnchorTopCenter: '上中央',
+ logOverlayEnable: '有効',
+ logOverlayFollowWindow: '追従ウィンドウ',
+ logOverlayFollowWindowNone: '未選択',
+ logOverlayFollowWindowAuto: '接続中のウィンドウ',
+ logOverlayRefreshWindows: 'ウィンドウ一覧を更新',
+ logOverlayWaitingLogs: 'ログを待機中...',
maxLogsPerInstance: 'インスタンスあたりのログ上限',
maxLogsPerInstanceHint: '上限を超えると古いログから自動的に破棄します(推奨 500~2000)',
resetWindowLayout: 'ウィンドウレイアウトをリセット',
diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts
index 07615b3..1a530a2 100644
--- a/src/i18n/locales/ko-KR.ts
+++ b/src/i18n/locales/ko-KR.ts
@@ -99,6 +99,25 @@ export default {
confirmBeforeDelete: '삭제 작업 확인',
confirmBeforeDeleteHint:
'삭제/목록 비우기/가져오기 덮어쓰기 등 전에 확인 대화 상자를 표시합니다',
+ logOverlay: '로그 오버레이',
+ logOverlayHint: '연결된 창 근처에 반투명 로그를 표시합니다. 연결 전에는 숨김',
+ logOverlayMode: '오버레이 모드',
+ logOverlayModeFixed: '고정 위치',
+ logOverlayModeFollow: '연결 창 따라가기',
+ logOverlayZOrder: '창 계층',
+ logOverlayZOrderTop: '맨 위에 표시',
+ logOverlayZOrderAboveTarget: '연결 창 위에',
+ logOverlayAnchor: '앵커 위치',
+ logOverlayAnchorLeftCenter: '왼쪽 가운데',
+ logOverlayAnchorRightTop: '오른쪽 위 (1/3)',
+ logOverlayAnchorRightBottom: '오른쪽 아래 (2/3)',
+ logOverlayAnchorTopCenter: '위쪽 가운데',
+ logOverlayEnable: '사용',
+ logOverlayFollowWindow: '따라갈 창',
+ logOverlayFollowWindowNone: '선택 안 함',
+ logOverlayFollowWindowAuto: '연결된 창',
+ logOverlayRefreshWindows: '창 목록 새로고침',
+ logOverlayWaitingLogs: '로그 대기 중...',
maxLogsPerInstance: '인스턴스당 로그 최대 개수',
maxLogsPerInstanceHint: '한도를 초과하면 가장 오래된 로그가 자동으로 삭제됩니다(권장 500~2000)',
resetWindowLayout: '창 레이아웃 초기화',
diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts
index 94c69a1..2ba65e3 100644
--- a/src/i18n/locales/zh-CN.ts
+++ b/src/i18n/locales/zh-CN.ts
@@ -98,6 +98,25 @@ export default {
'每次手动打开程序时,也自动执行上方选定的配置(关闭则仅在开机自启动时触发)',
confirmBeforeDelete: '删除操作需要二次确认',
confirmBeforeDeleteHint: '删除任务、清空列表、导入覆盖等操作会先弹出确认对话框',
+ logOverlay: '日志悬浮窗',
+ logOverlayHint: '在控制器连接的窗口附近显示半透明悬浮日志,未连接时不显示',
+ logOverlayMode: '悬浮模式',
+ logOverlayModeFixed: '定点悬浮',
+ logOverlayModeFollow: '跟随连接窗口',
+ logOverlayZOrder: '窗口层级',
+ logOverlayZOrderTop: '置顶',
+ logOverlayZOrderAboveTarget: '连接窗口上一层',
+ logOverlayAnchor: '锚定位置',
+ logOverlayAnchorLeftCenter: '左中',
+ logOverlayAnchorRightTop: '右上(1/3处)',
+ logOverlayAnchorRightBottom: '右下(2/3处)',
+ logOverlayAnchorTopCenter: '上中',
+ logOverlayEnable: '开启',
+ logOverlayFollowWindow: '跟随窗口',
+ logOverlayFollowWindowNone: '未选择',
+ logOverlayFollowWindowAuto: '已连接窗口',
+ logOverlayRefreshWindows: '刷新窗口列表',
+ logOverlayWaitingLogs: '等待日志...',
maxLogsPerInstance: '每个实例保留的日志上限',
maxLogsPerInstanceHint: '超过上限会自动丢弃最旧的日志(建议 500~2000)',
resetWindowLayout: '重置窗口布局',
diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts
index 4dbaae4..10d866d 100644
--- a/src/i18n/locales/zh-TW.ts
+++ b/src/i18n/locales/zh-TW.ts
@@ -97,6 +97,25 @@ export default {
'每次手動開啟程式時,也自動執行上方選定的配置(關閉則僅在開機自啟動時觸發)',
confirmBeforeDelete: '刪除操作需要二次確認',
confirmBeforeDeleteHint: '刪除任務、清空列表、匯入覆蓋等操作會先彈出確認對話框',
+ logOverlay: '日誌懸浮窗',
+ logOverlayHint: '在控制器連接的視窗附近顯示半透明懸浮日誌,未連接時不顯示',
+ logOverlayMode: '懸浮模式',
+ logOverlayModeFixed: '定點懸浮',
+ logOverlayModeFollow: '跟隨連接視窗',
+ logOverlayZOrder: '視窗層級',
+ logOverlayZOrderTop: '置頂',
+ logOverlayZOrderAboveTarget: '連接視窗上一層',
+ logOverlayAnchor: '錨定位置',
+ logOverlayAnchorLeftCenter: '左中',
+ logOverlayAnchorRightTop: '右上(1/3 處)',
+ logOverlayAnchorRightBottom: '右下(2/3 處)',
+ logOverlayAnchorTopCenter: '上中',
+ logOverlayEnable: '開啟',
+ logOverlayFollowWindow: '跟隨視窗',
+ logOverlayFollowWindowNone: '未選擇',
+ logOverlayFollowWindowAuto: '已連接視窗',
+ logOverlayRefreshWindows: '重新整理視窗列表',
+ logOverlayWaitingLogs: '等待日誌...',
maxLogsPerInstance: '每個實例保留的日誌上限',
maxLogsPerInstanceHint: '超出上限會自動丟棄最舊的日誌(建議 500~2000)',
resetWindowLayout: '重設視窗佈局',
diff --git a/src/logOverlay.tsx b/src/logOverlay.tsx
new file mode 100644
index 0000000..2f80d19
--- /dev/null
+++ b/src/logOverlay.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { LogOverlayApp } from './components/LogOverlay';
+import './i18n';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/src/services/logOverlayService.ts b/src/services/logOverlayService.ts
new file mode 100644
index 0000000..5d11339
--- /dev/null
+++ b/src/services/logOverlayService.ts
@@ -0,0 +1,454 @@
+/**
+ * 日志悬浮窗服务
+ *
+ * - 只有控制器连接后才显示
+ * - 定点模式:固定在屏幕位置,可拖拽,不跟随
+ * - 跟随模式:锚定在控制器连接的窗口(游戏窗口)附近,跟随移动
+ * - 层级选项:最置顶 / 只在连接窗口上一层(两种模式都生效)
+ * - 关闭时保存悬浮窗尺寸,下次打开时恢复
+ * - 定点模式下确保悬浮窗在屏幕可见范围内
+ *
+ * 所有坐标和尺寸统一使用物理像素(与 Win32 API 一致),避免 DPI 缩放问题。
+ */
+
+import { invoke } from '@tauri-apps/api/core';
+import { getAllWindows, availableMonitors } from '@tauri-apps/api/window';
+import { PhysicalPosition } from '@tauri-apps/api/dpi';
+import { listen } from '@tauri-apps/api/event';
+import { isTauri } from '@/utils/paths';
+import { loggers } from '@/utils/logger';
+import { useAppStore } from '@/stores/appStore';
+
+const log = loggers.app;
+
+const OVERLAY_LABEL = 'log-overlay';
+const DEFAULT_WIDTH = 360;
+const DEFAULT_HEIGHT = 260;
+const DEFAULT_X = 100;
+const DEFAULT_Y = 100;
+
+let pollIntervalId: ReturnType
| null = null;
+let closeListenerCleanup: (() => void) | null = null;
+let closeListenerPending = false;
+/** 程序主动关闭悬浮窗时为 true,close 事件中不修改 enabled 状态 */
+let programmaticClose = false;
+let saveTickCounter = 0;
+const SAVE_INTERVAL_TICKS = 15; // 每 15 次 poll (~4.5s) 保存一次几何信息
+
+// ========== Handle helpers ==========
+
+async function queryConnectedHandle(): Promise {
+ const state = useAppStore.getState();
+
+ // 优先查询当前激活实例的窗口句柄
+ if (state.activeInstanceId) {
+ const activeStatus = state.instanceConnectionStatus[state.activeInstanceId];
+ if (activeStatus === 'Connected') {
+ try {
+ const handle = await invoke('get_connected_window_handle', {
+ instanceId: state.activeInstanceId,
+ });
+ if (handle) return handle;
+ } catch {
+ // ignore
+ }
+ }
+ }
+
+ // 回退:查找任意已连接实例
+ for (const [instanceId, status] of Object.entries(state.instanceConnectionStatus)) {
+ if (instanceId === state.activeInstanceId) continue; // 已经查过了
+ if (status !== 'Connected') continue;
+ try {
+ const handle = await invoke('get_connected_window_handle', { instanceId });
+ if (handle) return handle;
+ } catch {
+ // ignore
+ }
+ }
+ return null;
+}
+
+function hasAnyConnection(): boolean {
+ const state = useAppStore.getState();
+ return Object.values(state.instanceConnectionStatus).some((s) => s === 'Connected');
+}
+
+async function queryTargetRect(handle: number) {
+ const [x, y, w, h, scale] = await invoke<[number, number, number, number, number]>(
+ 'get_window_rect_by_handle',
+ { handle },
+ );
+ return { x, y, w, h, scale };
+}
+
+// ========== Close event listener ==========
+
+/**
+ * 监听悬浮窗关闭事件(由 Rust on_window_event 发出)
+ * 保存尺寸并同步 toggle 状态
+ */
+function setupCloseListener() {
+ if (closeListenerCleanup || closeListenerPending) return;
+ closeListenerPending = true;
+
+ listen<{ width: number; height: number; x: number; y: number }>(
+ 'log-overlay-closed',
+ (event) => {
+ const { width, height, x, y } = event.payload;
+ log.info(`Log overlay closed, saving size: ${width}x${height}, pos: (${x}, ${y})`);
+
+ // 保存逻辑尺寸(Rust 端已将物理像素转为逻辑像素)
+ if (width > 0 && height > 0) {
+ useAppStore.getState().setLogOverlaySize(width, height);
+ }
+
+ // 保存位置(物理像素,与创建时一致)
+ if (x != null && y != null) {
+ useAppStore.getState().setLogOverlayPosition(x, y);
+ }
+
+ // 仅用户手动关闭时同步 enabled 状态;
+ // 程序主动关闭(断开连接等)不修改 enabled,保留用户偏好
+ if (!programmaticClose) {
+ useAppStore.getState().setLogOverlayEnabled(false);
+ }
+ programmaticClose = false;
+ stopPolling();
+ },
+ ).then((unlisten) => {
+ closeListenerCleanup = unlisten;
+ closeListenerPending = false;
+ });
+}
+
+// ========== Screen bounds check ==========
+
+/**
+ * 确保位置在屏幕可见范围内(定点模式用)
+ *
+ * 坐标空间说明:
+ * - 输入 x, y 为物理像素(来自 outerPosition / 保存的 logOverlayX/Y)
+ * - Tauri 2.0 的 availableMonitors() 返回的 Monitor.position (PhysicalPosition)
+ * 和 Monitor.size (PhysicalSize) 同样是物理像素
+ * - 因此两者在同一坐标空间,无需额外转换
+ */
+async function clampToScreen(
+ x: number,
+ y: number,
+): Promise<{ x: number; y: number }> {
+ try {
+ const monitors = await availableMonitors();
+ if (monitors.length === 0) return { x, y };
+
+ // 检查位置是否在任一显示器范围内(至少有一部分可见)
+ // position/size 均为物理像素
+ const isVisible = monitors.some((m) => {
+ const mx = m.position.x;
+ const my = m.position.y;
+ const mw = m.size.width;
+ const mh = m.size.height;
+ return x + 50 > mx && x < mx + mw && y + 30 > my && y < my + mh;
+ });
+
+ if (isVisible) return { x, y };
+
+ // 不可见,移到主显示器左上角
+ log.info('Log overlay: position out of screen, resetting');
+ const primary = monitors[0];
+ return {
+ x: primary.position.x + 50,
+ y: primary.position.y + 50,
+ };
+ } catch {
+ return { x, y };
+ }
+}
+
+// ========== Window management ==========
+
+export async function showLogOverlay(): Promise {
+ if (!isTauri()) return;
+
+ const state = useAppStore.getState();
+ if (!state.logOverlayEnabled) return;
+ if (!hasAnyConnection()) return;
+
+ // 确保关闭事件监听器已设置
+ setupCloseListener();
+
+ try {
+ const windows = await getAllWindows();
+ const existing = windows.find((w) => w.label === OVERLAY_LABEL);
+
+ if (existing) {
+ await existing.show();
+ startPolling();
+ return;
+ }
+
+ // 使用保存的尺寸或默认值
+ const overlayW = state.logOverlayWidth || DEFAULT_WIDTH;
+ const overlayH = state.logOverlayHeight || DEFAULT_HEIGHT;
+
+ let pos = await getInitialPosition(overlayW, overlayH);
+
+ // 定点模式下检查屏幕范围
+ if (state.logOverlayMode === 'fixed') {
+ pos = await clampToScreen(pos.x, pos.y);
+ }
+
+ const alwaysOnTop = state.logOverlayZOrder === 'always_on_top';
+
+ log.info(
+ `Creating log overlay: pos=(${pos.x}, ${pos.y}), size=${overlayW}x${overlayH}, alwaysOnTop=${alwaysOnTop}, mode=${state.logOverlayMode}`,
+ );
+
+ await invoke('create_log_overlay_window', {
+ x: pos.x,
+ y: pos.y,
+ width: overlayW,
+ height: overlayH,
+ alwaysOnTop,
+ });
+
+ log.info('Log overlay window created');
+ startPolling();
+ } catch (err) {
+ log.error('Failed to create log overlay window:', err);
+ }
+}
+
+export async function hideLogOverlay(keepEnabled = false): Promise {
+ if (!isTauri()) return;
+ stopPolling();
+ try {
+ // 关闭前保存尺寸
+ await saveOverlayGeometry();
+ // 标记为程序主动关闭,close 事件中不修改 enabled 状态
+ if (keepEnabled) {
+ programmaticClose = true;
+ }
+ await invoke('close_log_overlay');
+ log.info('Log overlay closed');
+ } catch (err) {
+ programmaticClose = false;
+ log.error('Failed to close log overlay window:', err);
+ }
+}
+
+export async function toggleLogOverlay(): Promise {
+ if (!isTauri()) return;
+ try {
+ const windows = await getAllWindows();
+ const existing = windows.find((w) => w.label === OVERLAY_LABEL);
+ if (existing) {
+ await hideLogOverlay();
+ } else {
+ await showLogOverlay();
+ }
+ } catch {
+ // ignore
+ }
+}
+
+// ========== Unified polling ==========
+
+function startPolling() {
+ if (pollIntervalId) return;
+ log.debug('Log overlay: starting poll');
+ pollIntervalId = setInterval(pollTick, 300);
+ pollTick();
+}
+
+function stopPolling() {
+ if (pollIntervalId) {
+ clearInterval(pollIntervalId);
+ pollIntervalId = null;
+ }
+}
+
+async function pollTick() {
+ const state = useAppStore.getState();
+ const needFollow = state.logOverlayMode === 'follow';
+ const needZOrder = state.logOverlayZOrder === 'above_target';
+
+ // 定期保存悬浮窗尺寸(无论什么模式都需要)
+ saveTickCounter++;
+ if (saveTickCounter >= SAVE_INTERVAL_TICKS) {
+ saveTickCounter = 0;
+ saveOverlayGeometry();
+ }
+
+ if (!needFollow && !needZOrder) return;
+
+ const handle = await queryConnectedHandle();
+ if (!handle) return;
+
+ try {
+ if (needFollow) {
+ const windows = await getAllWindows();
+ const overlayWin = windows.find((w) => w.label === OVERLAY_LABEL);
+ if (!overlayWin) return;
+
+ const target = await queryTargetRect(handle);
+
+ const physSize = await overlayWin.outerSize();
+ const ow = physSize.width;
+ const oh = physSize.height;
+
+ const pos = calcAnchorPosition(
+ target.x, target.y, target.w, target.h,
+ ow, oh,
+ state.logOverlayAnchor,
+ );
+ await overlayWin.setPosition(new PhysicalPosition(pos.x, pos.y));
+ }
+
+ if (needZOrder) {
+ await invoke('set_overlay_above_target', { targetHandle: handle });
+ }
+ } catch {
+ // window may have been closed or handle invalid
+ }
+}
+
+/**
+ * 保存悬浮窗当前尺寸和位置到 store(自动持久化到配置文件)
+ */
+async function saveOverlayGeometry() {
+ try {
+ const windows = await getAllWindows();
+ const overlayWin = windows.find((w) => w.label === OVERLAY_LABEL);
+ if (!overlayWin) return;
+
+ const physSize = await overlayWin.innerSize();
+ const scaleFactor = await overlayWin.scaleFactor();
+ // inner_size 返回物理像素,创建窗口时用逻辑像素,需要转换
+ const logicalW = Math.round(physSize.width / scaleFactor);
+ const logicalH = Math.round(physSize.height / scaleFactor);
+ if (logicalW > 0 && logicalH > 0) {
+ const { logOverlayWidth, logOverlayHeight } = useAppStore.getState();
+ if (logicalW !== logOverlayWidth || logicalH !== logOverlayHeight) {
+ useAppStore.getState().setLogOverlaySize(logicalW, logicalH);
+ }
+ }
+
+ // 保存位置(outer_position 返回物理像素,与创建时 PhysicalPosition 一致)
+ const physPos = await overlayWin.outerPosition();
+ const { logOverlayX, logOverlayY } = useAppStore.getState();
+ if (physPos.x !== logOverlayX || physPos.y !== logOverlayY) {
+ useAppStore.getState().setLogOverlayPosition(physPos.x, physPos.y);
+ }
+ } catch {
+ // overlay may have been closed
+ }
+}
+
+// ========== Position calculation ==========
+
+function calcAnchorPosition(
+ wx: number, wy: number, ww: number, wh: number,
+ ow: number, oh: number,
+ anchor: string,
+): { x: number; y: number } {
+ switch (anchor) {
+ case 'left-center':
+ return { x: wx, y: wy + Math.round((wh - oh) / 2) };
+ case 'right-top-third':
+ return { x: wx + ww - ow, y: wy + Math.round(wh / 3) - Math.round(oh / 2) };
+ case 'right-bottom-third':
+ return { x: wx + ww - ow, y: wy + Math.round((2 * wh) / 3) - Math.round(oh / 2) };
+ case 'top-center':
+ return { x: wx + Math.round((ww - ow) / 2), y: wy };
+ default:
+ return { x: wx + ww - ow, y: wy + Math.round(wh / 3) - Math.round(oh / 2) };
+ }
+}
+
+async function getInitialPosition(
+ overlayW: number,
+ overlayH: number,
+): Promise<{ x: number; y: number }> {
+ const state = useAppStore.getState();
+
+ if (state.logOverlayMode === 'follow') {
+ const handle = await queryConnectedHandle();
+ if (handle) {
+ try {
+ const target = await queryTargetRect(handle);
+ const ow = Math.round(overlayW * target.scale);
+ const oh = Math.round(overlayH * target.scale);
+ return calcAnchorPosition(
+ target.x, target.y, target.w, target.h,
+ ow, oh,
+ state.logOverlayAnchor,
+ );
+ } catch {
+ // fallback
+ }
+ }
+ }
+
+ // 定点模式:优先使用上次保存的位置
+ if (state.logOverlayX != null && state.logOverlayY != null) {
+ return { x: state.logOverlayX, y: state.logOverlayY };
+ }
+
+ return { x: DEFAULT_X, y: DEFAULT_Y };
+}
+
+export async function onOverlaySettingsChanged(): Promise {
+ const state = useAppStore.getState();
+
+ // 检查悬浮窗是否存在,不存在则无需操作
+ const windows = await getAllWindows();
+ if (!windows.some((w) => w.label === OVERLAY_LABEL)) return;
+
+ try {
+ await invoke('set_overlay_always_on_top', {
+ alwaysOnTop: state.logOverlayZOrder === 'always_on_top',
+ });
+ } catch {
+ // overlay may not exist yet
+ }
+
+ if (pollIntervalId) {
+ stopPolling();
+ }
+ startPolling();
+}
+
+export function subscribeConnectionStatus(): () => void {
+ // 设置关闭事件监听
+ setupCloseListener();
+
+ const unsub = useAppStore.subscribe(
+ (state) => ({
+ hasConnection: Object.values(state.instanceConnectionStatus).some((s) => s === 'Connected'),
+ enabled: state.logOverlayEnabled,
+ }),
+ (curr, prev) => {
+ if (!curr.enabled) return;
+
+ if (curr.hasConnection && !prev.hasConnection) {
+ log.info('Log overlay: connection detected, showing overlay');
+ setTimeout(() => showLogOverlay().catch(() => {}), 500);
+ } else if (!curr.hasConnection && prev.hasConnection) {
+ log.info('Log overlay: all disconnected, hiding overlay (keeping enabled)');
+ hideLogOverlay(true).catch(() => {});
+ }
+ },
+ {
+ equalityFn: (a, b) => a.hasConnection === b.hasConnection && a.enabled === b.enabled,
+ },
+ );
+
+ return () => {
+ unsub();
+ if (closeListenerCleanup) {
+ closeListenerCleanup();
+ closeListenerCleanup = null;
+ }
+ };
+}
diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts
index b31714a..c6f47c5 100644
--- a/src/stores/appStore.ts
+++ b/src/stores/appStore.ts
@@ -977,6 +977,14 @@ export const useAppStore = create()(
stopTasks: 'F11',
globalEnabled: false,
},
+ logOverlayEnabled: config.settings.logOverlay?.enabled ?? false,
+ logOverlayMode: config.settings.logOverlay?.mode ?? 'fixed',
+ logOverlayAnchor: config.settings.logOverlay?.anchor ?? 'right-top-third',
+ logOverlayZOrder: config.settings.logOverlay?.zOrder ?? 'always_on_top',
+ logOverlayWidth: config.settings.logOverlay?.width ?? 360,
+ logOverlayHeight: config.settings.logOverlay?.height ?? 260,
+ logOverlayX: config.settings.logOverlay?.x ?? null,
+ logOverlayY: config.settings.logOverlay?.y ?? null,
recentlyClosed: config.recentlyClosed || [],
// 记录新增任务,并在有新增时自动展开添加任务面板
newTaskNames: detectedNewTaskNames,
@@ -1280,6 +1288,32 @@ export const useAppStore = create()(
}
},
+ // 日志悬浮窗设置
+ logOverlayEnabled: false,
+ setLogOverlayEnabled: (enabled) => set({ logOverlayEnabled: enabled }),
+ logOverlayMode: 'fixed' as 'fixed' | 'follow',
+ setLogOverlayMode: (mode) => set({ logOverlayMode: mode }),
+ logOverlayAnchor: 'right-top-third' as 'left-center' | 'right-top-third' | 'right-bottom-third' | 'top-center',
+ setLogOverlayAnchor: (anchor) => set({ logOverlayAnchor: anchor }),
+ logOverlayZOrder: 'always_on_top' as 'always_on_top' | 'above_target',
+ setLogOverlayZOrder: (z) => set({ logOverlayZOrder: z }),
+ logOverlayWidth: 360,
+ logOverlayHeight: 260,
+ setLogOverlaySize: (width, height) => set({ logOverlayWidth: width, logOverlayHeight: height }),
+ logOverlayX: null as number | null,
+ logOverlayY: null as number | null,
+ setLogOverlayPosition: (x, y) => set({ logOverlayX: x, logOverlayY: y }),
+
+ // 控制器连接的窗口句柄
+ instanceConnectedHandle: {},
+ setConnectedHandle: (instanceId, handle) =>
+ set((state) => ({
+ instanceConnectedHandle: {
+ ...state.instanceConnectedHandle,
+ [instanceId]: handle,
+ },
+ })),
+
// 新用户引导
onboardingCompleted: false,
setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }),
@@ -1559,6 +1593,25 @@ export const useAppStore = create()(
: DEFAULT_MAX_LOGS_PER_INSTANCE;
const limit = Math.min(10000, Math.max(100, Math.floor(rawLimit)));
const updatedLogs = [...logs, newLog].slice(-limit);
+
+ // 广播日志到悬浮窗(fire-and-forget,不阻塞 store 更新)
+ import('@tauri-apps/api/event')
+ .then(({ emit }) =>
+ emit('log-overlay-new-log', {
+ instanceId,
+ log: {
+ id: newLog.id,
+ timestamp: newLog.timestamp.getTime(),
+ type: newLog.type,
+ message: newLog.message,
+ html: newLog.html,
+ },
+ }),
+ )
+ .catch(() => {
+ /* ignore in non-tauri env */
+ });
+
return {
instanceLogs: {
...state.instanceLogs,
@@ -1567,13 +1620,17 @@ export const useAppStore = create()(
};
}),
- clearLogs: (instanceId) =>
- set((state) => ({
+ clearLogs: (instanceId) => {
+ import('@tauri-apps/api/event')
+ .then(({ emit }) => emit('log-overlay-clear', { instanceId }))
+ .catch(() => {});
+ return set((state) => ({
instanceLogs: {
...state.instanceLogs,
[instanceId]: [],
},
- })),
+ }));
+ },
// 回调 ID 与名称的映射
ctrlIdToName: {},
@@ -1660,6 +1717,16 @@ function generateConfig(): MxuConfig {
minimizeToTray: state.minimizeToTray,
onboardingCompleted: state.onboardingCompleted,
hotkeys: state.hotkeys,
+ logOverlay: {
+ enabled: state.logOverlayEnabled,
+ mode: state.logOverlayMode,
+ anchor: state.logOverlayAnchor,
+ zOrder: state.logOverlayZOrder,
+ width: state.logOverlayWidth,
+ height: state.logOverlayHeight,
+ x: state.logOverlayX ?? undefined,
+ y: state.logOverlayY ?? undefined,
+ },
},
recentlyClosed: state.recentlyClosed,
// 保存当前 interface.json 的任务名列表快照,用于下次加载时检测新增任务
@@ -1718,6 +1785,14 @@ useAppStore.subscribe(
minimizeToTray: state.minimizeToTray,
onboardingCompleted: state.onboardingCompleted,
hotkeys: state.hotkeys,
+ logOverlayEnabled: state.logOverlayEnabled,
+ logOverlayMode: state.logOverlayMode,
+ logOverlayAnchor: state.logOverlayAnchor,
+ logOverlayZOrder: state.logOverlayZOrder,
+ logOverlayWidth: state.logOverlayWidth,
+ logOverlayHeight: state.logOverlayHeight,
+ logOverlayX: state.logOverlayX,
+ logOverlayY: state.logOverlayY,
recentlyClosed: state.recentlyClosed,
newTaskNames: state.newTaskNames,
customAccents: state.customAccents,
diff --git a/src/stores/types.ts b/src/stores/types.ts
index 896caa4..460a01e 100644
--- a/src/stores/types.ts
+++ b/src/stores/types.ts
@@ -322,6 +322,32 @@ export interface AppState {
minimizeToTray: boolean;
setMinimizeToTray: (enabled: boolean) => void;
+ // 日志悬浮窗设置
+ logOverlayEnabled: boolean;
+ setLogOverlayEnabled: (enabled: boolean) => void;
+ logOverlayMode: 'fixed' | 'follow';
+ setLogOverlayMode: (mode: 'fixed' | 'follow') => void;
+ /** 跟随模式下的锚定位置 */
+ logOverlayAnchor: 'left-center' | 'right-top-third' | 'right-bottom-third' | 'top-center';
+ setLogOverlayAnchor: (anchor: 'left-center' | 'right-top-third' | 'right-bottom-third' | 'top-center') => void;
+ /** 悬浮窗层级:always_on_top 最置顶,above_target 只在连接窗口上一层 */
+ logOverlayZOrder: 'always_on_top' | 'above_target';
+ setLogOverlayZOrder: (z: 'always_on_top' | 'above_target') => void;
+ /** 悬浮窗逻辑宽度 */
+ logOverlayWidth: number;
+ /** 悬浮窗逻辑高度 */
+ logOverlayHeight: number;
+ setLogOverlaySize: (width: number, height: number) => void;
+ /** 悬浮窗上次位置 X(物理像素,与 Win32 API / PhysicalPosition 一致) */
+ logOverlayX: number | null;
+ /** 悬浮窗上次位置 Y(物理像素,与 Win32 API / PhysicalPosition 一致) */
+ logOverlayY: number | null;
+ setLogOverlayPosition: (x: number, y: number) => void;
+
+ // 控制器连接的窗口句柄(Win32/Gamepad)
+ instanceConnectedHandle: Record;
+ setConnectedHandle: (instanceId: string, handle: number | null) => void;
+
// 启动后自动执行的实例 ID
autoStartInstanceId: string | undefined;
setAutoStartInstanceId: (id: string | undefined) => void;
diff --git a/src/types/config.ts b/src/types/config.ts
index 3987dc3..8737ae6 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -103,6 +103,22 @@ export interface HotkeySettings {
globalEnabled?: boolean;
}
+// 日志悬浮窗配置
+export interface LogOverlaySettings {
+ enabled: boolean;
+ mode: 'fixed' | 'follow';
+ anchor?: 'left-center' | 'right-top-third' | 'right-bottom-third' | 'top-center';
+ zOrder?: 'always_on_top' | 'above_target';
+ /** 悬浮窗逻辑宽度 */
+ width?: number;
+ /** 悬浮窗逻辑高度 */
+ height?: number;
+ /** 悬浮窗上次位置 X(物理像素) */
+ x?: number;
+ /** 悬浮窗上次位置 Y(物理像素) */
+ y?: number;
+}
+
// 应用设置
export interface AppSettings {
theme: 'light' | 'dark' | 'system';
@@ -132,6 +148,7 @@ export interface AppSettings {
autoStartInstanceId?: string; // 启动后自动执行的实例 ID(为空或 undefined 表示不自动执行)
autoRunOnLaunch?: boolean; // 非开机自启动的手动启动场景下,是否也自动执行选定的实例(默认 false)
autoStartRemovedInstanceName?: string; // 被删除的自动执行配置名称(用于提示用户)
+ logOverlay?: LogOverlaySettings; // 日志悬浮窗配置
}
// MXU 配置文件完整结构
diff --git a/vite.config.ts b/vite.config.ts
index f3df063..b5eebee 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,7 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
-import path from "path";
+import path, { resolve } from "path";
import { readFileSync } from "node:fs";
// @ts-expect-error process is a nodejs global
@@ -24,7 +24,17 @@ export default defineConfig(async () => ({
__MXU_VERSION__: JSON.stringify(mxuVersion),
},
build: {
+ // 启用压缩以减小输出大小
+ minify: "esbuild",
+ // 禁用 source map 以加快构建速度(生产环境通常不需要)
+ sourcemap: false,
+ // 优化 chunk 大小
+ chunkSizeWarningLimit: 1000,
rollupOptions: {
+ input: {
+ main: resolve(__dirname, "index.html"),
+ "log-overlay": resolve(__dirname, "log-overlay.html"),
+ },
output: {
manualChunks(id) {
const normalizedId = id.replace(/\\/g, "/");