| 1 | from __future__ import annotations |
| 2 | |
| 3 | import atexit |
| 4 | import fcntl |
| 5 | import hashlib |
| 6 | import json |
| 7 | import os |
| 8 | import shutil |
| 9 | import socket |
| 10 | import subprocess |
| 11 | import threading |
| 12 | import time |
| 13 | import uuid |
| 14 | import xml.etree.ElementTree as ET |
| 15 | from dataclasses import dataclass, field |
| 16 | from pathlib import Path |
| 17 | from typing import Any |
| 18 | |
| 19 | from helpers import files, virtual_desktop |
| 20 | from helpers.localization import Localization |
| 21 | from plugins._desktop.helpers import desktop_state |
| 22 | from plugins._office.helpers import document_store, libreoffice |
| 23 | |
| 24 | |
| 25 | OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"} |
| 26 | PLUGIN_NAME = "_desktop" |
| 27 | SYSTEM_SESSION_ID = "agent-zero-desktop" |
| 28 | SYSTEM_FILE_ID = "system-desktop" |
| 29 | SYSTEM_TITLE = "Desktop" |
| 30 | STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME)) |
| 31 | RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME)) |
| 32 | SESSION_DIR = STATE_DIR / "sessions" |
| 33 | PROFILE_DIR = STATE_DIR / "profiles" |
| 34 | LEGACY_SESSION_DIRS = ( |
| 35 | RETIRED_STATE_DIR / "sessions", |
| 36 | Path(files.get_abs_path("tmp", "_office", "desktop", "sessions")), |
| 37 | ) |
| 38 | DISPLAY_BASE = 120 |
| 39 | XPRA_PORT_BASE = 14500 |
| 40 | MAX_SESSIONS = 12 |
| 41 | DEFAULT_SCREEN_WIDTH = virtual_desktop.DEFAULT_WIDTH |
| 42 | DEFAULT_SCREEN_HEIGHT = virtual_desktop.DEFAULT_HEIGHT |
| 43 | MAX_SCREEN_WIDTH = virtual_desktop.MAX_WIDTH |
| 44 | MAX_SCREEN_HEIGHT = virtual_desktop.MAX_HEIGHT |
| 45 | BLOCKING_DIALOG_TITLES = ("Remote Files", "File Services") |
| 46 | DISPLAY_START_TIMEOUT_SECONDS = 30.0 |
| 47 | PORT_START_TIMEOUT_SECONDS = 30.0 |
| 48 | STARTUP_GRACE_SECONDS = 45 |
| 49 | RUNTIME_INSTALL_MESSAGE = ( |
| 50 | "Installing Agent Zero Desktop runtime dependencies. " |
| 51 | "This can take a few minutes after an update." |
| 52 | ) |
| 53 | HIDDEN_XPRA_DESKTOP_ENTRIES = ( |
| 54 | "xpra.desktop", |
| 55 | "xpra-gui.desktop", |
| 56 | "xpra-launcher.desktop", |
| 57 | "xpra-shadow.desktop", |
| 58 | "xpra-start.desktop", |
| 59 | ) |
| 60 | HIDDEN_XFCE_MENU_ENTRIES = ( |
| 61 | ("exo-mail-reader.desktop", "Mail Reader"), |
| 62 | ("exo-web-browser.desktop", "Web Browser"), |
| 63 | ("xfce4-mail-reader.desktop", "Mail Reader"), |
| 64 | ("xfce4-web-browser.desktop", "Web Browser"), |
| 65 | ("xfce4-session-logout.desktop", "Log Out"), |
| 66 | ("xfce4-lock-screen.desktop", "Lock Screen"), |
| 67 | ("xflock4.desktop", "Lock Screen"), |
| 68 | ("xfce4-switch-user.desktop", "Switch User"), |
| 69 | ) |
| 70 | DESKTOP_README_SOURCE = Path(__file__).resolve().parents[1] / "assets" / "desktop" / "README.md" |
| 71 | DESKTOP_FOLDER_LINKS = ( |
| 72 | ("Projects", ("usr", "projects")), |
| 73 | ("Skills", ("usr", "skills")), |
| 74 | ("Agents", ("usr", "agents")), |
| 75 | ("Downloads", ("usr", "downloads")), |
| 76 | ) |
| 77 | URL_INTENT_MAX_ITEMS = 50 |
| 78 | URL_INTENT_MAX_LENGTH = 8192 |
| 79 | URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop" |
| 80 | EDITOR_HANDLER_DESKTOP_ID = "agent-zero-editor.desktop" |
| 81 | WRITER_HANDLER_DESKTOP_ID = "libreoffice-writer.desktop" |
| 82 | SHUTDOWN_HANDLER_DESKTOP_ID = "agent-zero-shutdown.desktop" |
| 83 | SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID |
| 84 | SHUTDOWN_CONFIRM_SECONDS = 8 |
| 85 | OOR_NS = "http://openoffice.org/2001/registry" |
| 86 | XS_NS = "http://www.w3.org/2001/XMLSchema" |
| 87 | XSI_NS = "http://www.w3.org/2001/XMLSchema-instance" |
| 88 | |
| 89 | |
| 90 | @dataclass |
| 91 | class DesktopSession: |
| 92 | session_id: str |
| 93 | file_id: str |
| 94 | extension: str |
| 95 | path: str |
| 96 | title: str |
| 97 | display: int |
| 98 | xpra_port: int |
| 99 | token: str |
| 100 | url: str |
| 101 | profile_dir: Path |
| 102 | width: int = DEFAULT_SCREEN_WIDTH |
| 103 | height: int = DEFAULT_SCREEN_HEIGHT |
| 104 | processes: dict[str, subprocess.Popen[Any]] = field(default_factory=dict) |
| 105 | process_ids: dict[str, int] = field(default_factory=dict) |
| 106 | owns_processes: bool = True |
| 107 | started_at: float = field(default_factory=time.time) |
| 108 | timezone: str = field(default_factory=lambda: Localization.get().get_timezone()) |
| 109 | |
| 110 | def alive(self) -> bool: |
| 111 | return _running(self.processes.get("xpra")) or _pid_is_running(self.process_ids.get("xpra", 0)) |
| 112 | |
| 113 | def public(self, doc: dict[str, Any] | None = None) -> dict[str, Any]: |
| 114 | title = str(doc.get("basename") or "") if doc else self.title |
| 115 | path = str(doc.get("path") or "") if doc else self.path |
| 116 | extension = str(doc.get("extension") or "") if doc else self.extension |
| 117 | file_id = str(doc.get("file_id") or "") if doc else self.file_id |
| 118 | return { |
| 119 | "available": True, |
| 120 | "session_id": self.session_id, |
| 121 | "file_id": file_id, |
| 122 | "extension": extension, |
| 123 | "title": title, |
| 124 | "path": document_store.display_path(path), |
| 125 | "url": self.url, |
| 126 | "token": self.token, |
| 127 | "display": f":{self.display}", |
| 128 | "desktop_path": virtual_desktop.SESSION_PATH, |
| 129 | "width": self.width, |
| 130 | "height": self.height, |
| 131 | "started_at": self.started_at, |
| 132 | } |
| 133 | |
| 134 | |
| 135 | class DesktopSessionManager: |
| 136 | def __init__(self) -> None: |
| 137 | self._lock = threading.RLock() |
| 138 | self._sessions: dict[str, DesktopSession] = {} |
| 139 | |
| 140 | def ensure_system_desktop(self) -> dict[str, Any]: |
| 141 | try: |
| 142 | with self._lock: |
| 143 | self._reap_dead_locked() |
| 144 | session = self._ensure_system_desktop_locked() |
| 145 | return session.public() |
| 146 | except Exception as exc: |
| 147 | status = collect_desktop_status() |
| 148 | return { |
| 149 | "available": False, |
| 150 | "error": str(exc), |
| 151 | "status": status, |
| 152 | } |
| 153 | |
| 154 | def open(self, doc: dict[str, Any], *, refresh: bool = False) -> dict[str, Any]: |
| 155 | ext = str(doc.get("extension") or "").lower() |
| 156 | if ext not in OFFICIAL_EXTENSIONS: |
| 157 | return {"available": False, "reason": f".{ext} does not use the LibreOffice desktop surface."} |
| 158 | |
| 159 | with self._lock: |
| 160 | self._reap_dead_locked() |
| 161 | try: |
| 162 | session = self._ensure_system_desktop_locked() |
| 163 | except Exception as exc: |
| 164 | status = collect_desktop_status() |
| 165 | return { |
| 166 | "available": False, |
| 167 | "error": str(exc), |
| 168 | "status": status, |
| 169 | } |
| 170 | refreshed = False |
| 171 | try: |
| 172 | if refresh and session.file_id == str(doc.get("file_id") or ""): |
| 173 | refreshed = self._reload_document_locked(session, doc) |
| 174 | else: |
| 175 | self._open_document_locked(session, doc) |
| 176 | except Exception as exc: |
| 177 | return { |
| 178 | "available": False, |
| 179 | "error": str(exc), |
| 180 | "status": collect_desktop_status(), |
| 181 | } |
| 182 | session.file_id = str(doc["file_id"]) |
| 183 | session.extension = ext |
| 184 | session.path = str(doc["path"]) |
| 185 | session.title = str(doc["basename"]) |
| 186 | self._write_manifest(session) |
| 187 | public = session.public(doc) |
| 188 | public["refreshed"] = refreshed |
| 189 | return public |
| 190 | |
| 191 | def refresh_document(self, file_id: str) -> dict[str, Any]: |
| 192 | normalized = str(file_id or "").strip() |
| 193 | if not normalized: |
| 194 | return {"ok": True, "refreshed": False} |
| 195 | try: |
| 196 | doc = document_store.get_document(normalized) |
| 197 | except Exception: |
| 198 | return {"ok": False, "refreshed": False, "error": "Document not found."} |
| 199 | |
| 200 | ext = str(doc.get("extension") or "").lower() |
| 201 | if ext not in OFFICIAL_EXTENSIONS: |
| 202 | return {"ok": True, "refreshed": False} |
| 203 | |
| 204 | with self._lock: |
| 205 | self._reap_dead_locked() |
| 206 | session = self._find_by_file_id_locked(normalized) |
| 207 | if not session: |
| 208 | existing = self._load_system_desktop_from_manifest_locked() |
| 209 | if existing: |
| 210 | self._sessions[existing.session_id] = existing |
| 211 | self._register_virtual_desktop(existing) |
| 212 | if existing.file_id == normalized: |
| 213 | session = existing |
| 214 | if not session: |
| 215 | return {"ok": True, "refreshed": False} |
| 216 | refreshed = self._reload_document_locked(session, doc) |
| 217 | session.file_id = str(doc["file_id"]) |
| 218 | session.extension = ext |
| 219 | session.path = str(doc["path"]) |
| 220 | session.title = str(doc["basename"]) |
| 221 | self._write_manifest(session) |
| 222 | return {"ok": True, "refreshed": refreshed, "desktop": session.public(doc)} |
| 223 | |
| 224 | def save(self, session_id: str, file_id: str = "") -> dict[str, Any]: |
| 225 | with self._lock: |
| 226 | session = self.require(session_id) |
| 227 | doc = self._document_for_save(session, file_id) |
| 228 | if not doc: |
| 229 | return { |
| 230 | "ok": True, |
| 231 | "session_id": session.session_id, |
| 232 | "document": None, |
| 233 | "changed": False, |
| 234 | } |
| 235 | |
| 236 | xdotool = shutil.which("xdotool") |
| 237 | if not xdotool: |
| 238 | updated = document_store.register_document(doc["path"]) |
| 239 | return { |
| 240 | "ok": False, |
| 241 | "error": "xdotool is not installed; use LibreOffice's Save control inside the canvas.", |
| 242 | "document": _public_doc(updated), |
| 243 | } |
| 244 | |
| 245 | window_id = self._office_window_id_locked( |
| 246 | session, |
| 247 | title=str(doc.get("basename") or Path(doc["path"]).name), |
| 248 | fallback=False, |
| 249 | ) |
| 250 | if not window_id: |
| 251 | return { |
| 252 | "ok": False, |
| 253 | "error": f"LibreOffice window not found for {doc['basename']}.", |
| 254 | "document": _public_doc(doc), |
| 255 | } |
| 256 | |
| 257 | result = subprocess.run( |
| 258 | [ |
| 259 | xdotool, |
| 260 | "windowactivate", |
| 261 | "--sync", |
| 262 | window_id, |
| 263 | "key", |
| 264 | "--clearmodifiers", |
| 265 | "ctrl+s", |
| 266 | ], |
| 267 | check=False, |
| 268 | capture_output=True, |
| 269 | text=True, |
| 270 | timeout=8, |
| 271 | env=self._display_env(session), |
| 272 | ) |
| 273 | time.sleep(0.8) |
| 274 | updated = document_store.register_document(doc["path"]) |
| 275 | if result.returncode != 0: |
| 276 | detail = (result.stderr or result.stdout or "").strip() |
| 277 | return { |
| 278 | "ok": False, |
| 279 | "error": detail or "LibreOffice desktop save shortcut failed.", |
| 280 | "document": _public_doc(updated), |
| 281 | } |
| 282 | return { |
| 283 | "ok": True, |
| 284 | "session_id": session.session_id, |
| 285 | "document": _public_doc(updated), |
| 286 | "changed": updated.get("sha256") != doc.get("sha256"), |
| 287 | } |
| 288 | |
| 289 | def sync(self, session_id: str = "", file_id: str = "") -> dict[str, Any]: |
| 290 | session = self.get(session_id) if session_id else self._find_by_file_id(file_id) |
| 291 | if not session: |
| 292 | return {"ok": False, "error": "LibreOffice desktop session not found."} |
| 293 | if not _url_bridge_script_path(session).exists(): |
| 294 | try: |
| 295 | self._prepare_desktop_url_bridge(session) |
| 296 | self._refresh_xfce_desktop(session) |
| 297 | except Exception: |
| 298 | pass |
| 299 | url_intents = self.claim_url_intents(session.session_id) |
| 300 | shutdown_request = self.claim_shutdown_request(session.session_id) |
| 301 | if shutdown_request: |
| 302 | return self.shutdown_system_desktop( |
| 303 | save_first=True, |
| 304 | source=str(shutdown_request.get("source") or "tray"), |
| 305 | ) |
| 306 | doc = self._document_for_save(session, file_id) |
| 307 | if not doc: |
| 308 | return { |
| 309 | "ok": True, |
| 310 | "session_id": session.session_id, |
| 311 | "desktop": session.public(), |
| 312 | "url_intents": url_intents, |
| 313 | } |
| 314 | updated = document_store.register_document(doc["path"]) |
| 315 | return { |
| 316 | "ok": True, |
| 317 | "session_id": session.session_id, |
| 318 | "document": _public_doc(updated), |
| 319 | "url_intents": url_intents, |
| 320 | } |
| 321 | |
| 322 | def state(self, *, include_screenshot: bool = False, context_id: str = "") -> dict[str, Any]: |
| 323 | with self._lock: |
| 324 | self._reap_dead_locked() |
| 325 | return desktop_state.collect_state( |
| 326 | include_screenshot=include_screenshot, |
| 327 | context_id=context_id, |
| 328 | ) |
| 329 | |
| 330 | def claim_url_intents(self, session_id: str = SYSTEM_SESSION_ID) -> list[dict[str, Any]]: |
| 331 | session = self.get(session_id) or self.get(SYSTEM_SESSION_ID) |
| 332 | if not session: |
| 333 | return [] |
| 334 | return _claim_url_intents(session) |
| 335 | |
| 336 | def claim_shutdown_request(self, session_id: str = SYSTEM_SESSION_ID) -> dict[str, Any] | None: |
| 337 | session = self.get(session_id) or self.get(SYSTEM_SESSION_ID) |
| 338 | if not session: |
| 339 | return None |
| 340 | return _claim_shutdown_request(session) |
| 341 | |
| 342 | def shutdown_system_desktop(self, *, save_first: bool = True, source: str = "api") -> dict[str, Any]: |
| 343 | with self._lock: |
| 344 | session = self._sessions.get(SYSTEM_SESSION_ID) |
| 345 | if not session: |
| 346 | _remove_system_manifest() |
| 347 | return { |
| 348 | "ok": True, |
| 349 | "closed": 0, |
| 350 | "session_id": SYSTEM_SESSION_ID, |
| 351 | "shutdown": True, |
| 352 | "intentional_shutdown": True, |
| 353 | "source": source, |
| 354 | } |
| 355 | |
| 356 | save_result = None |
| 357 | if save_first: |
| 358 | try: |
| 359 | save_result = self.save(session.session_id) |
| 360 | except Exception as exc: |
| 361 | save_result = {"ok": False, "error": str(exc)} |
| 362 | |
| 363 | with self._lock: |
| 364 | if self._sessions.get(SYSTEM_SESSION_ID) is session: |
| 365 | self._sessions.pop(SYSTEM_SESSION_ID, None) |
| 366 | virtual_desktop.unregister_session(session.token) |
| 367 | self._terminate_session(session, include_rehydrated=True) |
| 368 | self._remove_manifest(session.session_id) |
| 369 | _clear_shutdown_request(session) |
| 370 | return { |
| 371 | "ok": True, |
| 372 | "closed": 1, |
| 373 | "session_id": session.session_id, |
| 374 | "shutdown": True, |
| 375 | "intentional_shutdown": True, |
| 376 | "source": source, |
| 377 | "save": save_result, |
| 378 | } |
| 379 | |
| 380 | def retarget_document(self, file_id: str, doc: dict[str, Any]) -> dict[str, Any]: |
| 381 | session = self._find_by_file_id(file_id) |
| 382 | if not session: |
| 383 | return {"ok": True, "updated": False} |
| 384 | with self._lock: |
| 385 | session.path = str(doc["path"]) |
| 386 | session.title = str(doc["basename"]) |
| 387 | session.extension = str(doc["extension"]) |
| 388 | self._write_manifest(session) |
| 389 | return {"ok": True, "updated": True, "desktop": session.public(doc)} |
| 390 | |
| 391 | def close(self, session_id: str, save_first: bool = True) -> dict[str, Any]: |
| 392 | with self._lock: |
| 393 | normalized = str(session_id or "").strip() |
| 394 | session = self._sessions.get(normalized) |
| 395 | if not session: |
| 396 | return {"ok": True, "closed": 0} |
| 397 | if session.session_id == SYSTEM_SESSION_ID: |
| 398 | save_result = None |
| 399 | if save_first: |
| 400 | try: |
| 401 | save_result = self.save(session.session_id) |
| 402 | except Exception as exc: |
| 403 | save_result = {"ok": False, "error": str(exc)} |
| 404 | return { |
| 405 | "ok": True, |
| 406 | "closed": 0, |
| 407 | "session_id": session.session_id, |
| 408 | "persistent": True, |
| 409 | "save": save_result, |
| 410 | } |
| 411 | |
| 412 | save_result = None |
| 413 | if save_first: |
| 414 | try: |
| 415 | save_result = self.save(session.session_id) |
| 416 | except Exception as exc: |
| 417 | save_result = {"ok": False, "error": str(exc)} |
| 418 | with self._lock: |
| 419 | self._sessions.pop(session.session_id, None) |
| 420 | virtual_desktop.unregister_session(session.token) |
| 421 | self._terminate_session(session, include_rehydrated=True) |
| 422 | self._remove_manifest(session.session_id) |
| 423 | return {"ok": True, "closed": 1, "session_id": session.session_id, "save": save_result} |
| 424 | |
| 425 | def close_file(self, file_id: str) -> int: |
| 426 | return 0 |
| 427 | |
| 428 | def resize(self, session_id: str, width: int, height: int) -> dict[str, Any]: |
| 429 | session = self.get(session_id) |
| 430 | if not session: |
| 431 | return {"ok": False, "error": "LibreOffice desktop session not found."} |
| 432 | is_system_desktop = session.session_id == SYSTEM_SESSION_ID and session.extension == "desktop" |
| 433 | if is_system_desktop: |
| 434 | width, height = virtual_desktop.normalize_desktop_display_size(width, height) |
| 435 | result = virtual_desktop.resize_display( |
| 436 | display=session.display, |
| 437 | width=width, |
| 438 | height=height, |
| 439 | max_width=MAX_SCREEN_WIDTH, |
| 440 | max_height=MAX_SCREEN_HEIGHT, |
| 441 | window_class="" if is_system_desktop else "libreoffice", |
| 442 | keys=() if is_system_desktop else ("Escape",), |
| 443 | xauthority=self._xauthority(session), |
| 444 | home=str(session.profile_dir), |
| 445 | ) |
| 446 | if result.get("ok"): |
| 447 | session.width = int(result["width"]) |
| 448 | session.height = int(result["height"]) |
| 449 | if not is_system_desktop: |
| 450 | self._dismiss_blocking_dialogs(session) |
| 451 | return result |
| 452 | |
| 453 | def proxy_for_token(self, token: str) -> tuple[str, int] | None: |
| 454 | normalized = str(token or "").strip() |
| 455 | with self._lock: |
| 456 | session = self._sessions.get(normalized) |
| 457 | if not session: |
| 458 | session = next((item for item in self._sessions.values() if item.token == normalized), None) |
| 459 | if not session or not session.alive(): |
| 460 | return None |
| 461 | return ("127.0.0.1", session.xpra_port) |
| 462 | |
| 463 | def resize_for_token(self, token: str, width: int, height: int) -> dict[str, Any]: |
| 464 | normalized = str(token or "").strip() |
| 465 | with self._lock: |
| 466 | session = self._sessions.get(normalized) |
| 467 | if not session: |
| 468 | session = next((item for item in self._sessions.values() if item.token == normalized), None) |
| 469 | if not session: |
| 470 | return {"ok": False, "error": "LibreOffice desktop session not found."} |
| 471 | return self.resize(session.session_id, width, height) |
| 472 | |
| 473 | def get(self, session_id: str) -> DesktopSession | None: |
| 474 | with self._lock: |
| 475 | session = self._sessions.get(str(session_id or "").strip()) |
| 476 | return session if session and session.alive() else None |
| 477 | |
| 478 | def require(self, session_id: str) -> DesktopSession: |
| 479 | session = self.get(session_id) |
| 480 | if not session: |
| 481 | raise FileNotFoundError(f"LibreOffice desktop session not found: {session_id}") |
| 482 | return session |
| 483 | |
| 484 | def shutdown(self) -> None: |
| 485 | with self._lock: |
| 486 | sessions = list(self._sessions.values()) |
| 487 | self._sessions.clear() |
| 488 | for session in sessions: |
| 489 | virtual_desktop.unregister_session(session.token) |
| 490 | if session.owns_processes: |
| 491 | self._terminate_session(session) |
| 492 | self._remove_manifest(session.session_id) |
| 493 | |
| 494 | def sync_timezone(self, timezone: str | None = None) -> dict[str, Any]: |
| 495 | target_timezone = str(timezone or Localization.get().get_timezone()).strip() |
| 496 | if not target_timezone: |
| 497 | target_timezone = Localization.get().get_timezone() |
| 498 | |
| 499 | with self._lock: |
| 500 | self._reap_dead_locked() |
| 501 | session = self._sessions.get(SYSTEM_SESSION_ID) |
| 502 | if not session or not session.alive(): |
| 503 | return {"ok": True, "restarted": False, "reason": "no_active_desktop"} |
| 504 | if session.timezone == target_timezone: |
| 505 | return {"ok": True, "restarted": False, "timezone": target_timezone} |
| 506 | |
| 507 | replacement = self._restart_system_desktop_for_timezone_locked(session) |
| 508 | return { |
| 509 | "ok": True, |
| 510 | "restarted": True, |
| 511 | "session_id": replacement.session_id, |
| 512 | "timezone": replacement.timezone, |
| 513 | } |
| 514 | |
| 515 | def _document_for_save(self, session: DesktopSession, file_id: str = "") -> dict[str, Any] | None: |
| 516 | normalized = str(file_id or "").strip() |
| 517 | if normalized == SYSTEM_FILE_ID: |
| 518 | return None |
| 519 | if normalized and normalized != SYSTEM_FILE_ID: |
| 520 | return document_store.get_document(normalized) |
| 521 | if session.file_id and session.file_id != SYSTEM_FILE_ID: |
| 522 | try: |
| 523 | return document_store.get_document(session.file_id) |
| 524 | except Exception: |
| 525 | path = Path(session.path) |
| 526 | if path.is_file(): |
| 527 | return document_store.register_document(path) |
| 528 | return None |
| 529 | |
| 530 | def _restart_system_desktop_for_timezone_locked(self, session: DesktopSession) -> DesktopSession: |
| 531 | try: |
| 532 | doc = self._document_for_save(session, session.file_id) |
| 533 | except Exception: |
| 534 | doc = None |
| 535 | if doc: |
| 536 | try: |
| 537 | self.save(session.session_id, str(doc.get("file_id") or "")) |
| 538 | except Exception: |
| 539 | pass |
| 540 | |
| 541 | virtual_desktop.unregister_session(session.token) |
| 542 | self._sessions.pop(session.session_id, None) |
| 543 | self._terminate_session(session, include_rehydrated=True) |
| 544 | self._remove_manifest(session.session_id) |
| 545 | |
| 546 | replacement = self._ensure_system_desktop_locked() |
| 547 | if doc: |
| 548 | self._open_document_locked(replacement, doc) |
| 549 | replacement.file_id = str(doc["file_id"]) |
| 550 | replacement.extension = str(doc["extension"]) |
| 551 | replacement.path = str(doc["path"]) |
| 552 | replacement.title = str(doc["basename"]) |
| 553 | self._write_manifest(replacement) |
| 554 | return replacement |
| 555 | |
| 556 | def _register_virtual_desktop(self, session: DesktopSession) -> None: |
| 557 | virtual_desktop.register_session( |
| 558 | token=session.token, |
| 559 | host="127.0.0.1", |
| 560 | port=session.xpra_port, |
| 561 | owner="desktop", |
| 562 | title=session.title, |
| 563 | resize=lambda width, height, session_id=session.session_id: self.resize(session_id, width, height), |
| 564 | ) |
| 565 | |
| 566 | def _ensure_system_desktop_locked(self) -> DesktopSession: |
| 567 | target_timezone = Localization.get().get_timezone() |
| 568 | existing = self._sessions.get(SYSTEM_SESSION_ID) |
| 569 | if existing and existing.alive(): |
| 570 | if existing.timezone != target_timezone: |
| 571 | return self._restart_system_desktop_for_timezone_locked(existing) |
| 572 | self._prepare_desktop_url_bridge(existing) |
| 573 | self._refresh_xfce_desktop(existing) |
| 574 | return existing |
| 575 | |
| 576 | existing = self._load_system_desktop_from_manifest_locked() |
| 577 | if existing: |
| 578 | self._sessions[existing.session_id] = existing |
| 579 | self._register_virtual_desktop(existing) |
| 580 | if existing.timezone != target_timezone: |
| 581 | return self._restart_system_desktop_for_timezone_locked(existing) |
| 582 | self._prepare_desktop_url_bridge(existing) |
| 583 | self._refresh_xfce_desktop(existing) |
| 584 | return existing |
| 585 | |
| 586 | status = collect_desktop_status() |
| 587 | if not status["healthy"]: |
| 588 | raise RuntimeError(status["message"]) |
| 589 | |
| 590 | display, xpra_port = self._allocate_endpoint_locked() |
| 591 | profile_dir = PROFILE_DIR / SYSTEM_SESSION_ID |
| 592 | session = DesktopSession( |
| 593 | session_id=SYSTEM_SESSION_ID, |
| 594 | file_id=SYSTEM_FILE_ID, |
| 595 | extension="desktop", |
| 596 | path=str(document_store.document_binary_home()), |
| 597 | title=SYSTEM_TITLE, |
| 598 | display=display, |
| 599 | xpra_port=xpra_port, |
| 600 | token=SYSTEM_SESSION_ID, |
| 601 | url=_xpra_url(SYSTEM_SESSION_ID), |
| 602 | profile_dir=profile_dir, |
| 603 | timezone=target_timezone, |
| 604 | ) |
| 605 | try: |
| 606 | self._prepare_profile(session) |
| 607 | self._prepare_desktop_launchers(session) |
| 608 | self._spawn_desktop_locked(session) |
| 609 | except Exception: |
| 610 | self._terminate_session(session) |
| 611 | raise |
| 612 | self._sessions[session.session_id] = session |
| 613 | self._register_virtual_desktop(session) |
| 614 | self._write_manifest(session) |
| 615 | return session |
| 616 | |
| 617 | def _load_system_desktop_from_manifest_locked(self) -> DesktopSession | None: |
| 618 | manifest = SESSION_DIR / f"{SYSTEM_SESSION_ID}.json" |
| 619 | if not manifest.exists(): |
| 620 | return None |
| 621 | try: |
| 622 | payload = json.loads(manifest.read_text(encoding="utf-8")) |
| 623 | display = int(payload.get("display") or 0) |
| 624 | xpra_port = int(payload.get("xpra_port") or 0) |
| 625 | process_ids = { |
| 626 | str(name): pid |
| 627 | for name, value in dict(payload.get("pids") or {}).items() |
| 628 | if (pid := _coerce_pid(value)) |
| 629 | } |
| 630 | if not display or not xpra_port: |
| 631 | return None |
| 632 | if not _pid_is_running(process_ids.get("xpra", 0)): |
| 633 | return None |
| 634 | if not _port_is_accepting("127.0.0.1", xpra_port): |
| 635 | return None |
| 636 | |
| 637 | path = str(payload.get("path") or document_store.document_binary_home()) |
| 638 | file_id = str(payload.get("file_id") or SYSTEM_FILE_ID) |
| 639 | extension = str(payload.get("extension") or "").lower() |
| 640 | if not extension: |
| 641 | extension = "desktop" if file_id == SYSTEM_FILE_ID else Path(path).suffix.lower().lstrip(".") |
| 642 | title = str(payload.get("title") or "") |
| 643 | if not title: |
| 644 | title = SYSTEM_TITLE if file_id == SYSTEM_FILE_ID else Path(path).name or SYSTEM_TITLE |
| 645 | return DesktopSession( |
| 646 | session_id=SYSTEM_SESSION_ID, |
| 647 | file_id=file_id, |
| 648 | extension=extension, |
| 649 | path=path, |
| 650 | title=title, |
| 651 | display=display, |
| 652 | xpra_port=xpra_port, |
| 653 | token=SYSTEM_SESSION_ID, |
| 654 | url=_xpra_url(SYSTEM_SESSION_ID), |
| 655 | profile_dir=_state_path_from_retired_root( |
| 656 | Path(payload.get("profile_dir") or PROFILE_DIR / SYSTEM_SESSION_ID) |
| 657 | ), |
| 658 | width=int(payload.get("width") or DEFAULT_SCREEN_WIDTH), |
| 659 | height=int(payload.get("height") or DEFAULT_SCREEN_HEIGHT), |
| 660 | process_ids=process_ids, |
| 661 | owns_processes=False, |
| 662 | started_at=float(payload.get("started_at") or time.time()), |
| 663 | timezone=str(payload.get("timezone") or Localization.get().get_timezone()), |
| 664 | ) |
| 665 | except Exception: |
| 666 | return None |
| 667 | |
| 668 | def _spawn_desktop_locked(self, session: DesktopSession) -> None: |
| 669 | STATE_DIR.mkdir(parents=True, exist_ok=True) |
| 670 | SESSION_DIR.mkdir(parents=True, exist_ok=True) |
| 671 | session.profile_dir.mkdir(parents=True, exist_ok=True) |
| 672 | |
| 673 | xpra = _require_binary("xpra") |
| 674 | xvfb = _require_binary("Xvfb") |
| 675 | _require_binary("xfce4-session") |
| 676 | _require_binary("dbus-launch") |
| 677 | xfce_launcher = self._prepare_xfce_launcher(session) |
| 678 | |
| 679 | session.processes["xvfb"] = subprocess.Popen( |
| 680 | _xvfb_command(xvfb, session), |
| 681 | stdin=subprocess.DEVNULL, |
| 682 | stdout=subprocess.DEVNULL, |
| 683 | stderr=subprocess.DEVNULL, |
| 684 | env=self._session_env(session), |
| 685 | ) |
| 686 | self._wait_for_display(session) |
| 687 | self._set_display_size(session, session.width, session.height) |
| 688 | self._prepare_root_window(session) |
| 689 | session.processes["xfce"] = subprocess.Popen( |
| 690 | [str(xfce_launcher)], |
| 691 | stdin=subprocess.DEVNULL, |
| 692 | stdout=subprocess.DEVNULL, |
| 693 | stderr=subprocess.DEVNULL, |
| 694 | env=self._display_env(session), |
| 695 | ) |
| 696 | self._wait_for_xfce(session) |
| 697 | session.processes["xpra"] = subprocess.Popen( |
| 698 | _xpra_shadow_command(xpra, session), |
| 699 | stdin=subprocess.DEVNULL, |
| 700 | stdout=subprocess.DEVNULL, |
| 701 | stderr=subprocess.DEVNULL, |
| 702 | env=self._display_env(session), |
| 703 | ) |
| 704 | _wait_for_port( |
| 705 | "127.0.0.1", |
| 706 | session.xpra_port, |
| 707 | timeout=PORT_START_TIMEOUT_SECONDS, |
| 708 | process=session.processes.get("xpra"), |
| 709 | ) |
| 710 | self._refresh_xfce_desktop(session) |
| 711 | |
| 712 | def _restart_xpra_shadow(self, session: DesktopSession) -> None: |
| 713 | xpra = _require_binary("xpra") |
| 714 | process = session.processes.get("xpra") |
| 715 | if process: |
| 716 | _terminate_process(process) |
| 717 | session.processes["xpra"] = subprocess.Popen( |
| 718 | _xpra_shadow_command(xpra, session), |
| 719 | stdin=subprocess.DEVNULL, |
| 720 | stdout=subprocess.DEVNULL, |
| 721 | stderr=subprocess.DEVNULL, |
| 722 | env=self._display_env(session), |
| 723 | ) |
| 724 | _wait_for_port( |
| 725 | "127.0.0.1", |
| 726 | session.xpra_port, |
| 727 | timeout=PORT_START_TIMEOUT_SECONDS, |
| 728 | process=session.processes.get("xpra"), |
| 729 | ) |
| 730 | |
| 731 | def _open_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> None: |
| 732 | soffice = libreoffice.find_soffice() |
| 733 | if not soffice: |
| 734 | raise RuntimeError("LibreOffice is not installed in this runtime.") |
| 735 | path = str(doc["path"]) |
| 736 | self._remove_stale_lock_file(session, path=path) |
| 737 | process_key = f"soffice-{doc['file_id']}" |
| 738 | session.processes[process_key] = subprocess.Popen( |
| 739 | [ |
| 740 | soffice, |
| 741 | "--norestore", |
| 742 | "--nofirststartwizard", |
| 743 | "--nolockcheck", |
| 744 | f"-env:UserInstallation=file://{session.profile_dir}", |
| 745 | path, |
| 746 | ], |
| 747 | cwd=str(Path(path).parent), |
| 748 | stdin=subprocess.DEVNULL, |
| 749 | stdout=subprocess.DEVNULL, |
| 750 | stderr=subprocess.DEVNULL, |
| 751 | env=self._display_env(session), |
| 752 | ) |
| 753 | self._fit_office_window(session, process=session.processes[process_key]) |
| 754 | window_id = self._wait_for_office_window_locked( |
| 755 | session, |
| 756 | title=str(doc.get("basename") or ""), |
| 757 | process=session.processes[process_key], |
| 758 | ) |
| 759 | if not window_id: |
| 760 | raise RuntimeError( |
| 761 | f"LibreOffice did not show {doc.get('basename') or 'the document'} " |
| 762 | f"on desktop :{session.display}.", |
| 763 | ) |
| 764 | self._fit_office_window_id_locked( |
| 765 | session, |
| 766 | window_id, |
| 767 | env=self._display_env(session), |
| 768 | keys=("Escape",), |
| 769 | ) |
| 770 | |
| 771 | def _reload_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool: |
| 772 | if self._close_document_window_locked(session, doc): |
| 773 | self._open_document_locked(session, doc) |
| 774 | return True |
| 775 | if self._send_reload_shortcut_locked(session, doc): |
| 776 | return True |
| 777 | self._open_document_locked(session, doc) |
| 778 | return False |
| 779 | |
| 780 | def _close_document_window_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool: |
| 781 | xdotool = shutil.which("xdotool") |
| 782 | if not xdotool: |
| 783 | return False |
| 784 | title = str(doc.get("basename") or Path(str(doc.get("path") or "")).name or "").strip() |
| 785 | if not title: |
| 786 | return False |
| 787 | window_id = self._office_window_id_locked(session, title=title, fallback=False) |
| 788 | if not window_id: |
| 789 | return False |
| 790 | env = self._display_env(session) |
| 791 | self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",)) |
| 792 | for command in ( |
| 793 | [xdotool, "key", "--clearmodifiers", "alt+F4"], |
| 794 | [xdotool, "windowclose", window_id], |
| 795 | ): |
| 796 | try: |
| 797 | subprocess.run( |
| 798 | command, |
| 799 | check=False, |
| 800 | stdout=subprocess.DEVNULL, |
| 801 | stderr=subprocess.DEVNULL, |
| 802 | timeout=3, |
| 803 | env=env, |
| 804 | ) |
| 805 | except (OSError, subprocess.TimeoutExpired): |
| 806 | continue |
| 807 | if self._wait_for_window_closed_locked(session, window_id): |
| 808 | return True |
| 809 | self._dismiss_blocking_dialogs(session) |
| 810 | return self._wait_for_window_closed_locked(session, window_id, timeout_seconds=1.5) |
| 811 | |
| 812 | def _send_reload_shortcut_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool: |
| 813 | xdotool = shutil.which("xdotool") |
| 814 | if not xdotool: |
| 815 | return False |
| 816 | window_id = self._office_window_id_locked( |
| 817 | session, |
| 818 | title=str(doc.get("basename") or ""), |
| 819 | ) |
| 820 | if not window_id: |
| 821 | return False |
| 822 | env = self._display_env(session) |
| 823 | self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",)) |
| 824 | try: |
| 825 | result = subprocess.run( |
| 826 | [xdotool, "key", "--clearmodifiers", "ctrl+shift+r"], |
| 827 | check=False, |
| 828 | capture_output=True, |
| 829 | text=True, |
| 830 | timeout=4, |
| 831 | env=env, |
| 832 | ) |
| 833 | except (OSError, subprocess.TimeoutExpired): |
| 834 | return False |
| 835 | time.sleep(0.8) |
| 836 | self._dismiss_blocking_dialogs(session) |
| 837 | self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",)) |
| 838 | return result.returncode == 0 |
| 839 | |
| 840 | def _office_window_id_locked( |
| 841 | self, |
| 842 | session: DesktopSession, |
| 843 | *, |
| 844 | title: str = "", |
| 845 | fallback: bool = True, |
| 846 | ) -> str: |
| 847 | xdotool = shutil.which("xdotool") |
| 848 | if not xdotool: |
| 849 | return "" |
| 850 | env = self._display_env(session) |
| 851 | title = str(title or "").strip() |
| 852 | try: |
| 853 | result = subprocess.run( |
| 854 | [xdotool, "search", "--onlyvisible", "--class", "libreoffice"], |
| 855 | check=False, |
| 856 | capture_output=True, |
| 857 | text=True, |
| 858 | timeout=2, |
| 859 | env=env, |
| 860 | ) |
| 861 | window_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()] |
| 862 | except (OSError, subprocess.TimeoutExpired): |
| 863 | window_ids = [] |
| 864 | if title: |
| 865 | for window_id in reversed(window_ids): |
| 866 | try: |
| 867 | result = subprocess.run( |
| 868 | [xdotool, "getwindowname", window_id], |
| 869 | check=False, |
| 870 | capture_output=True, |
| 871 | text=True, |
| 872 | timeout=2, |
| 873 | env=env, |
| 874 | ) |
| 875 | except (OSError, subprocess.TimeoutExpired): |
| 876 | continue |
| 877 | if title.casefold() in result.stdout.strip().casefold(): |
| 878 | return window_id |
| 879 | if fallback and window_ids: |
| 880 | return window_ids[-1] |
| 881 | if fallback: |
| 882 | try: |
| 883 | result = subprocess.run( |
| 884 | [xdotool, "search", "--onlyvisible", "--name", "LibreOffice"], |
| 885 | check=False, |
| 886 | capture_output=True, |
| 887 | text=True, |
| 888 | timeout=2, |
| 889 | env=env, |
| 890 | ) |
| 891 | window_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()] |
| 892 | except (OSError, subprocess.TimeoutExpired): |
| 893 | window_ids = [] |
| 894 | if window_ids: |
| 895 | return window_ids[-1] |
| 896 | return "" |
| 897 | |
| 898 | def _wait_for_office_window_locked( |
| 899 | self, |
| 900 | session: DesktopSession, |
| 901 | *, |
| 902 | title: str = "", |
| 903 | process: subprocess.Popen[Any] | None = None, |
| 904 | timeout_seconds: float = 20.0, |
| 905 | ) -> str: |
| 906 | deadline = time.time() + timeout_seconds |
| 907 | last_fallback = "" |
| 908 | title = str(title or "").strip() |
| 909 | while time.time() < deadline: |
| 910 | window_id = self._office_window_id_locked(session, title=title, fallback=False) |
| 911 | if window_id: |
| 912 | return window_id |
| 913 | last_fallback = self._office_window_id_locked(session, fallback=True) or last_fallback |
| 914 | if last_fallback and not title: |
| 915 | return last_fallback |
| 916 | if process and process.poll() is not None and last_fallback: |
| 917 | return last_fallback |
| 918 | time.sleep(0.25) |
| 919 | return self._office_window_id_locked(session, title=title, fallback=True) or last_fallback |
| 920 | |
| 921 | def _wait_for_window_closed_locked( |
| 922 | self, |
| 923 | session: DesktopSession, |
| 924 | window_id: str, |
| 925 | *, |
| 926 | timeout_seconds: float = 6.0, |
| 927 | ) -> bool: |
| 928 | deadline = time.time() + timeout_seconds |
| 929 | while time.time() < deadline: |
| 930 | if not self._window_exists_locked(session, window_id): |
| 931 | return True |
| 932 | time.sleep(0.2) |
| 933 | return not self._window_exists_locked(session, window_id) |
| 934 | |
| 935 | def _window_exists_locked(self, session: DesktopSession, window_id: str) -> bool: |
| 936 | xdotool = shutil.which("xdotool") |
| 937 | if not xdotool or not window_id: |
| 938 | return False |
| 939 | try: |
| 940 | result = subprocess.run( |
| 941 | [xdotool, "getwindowname", str(window_id)], |
| 942 | check=False, |
| 943 | stdout=subprocess.DEVNULL, |
| 944 | stderr=subprocess.DEVNULL, |
| 945 | timeout=2, |
| 946 | env=self._display_env(session), |
| 947 | ) |
| 948 | except (OSError, subprocess.TimeoutExpired): |
| 949 | return False |
| 950 | return result.returncode == 0 |
| 951 | |
| 952 | def _fit_office_window_id_locked( |
| 953 | self, |
| 954 | session: DesktopSession, |
| 955 | window_id: str, |
| 956 | *, |
| 957 | env: dict[str, str], |
| 958 | keys: tuple[str, ...] = (), |
| 959 | ) -> None: |
| 960 | xdotool = shutil.which("xdotool") |
| 961 | if not xdotool or not window_id: |
| 962 | return |
| 963 | for command in ( |
| 964 | [xdotool, "windowactivate", window_id], |
| 965 | [ |
| 966 | xdotool, |
| 967 | "windowmove", |
| 968 | window_id, |
| 969 | "0", |
| 970 | "0", |
| 971 | "windowsize", |
| 972 | window_id, |
| 973 | str(session.width), |
| 974 | str(session.height), |
| 975 | ], |
| 976 | ): |
| 977 | try: |
| 978 | subprocess.run( |
| 979 | command, |
| 980 | check=False, |
| 981 | stdout=subprocess.DEVNULL, |
| 982 | stderr=subprocess.DEVNULL, |
| 983 | timeout=2, |
| 984 | env=env, |
| 985 | ) |
| 986 | except (OSError, subprocess.TimeoutExpired): |
| 987 | continue |
| 988 | for key in keys: |
| 989 | try: |
| 990 | subprocess.run( |
| 991 | [xdotool, "key", "--clearmodifiers", key], |
| 992 | check=False, |
| 993 | stdout=subprocess.DEVNULL, |
| 994 | stderr=subprocess.DEVNULL, |
| 995 | timeout=2, |
| 996 | env=env, |
| 997 | ) |
| 998 | except (OSError, subprocess.TimeoutExpired): |
| 999 | continue |
| 1000 | |
| 1001 | def _prepare_profile(self, session: DesktopSession) -> None: |
| 1002 | user_dir = session.profile_dir / "user" |
| 1003 | user_dir.mkdir(parents=True, exist_ok=True) |
| 1004 | registry = user_dir / "registrymodifications.xcu" |
| 1005 | _write_libreoffice_registry_defaults(registry, document_store.document_home()) |
| 1006 | |
| 1007 | def _prepare_desktop_launchers(self, session: DesktopSession) -> None: |
| 1008 | soffice = libreoffice.find_soffice() |
| 1009 | if not soffice: |
| 1010 | raise RuntimeError("LibreOffice is not installed in this runtime.") |
| 1011 | workdir_home = document_store.document_home() |
| 1012 | workdir_home.mkdir(parents=True, exist_ok=True) |
| 1013 | documents_home = document_store.document_binary_home() |
| 1014 | documents_home.mkdir(parents=True, exist_ok=True) |
| 1015 | downloads_home = Path(files.get_abs_path("usr", "downloads")) |
| 1016 | downloads_home.mkdir(parents=True, exist_ok=True) |
| 1017 | |
| 1018 | desktop_dir = session.profile_dir / "Desktop" |
| 1019 | desktop_dir.mkdir(parents=True, exist_ok=True) |
| 1020 | _install_desktop_readme(desktop_dir) |
| 1021 | _remove_path_if_owned(desktop_dir / "Browser.desktop") |
| 1022 | _remove_path_if_owned(desktop_dir / "Files.desktop") |
| 1023 | config_dir = session.profile_dir / ".config" |
| 1024 | config_dir.mkdir(parents=True, exist_ok=True) |
| 1025 | _remove_path_if_owned(config_dir / "xfce4" / "panel") |
| 1026 | data_dir = session.profile_dir / ".local" / "share" |
| 1027 | data_dir.mkdir(parents=True, exist_ok=True) |
| 1028 | applications_dir = data_dir / "applications" |
| 1029 | applications_dir.mkdir(parents=True, exist_ok=True) |
| 1030 | cache_dir = session.profile_dir / ".cache" |
| 1031 | cache_dir.mkdir(parents=True, exist_ok=True) |
| 1032 | (config_dir / "user-dirs.dirs").write_text( |
| 1033 | "\n".join( |
| 1034 | [ |
| 1035 | 'XDG_DESKTOP_DIR="$HOME/Desktop"', |
| 1036 | f'XDG_DOCUMENTS_DIR="{workdir_home}"', |
| 1037 | f'XDG_DOWNLOAD_DIR="{downloads_home}"', |
| 1038 | f'XDG_TEMPLATES_DIR="{workdir_home}"', |
| 1039 | f'XDG_PUBLICSHARE_DIR="{workdir_home}"', |
| 1040 | f'XDG_MUSIC_DIR="{workdir_home}"', |
| 1041 | f'XDG_PICTURES_DIR="{downloads_home}"', |
| 1042 | f'XDG_VIDEOS_DIR="{workdir_home}"', |
| 1043 | "", |
| 1044 | ], |
| 1045 | ), |
| 1046 | encoding="utf-8", |
| 1047 | ) |
| 1048 | xfce_conf_dir = config_dir / "xfce4" / "xfconf" / "xfce-perchannel-xml" |
| 1049 | xfce_conf_dir.mkdir(parents=True, exist_ok=True) |
| 1050 | (xfce_conf_dir / "xfce4-desktop.xml").write_text( |
| 1051 | f"""<?xml version="1.1" encoding="UTF-8"?> |
| 1052 | |
| 1053 | <channel name="xfce4-desktop" version="1.0"> |
| 1054 | <property name="last-settings-migration-version" type="uint" value="1"/> |
| 1055 | <property name="backdrop" type="empty"> |
| 1056 | <property name="screen0" type="empty"> |
| 1057 | <property name="monitor0" type="empty"> |
| 1058 | <property name="image-path" type="string" value="{_xml_attr(str(downloads_home))}"/> |
| 1059 | </property> |
| 1060 | </property> |
| 1061 | </property> |
| 1062 | <property name="desktop-icons" type="empty"> |
| 1063 | <property name="style" type="int" value="2"/> |
| 1064 | <property name="file-icons" type="empty"> |
| 1065 | <property name="show-home" type="bool" value="false"/> |
| 1066 | <property name="show-filesystem" type="bool" value="false"/> |
| 1067 | <property name="show-removable" type="bool" value="false"/> |
| 1068 | <property name="show-trash" type="bool" value="false"/> |
| 1069 | </property> |
| 1070 | </property> |
| 1071 | </channel> |
| 1072 | """, |
| 1073 | encoding="utf-8", |
| 1074 | ) |
| 1075 | _write_thunar_defaults(xfce_conf_dir / "thunar.xml") |
| 1076 | self._hide_xpra_desktop_entries(applications_dir) |
| 1077 | self._hide_xfce_menu_entries(applications_dir) |
| 1078 | self._prepare_desktop_url_bridge(session) |
| 1079 | |
| 1080 | base_args = ( |
| 1081 | soffice, |
| 1082 | "--norestore", |
| 1083 | "--nofirststartwizard", |
| 1084 | "--nolockcheck", |
| 1085 | f"-env:UserInstallation=file://{session.profile_dir}", |
| 1086 | ) |
| 1087 | office_launchers = ( |
| 1088 | ("LibreOffice Writer", "libreoffice-writer", "--writer", "Office;WordProcessor;"), |
| 1089 | ("LibreOffice Calc", "libreoffice-calc", "--calc", "Office;Spreadsheet;"), |
| 1090 | ("LibreOffice Impress", "libreoffice-impress", "--impress", "Office;Presentation;"), |
| 1091 | ) |
| 1092 | for name, icon, mode, categories in office_launchers: |
| 1093 | _write_desktop_launcher( |
| 1094 | desktop_dir / f"{name}.desktop", |
| 1095 | name=name, |
| 1096 | exec_line=_desktop_exec(*base_args, mode), |
| 1097 | icon=icon, |
| 1098 | categories=categories, |
| 1099 | try_exec=soffice, |
| 1100 | working_dir=workdir_home, |
| 1101 | ) |
| 1102 | |
| 1103 | terminal = shutil.which("xfce4-terminal") or "xfce4-terminal" |
| 1104 | settings = shutil.which("xfce4-settings-manager") or "xfce4-settings-manager" |
| 1105 | desktop_apps = ( |
| 1106 | { |
| 1107 | "filename": "Terminal.desktop", |
| 1108 | "name": "Terminal", |
| 1109 | "exec": _desktop_exec(terminal, f"--working-directory={workdir_home}"), |
| 1110 | "try_exec": terminal, |
| 1111 | "icon": _desktop_icon( |
| 1112 | "/usr/share/icons/hicolor/128x128/apps/org.xfce.terminal.png", |
| 1113 | "/usr/share/icons/hicolor/scalable/apps/org.xfce.terminal.svg", |
| 1114 | "org.xfce.terminal", |
| 1115 | "utilities-terminal", |
| 1116 | ), |
| 1117 | "categories": "System;TerminalEmulator;", |
| 1118 | }, |
| 1119 | { |
| 1120 | "filename": "Settings.desktop", |
| 1121 | "name": "Settings", |
| 1122 | "exec": _desktop_exec(settings), |
| 1123 | "try_exec": settings, |
| 1124 | "icon": _desktop_icon( |
| 1125 | "/usr/share/icons/hicolor/128x128/apps/org.xfce.settings.manager.png", |
| 1126 | "/usr/share/icons/hicolor/scalable/apps/org.xfce.settings.manager.svg", |
| 1127 | "org.xfce.settings.manager", |
| 1128 | "preferences-system", |
| 1129 | ), |
| 1130 | "categories": "Settings;DesktopSettings;", |
| 1131 | }, |
| 1132 | ) |
| 1133 | for app in desktop_apps: |
| 1134 | _write_desktop_launcher( |
| 1135 | desktop_dir / str(app["filename"]), |
| 1136 | name=str(app["name"]), |
| 1137 | exec_line=str(app["exec"]), |
| 1138 | icon=str(app["icon"]), |
| 1139 | categories=str(app["categories"]), |
| 1140 | try_exec=str(app["try_exec"]), |
| 1141 | ) |
| 1142 | _ensure_desktop_folder_link(desktop_dir, "Workdir", workdir_home) |
| 1143 | for label, target_parts in DESKTOP_FOLDER_LINKS: |
| 1144 | _ensure_desktop_folder_link(desktop_dir, label, Path(files.get_abs_path(*target_parts))) |
| 1145 | |
| 1146 | self._trust_desktop_launchers(session, desktop_dir) |
| 1147 | self._prepare_xfce_panel_config(session) |
| 1148 | self._prepare_xfce_profile_autostart(session) |
| 1149 | |
| 1150 | def _prepare_desktop_url_bridge(self, session: DesktopSession) -> None: |
| 1151 | desktop_dir = session.profile_dir / "Desktop" |
| 1152 | config_dir = session.profile_dir / ".config" |
| 1153 | data_dir = session.profile_dir / ".local" / "share" |
| 1154 | applications_dir = data_dir / "applications" |
| 1155 | desktop_dir.mkdir(parents=True, exist_ok=True) |
| 1156 | config_dir.mkdir(parents=True, exist_ok=True) |
| 1157 | applications_dir.mkdir(parents=True, exist_ok=True) |
| 1158 | |
| 1159 | browser_bridge = _write_url_bridge_script(session) |
| 1160 | editor_bridge = _write_editor_bridge_script(session) |
| 1161 | shutdown_bridge = _write_shutdown_bridge_script(session) |
| 1162 | helpers_rc = config_dir / "xfce4" / "helpers.rc" |
| 1163 | helpers_rc.parent.mkdir(parents=True, exist_ok=True) |
| 1164 | helpers_rc.write_text( |
| 1165 | "\n".join( |
| 1166 | [ |
| 1167 | "TerminalEmulator=xfce4-terminal", |
| 1168 | "FileManager=thunar", |
| 1169 | "WebBrowser=agent-zero-browser", |
| 1170 | "", |
| 1171 | ], |
| 1172 | ), |
| 1173 | encoding="utf-8", |
| 1174 | ) |
| 1175 | _write_xfce_browser_helper( |
| 1176 | config_dir / "xfce4" / "helpers" / "agent-zero-browser.desktop", |
| 1177 | browser_bridge, |
| 1178 | ) |
| 1179 | _write_mimeapps_defaults(config_dir / "mimeapps.list", URL_HANDLER_DESKTOP_ID, EDITOR_HANDLER_DESKTOP_ID) |
| 1180 | _write_mimeapps_defaults( |
| 1181 | data_dir / "applications" / "mimeapps.list", |
| 1182 | URL_HANDLER_DESKTOP_ID, |
| 1183 | EDITOR_HANDLER_DESKTOP_ID, |
| 1184 | ) |
| 1185 | _write_desktop_launcher( |
| 1186 | applications_dir / URL_HANDLER_DESKTOP_ID, |
| 1187 | name="Agent Zero Browser", |
| 1188 | exec_line=_desktop_exec(browser_bridge, "%U"), |
| 1189 | icon="web-browser", |
| 1190 | categories="Network;WebBrowser;", |
| 1191 | try_exec=str(browser_bridge), |
| 1192 | mime_types=_url_handler_mime_types(), |
| 1193 | no_display=True, |
| 1194 | ) |
| 1195 | _write_desktop_launcher( |
| 1196 | applications_dir / EDITOR_HANDLER_DESKTOP_ID, |
| 1197 | name="Agent Zero Editor", |
| 1198 | exec_line=_desktop_exec(editor_bridge, "%F"), |
| 1199 | icon="accessories-text-editor", |
| 1200 | categories="Utility;TextEditor;", |
| 1201 | try_exec=str(editor_bridge), |
| 1202 | mime_types=_editor_text_handler_mime_types(), |
| 1203 | ) |
| 1204 | _write_desktop_launcher( |
| 1205 | applications_dir / SHUTDOWN_HANDLER_DESKTOP_ID, |
| 1206 | name="Shutdown Desktop", |
| 1207 | exec_line=_desktop_exec(shutdown_bridge), |
| 1208 | icon="system-shutdown", |
| 1209 | categories="System;", |
| 1210 | try_exec=str(shutdown_bridge), |
| 1211 | no_display=True, |
| 1212 | ) |
| 1213 | _write_desktop_launcher( |
| 1214 | config_dir / "xfce4" / "panel" / "launcher-9" / SHUTDOWN_HANDLER_DESKTOP_ID, |
| 1215 | name="Shutdown Desktop", |
| 1216 | exec_line=_desktop_exec(shutdown_bridge), |
| 1217 | icon="system-shutdown", |
| 1218 | categories="System;", |
| 1219 | try_exec=str(shutdown_bridge), |
| 1220 | ) |
| 1221 | _write_desktop_launcher( |
| 1222 | desktop_dir / "Browser.desktop", |
| 1223 | name="Browser", |
| 1224 | exec_line=_desktop_exec(browser_bridge), |
| 1225 | icon="web-browser", |
| 1226 | categories="Network;WebBrowser;", |
| 1227 | try_exec=str(browser_bridge), |
| 1228 | ) |
| 1229 | _write_desktop_launcher( |
| 1230 | desktop_dir / "Editor.desktop", |
| 1231 | name="Editor", |
| 1232 | exec_line=_desktop_exec(editor_bridge), |
| 1233 | icon="accessories-text-editor", |
| 1234 | categories="Utility;TextEditor;", |
| 1235 | try_exec=str(editor_bridge), |
| 1236 | ) |
| 1237 | self._trust_desktop_launchers(session, desktop_dir) |
| 1238 | |
| 1239 | def _hide_xpra_desktop_entries(self, applications_dir: Path) -> None: |
| 1240 | for filename in HIDDEN_XPRA_DESKTOP_ENTRIES: |
| 1241 | _write_hidden_application_entry(applications_dir / filename, "Xpra") |
| 1242 | |
| 1243 | def _hide_xfce_menu_entries(self, applications_dir: Path) -> None: |
| 1244 | for filename, name in HIDDEN_XFCE_MENU_ENTRIES: |
| 1245 | _write_hidden_application_entry(applications_dir / filename, name) |
| 1246 | |
| 1247 | def _prepare_xfce_panel_config(self, session: DesktopSession) -> None: |
| 1248 | panel_xml = ( |
| 1249 | session.profile_dir |
| 1250 | / ".config" |
| 1251 | / "xfce4" |
| 1252 | / "xfconf" |
| 1253 | / "xfce-perchannel-xml" |
| 1254 | / "xfce4-panel.xml" |
| 1255 | ) |
| 1256 | panel_xml.parent.mkdir(parents=True, exist_ok=True) |
| 1257 | |
| 1258 | root = ET.Element("channel", {"name": "xfce4-panel", "version": "1.0"}) |
| 1259 | ET.SubElement(root, "property", {"name": "configver", "type": "int", "value": "2"}) |
| 1260 | |
| 1261 | panels = ET.SubElement(root, "property", {"name": "panels", "type": "array"}) |
| 1262 | ET.SubElement(panels, "value", {"type": "int", "value": "1"}) |
| 1263 | panel = ET.SubElement(panels, "property", {"name": "panel-1", "type": "empty"}) |
| 1264 | for name, prop_type, value in ( |
| 1265 | ("position", "string", "p=6;x=0;y=0"), |
| 1266 | ("length", "uint", "100"), |
| 1267 | ("position-locked", "bool", "true"), |
| 1268 | ("size", "uint", "24"), |
| 1269 | ("mode", "uint", "0"), |
| 1270 | ("autohide-behavior", "uint", "0"), |
| 1271 | ("disable-struts", "bool", "false"), |
| 1272 | ("nrows", "uint", "1"), |
| 1273 | ): |
| 1274 | ET.SubElement(panel, "property", {"name": name, "type": prop_type, "value": value}) |
| 1275 | plugin_ids = ET.SubElement(panel, "property", {"name": "plugin-ids", "type": "array"}) |
| 1276 | for plugin_id in ("1", "2", "3", "4", "5", "6", "7", "8", "9"): |
| 1277 | ET.SubElement(plugin_ids, "value", {"type": "int", "value": plugin_id}) |
| 1278 | |
| 1279 | plugins = ET.SubElement(root, "property", {"name": "plugins", "type": "empty"}) |
| 1280 | ET.SubElement(plugins, "property", {"name": "plugin-1", "type": "string", "value": "applicationsmenu"}) |
| 1281 | ET.SubElement(plugins, "property", {"name": "plugin-2", "type": "string", "value": "tasklist"}) |
| 1282 | tasklist = _xfce_property(plugins, "plugin-2", "string", "tasklist") |
| 1283 | ET.SubElement(tasklist, "property", {"name": "flat-buttons", "type": "bool", "value": "true"}) |
| 1284 | ET.SubElement(tasklist, "property", {"name": "show-handle", "type": "bool", "value": "false"}) |
| 1285 | ET.SubElement(tasklist, "property", {"name": "show-labels", "type": "bool", "value": "true"}) |
| 1286 | separator = ET.SubElement(plugins, "property", {"name": "plugin-3", "type": "string", "value": "separator"}) |
| 1287 | ET.SubElement(separator, "property", {"name": "expand", "type": "bool", "value": "true"}) |
| 1288 | ET.SubElement(separator, "property", {"name": "style", "type": "uint", "value": "0"}) |
| 1289 | ET.SubElement(plugins, "property", {"name": "plugin-4", "type": "string", "value": "pager"}) |
| 1290 | ET.SubElement(plugins, "property", {"name": "plugin-5", "type": "string", "value": "systray"}) |
| 1291 | ET.SubElement(plugins, "property", {"name": "plugin-6", "type": "string", "value": "separator"}) |
| 1292 | ET.SubElement(plugins, "property", {"name": "plugin-7", "type": "string", "value": "clock"}) |
| 1293 | ET.SubElement(plugins, "property", {"name": "plugin-8", "type": "string", "value": "separator"}) |
| 1294 | shutdown = ET.SubElement(plugins, "property", {"name": "plugin-9", "type": "string", "value": "launcher"}) |
| 1295 | shutdown_items = ET.SubElement(shutdown, "property", {"name": "items", "type": "array"}) |
| 1296 | ET.SubElement(shutdown_items, "value", {"type": "string", "value": SHUTDOWN_PANEL_LAUNCHER_ID}) |
| 1297 | |
| 1298 | tree = ET.ElementTree(root) |
| 1299 | try: |
| 1300 | ET.indent(tree, space=" ") |
| 1301 | except AttributeError: |
| 1302 | pass |
| 1303 | tree.write(panel_xml, encoding="utf-8", xml_declaration=True) |
| 1304 | |
| 1305 | def _prepare_xfce_profile_autostart(self, session: DesktopSession) -> None: |
| 1306 | script = session.profile_dir / "prepare-xfce-profile.sh" |
| 1307 | script.write_text( |
| 1308 | """#!/bin/sh |
| 1309 | set -eu |
| 1310 | export HOME="${HOME:-%s}" |
| 1311 | export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" |
| 1312 | export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" |
| 1313 | export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" |
| 1314 | export XDG_CONFIG_DIRS="/etc/xdg${XDG_CONFIG_DIRS:+:$XDG_CONFIG_DIRS}" |
| 1315 | export XDG_DATA_DIRS="/usr/local/share:/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}" |
| 1316 | export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}" |
| 1317 | mkdir -p "$HOME/Desktop" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" |
| 1318 | if command -v xfconf-query >/dev/null 2>&1; then |
| 1319 | xfconf-query -c thunar -p /last-show-hidden -n -t bool -s true >/dev/null 2>&1 || true |
| 1320 | xfconf-query -c xfce4-desktop -p /desktop-icons/style -n -t int -s 2 >/dev/null 2>&1 || true |
| 1321 | xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-home -n -t bool -s false >/dev/null 2>&1 || true |
| 1322 | xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-filesystem -n -t bool -s false >/dev/null 2>&1 || true |
| 1323 | xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-removable -n -t bool -s false >/dev/null 2>&1 || true |
| 1324 | xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-trash -n -t bool -s false >/dev/null 2>&1 || true |
| 1325 | fi |
| 1326 | for launcher in "$HOME"/Desktop/*.desktop; do |
| 1327 | [ -f "$launcher" ] || continue |
| 1328 | chmod +x "$launcher" 2>/dev/null || true |
| 1329 | if command -v gio >/dev/null 2>&1; then |
| 1330 | checksum="$(sha256sum "$launcher" 2>/dev/null | cut -d " " -f 1)" |
| 1331 | gio set "$launcher" metadata::trusted true >/dev/null 2>&1 || true |
| 1332 | if [ -n "$checksum" ]; then |
| 1333 | gio set -t string "$launcher" metadata::xfce-exe-checksum "$checksum" >/dev/null 2>&1 || true |
| 1334 | fi |
| 1335 | fi |
| 1336 | done |
| 1337 | if command -v xfdesktop >/dev/null 2>&1; then |
| 1338 | timeout 4 xfdesktop --reload >/dev/null 2>&1 || true |
| 1339 | fi |
| 1340 | """ % str(session.profile_dir), |
| 1341 | encoding="utf-8", |
| 1342 | ) |
| 1343 | try: |
| 1344 | script.chmod(0o700) |
| 1345 | except OSError: |
| 1346 | pass |
| 1347 | |
| 1348 | autostart_dir = session.profile_dir / ".config" / "autostart" |
| 1349 | autostart_dir.mkdir(parents=True, exist_ok=True) |
| 1350 | autostart = autostart_dir / "agent-zero-desktop.desktop" |
| 1351 | autostart.write_text( |
| 1352 | "\n".join( |
| 1353 | [ |
| 1354 | "[Desktop Entry]", |
| 1355 | "Type=Application", |
| 1356 | "Name=Agent Zero desktop profile", |
| 1357 | f"Exec={script}", |
| 1358 | "Terminal=false", |
| 1359 | "OnlyShowIn=XFCE;", |
| 1360 | "X-GNOME-Autostart-enabled=true", |
| 1361 | "", |
| 1362 | ], |
| 1363 | ), |
| 1364 | encoding="utf-8", |
| 1365 | ) |
| 1366 | |
| 1367 | def _prepare_xfce_launcher(self, session: DesktopSession) -> Path: |
| 1368 | launcher = session.profile_dir / "start-xfce.sh" |
| 1369 | launcher.write_text( |
| 1370 | "\n".join( |
| 1371 | [ |
| 1372 | "#!/bin/sh", |
| 1373 | 'export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"', |
| 1374 | 'export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"', |
| 1375 | 'export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"', |
| 1376 | 'export XDG_CONFIG_DIRS="/etc/xdg${XDG_CONFIG_DIRS:+:$XDG_CONFIG_DIRS}"', |
| 1377 | 'export XDG_DATA_DIRS="/usr/local/share:/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"', |
| 1378 | 'export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"', |
| 1379 | ( |
| 1380 | "exec dbus-launch --exit-with-session sh -c " |
| 1381 | f"'\"{session.profile_dir / 'prepare-xfce-profile.sh'}\" >/dev/null 2>&1 || true; exec xfce4-session'" |
| 1382 | ), |
| 1383 | "", |
| 1384 | ], |
| 1385 | ), |
| 1386 | encoding="utf-8", |
| 1387 | ) |
| 1388 | try: |
| 1389 | launcher.chmod(0o700) |
| 1390 | except OSError: |
| 1391 | pass |
| 1392 | return launcher |
| 1393 | |
| 1394 | def _prepare_root_window(self, session: DesktopSession) -> None: |
| 1395 | xsetroot = shutil.which("xsetroot") |
| 1396 | if not xsetroot: |
| 1397 | return |
| 1398 | subprocess.run( |
| 1399 | [xsetroot, "-solid", "#20242a"], |
| 1400 | check=False, |
| 1401 | stdout=subprocess.DEVNULL, |
| 1402 | stderr=subprocess.DEVNULL, |
| 1403 | timeout=2, |
| 1404 | env=self._display_env(session), |
| 1405 | ) |
| 1406 | |
| 1407 | def _fit_office_window( |
| 1408 | self, |
| 1409 | session: DesktopSession, |
| 1410 | *, |
| 1411 | process: subprocess.Popen[Any] | None = None, |
| 1412 | ) -> None: |
| 1413 | virtual_desktop.fit_window_until( |
| 1414 | display=session.display, |
| 1415 | width=session.width, |
| 1416 | height=session.height, |
| 1417 | window_class="libreoffice", |
| 1418 | keys=("Escape",), |
| 1419 | settle_seconds=4, |
| 1420 | timeout_seconds=10, |
| 1421 | process=process, |
| 1422 | xauthority=self._xauthority(session), |
| 1423 | home=str(session.profile_dir), |
| 1424 | ) |
| 1425 | self._dismiss_blocking_dialogs(session) |
| 1426 | |
| 1427 | def _set_display_size(self, session: DesktopSession, width: int, height: int) -> dict[str, Any]: |
| 1428 | result = virtual_desktop.resize_display( |
| 1429 | display=session.display, |
| 1430 | width=width, |
| 1431 | height=height, |
| 1432 | max_width=MAX_SCREEN_WIDTH, |
| 1433 | max_height=MAX_SCREEN_HEIGHT, |
| 1434 | window_class="", |
| 1435 | keys=(), |
| 1436 | xauthority=self._xauthority(session), |
| 1437 | home=str(session.profile_dir), |
| 1438 | ) |
| 1439 | if result.get("ok"): |
| 1440 | session.width = int(result["width"]) |
| 1441 | session.height = int(result["height"]) |
| 1442 | return result |
| 1443 | |
| 1444 | def _dismiss_blocking_dialogs(self, session: DesktopSession) -> None: |
| 1445 | virtual_desktop.close_windows( |
| 1446 | display=session.display, |
| 1447 | names=BLOCKING_DIALOG_TITLES, |
| 1448 | xauthority=self._xauthority(session), |
| 1449 | home=str(session.profile_dir), |
| 1450 | ) |
| 1451 | |
| 1452 | def _refresh_xfce_desktop(self, session: DesktopSession) -> None: |
| 1453 | xfdesktop = shutil.which("xfdesktop") |
| 1454 | if not xfdesktop: |
| 1455 | return |
| 1456 | env = self._xfce_process_env(session, "xfdesktop") |
| 1457 | try: |
| 1458 | subprocess.run( |
| 1459 | [xfdesktop, "--reload"], |
| 1460 | check=False, |
| 1461 | stdout=subprocess.DEVNULL, |
| 1462 | stderr=subprocess.DEVNULL, |
| 1463 | timeout=4, |
| 1464 | env=env, |
| 1465 | ) |
| 1466 | except (OSError, subprocess.TimeoutExpired): |
| 1467 | return |
| 1468 | |
| 1469 | def _trust_desktop_launchers(self, session: DesktopSession, desktop_dir: Path) -> None: |
| 1470 | gio = shutil.which("gio") |
| 1471 | if not gio: |
| 1472 | return |
| 1473 | env = self._xfce_process_env(session, "xfdesktop") |
| 1474 | for launcher in desktop_dir.glob("*.desktop"): |
| 1475 | try: |
| 1476 | launcher.chmod(0o755) |
| 1477 | checksum = hashlib.sha256(launcher.read_bytes()).hexdigest() |
| 1478 | except OSError: |
| 1479 | continue |
| 1480 | for command in ( |
| 1481 | [gio, "set", str(launcher), "metadata::trusted", "true"], |
| 1482 | [gio, "set", "-t", "string", str(launcher), "metadata::xfce-exe-checksum", checksum], |
| 1483 | ): |
| 1484 | try: |
| 1485 | subprocess.run( |
| 1486 | command, |
| 1487 | check=False, |
| 1488 | stdout=subprocess.DEVNULL, |
| 1489 | stderr=subprocess.DEVNULL, |
| 1490 | timeout=4, |
| 1491 | env=env, |
| 1492 | ) |
| 1493 | except (OSError, subprocess.TimeoutExpired): |
| 1494 | continue |
| 1495 | |
| 1496 | def _xfce_process_env(self, session: DesktopSession, command_name: str) -> dict[str, str]: |
| 1497 | env = self._display_env(session) |
| 1498 | proc = Path("/proc") |
| 1499 | for candidate in proc.iterdir(): |
| 1500 | if not candidate.name.isdigit(): |
| 1501 | continue |
| 1502 | try: |
| 1503 | if (candidate / "comm").read_text(encoding="utf-8").strip() != command_name: |
| 1504 | continue |
| 1505 | process_env = self._read_process_env(candidate) |
| 1506 | except OSError: |
| 1507 | continue |
| 1508 | if process_env.get("HOME") != str(session.profile_dir): |
| 1509 | continue |
| 1510 | if process_env.get("DISPLAY") != f":{session.display}": |
| 1511 | continue |
| 1512 | for key, value in process_env.items(): |
| 1513 | if ( |
| 1514 | key in {"DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "HOME", "XAUTHORITY"} |
| 1515 | or key.startswith("XDG_") |
| 1516 | ): |
| 1517 | env[key] = value |
| 1518 | break |
| 1519 | return env |
| 1520 | |
| 1521 | def _read_process_env(self, proc_dir: Path) -> dict[str, str]: |
| 1522 | raw = (proc_dir / "environ").read_bytes() |
| 1523 | env: dict[str, str] = {} |
| 1524 | for item in raw.split(b"\0"): |
| 1525 | if not item or b"=" not in item: |
| 1526 | continue |
| 1527 | key, value = item.split(b"=", 1) |
| 1528 | env[key.decode("utf-8", errors="ignore")] = value.decode("utf-8", errors="ignore") |
| 1529 | return env |
| 1530 | |
| 1531 | def _session_env(self, session: DesktopSession) -> dict[str, str]: |
| 1532 | env = { |
| 1533 | **os.environ, |
| 1534 | "HOME": str(session.profile_dir), |
| 1535 | "LANG": os.environ.get("LANG") or "C.UTF-8", |
| 1536 | "TZ": session.timezone or Localization.get().get_timezone(), |
| 1537 | } |
| 1538 | browser_bridge = _url_bridge_script_path(session) |
| 1539 | if browser_bridge.exists(): |
| 1540 | env["BROWSER"] = str(browser_bridge) |
| 1541 | env.setdefault("XDG_RUNTIME_DIR", str(STATE_DIR / "xdg-runtime")) |
| 1542 | runtime_dir = Path(env["XDG_RUNTIME_DIR"]) |
| 1543 | runtime_dir.mkdir(parents=True, exist_ok=True) |
| 1544 | try: |
| 1545 | runtime_dir.chmod(0o700) |
| 1546 | except OSError: |
| 1547 | pass |
| 1548 | return env |
| 1549 | |
| 1550 | def _display_env(self, session: DesktopSession) -> dict[str, str]: |
| 1551 | env = { |
| 1552 | **self._session_env(session), |
| 1553 | "DISPLAY": f":{session.display}", |
| 1554 | "SAL_USE_VCLPLUGIN": os.environ.get("SAL_USE_VCLPLUGIN") or "gtk3", |
| 1555 | } |
| 1556 | xauthority = self._xauthority(session) |
| 1557 | if xauthority: |
| 1558 | env["XAUTHORITY"] = xauthority |
| 1559 | return env |
| 1560 | |
| 1561 | def _xauthority(self, session: DesktopSession) -> str: |
| 1562 | path = session.profile_dir / ".Xauthority" |
| 1563 | return str(path) if path.exists() else "" |
| 1564 | |
| 1565 | def _allocate_endpoint_locked(self) -> tuple[int, int]: |
| 1566 | used_displays = {session.display for session in self._sessions.values()} |
| 1567 | used_ports = {session.xpra_port for session in self._sessions.values()} |
| 1568 | for offset in range(MAX_SESSIONS): |
| 1569 | display = DISPLAY_BASE + offset |
| 1570 | port = XPRA_PORT_BASE + offset |
| 1571 | if display in used_displays or port in used_ports: |
| 1572 | continue |
| 1573 | if _port_is_free(port): |
| 1574 | return display, port |
| 1575 | raise RuntimeError("No LibreOffice desktop slots are available.") |
| 1576 | |
| 1577 | def _find_by_file_id_locked(self, file_id: str) -> DesktopSession | None: |
| 1578 | for session in self._sessions.values(): |
| 1579 | if session.file_id == file_id and session.alive(): |
| 1580 | return session |
| 1581 | return None |
| 1582 | |
| 1583 | def _find_by_file_id(self, file_id: str) -> DesktopSession | None: |
| 1584 | with self._lock: |
| 1585 | return self._find_by_file_id_locked(str(file_id or "").strip()) |
| 1586 | |
| 1587 | def _reap_dead_locked(self) -> None: |
| 1588 | for session_id, session in list(self._sessions.items()): |
| 1589 | if not session.alive(): |
| 1590 | self._terminate_session(session) |
| 1591 | virtual_desktop.unregister_session(session.token) |
| 1592 | self._sessions.pop(session_id, None) |
| 1593 | self._remove_manifest(session_id) |
| 1594 | |
| 1595 | def _wait_for_display(self, session: DesktopSession) -> None: |
| 1596 | deadline = time.time() + DISPLAY_START_TIMEOUT_SECONDS |
| 1597 | while time.time() < deadline: |
| 1598 | process = session.processes.get("xvfb") or session.processes.get("xpra") |
| 1599 | if process and process.poll() is not None: |
| 1600 | raise RuntimeError("The LibreOffice X display exited before it was ready.") |
| 1601 | try: |
| 1602 | if virtual_desktop.current_display_size( |
| 1603 | session.display, |
| 1604 | xauthority=self._xauthority(session), |
| 1605 | home=str(session.profile_dir), |
| 1606 | ): |
| 1607 | return |
| 1608 | except (OSError, subprocess.TimeoutExpired): |
| 1609 | pass |
| 1610 | time.sleep(0.1) |
| 1611 | raise TimeoutError("Timed out waiting for the LibreOffice X display.") |
| 1612 | |
| 1613 | def _wait_for_xfce(self, session: DesktopSession) -> None: |
| 1614 | deadline = time.time() + STARTUP_GRACE_SECONDS |
| 1615 | while time.time() < deadline: |
| 1616 | process = session.processes.get("xfce") |
| 1617 | if process and process.poll() is not None: |
| 1618 | raise RuntimeError("The XFCE desktop session exited before it was ready.") |
| 1619 | if virtual_desktop.has_window( |
| 1620 | display=session.display, |
| 1621 | name="xfce4-panel", |
| 1622 | xauthority=self._xauthority(session), |
| 1623 | home=str(session.profile_dir), |
| 1624 | ): |
| 1625 | return |
| 1626 | time.sleep(0.25) |
| 1627 | |
| 1628 | def _write_manifest(self, session: DesktopSession) -> None: |
| 1629 | SESSION_DIR.mkdir(parents=True, exist_ok=True) |
| 1630 | pids = dict(session.process_ids) |
| 1631 | pids.update({name: process.pid for name, process in session.processes.items()}) |
| 1632 | payload = { |
| 1633 | "session_id": session.session_id, |
| 1634 | "file_id": session.file_id, |
| 1635 | "extension": session.extension, |
| 1636 | "path": session.path, |
| 1637 | "title": session.title, |
| 1638 | "display": session.display, |
| 1639 | "xpra_port": session.xpra_port, |
| 1640 | "profile_dir": str(session.profile_dir), |
| 1641 | "width": session.width, |
| 1642 | "height": session.height, |
| 1643 | "started_at": session.started_at, |
| 1644 | "timezone": session.timezone, |
| 1645 | "owner_pid": os.getpid(), |
| 1646 | "pids": pids, |
| 1647 | } |
| 1648 | manifest = SESSION_DIR / f"{session.session_id}.json" |
| 1649 | tmp = manifest.with_name(f".{manifest.name}.{uuid.uuid4().hex}.tmp") |
| 1650 | try: |
| 1651 | tmp.write_text(json.dumps(payload), encoding="utf-8") |
| 1652 | os.replace(tmp, manifest) |
| 1653 | finally: |
| 1654 | tmp.unlink(missing_ok=True) |
| 1655 | |
| 1656 | def _remove_manifest(self, session_id: str) -> None: |
| 1657 | (SESSION_DIR / f"{session_id}.json").unlink(missing_ok=True) |
| 1658 | |
| 1659 | def _terminate_session(self, session: DesktopSession, *, include_rehydrated: bool = False) -> None: |
| 1660 | process_names = [name for name in session.processes if name.startswith("soffice")] |
| 1661 | process_names.extend(["xfce", "xpra", "xvfb"]) |
| 1662 | terminated_pids: set[int] = set() |
| 1663 | for name in process_names: |
| 1664 | process = session.processes.get(name) |
| 1665 | if not process: |
| 1666 | continue |
| 1667 | if process.pid: |
| 1668 | terminated_pids.add(process.pid) |
| 1669 | _terminate_process(process) |
| 1670 | if session.owns_processes or include_rehydrated: |
| 1671 | for name, pid in session.process_ids.items(): |
| 1672 | if pid in terminated_pids: |
| 1673 | continue |
| 1674 | if name.startswith("soffice") or name in {"xfce", "xpra", "xvfb"}: |
| 1675 | _kill_pid(pid) |
| 1676 | self._remove_stale_lock_file(session) |
| 1677 | |
| 1678 | def _remove_stale_lock_file(self, session: DesktopSession, *, path: str | Path | None = None) -> None: |
| 1679 | path = Path(path or session.path) |
| 1680 | if not path.name: |
| 1681 | return |
| 1682 | lock_file = path.with_name(f".~lock.{path.name}#") |
| 1683 | try: |
| 1684 | lock_file.unlink(missing_ok=True) |
| 1685 | except OSError: |
| 1686 | pass |
| 1687 | |
| 1688 | |
| 1689 | def collect_desktop_status() -> dict[str, Any]: |
| 1690 | desktop = virtual_desktop.collect_status() |
| 1691 | binaries = { |
| 1692 | **desktop["binaries"], |
| 1693 | "soffice": libreoffice.find_soffice(), |
| 1694 | "thunar": shutil.which("thunar") or "", |
| 1695 | "xfce4-terminal": shutil.which("xfce4-terminal") or "", |
| 1696 | "xfce4-settings-manager": shutil.which("xfce4-settings-manager") or "", |
| 1697 | "gio": shutil.which("gio") or "", |
| 1698 | } |
| 1699 | missing = [ |
| 1700 | name |
| 1701 | for name in ( |
| 1702 | "soffice", |
| 1703 | "thunar", |
| 1704 | "xfce4-terminal", |
| 1705 | "xfce4-settings-manager", |
| 1706 | "gio", |
| 1707 | ) |
| 1708 | if not binaries[name] |
| 1709 | ] |
| 1710 | missing.extend( |
| 1711 | name |
| 1712 | for name in ("xpra", "Xvfb", "xfce4-session", "dbus-launch", "xrandr", "xdotool") |
| 1713 | if not binaries.get(name) |
| 1714 | ) |
| 1715 | if not desktop.get("xpra_html_root"): |
| 1716 | missing.append("xpra-html5") |
| 1717 | if desktop.get("binaries", {}).get("xpra") and desktop.get("packages", {}).get("xpra-x11") is False: |
| 1718 | missing.append("xpra-x11") |
| 1719 | healthy = not missing |
| 1720 | preparation = _runtime_preparation_status() |
| 1721 | installing = bool(preparation.get("preparing")) and not healthy |
| 1722 | return { |
| 1723 | "ok": True, |
| 1724 | "healthy": healthy, |
| 1725 | "state": "healthy" if healthy else "installing" if installing else "missing", |
| 1726 | "installing": installing, |
| 1727 | "missing": missing, |
| 1728 | "preparation": preparation, |
| 1729 | "binaries": binaries, |
| 1730 | "xpra_html_root": str(desktop.get("xpra_html_root") or ""), |
| 1731 | "message": ( |
| 1732 | "Agent Zero Desktop sessions are available." |
| 1733 | if healthy |
| 1734 | else RUNTIME_INSTALL_MESSAGE |
| 1735 | if installing |
| 1736 | else f"Agent Zero Desktop sessions need: {', '.join(missing)}." |
| 1737 | ), |
| 1738 | } |
| 1739 | |
| 1740 | |
| 1741 | def _state_path_from_retired_root(path: Path) -> Path: |
| 1742 | try: |
| 1743 | relative = path.resolve(strict=False).relative_to( |
| 1744 | RETIRED_STATE_DIR.resolve(strict=False) |
| 1745 | ) |
| 1746 | except ValueError: |
| 1747 | return path |
| 1748 | return STATE_DIR / relative |
| 1749 | |
| 1750 | |
| 1751 | def _runtime_preparation_status() -> dict[str, Any]: |
| 1752 | try: |
| 1753 | from plugins._desktop import hooks |
| 1754 | |
| 1755 | return hooks.runtime_preparation_status() |
| 1756 | except Exception: |
| 1757 | return {"preparing": False, "active_count": 0, "started_at": 0.0, "completed_at": 0.0} |
| 1758 | |
| 1759 | |
| 1760 | def cleanup_stale_runtime_state() -> dict[str, Any]: |
| 1761 | killed: list[int] = [] |
| 1762 | errors: list[str] = [] |
| 1763 | seen_dirs: set[Path] = set() |
| 1764 | for session_dir in (SESSION_DIR, *LEGACY_SESSION_DIRS): |
| 1765 | if session_dir in seen_dirs: |
| 1766 | continue |
| 1767 | seen_dirs.add(session_dir) |
| 1768 | if session_dir.exists(): |
| 1769 | for manifest in session_dir.glob("*.json"): |
| 1770 | try: |
| 1771 | payload = json.loads(manifest.read_text(encoding="utf-8")) |
| 1772 | owner_pid = _coerce_pid(payload.get("owner_pid")) |
| 1773 | if owner_pid and _pid_is_running(owner_pid): |
| 1774 | continue |
| 1775 | for pid in dict(payload.get("pids") or {}).values(): |
| 1776 | pid_int = _coerce_pid(pid) |
| 1777 | if not pid_int: |
| 1778 | continue |
| 1779 | if _kill_pid(pid_int): |
| 1780 | killed.append(pid_int) |
| 1781 | manifest.unlink(missing_ok=True) |
| 1782 | except Exception as exc: |
| 1783 | errors.append(str(exc)) |
| 1784 | return {"ok": not errors, "killed": killed, "errors": errors} |
| 1785 | |
| 1786 | |
| 1787 | def get_manager() -> DesktopSessionManager: |
| 1788 | global _manager |
| 1789 | try: |
| 1790 | return _manager |
| 1791 | except NameError: |
| 1792 | _manager = DesktopSessionManager() |
| 1793 | atexit.register(_manager.shutdown) |
| 1794 | return _manager |
| 1795 | |
| 1796 | |
| 1797 | def _xpra_url(token: str) -> str: |
| 1798 | return virtual_desktop.session_url(token, title="Desktop") |
| 1799 | |
| 1800 | |
| 1801 | def _xvfb_command(xvfb: str, session: DesktopSession) -> list[str]: |
| 1802 | return [ |
| 1803 | xvfb, |
| 1804 | f":{session.display}", |
| 1805 | "-screen", |
| 1806 | "0", |
| 1807 | f"{MAX_SCREEN_WIDTH}x{MAX_SCREEN_HEIGHT}x24", |
| 1808 | "+extension", |
| 1809 | "GLX", |
| 1810 | "+extension", |
| 1811 | "RANDR", |
| 1812 | "+extension", |
| 1813 | "RENDER", |
| 1814 | "+extension", |
| 1815 | "Composite", |
| 1816 | "-extension", |
| 1817 | "DOUBLE-BUFFER", |
| 1818 | "-nolisten", |
| 1819 | "tcp", |
| 1820 | "-noreset", |
| 1821 | "-ac", |
| 1822 | ] |
| 1823 | |
| 1824 | |
| 1825 | def _xpra_shadow_command(xpra: str, session: DesktopSession) -> list[str]: |
| 1826 | return [ |
| 1827 | xpra, |
| 1828 | "shadow", |
| 1829 | f":{session.display}", |
| 1830 | "--daemon=no", |
| 1831 | "--mdns=no", |
| 1832 | "--html=on", |
| 1833 | "--tray=no", |
| 1834 | "--system-tray=no", |
| 1835 | "--notifications=no", |
| 1836 | "--clipboard=yes", |
| 1837 | "--clipboard-direction=both", |
| 1838 | "--file-transfer=yes", |
| 1839 | "--open-files=no", |
| 1840 | "--open-url=no", |
| 1841 | "--printing=yes", |
| 1842 | "--audio=no", |
| 1843 | "--speaker=off", |
| 1844 | "--microphone=off", |
| 1845 | "--encoding=jpeg", |
| 1846 | "--quality=85", |
| 1847 | "--speed=80", |
| 1848 | f"--bind-tcp=127.0.0.1:{session.xpra_port}", |
| 1849 | "--resize-display=yes", |
| 1850 | f"--log-dir={session.profile_dir}", |
| 1851 | "--log-file=xpra.log", |
| 1852 | ] |
| 1853 | |
| 1854 | |
| 1855 | def _desktop_exec(*args: str | Path) -> str: |
| 1856 | return " ".join(_desktop_exec_arg(str(arg)) for arg in args if str(arg)) |
| 1857 | |
| 1858 | |
| 1859 | def _desktop_icon(*candidates: str) -> str: |
| 1860 | for candidate in candidates: |
| 1861 | if candidate.startswith("/") and Path(candidate).exists(): |
| 1862 | return candidate |
| 1863 | return next( |
| 1864 | (candidate for candidate in candidates if not candidate.startswith("/")), |
| 1865 | candidates[-1], |
| 1866 | ) |
| 1867 | |
| 1868 | |
| 1869 | def _ensure_desktop_folder_link(desktop_dir: Path, label: str, target: Path) -> None: |
| 1870 | target.mkdir(parents=True, exist_ok=True) |
| 1871 | link = desktop_dir / label |
| 1872 | try: |
| 1873 | if link.is_symlink() or link.is_file(): |
| 1874 | link.unlink() |
| 1875 | if not link.exists(): |
| 1876 | link.symlink_to(target, target_is_directory=True) |
| 1877 | except OSError: |
| 1878 | return |
| 1879 | |
| 1880 | |
| 1881 | def _url_bridge_dir(session: DesktopSession) -> Path: |
| 1882 | return session.profile_dir / ".agent-zero" |
| 1883 | |
| 1884 | |
| 1885 | def _url_bridge_script_path(session: DesktopSession) -> Path: |
| 1886 | return _url_bridge_dir(session) / "open-url" |
| 1887 | |
| 1888 | |
| 1889 | def _editor_bridge_script_path(session: DesktopSession) -> Path: |
| 1890 | return _url_bridge_dir(session) / "open-editor" |
| 1891 | |
| 1892 | |
| 1893 | def _url_bridge_queue_path(session: DesktopSession) -> Path: |
| 1894 | return _url_bridge_dir(session) / "browser-url-intents.jsonl" |
| 1895 | |
| 1896 | |
| 1897 | def _url_bridge_lock_path(session: DesktopSession) -> Path: |
| 1898 | return _url_bridge_dir(session) / "browser-url-intents.lock" |
| 1899 | |
| 1900 | |
| 1901 | def _shutdown_request_path(session: DesktopSession) -> Path: |
| 1902 | return _url_bridge_dir(session) / "shutdown-request.json" |
| 1903 | |
| 1904 | |
| 1905 | def _shutdown_arm_path(session: DesktopSession) -> Path: |
| 1906 | return _url_bridge_dir(session) / "shutdown-request.arm.json" |
| 1907 | |
| 1908 | |
| 1909 | def _shutdown_lock_path(session: DesktopSession) -> Path: |
| 1910 | return _url_bridge_dir(session) / "shutdown-request.lock" |
| 1911 | |
| 1912 | |
| 1913 | def _write_url_bridge_script(session: DesktopSession) -> Path: |
| 1914 | bridge_dir = _url_bridge_dir(session) |
| 1915 | bridge_dir.mkdir(parents=True, exist_ok=True) |
| 1916 | script = _url_bridge_script_path(session) |
| 1917 | queue = _url_bridge_queue_path(session) |
| 1918 | lock = _url_bridge_lock_path(session) |
| 1919 | script.write_text( |
| 1920 | f"""#!/usr/bin/env python3 |
| 1921 | import fcntl |
| 1922 | import json |
| 1923 | import os |
| 1924 | import sys |
| 1925 | import time |
| 1926 | |
| 1927 | QUEUE_PATH = {str(queue)!r} |
| 1928 | LOCK_PATH = {str(lock)!r} |
| 1929 | MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH} |
| 1930 | |
| 1931 | |
| 1932 | def main(): |
| 1933 | urls = [str(arg or "").strip()[:MAX_URL_LENGTH] for arg in sys.argv[1:] if str(arg or "").strip()] |
| 1934 | if not urls: |
| 1935 | urls = [""] |
| 1936 | os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True) |
| 1937 | with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file: |
| 1938 | fcntl.flock(lock_file, fcntl.LOCK_EX) |
| 1939 | with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file: |
| 1940 | for url in urls: |
| 1941 | queue_file.write(json.dumps({{ |
| 1942 | "url": url, |
| 1943 | "created_at": time.time(), |
| 1944 | "source": "desktop", |
| 1945 | }}, ensure_ascii=True) + "\\n") |
| 1946 | queue_file.flush() |
| 1947 | os.fsync(queue_file.fileno()) |
| 1948 | fcntl.flock(lock_file, fcntl.LOCK_UN) |
| 1949 | |
| 1950 | |
| 1951 | if __name__ == "__main__": |
| 1952 | main() |
| 1953 | """, |
| 1954 | encoding="utf-8", |
| 1955 | ) |
| 1956 | try: |
| 1957 | script.chmod(0o755) |
| 1958 | except OSError: |
| 1959 | pass |
| 1960 | return script |
| 1961 | |
| 1962 | |
| 1963 | def _write_editor_bridge_script(session: DesktopSession) -> Path: |
| 1964 | bridge_dir = _url_bridge_dir(session) |
| 1965 | bridge_dir.mkdir(parents=True, exist_ok=True) |
| 1966 | script = _editor_bridge_script_path(session) |
| 1967 | queue = _url_bridge_queue_path(session) |
| 1968 | lock = _url_bridge_lock_path(session) |
| 1969 | script.write_text( |
| 1970 | f"""#!/usr/bin/env python3 |
| 1971 | import fcntl |
| 1972 | import json |
| 1973 | import os |
| 1974 | import sys |
| 1975 | import time |
| 1976 | from urllib.parse import quote |
| 1977 | |
| 1978 | QUEUE_PATH = {str(queue)!r} |
| 1979 | LOCK_PATH = {str(lock)!r} |
| 1980 | MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH} |
| 1981 | |
| 1982 | |
| 1983 | def editor_url(path): |
| 1984 | path = str(path or "").strip() |
| 1985 | if not path: |
| 1986 | return "a0-editor://open" |
| 1987 | return "a0-editor://open?path=" + quote(path[:MAX_URL_LENGTH], safe="/:") |
| 1988 | |
| 1989 | |
| 1990 | def main(): |
| 1991 | urls = [editor_url(arg) for arg in sys.argv[1:] if str(arg or "").strip()] |
| 1992 | if not urls: |
| 1993 | urls = [editor_url("")] |
| 1994 | os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True) |
| 1995 | with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file: |
| 1996 | fcntl.flock(lock_file, fcntl.LOCK_EX) |
| 1997 | with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file: |
| 1998 | for url in urls: |
| 1999 | queue_file.write(json.dumps({{ |
| 2000 | "url": url, |
| 2001 | "created_at": time.time(), |
| 2002 | "source": "desktop-editor", |
| 2003 | }}, ensure_ascii=True) + "\\n") |
| 2004 | queue_file.flush() |
| 2005 | os.fsync(queue_file.fileno()) |
| 2006 | fcntl.flock(lock_file, fcntl.LOCK_UN) |
| 2007 | |
| 2008 | |
| 2009 | if __name__ == "__main__": |
| 2010 | main() |
| 2011 | """, |
| 2012 | encoding="utf-8", |
| 2013 | ) |
| 2014 | try: |
| 2015 | script.chmod(0o755) |
| 2016 | except OSError: |
| 2017 | pass |
| 2018 | return script |
| 2019 | |
| 2020 | |
| 2021 | def _write_shutdown_bridge_script(session: DesktopSession) -> Path: |
| 2022 | bridge_dir = _url_bridge_dir(session) |
| 2023 | bridge_dir.mkdir(parents=True, exist_ok=True) |
| 2024 | script = bridge_dir / "shutdown-desktop" |
| 2025 | request = _shutdown_request_path(session) |
| 2026 | arm = _shutdown_arm_path(session) |
| 2027 | lock = _shutdown_lock_path(session) |
| 2028 | script.write_text( |
| 2029 | f"""#!/usr/bin/env python3 |
| 2030 | import fcntl |
| 2031 | import json |
| 2032 | import os |
| 2033 | import shutil |
| 2034 | import subprocess |
| 2035 | import time |
| 2036 | |
| 2037 | REQUEST_PATH = {str(request)!r} |
| 2038 | ARM_PATH = {str(arm)!r} |
| 2039 | LOCK_PATH = {str(lock)!r} |
| 2040 | CONFIRM_SECONDS = {SHUTDOWN_CONFIRM_SECONDS} |
| 2041 | |
| 2042 | |
| 2043 | def notify(message, timeout=None): |
| 2044 | if not os.environ.get("DISPLAY"): |
| 2045 | return |
| 2046 | xmessage = shutil.which("xmessage") |
| 2047 | if not xmessage: |
| 2048 | return |
| 2049 | try: |
| 2050 | subprocess.Popen( |
| 2051 | [ |
| 2052 | xmessage, |
| 2053 | "-buttons", |
| 2054 | "", |
| 2055 | "-timeout", |
| 2056 | str(timeout or CONFIRM_SECONDS), |
| 2057 | "-center", |
| 2058 | message, |
| 2059 | ], |
| 2060 | stdin=subprocess.DEVNULL, |
| 2061 | stdout=subprocess.DEVNULL, |
| 2062 | stderr=subprocess.DEVNULL, |
| 2063 | start_new_session=True, |
| 2064 | ) |
| 2065 | except OSError: |
| 2066 | pass |
| 2067 | |
| 2068 | |
| 2069 | def read_arm(now): |
| 2070 | try: |
| 2071 | with open(ARM_PATH, "r", encoding="utf-8") as handle: |
| 2072 | payload = json.load(handle) |
| 2073 | except (OSError, json.JSONDecodeError): |
| 2074 | return None |
| 2075 | try: |
| 2076 | created_at = float(payload.get("created_at")) |
| 2077 | except (TypeError, ValueError): |
| 2078 | return None |
| 2079 | if now - created_at > CONFIRM_SECONDS: |
| 2080 | return None |
| 2081 | return created_at |
| 2082 | |
| 2083 | |
| 2084 | def write_json_atomic(path, payload): |
| 2085 | tmp_path = path + ".tmp" |
| 2086 | with open(tmp_path, "w", encoding="utf-8") as handle: |
| 2087 | json.dump(payload, handle, ensure_ascii=True) |
| 2088 | handle.write("\\n") |
| 2089 | handle.flush() |
| 2090 | os.fsync(handle.fileno()) |
| 2091 | os.replace(tmp_path, path) |
| 2092 | |
| 2093 | |
| 2094 | def main(): |
| 2095 | os.makedirs(os.path.dirname(REQUEST_PATH), exist_ok=True) |
| 2096 | now = time.time() |
| 2097 | with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file: |
| 2098 | fcntl.flock(lock_file, fcntl.LOCK_EX) |
| 2099 | armed_at = read_arm(now) |
| 2100 | if armed_at is None: |
| 2101 | write_json_atomic(ARM_PATH, {{"created_at": now, "source": "tray"}}) |
| 2102 | notify( |
| 2103 | f"Shutdown Desktop armed. Click Shutdown Desktop again within {{CONFIRM_SECONDS}} seconds to close it.", |
| 2104 | CONFIRM_SECONDS, |
| 2105 | ) |
| 2106 | return |
| 2107 | try: |
| 2108 | os.unlink(ARM_PATH) |
| 2109 | except OSError: |
| 2110 | pass |
| 2111 | payload = {{ |
| 2112 | "created_at": now, |
| 2113 | "armed_at": armed_at, |
| 2114 | "source": "tray", |
| 2115 | }} |
| 2116 | write_json_atomic(REQUEST_PATH, payload) |
| 2117 | notify("Shutting down Agent Zero Desktop.", 2) |
| 2118 | |
| 2119 | |
| 2120 | if __name__ == "__main__": |
| 2121 | main() |
| 2122 | """, |
| 2123 | encoding="utf-8", |
| 2124 | ) |
| 2125 | try: |
| 2126 | script.chmod(0o755) |
| 2127 | except OSError: |
| 2128 | pass |
| 2129 | return script |
| 2130 | |
| 2131 | |
| 2132 | def _claim_url_intents(session: DesktopSession) -> list[dict[str, Any]]: |
| 2133 | queue = _url_bridge_queue_path(session) |
| 2134 | lock = _url_bridge_lock_path(session) |
| 2135 | if not queue.exists(): |
| 2136 | return [] |
| 2137 | lock.parent.mkdir(parents=True, exist_ok=True) |
| 2138 | try: |
| 2139 | with open(lock, "a+", encoding="utf-8") as lock_file: |
| 2140 | fcntl.flock(lock_file, fcntl.LOCK_EX) |
| 2141 | try: |
| 2142 | raw = queue.read_text(encoding="utf-8") |
| 2143 | queue.write_text("", encoding="utf-8") |
| 2144 | finally: |
| 2145 | fcntl.flock(lock_file, fcntl.LOCK_UN) |
| 2146 | except OSError: |
| 2147 | return [] |
| 2148 | |
| 2149 | intents: list[dict[str, Any]] = [] |
| 2150 | for line in raw.splitlines(): |
| 2151 | try: |
| 2152 | payload = json.loads(line) |
| 2153 | except json.JSONDecodeError: |
| 2154 | continue |
| 2155 | url = str(payload.get("url") or "").strip() |
| 2156 | if len(url) > URL_INTENT_MAX_LENGTH: |
| 2157 | url = url[:URL_INTENT_MAX_LENGTH] |
| 2158 | created_at = payload.get("created_at") |
| 2159 | try: |
| 2160 | created_at = float(created_at) |
| 2161 | except (TypeError, ValueError): |
| 2162 | created_at = time.time() |
| 2163 | intents.append( |
| 2164 | { |
| 2165 | "url": url, |
| 2166 | "created_at": created_at, |
| 2167 | "source": str(payload.get("source") or "desktop"), |
| 2168 | }, |
| 2169 | ) |
| 2170 | if len(intents) >= URL_INTENT_MAX_ITEMS: |
| 2171 | break |
| 2172 | return intents |
| 2173 | |
| 2174 | |
| 2175 | def _claim_shutdown_request(session: DesktopSession) -> dict[str, Any] | None: |
| 2176 | request = _shutdown_request_path(session) |
| 2177 | if not request.exists(): |
| 2178 | return None |
| 2179 | try: |
| 2180 | raw = request.read_text(encoding="utf-8") |
| 2181 | request.unlink(missing_ok=True) |
| 2182 | except OSError: |
| 2183 | return None |
| 2184 | try: |
| 2185 | payload = json.loads(raw) |
| 2186 | except json.JSONDecodeError: |
| 2187 | payload = {} |
| 2188 | created_at = payload.get("created_at") |
| 2189 | try: |
| 2190 | created_at = float(created_at) |
| 2191 | except (TypeError, ValueError): |
| 2192 | created_at = time.time() |
| 2193 | return { |
| 2194 | "created_at": created_at, |
| 2195 | "source": str(payload.get("source") or "tray"), |
| 2196 | } |
| 2197 | |
| 2198 | |
| 2199 | def _clear_shutdown_request(session: DesktopSession) -> None: |
| 2200 | request = _shutdown_request_path(session) |
| 2201 | arm = _shutdown_arm_path(session) |
| 2202 | lock = _shutdown_lock_path(session) |
| 2203 | request.unlink(missing_ok=True) |
| 2204 | request.with_suffix(request.suffix + ".tmp").unlink(missing_ok=True) |
| 2205 | arm.unlink(missing_ok=True) |
| 2206 | arm.with_suffix(arm.suffix + ".tmp").unlink(missing_ok=True) |
| 2207 | lock.unlink(missing_ok=True) |
| 2208 | |
| 2209 | |
| 2210 | def _remove_system_manifest() -> None: |
| 2211 | (SESSION_DIR / f"{SYSTEM_SESSION_ID}.json").unlink(missing_ok=True) |
| 2212 | |
| 2213 | |
| 2214 | def _url_handler_mime_types() -> tuple[str, ...]: |
| 2215 | return ( |
| 2216 | "x-scheme-handler/http", |
| 2217 | "x-scheme-handler/https", |
| 2218 | "text/html", |
| 2219 | "application/xhtml+xml", |
| 2220 | ) |
| 2221 | |
| 2222 | |
| 2223 | def _editor_text_handler_mime_types() -> tuple[str, ...]: |
| 2224 | return ( |
| 2225 | "text/markdown", |
| 2226 | "text/x-markdown", |
| 2227 | "text/plain", |
| 2228 | ) |
| 2229 | |
| 2230 | |
| 2231 | def _write_mimeapps_defaults(path: Path, url_desktop_id: str, editor_desktop_id: str) -> None: |
| 2232 | url_associations = ";".join([url_desktop_id, ""]) |
| 2233 | # LibreOffice Writer stays the default so double-clicks keep the user (and |
| 2234 | # agents) inside the Desktop; the Agent Zero Editor remains an "Open With" |
| 2235 | # option. When Writer's desktop entry is missing, xdg falls back through |
| 2236 | # the added associations to the editor. |
| 2237 | text_associations = ";".join([WRITER_HANDLER_DESKTOP_ID, editor_desktop_id, ""]) |
| 2238 | lines = [ |
| 2239 | "[Default Applications]", |
| 2240 | *(f"{mime_type}={url_desktop_id}" for mime_type in _url_handler_mime_types()), |
| 2241 | *(f"{mime_type}={WRITER_HANDLER_DESKTOP_ID}" for mime_type in _editor_text_handler_mime_types()), |
| 2242 | "", |
| 2243 | "[Added Associations]", |
| 2244 | *(f"{mime_type}={url_associations}" for mime_type in _url_handler_mime_types()), |
| 2245 | *(f"{mime_type}={text_associations}" for mime_type in _editor_text_handler_mime_types()), |
| 2246 | "", |
| 2247 | ] |
| 2248 | path.parent.mkdir(parents=True, exist_ok=True) |
| 2249 | path.write_text("\n".join(lines), encoding="utf-8") |
| 2250 | |
| 2251 | |
| 2252 | def _write_xfce_browser_helper(path: Path, bridge_script: Path) -> None: |
| 2253 | command = _desktop_exec(bridge_script) |
| 2254 | command_with_parameter = _desktop_exec(bridge_script, "%s") |
| 2255 | path.parent.mkdir(parents=True, exist_ok=True) |
| 2256 | path.write_text( |
| 2257 | "\n".join( |
| 2258 | [ |
| 2259 | "[Desktop Entry]", |
| 2260 | "NoDisplay=true", |
| 2261 | "Version=1.0", |
| 2262 | "Type=X-XFCE-Helper", |
| 2263 | "X-XFCE-Category=WebBrowser", |
| 2264 | f"X-XFCE-Commands={command}", |
| 2265 | f"X-XFCE-CommandsWithParameter={command_with_parameter}", |
| 2266 | "Icon=web-browser", |
| 2267 | "Name=Agent Zero Browser", |
| 2268 | "", |
| 2269 | ], |
| 2270 | ), |
| 2271 | encoding="utf-8", |
| 2272 | ) |
| 2273 | |
| 2274 | |
| 2275 | def _remove_path_if_owned(path: Path) -> None: |
| 2276 | try: |
| 2277 | if path.is_symlink() or path.is_file(): |
| 2278 | path.unlink() |
| 2279 | elif path.is_dir(): |
| 2280 | shutil.rmtree(path) |
| 2281 | except OSError: |
| 2282 | return |
| 2283 | |
| 2284 | |
| 2285 | def _desktop_exec_arg(value: str) -> str: |
| 2286 | if not any(char.isspace() or char in '"\\' for char in value): |
| 2287 | return value |
| 2288 | escaped = value.replace("\\", "\\\\").replace('"', '\\"') |
| 2289 | return f'"{escaped}"' |
| 2290 | |
| 2291 | |
| 2292 | def _xml_attr(value: str) -> str: |
| 2293 | return ( |
| 2294 | str(value) |
| 2295 | .replace("&", "&") |
| 2296 | .replace('"', """) |
| 2297 | .replace("<", "<") |
| 2298 | .replace(">", ">") |
| 2299 | ) |
| 2300 | |
| 2301 | |
| 2302 | def _oor(name: str) -> str: |
| 2303 | return f"{{{OOR_NS}}}{name}" |
| 2304 | |
| 2305 | |
| 2306 | def _file_uri(path: str | Path) -> str: |
| 2307 | return Path(path).resolve(strict=False).as_uri() |
| 2308 | |
| 2309 | |
| 2310 | def _write_libreoffice_registry_defaults(registry: Path, workdir: str | Path) -> None: |
| 2311 | Path(workdir).mkdir(parents=True, exist_ok=True) |
| 2312 | ET.register_namespace("oor", OOR_NS) |
| 2313 | ET.register_namespace("xs", XS_NS) |
| 2314 | ET.register_namespace("xsi", XSI_NS) |
| 2315 | root = _read_libreoffice_registry(registry) |
| 2316 | workdir_uri = _file_uri(workdir) |
| 2317 | for path, prop, value in ( |
| 2318 | ("/org.openoffice.Office.Common/Misc", "FirstRun", "false"), |
| 2319 | ("/org.openoffice.Setup/Office", "ooSetupInstCompleted", "true"), |
| 2320 | ("/org.openoffice.Setup/Office", "MigrationCompleted", "true"), |
| 2321 | ("/org.openoffice.Setup/Office", "OfficeRestartInProgress", "false"), |
| 2322 | ("/org.openoffice.Setup/L10N", "ooLocale", "en-US"), |
| 2323 | ("/org.openoffice.Office.Paths/Variables", "Work", workdir_uri), |
| 2324 | ( |
| 2325 | "/org.openoffice.Office.Paths/Paths/org.openoffice.Office.Paths:NamedPath['Work']", |
| 2326 | "WritePath", |
| 2327 | workdir_uri, |
| 2328 | ), |
| 2329 | ): |
| 2330 | _set_registry_prop(root, path, prop, value) |
| 2331 | registry.parent.mkdir(parents=True, exist_ok=True) |
| 2332 | ET.ElementTree(root).write(registry, encoding="utf-8", xml_declaration=True) |
| 2333 | |
| 2334 | |
| 2335 | def _read_libreoffice_registry(registry: Path) -> ET.Element: |
| 2336 | if registry.exists(): |
| 2337 | try: |
| 2338 | return ET.parse(registry).getroot() |
| 2339 | except ET.ParseError: |
| 2340 | pass |
| 2341 | return ET.Element( |
| 2342 | _oor("items"), |
| 2343 | { |
| 2344 | "xmlns:xs": XS_NS, |
| 2345 | "xmlns:xsi": XSI_NS, |
| 2346 | }, |
| 2347 | ) |
| 2348 | |
| 2349 | |
| 2350 | def _set_registry_prop(root: ET.Element, item_path: str, prop_name: str, value: str) -> None: |
| 2351 | item = _find_registry_item(root, item_path) |
| 2352 | if item is None: |
| 2353 | item = ET.SubElement(root, "item", {_oor("path"): item_path}) |
| 2354 | prop = next((child for child in item.findall("prop") if child.get(_oor("name")) == prop_name), None) |
| 2355 | if prop is None: |
| 2356 | prop = ET.SubElement(item, "prop", {_oor("name"): prop_name, _oor("op"): "fuse"}) |
| 2357 | else: |
| 2358 | prop.set(_oor("op"), "fuse") |
| 2359 | value_node = prop.find("value") |
| 2360 | if value_node is None: |
| 2361 | value_node = ET.SubElement(prop, "value") |
| 2362 | value_node.text = str(value) |
| 2363 | |
| 2364 | |
| 2365 | def _find_registry_item(root: ET.Element, item_path: str) -> ET.Element | None: |
| 2366 | for item in root.findall("item"): |
| 2367 | if item.get(_oor("path")) == item_path: |
| 2368 | return item |
| 2369 | return None |
| 2370 | |
| 2371 | |
| 2372 | def _write_desktop_launcher( |
| 2373 | path: Path, |
| 2374 | *, |
| 2375 | name: str, |
| 2376 | exec_line: str, |
| 2377 | icon: str, |
| 2378 | categories: str, |
| 2379 | try_exec: str = "", |
| 2380 | working_dir: str | Path | None = None, |
| 2381 | mime_types: tuple[str, ...] = (), |
| 2382 | no_display: bool = False, |
| 2383 | ) -> None: |
| 2384 | path.parent.mkdir(parents=True, exist_ok=True) |
| 2385 | lines = [ |
| 2386 | "[Desktop Entry]", |
| 2387 | "Version=1.0", |
| 2388 | "Type=Application", |
| 2389 | f"Name={name}", |
| 2390 | f"Exec={exec_line}", |
| 2391 | ] |
| 2392 | if try_exec: |
| 2393 | lines.append(f"TryExec={try_exec}") |
| 2394 | if working_dir: |
| 2395 | lines.append(f"Path={working_dir}") |
| 2396 | if mime_types: |
| 2397 | lines.append(f"MimeType={';'.join(mime_types)};") |
| 2398 | if no_display: |
| 2399 | lines.append("NoDisplay=true") |
| 2400 | lines.extend( |
| 2401 | [ |
| 2402 | f"Icon={icon}", |
| 2403 | "Terminal=false", |
| 2404 | f"Categories={categories}", |
| 2405 | "StartupNotify=true", |
| 2406 | "X-XFCE-Trusted=true", |
| 2407 | "", |
| 2408 | ], |
| 2409 | ) |
| 2410 | path.write_text("\n".join(lines), encoding="utf-8") |
| 2411 | try: |
| 2412 | path.chmod(0o755) |
| 2413 | except OSError: |
| 2414 | pass |
| 2415 | |
| 2416 | |
| 2417 | def _write_hidden_application_entry(path: Path, name: str) -> None: |
| 2418 | path.parent.mkdir(parents=True, exist_ok=True) |
| 2419 | path.write_text( |
| 2420 | "\n".join( |
| 2421 | [ |
| 2422 | "[Desktop Entry]", |
| 2423 | "Type=Application", |
| 2424 | f"Name={name}", |
| 2425 | "NoDisplay=true", |
| 2426 | "Hidden=true", |
| 2427 | "", |
| 2428 | ], |
| 2429 | ), |
| 2430 | encoding="utf-8", |
| 2431 | ) |
| 2432 | |
| 2433 | |
| 2434 | def _write_thunar_defaults(path: Path) -> None: |
| 2435 | root = _read_xfce_channel(path, "thunar") |
| 2436 | if _find_xfce_property(root, "last-view") is None: |
| 2437 | _xfce_property(root, "last-view", "string", "ThunarIconView") |
| 2438 | _xfce_property(root, "last-show-hidden", "bool", "true") |
| 2439 | _write_xfce_channel(path, root) |
| 2440 | |
| 2441 | |
| 2442 | def _read_xfce_channel(path: Path, channel_name: str) -> ET.Element: |
| 2443 | if path.exists(): |
| 2444 | try: |
| 2445 | root = ET.parse(path).getroot() |
| 2446 | if root.tag == "channel" and root.get("name") == channel_name: |
| 2447 | root.set("version", root.get("version") or "1.0") |
| 2448 | return root |
| 2449 | except (ET.ParseError, OSError): |
| 2450 | pass |
| 2451 | return ET.Element("channel", {"name": channel_name, "version": "1.0"}) |
| 2452 | |
| 2453 | |
| 2454 | def _write_xfce_channel(path: Path, root: ET.Element) -> None: |
| 2455 | path.parent.mkdir(parents=True, exist_ok=True) |
| 2456 | tree = ET.ElementTree(root) |
| 2457 | try: |
| 2458 | ET.indent(tree, space=" ") |
| 2459 | except AttributeError: |
| 2460 | pass |
| 2461 | tree.write(path, encoding="utf-8", xml_declaration=True) |
| 2462 | |
| 2463 | |
| 2464 | def _find_xfce_property(parent: ET.Element, name: str) -> ET.Element | None: |
| 2465 | return next((child for child in parent.findall("property") if child.get("name") == name), None) |
| 2466 | |
| 2467 | |
| 2468 | def _install_desktop_readme(desktop_dir: Path) -> None: |
| 2469 | if not DESKTOP_README_SOURCE.exists(): |
| 2470 | return |
| 2471 | target = desktop_dir / "README.md" |
| 2472 | try: |
| 2473 | content = DESKTOP_README_SOURCE.read_text(encoding="utf-8") |
| 2474 | if target.exists() and target.read_text(encoding="utf-8") == content: |
| 2475 | return |
| 2476 | target.write_text(content, encoding="utf-8") |
| 2477 | target.chmod(0o644) |
| 2478 | except OSError: |
| 2479 | return |
| 2480 | |
| 2481 | |
| 2482 | def _xfce_property(parent: ET.Element, name: str, property_type: str, value: str | None = None) -> ET.Element: |
| 2483 | for child in parent.findall("property"): |
| 2484 | if child.get("name") == name: |
| 2485 | child.set("type", property_type) |
| 2486 | if value is None: |
| 2487 | child.attrib.pop("value", None) |
| 2488 | else: |
| 2489 | child.set("value", value) |
| 2490 | return child |
| 2491 | attributes = {"name": name, "type": property_type} |
| 2492 | if value is not None: |
| 2493 | attributes["value"] = value |
| 2494 | return ET.SubElement(parent, "property", attributes) |
| 2495 | |
| 2496 | |
| 2497 | def _public_doc(doc: dict[str, Any]) -> dict[str, Any]: |
| 2498 | return { |
| 2499 | "file_id": doc["file_id"], |
| 2500 | "path": document_store.display_path(doc["path"]), |
| 2501 | "basename": doc["basename"], |
| 2502 | "extension": doc["extension"], |
| 2503 | "size": doc["size"], |
| 2504 | "version": document_store.item_version(doc), |
| 2505 | "last_modified": doc["last_modified"], |
| 2506 | } |
| 2507 | |
| 2508 | |
| 2509 | def _require_binary(name: str) -> str: |
| 2510 | found = shutil.which(name) |
| 2511 | if not found: |
| 2512 | raise RuntimeError(f"{name} is required for official LibreOffice desktop sessions.") |
| 2513 | return found |
| 2514 | |
| 2515 | |
| 2516 | def _running(process: subprocess.Popen[Any] | None) -> bool: |
| 2517 | return bool(process and process.poll() is None) |
| 2518 | |
| 2519 | |
| 2520 | def _wait_for_port( |
| 2521 | host: str, |
| 2522 | port: int, |
| 2523 | timeout: float = 15.0, |
| 2524 | process: subprocess.Popen[Any] | None = None, |
| 2525 | ) -> None: |
| 2526 | deadline = time.time() + timeout |
| 2527 | while time.time() < deadline: |
| 2528 | if process and process.poll() is not None: |
| 2529 | raise RuntimeError(f"Xpra exited before port {port} was ready.") |
| 2530 | try: |
| 2531 | with socket.create_connection((host, port), timeout=0.2): |
| 2532 | return |
| 2533 | except OSError: |
| 2534 | time.sleep(0.1) |
| 2535 | raise TimeoutError(f"Timed out waiting for Xpra port {port}.") |
| 2536 | |
| 2537 | |
| 2538 | def _port_is_free(port: int) -> bool: |
| 2539 | try: |
| 2540 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: |
| 2541 | probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) |
| 2542 | probe.bind(("127.0.0.1", port)) |
| 2543 | return True |
| 2544 | except OSError: |
| 2545 | return False |
| 2546 | |
| 2547 | |
| 2548 | def _port_is_accepting(host: str, port: int) -> bool: |
| 2549 | try: |
| 2550 | with socket.create_connection((host, port), timeout=0.2): |
| 2551 | return True |
| 2552 | except OSError: |
| 2553 | return False |
| 2554 | |
| 2555 | |
| 2556 | def _terminate_process(process: subprocess.Popen[Any]) -> None: |
| 2557 | if process.poll() is not None: |
| 2558 | return |
| 2559 | try: |
| 2560 | process.terminate() |
| 2561 | process.wait(timeout=2) |
| 2562 | return |
| 2563 | except Exception: |
| 2564 | pass |
| 2565 | try: |
| 2566 | process.kill() |
| 2567 | process.wait(timeout=2) |
| 2568 | except Exception: |
| 2569 | pass |
| 2570 | |
| 2571 | |
| 2572 | def _kill_pid(pid: int) -> bool: |
| 2573 | if pid <= 0: |
| 2574 | return False |
| 2575 | try: |
| 2576 | os.kill(pid, 15) |
| 2577 | return True |
| 2578 | except ProcessLookupError: |
| 2579 | return False |
| 2580 | except PermissionError: |
| 2581 | return False |
| 2582 | |
| 2583 | |
| 2584 | def _coerce_pid(value: Any) -> int: |
| 2585 | try: |
| 2586 | pid = int(value) |
| 2587 | except (TypeError, ValueError): |
| 2588 | return 0 |
| 2589 | return pid if pid > 0 else 0 |
| 2590 | |
| 2591 | |
| 2592 | def _pid_is_running(pid: int) -> bool: |
| 2593 | if pid <= 0: |
| 2594 | return False |
| 2595 | try: |
| 2596 | os.kill(pid, 0) |
| 2597 | return True |
| 2598 | except ProcessLookupError: |
| 2599 | return False |
| 2600 | except PermissionError: |
| 2601 | return True |