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
68 changes: 45 additions & 23 deletions src/openadapt_tray/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
* the desktop app is the source of truth for all state — the tray renders it.
"""

import subprocess
import sys
import threading
import webbrowser

import pystray

from openadapt_tray.config import ConfigLoadError, TrayConfig
from openadapt_tray.desktop import DesktopLaunchError, launch_native_desktop
from openadapt_tray.hosted import (
CountResult,
HostedPoller,
Expand All @@ -40,9 +40,6 @@
TrayState,
)

# How the tray launches the desktop app when the socket is unreachable.
DESKTOP_APP_COMMAND = "openadapt-desktop"


class TrayApplication:
"""Main system tray application."""
Expand Down Expand Up @@ -243,29 +240,30 @@ def ensure_desktop_connection(self) -> bool:
return True

# Desktop not running — launch it, then poll for the discovery file.
self._launch_desktop_app()
if not self._launch_desktop_app():
return False
for _ in range(20): # ~10s
if self.ipc.refresh_from_discovery() and self.ipc.connect():
return True
threading.Event().wait(0.5)

return False

def _launch_desktop_app(self) -> None:
"""Spawn the desktop app process (best-effort)."""
def _launch_desktop_app(self) -> bool:
"""Launch the installed native desktop application."""
try:
subprocess.Popen(
[DESKTOP_APP_COMMAND],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
launch_native_desktop()
except DesktopLaunchError as e:
self.notifications.show(
"Desktop app not found",
"Install the OpenAdapt desktop app to record and manage workflows.",
)
print(f"Failed to launch desktop app: {e}")
return False
except Exception as e:
print(f"Failed to launch desktop app: {e}")
return False
return True

# --- recording actions (delegated to the desktop over IPC) --------------

Expand Down Expand Up @@ -484,7 +482,7 @@ def _on_ipc_recording_started(self, message) -> None:
data = message.data or {}
self.state.transition(
TrayState.RECORDING,
current_capture=data.get("name"),
current_capture=data.get("capture_id") or data.get("name"),
)

def _on_ipc_recording_stopped(self, message) -> None:
Expand All @@ -502,8 +500,8 @@ def _on_ipc_recording_error(self, message) -> None:
def _on_ipc_status_update(self, message) -> None:
"""Handle a full status update from the desktop (source of truth).

The payload may carry ``state``, ``deployment_lane``, ``sync_state``,
``break_count`` and ``offline``.
Protocol v1 carries the canonical ``recording`` boolean and optional
``capture_id``. Hosted status fields remain orthogonal.
"""
data = message.data or {}

Expand All @@ -518,25 +516,49 @@ def _on_ipc_status_update(self, message) -> None:
if "break_count" in data:
self._apply_break_count(data.get("break_count"))

recording = data.get("recording")
if type(recording) is bool:
self.state.transition(
TrayState.RECORDING if recording else TrayState.IDLE,
current_capture=(data.get("capture_id") if recording else None),
)
return

# Compatibility with the pre-v1 status projection. Protocol v1 uses
# the canonical ``recording`` boolean above.
state_name = data.get("state")
if state_name:
if isinstance(state_name, str):
try:
self.state.transition(TrayState[state_name])
self.state.transition(TrayState[state_name.upper()])
except KeyError:
pass

def _on_ipc_compile_progress(self, message) -> None:
"""Handle a compile-progress event (recording → workflow)."""
data = message.data or {}
# Any progress signal means we are compiling; a terminal signal returns
# to idle.
if data.get("done"):
state = str(data.get("state") or "").lower()
capture_id = data.get("capture_id") or data.get("name")
if state == "compiled":
self.state.transition(TrayState.IDLE)
else:
elif state in {"failed", "review_failed"}:
default_error = (
"The action review could not open."
if state == "review_failed"
else "The recording could not be compiled."
)
self.state.transition(
TrayState.ERROR,
current_capture=capture_id,
error_message=str(data.get("error") or default_error),
)
elif state == "compiling":
self.state.transition(
TrayState.COMPILING,
current_capture=data.get("name"),
current_capture=capture_id,
)
elif data.get("done") is True:
# Compatibility with an older terminal projection.
self.state.transition(TrayState.IDLE)

def _on_ipc_sync_state(self, message) -> None:
"""Handle a sync-state event from the desktop."""
Expand Down
135 changes: 135 additions & 0 deletions src/openadapt_tray/desktop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Native OpenAdapt Desktop discovery and launch helpers."""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
from collections.abc import Callable, Mapping
from pathlib import Path

DESKTOP_APP_NAME = "OpenAdapt Desktop"
DESKTOP_BUNDLE_IDENTIFIER = "ai.openadapt.desktop"
LINUX_DESKTOP_IDENTITY = DESKTOP_APP_NAME
LINUX_NATIVE_EXECUTABLE = Path("/usr/bin/openadapt-desktop")


class DesktopLaunchError(RuntimeError):
"""The installed native Desktop application could not be launched."""


def _run_launcher(
command: list[str],
*,
runner: Callable[..., subprocess.CompletedProcess],
) -> bool:
"""Run an OS launcher and return whether it accepted the application."""
try:
result = runner(
command,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=10,
check=False,
)
except (OSError, subprocess.SubprocessError):
return False
return result.returncode == 0


def _spawn_executable(
executable: Path,
*,
spawner: Callable[..., subprocess.Popen],
) -> bool:
"""Start an exact native executable path without resolving a CLI name."""
try:
spawner(
[str(executable)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except OSError:
return False
return True


def _windows_candidates(environment: Mapping[str, str]) -> tuple[Path, ...]:
"""Return the native paths produced by Desktop's MSI and NSIS installers."""
candidates: list[Path] = []
for variable in ("LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)"):
root = environment.get(variable)
if root:
candidates.append(Path(root) / DESKTOP_APP_NAME / "openadapt-desktop.exe")
return tuple(candidates)


def launch_native_desktop(
*,
platform: str | None = None,
environment: Mapping[str, str] | None = None,
runner: Callable[..., subprocess.CompletedProcess] = subprocess.run,
spawner: Callable[..., subprocess.Popen] = subprocess.Popen,
finder: Callable[[str], str | None] = shutil.which,
is_file: Callable[[Path], bool] = Path.is_file,
) -> None:
"""Launch the installed native Desktop application for the current OS.

The function never resolves the Python ``openadapt-desktop`` console script.
It uses the macOS bundle identifier, a registered Linux desktop identity,
or an exact path produced by a Windows native installer.
"""
platform = platform or sys.platform
if environment is None:
environment = os.environ

if platform == "darwin":
if _run_launcher(
["open", "-b", DESKTOP_BUNDLE_IDENTIFIER],
runner=runner,
):
return
raise DesktopLaunchError(
f"macOS could not open {DESKTOP_APP_NAME} ({DESKTOP_BUNDLE_IDENTIFIER})."
)

if platform == "win32":
for executable in _windows_candidates(environment):
if is_file(executable) and _spawn_executable(
executable,
spawner=spawner,
):
return
raise DesktopLaunchError(
f"Windows could not find an installed {DESKTOP_APP_NAME} application."
)

if platform.startswith("linux"):
gtk_launch = finder("gtk-launch")
if gtk_launch and _run_launcher(
[gtk_launch, LINUX_DESKTOP_IDENTITY],
runner=runner,
):
return

if is_file(LINUX_NATIVE_EXECUTABLE) and _spawn_executable(
LINUX_NATIVE_EXECUTABLE,
spawner=spawner,
):
return

appimage = environment.get("OPENADAPT_DESKTOP_APPIMAGE")
if appimage:
executable = Path(appimage).expanduser()
if is_file(executable) and _spawn_executable(
executable,
spawner=spawner,
):
return

raise DesktopLaunchError(
f"Linux could not open the registered {DESKTOP_APP_NAME} application."
)

raise DesktopLaunchError(f"Desktop launch is not supported on {platform}.")
28 changes: 22 additions & 6 deletions src/openadapt_tray/ipc.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

# Discovery file the desktop app writes on startup (see §3d of the spec).
DEFAULT_DISCOVERY_PATH = Path.home() / ".openadapt" / "desktop_ipc.json"
SUPPORTED_PROTOCOL_VERSION = 1


class IPCMessageType(Enum):
Expand Down Expand Up @@ -85,14 +86,13 @@ def from_json(cls, json_str: str) -> "IPCMessage":
class DesktopEndpoint:
"""Discovered desktop-app IPC endpoint."""

protocol_version: int
host: str
port: int
token: str | None = None

@classmethod
def load(
cls, path: Path | None = None
) -> Optional["DesktopEndpoint"]:
def load(cls, path: Path | None = None) -> Optional["DesktopEndpoint"]:
"""Load the desktop IPC endpoint from the discovery file.

Args:
Expand All @@ -108,10 +108,21 @@ def load(
if not path.exists():
return None
data = json.loads(path.read_text())
protocol_version = data.get("protocol_version")
if (
type(protocol_version) is not int
or protocol_version != SUPPORTED_PROTOCOL_VERSION
):
print(
"Could not use desktop IPC discovery file: "
f"protocol_version must be {SUPPORTED_PROTOCOL_VERSION}"
)
return None
port = data.get("port")
if port is None:
return None
return cls(
protocol_version=protocol_version,
host=data.get("host", "127.0.0.1"),
port=int(port),
token=data.get("token"),
Expand Down Expand Up @@ -150,9 +161,7 @@ def __init__(
self._handlers: dict[IPCMessageType, Callable[[IPCMessage], None]] = {}

@classmethod
def from_discovery(
cls, path: Path | None = None
) -> Optional["IPCClient"]:
def from_discovery(cls, path: Path | None = None) -> Optional["IPCClient"]:
"""Build a client from the desktop discovery file, if present.

Args:
Expand Down Expand Up @@ -221,6 +230,13 @@ def connect(self) -> bool:
)
self._listener_thread.start()

# The desktop is authoritative. Request a complete status snapshot
# before the tray accepts any user action based on its default
# in-memory state.
if not self.send_get_status():
self.close()
return False

return True
except OSError as e:
print(f"IPC connection failed: {e}")
Expand Down
Loading
Loading