Files
Modus/src/services/screenshot.py
T
sankalp a47abda55a fix: resolve wifi crashes, shell injections, race conditions, and add debug mode
- Remove transient NEED_AUTH/DISCONNECTED error paths from wifi connect
- Guard double-callback in connect_wifi_bssid/connect_wifi_with_password
- Shell-quote user paths in wallpaper, screenshot, screenrecorder services
- Fix OSD hide-timer use-after-free and applet_window stale source ID
- Fix brightness poll timer leak on read failure
- Fix spotlight search stale idle source removal
- Lock dbusmenu _cache mutation under _registry_lock
- Fix entry.revision written outside _registry_lock scope
- Add fabric-cli exec wrapper for lock screen (lock_screen.lock())
- Replace hyprlock with lock screen in power menu and dropdown
- Add debug mode toggle in settings (controls log verbosity)
- Add diagnostic logging to globalmenu click chain
2026-07-16 11:47:53 +05:45

183 lines
6.1 KiB
Python

"""Screenshot service.
Owns screenshot capture only; recording lives in
:mod:`services.screenrecorder`. The service resolves the desired target
into a concrete request, hands it to a :class:`CaptureBackend`, then raises
notifications and signals based on the result.
"""
import shlex
from datetime import datetime
from pathlib import Path
from fabric.core.service import Property, Service, Signal
from fabric.utils import exec_shell_command_async
from shared.capture import (
CaptureBackend,
CaptureNotifier,
CaptureResult,
HyprlandCaptureBackend,
ScreenshotRequest,
)
from utils.functions import find_binary, resolve_monitor
class Screenshot(Service):
"""Capture screenshots through a pluggable :class:`CaptureBackend`."""
@Signal
def captured(self, path: str) -> None:
"""Emitted with the saved path when a screenshot succeeds."""
@Signal
def failed(self, reason: str) -> None:
"""Emitted when a screenshot is cancelled or fails."""
_instance: "Screenshot | None" = None
def __new__(cls) -> "Screenshot":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, backend: CaptureBackend | None = None, **kwargs) -> None:
if getattr(self, "_initialized", False):
return
super().__init__(**kwargs)
self._initialized = True
self._backend = backend or HyprlandCaptureBackend()
self._notifier = CaptureNotifier()
self._busy = False
self._last_file: str | None = None
self._has_satty = find_binary("satty") is not None
self.screenshots_dir = Path.home() / "Pictures" / "Screenshots"
self.screenshots_dir.mkdir(parents=True, exist_ok=True)
# -- properties --------------------------------------------------------
@Property(bool, "readable", default_value=False)
def busy(self) -> bool:
"""Whether a capture is currently in progress."""
return self._busy
@Property(str, "readable", default_value="")
def last_file(self) -> str:
"""Path of the most recently captured screenshot."""
return self._last_file or ""
@Property(str, "readable", default_value="")
def backend(self) -> str:
"""Name of the active capture backend."""
return self._backend.name
@Property(bool, "readable", default_value=False)
def available(self) -> bool:
"""Whether the backend can take screenshots on this system."""
return self._backend.capabilities.can_screenshot
@Property(bool, "readable", default_value=False)
def can_edit(self) -> bool:
"""Whether an external editor (``satty``) is available."""
return self._has_satty
# -- public API --------------------------------------------------------
def screenshot(
self,
target: str = "region",
output_dir: str | Path | None = None,
show_cursor: bool = False,
) -> bool:
"""Take a screenshot.
:param target: ``region``, ``window``, ``active``, ``output`` or a
specific monitor name.
:param output_dir: destination directory; defaults to the standard
screenshots directory.
:param show_cursor: include the mouse cursor in the capture.
:returns: ``True`` if the capture attempt was launched.
"""
if not self._backend.capabilities.can_screenshot:
self._fail("Screenshot backend is unavailable")
return False
request = self._build_request(target, output_dir, show_cursor)
self._set_busy(True)
launched = self._backend.screenshot(request, self._on_result)
if not launched:
self._set_busy(False)
return launched
# -- internals ---------------------------------------------------------
def _build_request(
self, target: str, output_dir: str | Path | None, show_cursor: bool
) -> ScreenshotRequest:
save_dir = Path(output_dir).expanduser() if output_dir else self.screenshots_dir
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
monitor = resolve_monitor(target)
return ScreenshotRequest(
target=target,
save_path=save_dir / f"{timestamp}.png",
show_cursor=show_cursor,
monitor=monitor,
)
def _on_result(self, result: CaptureResult) -> None:
self._set_busy(False)
if result.success and result.path:
self._on_success(result.path)
elif result.cancelled:
self._notify_cancelled()
self.failed("cancelled")
else:
self._fail(result.error or "Screenshot failed")
def _on_success(self, path: str) -> None:
self._last_file = path
self.notify("last-file")
self._notify_saved(path)
self.captured(path)
def _notify_saved(self, path: str) -> None:
actions = [("files", "Show in Files"), ("view", "View")]
if self._has_satty:
actions.append(("edit", "Edit"))
self._notifier.notify(
"Screenshot Saved",
f"Saved Screenshot at {path}",
icon="camera-photo-symbolic",
actions=actions,
hints=[f"STRING:image-path:{path}"],
on_action=lambda action: self._handle_action(action, path),
)
def _handle_action(self, action: str, path: str) -> None:
match action:
case "files":
exec_shell_command_async(
f"xdg-open {shlex.quote(str(self.screenshots_dir))}"
)
case "view":
exec_shell_command_async(f"xdg-open {shlex.quote(path)}")
case "edit":
exec_shell_command_async(f"satty -f {shlex.quote(path)}")
def _notify_cancelled(self) -> None:
self._notifier.notify(
"Screenshot cancelled",
"Selection was cancelled",
icon="camera-photo-symbolic",
)
def _fail(self, reason: str) -> None:
self.failed(reason)
def _set_busy(self, value: bool) -> None:
if value != self._busy:
self._busy = value
self.notify("busy")