diff --git a/server/src/wled_controller/core/profiles/platform_detector.py b/server/src/wled_controller/core/profiles/platform_detector.py index 90993a0..6079305 100644 --- a/server/src/wled_controller/core/profiles/platform_detector.py +++ b/server/src/wled_controller/core/profiles/platform_detector.py @@ -39,10 +39,14 @@ class PlatformDetector: logger.error(f"Failed to enumerate processes: {e}") return set() - def _get_topmost_process_sync(self) -> Optional[str]: - """Get lowercase process name of the foreground window (blocking, call via executor).""" + def _get_topmost_process_sync(self) -> tuple: + """Get (process_name, is_fullscreen) of the foreground window. + + Returns (None, False) when detection fails. + Blocking — call via executor. + """ if not _IS_WINDOWS: - return None + return (None, False) try: user32 = ctypes.windll.user32 @@ -51,12 +55,12 @@ class PlatformDetector: hwnd = user32.GetForegroundWindow() if not hwnd: - return None + return (None, False) pid = ctypes.wintypes.DWORD() user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) if not pid.value: - return None + return (None, False) PROCESS_QUERY_INFORMATION = 0x0400 PROCESS_VM_READ = 0x0010 @@ -64,28 +68,166 @@ class PlatformDetector: PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid.value ) if not handle: - return None + return (None, False) + proc_name = None try: buf = ctypes.create_unicode_buffer(512) psapi.GetModuleFileNameExW(handle, None, buf, 512) full_path = buf.value if full_path: - return os.path.basename(full_path).lower() + proc_name = os.path.basename(full_path).lower() finally: kernel32.CloseHandle(handle) - return None + if proc_name is None: + return (None, False) + + # Check if the foreground window covers its entire monitor + is_fullscreen = self._is_window_fullscreen(user32, hwnd) + + return (proc_name, is_fullscreen) except Exception as e: logger.error(f"Failed to get foreground process: {e}") - return None + return (None, False) + + @staticmethod + def _is_window_fullscreen(user32, hwnd) -> bool: + """Check whether *hwnd* covers its monitor completely.""" + try: + # Get window rectangle + win_rect = ctypes.wintypes.RECT() + user32.GetWindowRect(hwnd, ctypes.byref(win_rect)) + + # Get the monitor this window is on + MONITOR_DEFAULTTONEAREST = 2 + hmon = user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) + if not hmon: + return False + + # MONITORINFO struct + class MONITORINFO(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.wintypes.DWORD), + ("rcMonitor", ctypes.wintypes.RECT), + ("rcWork", ctypes.wintypes.RECT), + ("dwFlags", ctypes.wintypes.DWORD), + ] + + mi = MONITORINFO() + mi.cbSize = ctypes.sizeof(MONITORINFO) + if not user32.GetMonitorInfoW(hmon, ctypes.byref(mi)): + return False + + mr = mi.rcMonitor + return ( + win_rect.left <= mr.left + and win_rect.top <= mr.top + and win_rect.right >= mr.right + and win_rect.bottom >= mr.bottom + ) + except Exception: + return False + + def _get_fullscreen_processes_sync(self) -> Set[str]: + """Get set of lowercase process names that have a fullscreen window. + + Enumerates all top-level windows and checks each for fullscreen. + Returns process names (lowercase) whose window covers an entire monitor. + """ + if not _IS_WINDOWS: + return set() + + try: + user32 = ctypes.windll.user32 + kernel32 = ctypes.windll.kernel32 + psapi = ctypes.windll.psapi + + PROCESS_QUERY_INFORMATION = 0x0400 + PROCESS_VM_READ = 0x0010 + + fullscreen_procs: Set[str] = set() + + # Callback receives (hwnd, lparam); return True to continue enumeration + WNDENUMPROC = ctypes.WINFUNCTYPE( + ctypes.wintypes.BOOL, + ctypes.wintypes.HWND, + ctypes.wintypes.LPARAM, + ) + + # Skip the desktop and shell windows (always cover the full monitor) + desktop_hwnd = user32.GetDesktopWindow() + shell_hwnd = user32.GetShellWindow() + + # Get shell process PID to filter all explorer desktop windows + shell_pid = 0 + if shell_hwnd: + _spid = ctypes.wintypes.DWORD() + user32.GetWindowThreadProcessId(shell_hwnd, ctypes.byref(_spid)) + shell_pid = _spid.value + + GWL_EXSTYLE = -20 + WS_EX_TOOLWINDOW = 0x00000080 + WS_EX_NOACTIVATE = 0x08000000 + + def _enum_callback(hwnd, _lparam): + # Skip invisible windows + if not user32.IsWindowVisible(hwnd): + return True + if hwnd == desktop_hwnd: + return True + + # Skip tool/overlay/non-activatable windows (system UI, input hosts) + ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE) + if ex_style & (WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE): + return True + + # Quick fullscreen check before expensive process name lookup + if not self._is_window_fullscreen(user32, hwnd): + return True + + # Get PID; skip shell process windows (desktop, taskbar) + pid = ctypes.wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + if not pid.value or pid.value == shell_pid: + return True + + handle = kernel32.OpenProcess( + PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid.value + ) + if not handle: + return True + + try: + buf = ctypes.create_unicode_buffer(512) + psapi.GetModuleFileNameExW(handle, None, buf, 512) + full_path = buf.value + if full_path: + fullscreen_procs.add(os.path.basename(full_path).lower()) + finally: + kernel32.CloseHandle(handle) + + return True + + callback = WNDENUMPROC(_enum_callback) + user32.EnumWindows(callback, 0) + + return fullscreen_procs + except Exception as e: + logger.error(f"Failed to enumerate fullscreen windows: {e}") + return set() async def get_running_processes(self) -> Set[str]: """Get set of lowercase process names (async-safe).""" loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._get_running_processes_sync) - async def get_topmost_process(self) -> Optional[str]: - """Get lowercase process name of the foreground window (async-safe).""" + async def get_topmost_process(self) -> tuple: + """Get (process_name, is_fullscreen) of the foreground window (async-safe).""" loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._get_topmost_process_sync) + + async def get_fullscreen_processes(self) -> Set[str]: + """Get set of process names that have a fullscreen window (async-safe).""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._get_fullscreen_processes_sync) diff --git a/server/src/wled_controller/core/profiles/profile_engine.py b/server/src/wled_controller/core/profiles/profile_engine.py index 715def1..7041dd1 100644 --- a/server/src/wled_controller/core/profiles/profile_engine.py +++ b/server/src/wled_controller/core/profiles/profile_engine.py @@ -76,18 +76,35 @@ class ProfileEngine: await self._deactivate_profile(pid) return - # Only enumerate processes when at least one enabled profile has conditions - needs_detection = any( - p.enabled and len(p.conditions) > 0 - for p in profiles + # Determine which detection methods are actually needed + match_types_used: set = set() + for p in profiles: + if p.enabled: + for c in p.conditions: + mt = getattr(c, "match_type", "running") + match_types_used.add(mt) + + # WMI process enumeration (~3s) — only needed for "running" match type + needs_running = "running" in match_types_used + running_procs = ( + await self._detector.get_running_processes() + if needs_running else set() ) - if needs_detection: - running_procs = await self._detector.get_running_processes() - topmost_proc = await self._detector.get_topmost_process() + # Foreground window check (<1ms) — needed for "topmost" and "topmost_fullscreen" + needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"}) + if needs_topmost: + topmost_proc, topmost_fullscreen = await self._detector.get_topmost_process() else: - running_procs = set() topmost_proc = None + topmost_fullscreen = False + + # Fullscreen window enumeration (<1ms) — only needed for "fullscreen" + needs_fullscreen = "fullscreen" in match_types_used + fullscreen_procs = ( + await self._detector.get_fullscreen_processes() + if needs_fullscreen else set() + ) active_profile_ids = set() @@ -95,7 +112,7 @@ class ProfileEngine: should_be_active = ( profile.enabled and (len(profile.conditions) == 0 - or self._evaluate_conditions(profile, running_procs, topmost_proc)) + or self._evaluate_conditions(profile, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)) ) is_active = profile.id in self._active_profiles @@ -114,10 +131,12 @@ class ProfileEngine: await self._deactivate_profile(pid) def _evaluate_conditions( - self, profile: Profile, running_procs: Set[str], topmost_proc: Optional[str] + self, profile: Profile, running_procs: Set[str], + topmost_proc: Optional[str], topmost_fullscreen: bool, + fullscreen_procs: Set[str], ) -> bool: results = [ - self._evaluate_condition(c, running_procs, topmost_proc) + self._evaluate_condition(c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs) for c in profile.conditions ] @@ -126,10 +145,12 @@ class ProfileEngine: return any(results) # "or" is default def _evaluate_condition( - self, condition: Condition, running_procs: Set[str], topmost_proc: Optional[str] + self, condition: Condition, running_procs: Set[str], + topmost_proc: Optional[str], topmost_fullscreen: bool, + fullscreen_procs: Set[str], ) -> bool: if isinstance(condition, ApplicationCondition): - return self._evaluate_app_condition(condition, running_procs, topmost_proc) + return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs) return False def _evaluate_app_condition( @@ -137,12 +158,22 @@ class ProfileEngine: condition: ApplicationCondition, running_procs: Set[str], topmost_proc: Optional[str], + topmost_fullscreen: bool, + fullscreen_procs: Set[str], ) -> bool: if not condition.apps: return False apps_lower = [a.lower() for a in condition.apps] + if condition.match_type == "fullscreen": + return any(app in fullscreen_procs for app in apps_lower) + + if condition.match_type == "topmost_fullscreen": + if topmost_proc is None or not topmost_fullscreen: + return False + return any(app == topmost_proc for app in apps_lower) + if condition.match_type == "topmost": if topmost_proc is None: return False diff --git a/server/src/wled_controller/static/js/features/profiles.js b/server/src/wled_controller/static/js/features/profiles.js index 33f611e..123f232 100644 --- a/server/src/wled_controller/static/js/features/profiles.js +++ b/server/src/wled_controller/static/js/features/profiles.js @@ -67,7 +67,7 @@ function createProfileCard(profile) { const parts = profile.conditions.map(c => { if (c.condition_type === 'application') { const apps = (c.apps || []).join(', '); - const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running'); + const matchLabel = t('profiles.condition.application.match_type.' + (c.match_type || 'running')); return `${t('profiles.condition.application')}: ${apps} (${matchLabel})`; } return `${c.condition_type}`; @@ -212,6 +212,8 @@ function addProfileConditionRow(condition) {
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 7715df6..592adfb 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -504,6 +504,8 @@ "profiles.condition.application.match_type.hint": "How to detect the application", "profiles.condition.application.match_type.running": "Running", "profiles.condition.application.match_type.topmost": "Topmost (foreground)", + "profiles.condition.application.match_type.topmost_fullscreen": "Topmost + Fullscreen", + "profiles.condition.application.match_type.fullscreen": "Fullscreen", "profiles.targets": "Targets:", "profiles.targets.hint": "Targets to start when this profile activates", "profiles.targets.empty": "No targets available", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index d0468ea..1b165e0 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -504,6 +504,8 @@ "profiles.condition.application.match_type.hint": "Как определять наличие приложения", "profiles.condition.application.match_type.running": "Запущено", "profiles.condition.application.match_type.topmost": "На переднем плане", + "profiles.condition.application.match_type.topmost_fullscreen": "На переднем плане + Полный экран", + "profiles.condition.application.match_type.fullscreen": "Полный экран", "profiles.targets": "Цели:", "profiles.targets.hint": "Цели для запуска при активации профиля", "profiles.targets.empty": "Нет доступных целей",