Source code for exlab_wizard.tray.window_launcher

"""Spawn / focus the on-demand window subprocess. Backend Spec §4.3.2.

The tray clicks **Open** -> :class:`WindowLauncher` either:

* spawns a fresh ``exlab-wizard-window`` subprocess via
  :func:`subprocess.Popen` when no window is currently alive, or
* focuses the existing window (best-effort) when one is already up.

The tray process never blocks on the window subprocess; it polls
``Popen.poll()`` to detect window-exit and treats a stale child as "no
window present" on the next Open click.

This module is deliberately small: pywebview-driven focus is OS-specific
and best-effort. On Linux without a working ``xdotool`` the launcher
falls back to spawning a second window, which pywebview tolerates on
some desktop environments and not others -- this is acceptable per
§4.3.2's "best-effort" note.
"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
from pathlib import Path

from exlab_wizard.logging import get_logger

__all__ = ["WindowLauncher"]

_log = get_logger(__name__)


def _resolve_window_executable() -> list[str]:
    """Return the argv prefix for spawning ``exlab-wizard-window``.

    Resolution order:

    1. ``EXLAB_WINDOW_EXECUTABLE`` env var (used by tests and by
       PyInstaller-bundled artifacts to point at the bundled binary).
    2. ``exlab-wizard-window`` on PATH (development install).
    3. Fallback: ``sys.executable -m exlab_wizard.window.main`` (always
       works as long as the package is importable).
    """
    override = os.environ.get("EXLAB_WINDOW_EXECUTABLE")
    if override:
        return [override]
    discovered = shutil.which("exlab-wizard-window")
    if discovered:
        return [discovered]
    return [sys.executable, "-m", "exlab_wizard.window.main"]


[docs] class WindowLauncher: """Spawn ``exlab-wizard-window`` as a subprocess, track its PID. On a re-open request with an existing live child, focuses the existing window (best-effort) rather than spawning a duplicate (Backend §4.1: "single-instance window"). """ def __init__( self, *, window_executable: str | None = None, state_dir: Path, ) -> None: self._state_dir = Path(state_dir) self._executable_override = window_executable self._proc: subprocess.Popen[bytes] | None = None # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------
[docs] def open(self) -> None: """Spawn a window subprocess, or focus the existing one.""" if self.is_alive: self._focus_existing() return argv = self._argv() _log.info("spawning window subprocess: %s", argv) self._proc = subprocess.Popen( argv, stdin=subprocess.DEVNULL, )
[docs] def close(self) -> None: """Terminate the live window subprocess, if any. Idempotent.""" if self._proc is None: return if self._proc.poll() is None: self._proc.terminate() try: self._proc.wait(timeout=5.0) except subprocess.TimeoutExpired: self._proc.kill() self._proc.wait(timeout=2.0) self._proc = None
@property def is_alive(self) -> bool: """Return ``True`` while the window subprocess is running.""" if self._proc is None: return False return self._proc.poll() is None @property def pid(self) -> int | None: """Return the live window's PID, or ``None`` if no window is up.""" if not self.is_alive: return None return self._proc.pid if self._proc is not None else None # ------------------------------------------------------------------ # Internals # ------------------------------------------------------------------ def _argv(self) -> list[str]: """Return the argv to spawn the window subprocess.""" if self._executable_override: return [self._executable_override] return _resolve_window_executable() def _focus_existing(self) -> None: """Best-effort raise/focus of the existing window. v1 logs the request; the real focus path (xdotool / win32 / cocoa) is platform-specific and out of scope for the unit-test surface. Backend §4.3.2 explicitly allows the trivial fallback of "spawn a second window" when focus is unavailable. """ _log.info("window already open; focus requested (best-effort)")