Skip to content
Merged
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
48 changes: 39 additions & 9 deletions openadapt_capture/platform/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,21 @@ def get_display_pixel_ratio() -> float:
...

@staticmethod
def is_accessibility_enabled() -> bool:
def is_accessibility_enabled() -> bool | None:
"""Check if accessibility permissions are enabled."""
...


class DisplayMetricsUnavailable(RuntimeError):
"""A display measurement could not be taken.

Raised instead of returning a plausible default. A fabricated pixel ratio
or screen size is written into the recording and is indistinguishable from
a measured one, so every downstream coordinate is silently reinterpreted
at the wrong scale while the recording reports success.
"""


def get_platform() -> str:
"""Get the current platform identifier.

Expand Down Expand Up @@ -77,18 +87,26 @@ def get_screen_dimensions() -> tuple[int, int]:

Returns:
Tuple of (width, height) in physical pixels.

Raises:
DisplayMetricsUnavailable: If the screen could not be measured. A
plausible default is never returned: it would be recorded as a
real measurement.
"""
try:
provider = get_platform_provider()
return provider.get_screen_dimensions()
except (NotImplementedError, ImportError):
except (NotImplementedError, ImportError) as exc:
# Fallback to generic implementation
try:
from PIL import ImageGrab
screenshot = ImageGrab.grab()
return screenshot.size
except Exception:
return (1920, 1080) # Default fallback
except Exception as grab_exc:
raise DisplayMetricsUnavailable(
f"Could not measure screen dimensions: {exc}; "
f"the generic screenshot fallback also failed: {grab_exc}"
) from grab_exc


def get_display_pixel_ratio() -> float:
Expand All @@ -99,31 +117,43 @@ def get_display_pixel_ratio() -> float:

Returns:
Pixel ratio (e.g., 1.0 for standard displays, 2.0 for Retina).

Raises:
DisplayMetricsUnavailable: If the ratio could not be measured. `1.0` is
never substituted: a Retina display whose probe failed would be
recorded as a standard display and every captured coordinate would
be reinterpreted at half scale.
"""
try:
provider = get_platform_provider()
return provider.get_display_pixel_ratio()
except (NotImplementedError, ImportError):
return 1.0
except (NotImplementedError, ImportError) as exc:
raise DisplayMetricsUnavailable(
f"Could not measure the display pixel ratio: {exc}"
) from exc


def is_accessibility_enabled() -> bool:
def is_accessibility_enabled() -> bool | None:
"""Check if accessibility permissions are enabled.

On macOS, this checks if the application has accessibility permissions
required for keyboard and mouse event capture.

Returns:
True if accessibility is enabled, False otherwise.
True if accessibility is verified enabled, False if it is verified
disabled, and None if the permission state could not be determined.
None is not a synonym for True: a caller gating "safe to record" on an
undetermined state would start a recording that captures no input.
"""
try:
provider = get_platform_provider()
return provider.is_accessibility_enabled()
except (NotImplementedError, ImportError):
return True # Assume enabled on unknown platforms
return None


__all__ = [
"DisplayMetricsUnavailable",
"get_platform",
"get_platform_provider",
"get_screen_dimensions",
Expand Down
54 changes: 40 additions & 14 deletions openadapt_capture/platform/darwin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

import sys

from openadapt_capture.platform import DisplayMetricsUnavailable

if sys.platform != "darwin":
raise ImportError("This module is only available on macOS")

Expand All @@ -30,12 +32,15 @@ def get_screen_dimensions() -> tuple[int, int]:

Returns:
Tuple of (width, height) in physical pixels.

Raises:
DisplayMetricsUnavailable: If the screen could not be measured.
"""
try:
from PIL import ImageGrab
screenshot = ImageGrab.grab()
return screenshot.size
except Exception:
except Exception as grab_exc:
# Fallback using Quartz
try:
import Quartz
Expand All @@ -44,8 +49,12 @@ def get_screen_dimensions() -> tuple[int, int]:
width = Quartz.CGDisplayPixelsWide(main_display)
height = Quartz.CGDisplayPixelsHigh(main_display)
return (width, height)
except Exception:
return (1920, 1080)
except Exception as quartz_exc:
raise DisplayMetricsUnavailable(
"Could not measure macOS screen dimensions: "
f"ImageGrab failed ({grab_exc}) and Quartz failed "
f"({quartz_exc})"
) from quartz_exc

@staticmethod
def get_display_pixel_ratio() -> float:
Expand Down Expand Up @@ -73,8 +82,11 @@ def get_display_pixel_ratio() -> float:
if logical_width > 0:
return physical_width / logical_width

return 1.0
except ImportError:
raise DisplayMetricsUnavailable(
"macOS reported a non-positive logical display width; "
"the pixel ratio could not be measured"
)
except ImportError as import_exc:
# Try using Quartz directly
try:
import Quartz
Expand All @@ -91,21 +103,35 @@ def get_display_pixel_ratio() -> float:
if logical_width > 0:
return physical_width / logical_width

return 1.0
except Exception:
return 1.0
except Exception:
return 1.0
raise DisplayMetricsUnavailable(
"Quartz did not report a usable display mode; "
"the pixel ratio could not be measured"
)
except DisplayMetricsUnavailable:
raise
except Exception as quartz_exc:
raise DisplayMetricsUnavailable(
"Could not measure the macOS pixel ratio: mss/PIL missing "
f"({import_exc}) and Quartz failed ({quartz_exc})"
) from quartz_exc
except DisplayMetricsUnavailable:
raise
except Exception as exc:
raise DisplayMetricsUnavailable(
f"Could not measure the macOS pixel ratio: {exc}"
) from exc

@staticmethod
def is_accessibility_enabled() -> bool:
def is_accessibility_enabled() -> bool | None:
"""Check if accessibility permissions are enabled.

macOS requires accessibility permissions for capturing
keyboard and mouse events globally.

Returns:
True if accessibility is enabled, False otherwise.
True if verified enabled, False if verified disabled, and None if
the permission state could not be determined. None must not be
read as True: an unchecked permission is not a granted one.
"""
try:
import Quartz # noqa: F401 - needed for ApplicationServices
Expand Down Expand Up @@ -136,9 +162,9 @@ def is_accessibility_enabled() -> bool:
)
return result.returncode == 0
except Exception:
return True # Assume enabled if we can't check
return None # Undetermined - never report this as enabled
except Exception:
return True # Assume enabled if we can't check
return None # Undetermined - never report this as enabled

@staticmethod
def get_active_window_info() -> dict | None:
Expand Down
22 changes: 16 additions & 6 deletions openadapt_capture/platform/linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import sys

from openadapt_capture.platform import DisplayMetricsUnavailable
from openadapt_capture.x11_threads import ensure_xlib_thread_support

if not sys.platform.startswith("linux"):
Expand Down Expand Up @@ -99,7 +100,10 @@ def get_screen_dimensions() -> tuple[int, int]:
except Exception:
pass

return (1920, 1080)
raise DisplayMetricsUnavailable(
"Could not measure Linux screen dimensions: none of mss/PIL, "
"xdpyinfo, or wlr-randr produced a usable result"
)

@staticmethod
def get_display_pixel_ratio() -> float:
Expand Down Expand Up @@ -164,18 +168,23 @@ def get_display_pixel_ratio() -> float:
except Exception:
pass

return 1.0
raise DisplayMetricsUnavailable(
"Could not measure the Linux pixel ratio: gsettings, GDK_SCALE, "
"QT_SCALE_FACTOR, and the mss/PIL comparison all failed"
)

@staticmethod
def is_accessibility_enabled() -> bool:
def is_accessibility_enabled() -> bool | None:
"""Check if input capture is available.

On Linux, this typically requires:
- X11: xdotool or similar tool access
- Wayland: Portal permissions or root access

Returns:
True if input capture is likely available, False otherwise.
True if input capture is verified available, False if it is
verified unavailable, and None if the state could not be
determined. None must not be read as True.
"""
import os

Expand Down Expand Up @@ -213,8 +222,9 @@ def is_accessibility_enabled() -> bool:
except Exception:
pass

# Assume enabled if we can't determine
return True
# Undetermined: the portal probe could not run. Never report this as
# enabled - a caller would start a recording that captures no input.
return None

@staticmethod
def get_active_window_info() -> dict | None:
Expand Down
52 changes: 34 additions & 18 deletions openadapt_capture/platform/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

import sys

from openadapt_capture.platform import DisplayMetricsUnavailable

if sys.platform != "win32":
raise ImportError("This module is only available on Windows")

Expand All @@ -35,7 +37,7 @@ def get_screen_dimensions() -> tuple[int, int]:
from PIL import ImageGrab
screenshot = ImageGrab.grab()
return screenshot.size
except Exception:
except Exception as grab_exc:
# Fallback using ctypes
try:
import ctypes
Expand All @@ -53,8 +55,12 @@ def get_screen_dimensions() -> tuple[int, int]:
width = user32.GetSystemMetrics(0) # SM_CXSCREEN
height = user32.GetSystemMetrics(1) # SM_CYSCREEN
return (width, height)
except Exception:
return (1920, 1080)
except Exception as ctypes_exc:
raise DisplayMetricsUnavailable(
"Could not measure Windows screen dimensions: ImageGrab "
f"failed ({grab_exc}) and GetSystemMetrics failed "
f"({ctypes_exc})"
) from ctypes_exc

@staticmethod
def get_display_pixel_ratio() -> float:
Expand Down Expand Up @@ -96,32 +102,42 @@ def get_display_pixel_ratio() -> float:
except Exception:
pass

return 1.0
except Exception:
return 1.0
raise DisplayMetricsUnavailable(
"Could not measure the Windows pixel ratio: neither "
"GetDpiForMonitor nor GetDeviceCaps produced a result"
)
except DisplayMetricsUnavailable:
raise
except Exception as exc:
raise DisplayMetricsUnavailable(
f"Could not measure the Windows pixel ratio: {exc}"
) from exc

@staticmethod
def is_accessibility_enabled() -> bool:
def is_accessibility_enabled() -> bool | None:
"""Check if the application can capture input events.

On Windows, input capture typically works without special permissions,
but we check if we're running with sufficient privileges.
On Windows, input capture works for both administrator and standard
users, so a successful probe of the Win32 privilege API is what makes
the True answer a *checked* one rather than an assumed one.

Returns:
True if input capture is available, False otherwise.
True if the Win32 privilege API answered, and None if it could not
be reached at all. None must not be read as True: the previous
version computed `IsUserAnAdmin()`, discarded the result, and
returned True from every branch including the failure handler, so
the answer carried no information.
"""
try:
import ctypes

# Check if running as administrator
try:
ctypes.windll.shell32.IsUserAnAdmin()
# Even non-admin can typically capture input
return True
except Exception:
return True # Assume enabled
except Exception:
# The result is intentionally not used to gate the answer: a
# standard user can capture input. Reaching the API at all is what
# is being verified.
ctypes.windll.shell32.IsUserAnAdmin()
return True
except Exception:
return None

@staticmethod
def get_active_window_info() -> dict | None:
Expand Down
Loading