Add fullscreen and topmost+fullscreen profile condition modes

New match types for application conditions:
- "fullscreen": app has a fullscreen window on any monitor (detected via
  EnumWindows, works even when another window is focused on a different
  display)
- "topmost_fullscreen": app is the focused foreground window AND fullscreen

Optimizes profile evaluation to only call expensive detection methods when
needed: WMI process enumeration (~3s) is skipped when no condition uses
"running" mode; foreground/fullscreen checks (<1ms each) are called
selectively based on active match types.

Filters false positives from fullscreen detection by excluding desktop/shell
process windows, tool windows, and non-activatable overlay windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 22:26:50 +03:00
parent ff4e054ef8
commit ab8041269e
5 changed files with 204 additions and 25 deletions

View File

@@ -39,10 +39,14 @@ class PlatformDetector:
logger.error(f"Failed to enumerate processes: {e}") logger.error(f"Failed to enumerate processes: {e}")
return set() return set()
def _get_topmost_process_sync(self) -> Optional[str]: def _get_topmost_process_sync(self) -> tuple:
"""Get lowercase process name of the foreground window (blocking, call via executor).""" """Get (process_name, is_fullscreen) of the foreground window.
Returns (None, False) when detection fails.
Blocking — call via executor.
"""
if not _IS_WINDOWS: if not _IS_WINDOWS:
return None return (None, False)
try: try:
user32 = ctypes.windll.user32 user32 = ctypes.windll.user32
@@ -51,12 +55,12 @@ class PlatformDetector:
hwnd = user32.GetForegroundWindow() hwnd = user32.GetForegroundWindow()
if not hwnd: if not hwnd:
return None return (None, False)
pid = ctypes.wintypes.DWORD() pid = ctypes.wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if not pid.value: if not pid.value:
return None return (None, False)
PROCESS_QUERY_INFORMATION = 0x0400 PROCESS_QUERY_INFORMATION = 0x0400
PROCESS_VM_READ = 0x0010 PROCESS_VM_READ = 0x0010
@@ -64,28 +68,166 @@ class PlatformDetector:
PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid.value PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid.value
) )
if not handle: 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:
proc_name = os.path.basename(full_path).lower()
finally:
kernel32.CloseHandle(handle)
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, 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: try:
buf = ctypes.create_unicode_buffer(512) buf = ctypes.create_unicode_buffer(512)
psapi.GetModuleFileNameExW(handle, None, buf, 512) psapi.GetModuleFileNameExW(handle, None, buf, 512)
full_path = buf.value full_path = buf.value
if full_path: if full_path:
return os.path.basename(full_path).lower() fullscreen_procs.add(os.path.basename(full_path).lower())
finally: finally:
kernel32.CloseHandle(handle) kernel32.CloseHandle(handle)
return None return True
callback = WNDENUMPROC(_enum_callback)
user32.EnumWindows(callback, 0)
return fullscreen_procs
except Exception as e: except Exception as e:
logger.error(f"Failed to get foreground process: {e}") logger.error(f"Failed to enumerate fullscreen windows: {e}")
return None return set()
async def get_running_processes(self) -> Set[str]: async def get_running_processes(self) -> Set[str]:
"""Get set of lowercase process names (async-safe).""" """Get set of lowercase process names (async-safe)."""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._get_running_processes_sync) return await loop.run_in_executor(None, self._get_running_processes_sync)
async def get_topmost_process(self) -> Optional[str]: async def get_topmost_process(self) -> tuple:
"""Get lowercase process name of the foreground window (async-safe).""" """Get (process_name, is_fullscreen) of the foreground window (async-safe)."""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._get_topmost_process_sync) 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)

View File

@@ -76,18 +76,35 @@ class ProfileEngine:
await self._deactivate_profile(pid) await self._deactivate_profile(pid)
return return
# Only enumerate processes when at least one enabled profile has conditions # Determine which detection methods are actually needed
needs_detection = any( match_types_used: set = set()
p.enabled and len(p.conditions) > 0 for p in profiles:
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: # Foreground window check (<1ms) — needed for "topmost" and "topmost_fullscreen"
running_procs = await self._detector.get_running_processes() needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
topmost_proc = await self._detector.get_topmost_process() if needs_topmost:
topmost_proc, topmost_fullscreen = await self._detector.get_topmost_process()
else: else:
running_procs = set()
topmost_proc = None 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() active_profile_ids = set()
@@ -95,7 +112,7 @@ class ProfileEngine:
should_be_active = ( should_be_active = (
profile.enabled profile.enabled
and (len(profile.conditions) == 0 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 is_active = profile.id in self._active_profiles
@@ -114,10 +131,12 @@ class ProfileEngine:
await self._deactivate_profile(pid) await self._deactivate_profile(pid)
def _evaluate_conditions( 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: ) -> bool:
results = [ 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 for c in profile.conditions
] ]
@@ -126,10 +145,12 @@ class ProfileEngine:
return any(results) # "or" is default return any(results) # "or" is default
def _evaluate_condition( 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: ) -> bool:
if isinstance(condition, ApplicationCondition): 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 return False
def _evaluate_app_condition( def _evaluate_app_condition(
@@ -137,12 +158,22 @@ class ProfileEngine:
condition: ApplicationCondition, condition: ApplicationCondition,
running_procs: Set[str], running_procs: Set[str],
topmost_proc: Optional[str], topmost_proc: Optional[str],
topmost_fullscreen: bool,
fullscreen_procs: Set[str],
) -> bool: ) -> bool:
if not condition.apps: if not condition.apps:
return False return False
apps_lower = [a.lower() for a in condition.apps] 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 condition.match_type == "topmost":
if topmost_proc is None: if topmost_proc is None:
return False return False

View File

@@ -67,7 +67,7 @@ function createProfileCard(profile) {
const parts = profile.conditions.map(c => { const parts = profile.conditions.map(c => {
if (c.condition_type === 'application') { if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', '); 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 `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`; return `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`;
} }
return `<span class="stream-card-prop">${c.condition_type}</span>`; return `<span class="stream-card-prop">${c.condition_type}</span>`;
@@ -212,6 +212,8 @@ function addProfileConditionRow(condition) {
<select class="condition-match-type"> <select class="condition-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('profiles.condition.application.match_type.running')}</option> <option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('profiles.condition.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost')}</option> <option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost')}</option>
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost_fullscreen')}</option>
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('profiles.condition.application.match_type.fullscreen')}</option>
</select> </select>
</div> </div>
<div class="condition-field"> <div class="condition-field">

View File

@@ -504,6 +504,8 @@
"profiles.condition.application.match_type.hint": "How to detect the application", "profiles.condition.application.match_type.hint": "How to detect the application",
"profiles.condition.application.match_type.running": "Running", "profiles.condition.application.match_type.running": "Running",
"profiles.condition.application.match_type.topmost": "Topmost (foreground)", "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": "Targets:",
"profiles.targets.hint": "Targets to start when this profile activates", "profiles.targets.hint": "Targets to start when this profile activates",
"profiles.targets.empty": "No targets available", "profiles.targets.empty": "No targets available",

View File

@@ -504,6 +504,8 @@
"profiles.condition.application.match_type.hint": "Как определять наличие приложения", "profiles.condition.application.match_type.hint": "Как определять наличие приложения",
"profiles.condition.application.match_type.running": "Запущено", "profiles.condition.application.match_type.running": "Запущено",
"profiles.condition.application.match_type.topmost": "На переднем плане", "profiles.condition.application.match_type.topmost": "На переднем плане",
"profiles.condition.application.match_type.topmost_fullscreen": "На переднем плане + Полный экран",
"profiles.condition.application.match_type.fullscreen": "Полный экран",
"profiles.targets": "Цели:", "profiles.targets": "Цели:",
"profiles.targets.hint": "Цели для запуска при активации профиля", "profiles.targets.hint": "Цели для запуска при активации профиля",
"profiles.targets.empty": "Нет доступных целей", "profiles.targets.empty": "Нет доступных целей",