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:
@@ -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:
|
||||
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:
|
||||
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()
|
||||
fullscreen_procs.add(os.path.basename(full_path).lower())
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
return None
|
||||
return True
|
||||
|
||||
callback = WNDENUMPROC(_enum_callback)
|
||||
user32.EnumWindows(callback, 0)
|
||||
|
||||
return fullscreen_procs
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get foreground process: {e}")
|
||||
return None
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<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>`;
|
||||
@@ -212,6 +212,8 @@ function addProfileConditionRow(condition) {
|
||||
<select class="condition-match-type">
|
||||
<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_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>
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Нет доступных целей",
|
||||
|
||||
Reference in New Issue
Block a user