Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"core:window:allow-unminimize",
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"core:window:allow-set-always-on-top",
"core:window:allow-is-always-on-top",
"core:window:allow-set-focus",
"core:window:allow-close",
"core:window:allow-start-dragging",
Expand Down
106 changes: 88 additions & 18 deletions src/components/TitleBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Minus, Square, X, Copy, Box } from 'lucide-react';
import { Minus, Square, X, Copy, Box, Pin } from 'lucide-react';
import { useAppStore } from '@/stores/appStore';
import { getInterfaceLangKey } from '@/i18n';
import { loadIconAsDataUrl } from '@/services/contentResolver';
Expand All @@ -10,13 +10,18 @@ import { isTauri } from '@/utils/paths';
// 平台类型
type Platform = 'windows' | 'macos' | 'linux' | 'unknown';

// Win32 前台截图方法(需要禁用置顶)
const FOREGROUND_SCREENCAP_METHODS = new Set(['GDI', '1', 'DXGI_DesktopDup', '4', 'DXGI_DesktopDup_Window', '8', 'ScreenDC', '32']);

export function TitleBar() {
const { t } = useTranslation();
const [isMaximized, setIsMaximized] = useState(false);
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false);
const [platform, setPlatform] = useState<Platform>('unknown');
const [iconUrl, setIconUrl] = useState<string | undefined>(undefined);
const windowRef = useRef<Awaited<ReturnType<typeof import('@tauri-apps/api/window').getCurrentWindow>> | null>(null);

const { projectInterface, language, resolveI18nText, basePath, interfaceTranslations } =
const { projectInterface, language, resolveI18nText, basePath, interfaceTranslations, instances, activeInstanceId } =
useAppStore();

const langKey = getInterfaceLangKey(language);
Expand All @@ -39,24 +44,59 @@ export function TitleBar() {
loadIconAsDataUrl(projectInterface.icon, basePath, translations).then(setIconUrl);
}, [projectInterface?.icon, basePath, translations]);

// 监听窗口最大化状态变化(仅 Windows,用于切换最大化/还原按钮图标)
// 检测是否使用前台截图(前台截图时禁用置顶)
const isPinDisabled = useMemo(() => {
if (!projectInterface || !activeInstanceId) return false;

const activeInstance = instances.find((i) => i.id === activeInstanceId);
if (!activeInstance?.controllerName) return false;

const controller = projectInterface.controller.find((c) => c.name === activeInstance.controllerName);
if (!controller) return false;

// 仅 Win32 的特定前台截图方法需要禁用置顶
if (controller.type === 'Win32' && controller.win32?.screencap) {
const screencap = controller.win32.screencap;
return FOREGROUND_SCREENCAP_METHODS.has(screencap);
}

// 其他情况(ADB、PlayCover、Gamepad 或 Win32 后台截图)均支持置顶
return false;
}, [projectInterface, instances, activeInstanceId]);

// 当置顶被禁用时,自动取消置顶
useEffect(() => {
if (isPinDisabled && isAlwaysOnTop && windowRef.current) {
setIsAlwaysOnTop(false);
windowRef.current.setAlwaysOnTop(false).catch((err: unknown) => {
loggers.ui.warn('Failed to disable always on top:', err);
});
}
}, [isPinDisabled, isAlwaysOnTop]);

// 初始化 Tauri 窗口引用,并在 Windows 上监听最大化状态变化
useEffect(() => {
if (!isTauri() || platform !== 'windows') return;
if (!isTauri()) return;

let unlisten: (() => void) | null = null;

const setup = async () => {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const appWindow = getCurrentWindow();
windowRef.current = appWindow;

// 获取初始状态
setIsMaximized(await appWindow.isMaximized());
setIsAlwaysOnTop(await appWindow.isAlwaysOnTop());

// 监听窗口状态变化
unlisten = await appWindow.onResized(async () => {
// 仅 Windows 需要追踪最大化状态(macOS/Linux 使用原生标题栏)
if (platform === 'windows') {
setIsMaximized(await appWindow.isMaximized());
});

unlisten = await appWindow.onResized(async () => {
setIsMaximized(await appWindow.isMaximized());
});
}
} catch (err) {
loggers.ui.warn('Failed to setup window state listener:', err);
}
Expand All @@ -66,34 +106,44 @@ export function TitleBar() {

return () => {
if (unlisten) unlisten();
windowRef.current = null;
};
}, [platform]);

const handleMinimize = async () => {
if (!isTauri()) return;
if (!windowRef.current) return;
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().minimize();
await windowRef.current.minimize();
} catch (err) {
loggers.ui.warn('Failed to minimize window:', err);
}
};

const handleToggleAlwaysOnTop = async () => {
if (!windowRef.current) return;
const newState = !isAlwaysOnTop;
setIsAlwaysOnTop(newState); // Optimistic update
try {
await windowRef.current.setAlwaysOnTop(newState);
} catch (err) {
setIsAlwaysOnTop(!newState); // Revert on error
loggers.ui.warn('Failed to toggle always on top:', err);
}
};

const handleToggleMaximize = async () => {
if (!isTauri()) return;
if (!windowRef.current) return;
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().toggleMaximize();
await windowRef.current.toggleMaximize();
} catch (err) {
loggers.ui.warn('Failed to toggle maximize:', err);
}
};

const handleClose = async () => {
if (!isTauri()) return;
if (!windowRef.current) return;
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().close();
await windowRef.current.close();
} catch (err) {
loggers.ui.warn('Failed to close window:', err);
}
Expand Down Expand Up @@ -142,6 +192,26 @@ export function TitleBar() {
{/* 右侧:窗口控制按钮(仅 Windows/Linux 显示) */}
{isTauri() && (
<div className="flex h-full">
<button
onClick={handleToggleAlwaysOnTop}
disabled={isPinDisabled}
className={`w-12 h-full flex items-center justify-center transition-colors ${
isPinDisabled
? 'text-text-tertiary cursor-not-allowed'
: isAlwaysOnTop
? 'text-accent bg-accent/10 hover:bg-accent/20'
: 'text-text-secondary hover:bg-bg-hover'
}`}
title={
isPinDisabled
? t('windowControls.pinDisabled')
: isAlwaysOnTop
? t('windowControls.unpin')
: t('windowControls.pin')
}
>
<Pin className={`w-4 h-4 transition-transform ${isAlwaysOnTop ? '' : 'rotate-45'}`} />
</button>
<button
onClick={handleMinimize}
className="w-12 h-full flex items-center justify-center text-text-secondary hover:bg-bg-hover transition-colors"
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export default {
maximize: 'Maximize',
restore: 'Restore',
close: 'Close',
pin: 'Pin',
unpin: 'Unpin',
pinDisabled: 'Pin disabled (foreground screenshot)',
},

// Settings
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export default {
maximize: '最大化',
restore: '元に戻す',
close: '閉じる',
pin: '常に手前に表示',
unpin: '常に手前に表示を解除',
pinDisabled: 'フォアグラウンドスクリーンショットのため固定不可',
},

// 設定
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/ko-KR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export default {
maximize: '최대화',
restore: '복원',
close: '닫기',
pin: '항상 위',
unpin: '항상 위 해제',
pinDisabled: '전면 스크린샷 사용 중, 고정 불가',
},

// 설정
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export default {
maximize: '最大化',
restore: '还原',
close: '关闭',
pin: '置顶',
unpin: '取消置顶',
pinDisabled: '当前控制器使用前台截图,无法置顶',
},

// 设置
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export default {
maximize: '最大化',
restore: '還原',
close: '關閉',
pin: '置頂',
unpin: '取消置頂',
pinDisabled: '目前控制器使用前台截圖,無法置頂',
},

// 設定
Expand Down
Loading