main
py 2,601 lines 94 KB
Raw
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("&", "&amp;")
2296 .replace('"', "&quot;")
2297 .replace("<", "&lt;")
2298 .replace(">", "&gt;")
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