Move Linux Desktop runtime into _desktop

Add the built-in _desktop plugin as the owner of Xpra/Xfce lifecycle, /desktop route installation, Desktop state, session APIs, surface registration, and the linux-desktop skill. Leave _office with explicit compatibility facades and self-update delegates so users coming from 1.10 through 1.13 keep their runtime cleanup path.

Alessandro committed May 7, 2026 at 00:14 UTC 76a282ba8f7a87e1a644f46eb9da6b8ad31afcfc
28 files changed +6703 -2720
helpers/virtual_desktop.py
+1 -1
@@ -15,7 +15,7 @@ from urllib.parse import quote, urlencode
15 from helpers import files
16
17
18 -STATE_DIR = Path(files.get_abs_path("tmp", "virtual_desktop"))
18 +STATE_DIR = Path(files.get_abs_path("usr", "_desktop", "virtual_desktop"))
19 DEFAULT_WIDTH = 1440
20 DEFAULT_HEIGHT = 900
21 MAX_WIDTH = 1920
helpers/virtual_desktop_routes.py
+39
@@ -39,6 +39,35 @@ HOP_BY_HOP_HEADERS = {
39 }
40
41
42 +XPRA_MENU_CUSTOM_PATCH = b"""
43 +;(function () {
44 + function a0DesktopElement(selector) {
45 + return document.querySelector(selector);
46 + }
47 +
48 + window.noWindowList = function noWindowList() {
49 + const openWindows = a0DesktopElement("#open_windows");
50 + if (openWindows) openWindows.remove();
51 + };
52 +
53 + const originalAddWindowListItem = window.addWindowListItem;
54 + if (typeof originalAddWindowListItem === "function" && !originalAddWindowListItem.__a0SafeWindowList) {
55 + const safeAddWindowListItem = function addWindowListItem(...args) {
56 + if (!a0DesktopElement("#open_windows_list")) return undefined;
57 + return originalAddWindowListItem.apply(this, args);
58 + };
59 + safeAddWindowListItem.__a0SafeWindowList = true;
60 + window.addWindowListItem = safeAddWindowListItem;
61 + }
62 +}());
63 +"""
64 +
65 +XPRA_WINDOW_OFFSET_WARNING = b'&&this.warn("window does not fit in canvas, offsets: ",x,y)'
66 +XPRA_WINDOW_OFFSET_WARNING_PATCH = b'&&false&&this.warn("window does not fit in canvas, offsets: ",x,y)'
67 +XPRA_WINDOW_SCRIPT = b'src="js/Window.js"'
68 +XPRA_WINDOW_SCRIPT_PATCH = b'src="js/Window.js?a0_desktop_patch=20260506"'
69 +
70 +
71 class VirtualDesktopGateway:
72 def __init__(self, flask_app=None, mount_path: str = "/desktop") -> None:
73 self.flask_app = flask_app
@@ -120,6 +149,7 @@ class VirtualDesktopGateway:
149 self.proxy_request_headers(scope),
150 body,
151 )
152 + content = self.proxy_response_content(upstream_path, content)
153 await Response(
154 content,
155 status_code=status,
@@ -311,6 +341,15 @@ class VirtualDesktopGateway:
341 response_headers[name] = str(value)
342 return response_headers
343
344 + def proxy_response_content(self, upstream_path: str, content: bytes) -> bytes:
345 + if upstream_path.endswith("/index.html") and XPRA_WINDOW_SCRIPT in content:
346 + content = content.replace(XPRA_WINDOW_SCRIPT, XPRA_WINDOW_SCRIPT_PATCH)
347 + if upstream_path.endswith("/js/MenuCustom.js") and XPRA_MENU_CUSTOM_PATCH not in content:
348 + return content + XPRA_MENU_CUSTOM_PATCH
349 + if upstream_path.endswith("/js/Window.js") and XPRA_WINDOW_OFFSET_WARNING in content:
350 + return content.replace(XPRA_WINDOW_OFFSET_WARNING, XPRA_WINDOW_OFFSET_WARNING_PATCH)
351 + return content
352 +
353 def rewrite_location(self, location: str, token: str) -> str:
354 quoted_token = quote(str(token), safe="")
355 prefix = f"{self.mount_path}/session/{quoted_token}"
plugins/_desktop/api/desktop_session.py new
+151
@@ -0,0 +1,151 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request
4 +from plugins._desktop.helpers import desktop_session
5 +from plugins._office.helpers import document_store
6 +from plugins._office.helpers import libreoffice
7 +
8 +
9 +class DesktopSession(ApiHandler):
10 + async def process(self, input: dict, request: Request) -> dict:
11 + action = str(input.get("action") or "desktop").lower().strip()
12 +
13 + if action == "status":
14 + return desktop_session.collect_desktop_status()
15 + if action in {"desktop", "open", "session"}:
16 + return self._desktop()
17 + if action in {"open_document", "document"}:
18 + return self._open_document(input, request)
19 + if action in {"save", "desktop_save"}:
20 + return self._save(input)
21 + if action in {"sync", "desktop_sync", "heartbeat"}:
22 + return self._sync(input)
23 + if action in {"state", "desktop_state"}:
24 + return self._state(input)
25 + if action in {"shutdown", "desktop_shutdown"}:
26 + return self._shutdown(input)
27 + return {"ok": False, "error": f"Unsupported desktop session action: {action}"}
28 +
29 + def _desktop(self) -> dict:
30 + desktop = desktop_session.get_manager().ensure_system_desktop()
31 + if not desktop.get("available"):
32 + return {
33 + "ok": False,
34 + "error": desktop.get("error") or "Desktop session is unavailable.",
35 + "desktop": desktop,
36 + "libreoffice": libreoffice.collect_status(),
37 + }
38 + document = {
39 + "file_id": desktop_session.SYSTEM_FILE_ID,
40 + "path": desktop["path"],
41 + "basename": desktop["title"],
42 + "title": desktop["title"],
43 + "extension": "desktop",
44 + "size": 0,
45 + "version": 0,
46 + }
47 + return {
48 + "ok": True,
49 + "session_id": desktop["session_id"],
50 + "desktop_session_id": desktop["session_id"],
51 + "file_id": desktop_session.SYSTEM_FILE_ID,
52 + "title": desktop["title"],
53 + "extension": "desktop",
54 + "path": desktop["path"],
55 + "text": "",
56 + "document": document,
57 + "version": 0,
58 + "desktop": desktop,
59 + "store_session_id": "",
60 + "mode": "desktop",
61 + }
62 +
63 + def _open_document(self, input: dict, request: Request) -> dict:
64 + context_id = str(input.get("ctxid") or input.get("context_id") or "").strip()
65 + file_id = str(input.get("file_id") or "").strip()
66 + try:
67 + doc = (
68 + document_store.get_document(file_id)
69 + if file_id
70 + else document_store.register_document(str(input.get("path") or ""), context_id=context_id)
71 + )
72 + except Exception as exc:
73 + return {"ok": False, "error": str(exc)}
74 +
75 + ext = str(doc.get("extension") or "").lower()
76 + if ext not in desktop_session.OFFICIAL_EXTENSIONS:
77 + return {"ok": False, "error": f".{ext} documents do not use the Desktop surface."}
78 +
79 + store_session = document_store.create_session(
80 + doc["file_id"],
81 + user_id=str(input.get("user_id") or "agent-zero-user"),
82 + permission="write",
83 + origin=self._origin(request),
84 + )
85 + desktop = desktop_session.get_manager().open(doc, refresh=input.get("refresh") is True)
86 + if not desktop.get("available"):
87 + document_store.close_session(session_id=store_session["session_id"])
88 + return {
89 + "ok": False,
90 + "error": desktop.get("error") or desktop.get("reason") or "Desktop session is unavailable.",
91 + "desktop": desktop,
92 + "libreoffice": libreoffice.collect_status(),
93 + }
94 + return {
95 + "ok": True,
96 + "session_id": desktop["session_id"],
97 + "desktop_session_id": desktop["session_id"],
98 + "file_id": doc["file_id"],
99 + "title": doc["basename"],
100 + "extension": doc["extension"],
101 + "path": doc["path"],
102 + "text": "",
103 + "document": _public_doc(doc),
104 + "version": document_store.item_version(doc),
105 + "desktop": desktop,
106 + "store_session_id": store_session["session_id"],
107 + "mode": "edit",
108 + }
109 +
110 + def _save(self, input: dict) -> dict:
111 + session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip()
112 + if not session_id:
113 + return {"ok": False, "error": "desktop_session_id is required."}
114 + return desktop_session.get_manager().save(
115 + session_id,
116 + file_id=str(input.get("file_id") or ""),
117 + )
118 +
119 + def _sync(self, input: dict) -> dict:
120 + return desktop_session.get_manager().sync(
121 + session_id=str(input.get("desktop_session_id") or input.get("session_id") or ""),
122 + file_id=str(input.get("file_id") or ""),
123 + )
124 +
125 + def _state(self, input: dict) -> dict:
126 + return desktop_session.get_manager().state(
127 + include_screenshot=bool(input.get("include_screenshot") is True),
128 + )
129 +
130 + def _shutdown(self, input: dict) -> dict:
131 + return desktop_session.get_manager().shutdown_system_desktop(
132 + save_first=input.get("save_first") is not False,
133 + source=str(input.get("source") or "api"),
134 + )
135 +
136 + def _origin(self, request: Request) -> str:
137 + origin = request.headers.get("Origin") or request.host_url.rstrip("/")
138 + return origin.rstrip("/")
139 +
140 +
141 +def _public_doc(doc: dict) -> dict:
142 + return {
143 + "file_id": doc["file_id"],
144 + "path": document_store.display_path(doc["path"]),
145 + "basename": doc["basename"],
146 + "title": doc["basename"],
147 + "extension": doc["extension"],
148 + "size": doc["size"],
149 + "version": document_store.item_version(doc),
150 + "last_modified": doc["last_modified"],
151 + }
plugins/_desktop/assets/desktop/README.md renamed
plugins/_desktop/extensions/python/startup_migration/_20_desktop_routes.py new
+47
@@ -0,0 +1,47 @@
1 +from __future__ import annotations
2 +
3 +import threading
4 +from typing import Any
5 +
6 +from helpers.extension import Extension
7 +from helpers.print_style import PrintStyle
8 +from helpers import virtual_desktop_routes
9 +from plugins._desktop import hooks
10 +
11 +
12 +_startup_preparation_thread: threading.Thread | None = None
13 +
14 +
15 +class DesktopStartup(Extension):
16 + def execute(self, **kwargs):
17 + virtual_desktop_routes.install_route_hooks()
18 + _start_background_runtime_preparation()
19 +
20 +
21 +def _start_background_runtime_preparation() -> threading.Thread:
22 + global _startup_preparation_thread
23 +
24 + if _startup_preparation_thread and _startup_preparation_thread.is_alive():
25 + return _startup_preparation_thread
26 +
27 + _startup_preparation_thread = threading.Thread(
28 + target=_prepare_runtime_safely,
29 + name="a0-desktop-runtime-preparation",
30 + daemon=True,
31 + )
32 + _startup_preparation_thread.start()
33 + return _startup_preparation_thread
34 +
35 +
36 +def _prepare_runtime_safely() -> None:
37 + try:
38 + _log_runtime_preparation_result(hooks.cleanup_stale_runtime_state())
39 + except Exception as exc:
40 + PrintStyle.warning("Desktop runtime preparation failed:", exc)
41 +
42 +
43 +def _log_runtime_preparation_result(result: dict[str, Any]) -> None:
44 + if result.get("errors"):
45 + PrintStyle.warning("Desktop runtime preparation reported errors:", result["errors"])
46 + elif result.get("installed") or result.get("removed"):
47 + PrintStyle.info("Desktop runtime prepared:", result)
plugins/_desktop/extensions/webui/right-canvas-panels/desktop-panel.html new
+15
@@ -0,0 +1,15 @@
1 +<div
2 + class="right-canvas-surface-panel desktop-canvas-surface"
3 + data-surface-id="desktop"
4 + :class="{
5 + 'is-active': $store.rightCanvas?.isSurfaceVisible('desktop'),
6 + 'is-mounted': $store.rightCanvas?.isSurfaceRendered('desktop')
7 + }"
8 + :aria-hidden="(!$store.rightCanvas?.isSurfaceVisible('desktop')).toString()"
9 + x-effect="(() => {
10 + const visible = Boolean($store.rightCanvas?.isSurfaceRendered('desktop'));
11 + (globalThis.queueMicrotask || ((callback) => globalThis.setTimeout(callback, 0)))(() => $store.desktop?.setDesktopHostVisible?.(visible));
12 + })()"
13 +>
14 + <x-component path="/plugins/_desktop/webui/desktop-panel.html"></x-component>
15 +</div>
plugins/_desktop/extensions/webui/right-canvas-toolbar-start/desktop-new-menu.html new
+42
@@ -0,0 +1,42 @@
1 +<div
2 + class="office-header-actions right-canvas-desktop-actions"
3 + x-data="{ open: false }"
4 + x-show="$store.rightCanvas?.isSurfaceActive('desktop')"
5 + @click.outside="open = false"
6 + @keydown.escape.window="open = false"
7 + style="display: none;"
8 +>
9 + <button
10 + type="button"
11 + class="office-header-new-button"
12 + aria-haspopup="menu"
13 + :aria-expanded="open.toString()"
14 + @click.stop="open = !open"
15 + >
16 + <span class="material-symbols-outlined" aria-hidden="true">add</span>
17 + <span>New</span>
18 + <span class="material-symbols-outlined office-new-chevron" aria-hidden="true">expand_more</span>
19 + </button>
20 + <div class="office-new-menu" role="menu" x-show="open" @click.stop style="display: none;">
21 + <button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('open')">
22 + <span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
23 + <span>Open</span>
24 + </button>
25 + <button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('markdown')">
26 + <span class="material-symbols-outlined" aria-hidden="true">article</span>
27 + <span>Markdown</span>
28 + </button>
29 + <button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('writer')">
30 + <span class="material-symbols-outlined" aria-hidden="true">description</span>
31 + <span>Writer</span>
32 + </button>
33 + <button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('spreadsheet')">
34 + <span class="material-symbols-outlined" aria-hidden="true">table_chart</span>
35 + <span>Spreadsheet</span>
36 + </button>
37 + <button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('presentation')">
38 + <span class="material-symbols-outlined" aria-hidden="true">co_present</span>
39 + <span>Presentation</span>
40 + </button>
41 + </div>
42 +</div>
plugins/_desktop/extensions/webui/right_canvas_register_surfaces/register-desktop.js new
+49
@@ -0,0 +1,49 @@
1 +import { store as desktopStore } from "/plugins/_desktop/webui/desktop-store.js";
2 +
3 +function waitForElement(selector, timeoutMs = 10000) {
4 + const found = document.querySelector(selector);
5 + if (found) return Promise.resolve(found);
6 + return new Promise((resolve) => {
7 + const timeout = globalThis.setTimeout(() => {
8 + observer.disconnect();
9 + resolve(document.querySelector(selector));
10 + }, timeoutMs);
11 + const observer = new MutationObserver(() => {
12 + const element = document.querySelector(selector);
13 + if (!element) return;
14 + globalThis.clearTimeout(timeout);
15 + observer.disconnect();
16 + resolve(element);
17 + });
18 + observer.observe(document.body, { childList: true, subtree: true });
19 + });
20 +}
21 +
22 +export default async function registerDesktopSurface(surfaces) {
23 + surfaces.registerSurface({
24 + id: "desktop",
25 + title: "Desktop",
26 + icon: "desktop_windows",
27 + order: 20,
28 + modalPath: "/plugins/_desktop/webui/main.html",
29 + async beginDockHandoff() {
30 + desktopStore.beforeDesktopHostHandoff?.();
31 + },
32 + async finishDockHandoff(payload = {}) {
33 + if (payload.opened !== false) desktopStore.afterDesktopHostShown?.({ source: "dock" });
34 + },
35 + async cancelDockHandoff() {
36 + desktopStore.cancelDesktopHostHandoff?.();
37 + },
38 + async open(payload = {}) {
39 + const panel = await waitForElement('[data-surface-id="desktop"] .office-panel');
40 + if (!panel) throw new Error("Desktop surface panel did not mount.");
41 + await desktopStore.onMount?.(panel, { mode: "canvas" });
42 + await desktopStore.onOpen?.(payload);
43 + desktopStore.afterDesktopHostShown?.({ source: payload?.source || "canvas" });
44 + },
45 + async close(payload = {}) {
46 + desktopStore.beforeHostHidden?.({ unloadDesktop: payload?.reason === "mobile" });
47 + },
48 + });
49 +}
plugins/_desktop/extensions/webui/surfaces_register/register-desktop.js new
+49
@@ -0,0 +1,49 @@
1 +import { store as desktopStore } from "/plugins/_desktop/webui/desktop-store.js";
2 +
3 +function waitForElement(selector, timeoutMs = 10000) {
4 + const found = document.querySelector(selector);
5 + if (found) return Promise.resolve(found);
6 + return new Promise((resolve) => {
7 + const timeout = globalThis.setTimeout(() => {
8 + observer.disconnect();
9 + resolve(document.querySelector(selector));
10 + }, timeoutMs);
11 + const observer = new MutationObserver(() => {
12 + const element = document.querySelector(selector);
13 + if (!element) return;
14 + globalThis.clearTimeout(timeout);
15 + observer.disconnect();
16 + resolve(element);
17 + });
18 + observer.observe(document.body, { childList: true, subtree: true });
19 + });
20 +}
21 +
22 +export default async function registerDesktopSurface(surfaces) {
23 + surfaces.registerSurface({
24 + id: "desktop",
25 + title: "Desktop",
26 + icon: "desktop_windows",
27 + order: 20,
28 + modalPath: "/plugins/_desktop/webui/main.html",
29 + async beginDockHandoff() {
30 + desktopStore.beforeDesktopHostHandoff?.();
31 + },
32 + async finishDockHandoff(payload = {}) {
33 + if (payload.opened !== false) desktopStore.afterDesktopHostShown?.({ source: "dock" });
34 + },
35 + async cancelDockHandoff() {
36 + desktopStore.cancelDesktopHostHandoff?.();
37 + },
38 + async open(payload = {}) {
39 + const panel = await waitForElement('[data-surface-id="desktop"] .office-panel');
40 + if (!panel) throw new Error("Desktop surface panel did not mount.");
41 + await desktopStore.onMount?.(panel, { mode: "canvas" });
42 + await desktopStore.onOpen?.(payload);
43 + desktopStore.afterDesktopHostShown?.({ source: payload?.source || "canvas" });
44 + },
45 + async close(payload = {}) {
46 + desktopStore.beforeHostHidden?.({ unloadDesktop: payload?.reason === "mobile" });
47 + },
48 + });
49 +}
plugins/_desktop/helpers/desktop_routes.py new
+6
@@ -0,0 +1,6 @@
1 +from __future__ import annotations
2 +
3 +from helpers.virtual_desktop_routes import VirtualDesktopGateway, install_route_hooks, is_installed
4 +
5 +
6 +__all__ = ["VirtualDesktopGateway", "install_route_hooks", "is_installed"]
plugins/_desktop/helpers/desktop_session.py new
+2366
@@ -0,0 +1,2366 @@
1 +from __future__ import annotations
2 +
3 +import atexit
4 +import fcntl
5 +import hashlib
6 +import json
7 +import os
8 +import re
9 +import shutil
10 +import socket
11 +import subprocess
12 +import threading
13 +import time
14 +import uuid
15 +import xml.etree.ElementTree as ET
16 +from dataclasses import dataclass, field
17 +from pathlib import Path
18 +from typing import Any
19 +
20 +from helpers import files, virtual_desktop
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 +SYSTEM_SESSION_ID = "agent-zero-desktop"
27 +SYSTEM_FILE_ID = "system-desktop"
28 +SYSTEM_TITLE = "Desktop"
29 +STATE_DIR = Path(files.get_abs_path("usr", "_desktop"))
30 +SESSION_DIR = STATE_DIR / "sessions"
31 +PROFILE_DIR = STATE_DIR / "profiles"
32 +LEGACY_SESSION_DIRS = (
33 + Path(files.get_abs_path("tmp", "_office", "desktop", "sessions")),
34 +)
35 +DISPLAY_BASE = 120
36 +XPRA_PORT_BASE = 14500
37 +MAX_SESSIONS = 12
38 +DEFAULT_SCREEN_WIDTH = virtual_desktop.DEFAULT_WIDTH
39 +DEFAULT_SCREEN_HEIGHT = virtual_desktop.DEFAULT_HEIGHT
40 +MAX_SCREEN_WIDTH = virtual_desktop.MAX_WIDTH
41 +MAX_SCREEN_HEIGHT = virtual_desktop.MAX_HEIGHT
42 +BLOCKING_DIALOG_TITLES = ("Remote Files", "File Services")
43 +DISPLAY_START_TIMEOUT_SECONDS = 30.0
44 +PORT_START_TIMEOUT_SECONDS = 30.0
45 +STARTUP_GRACE_SECONDS = 45
46 +HIDDEN_XPRA_DESKTOP_ENTRIES = (
47 + "xpra.desktop",
48 + "xpra-gui.desktop",
49 + "xpra-launcher.desktop",
50 + "xpra-shadow.desktop",
51 + "xpra-start.desktop",
52 +)
53 +HIDDEN_XFCE_MENU_ENTRIES = (
54 + ("exo-mail-reader.desktop", "Mail Reader"),
55 + ("exo-web-browser.desktop", "Web Browser"),
56 + ("xfce4-mail-reader.desktop", "Mail Reader"),
57 + ("xfce4-web-browser.desktop", "Web Browser"),
58 + ("xfce4-session-logout.desktop", "Log Out"),
59 + ("xfce4-lock-screen.desktop", "Lock Screen"),
60 + ("xflock4.desktop", "Lock Screen"),
61 + ("xfce4-switch-user.desktop", "Switch User"),
62 +)
63 +DESKTOP_README_SOURCE = Path(__file__).resolve().parents[1] / "assets" / "desktop" / "README.md"
64 +DESKTOP_FOLDER_LINKS = (
65 + ("Projects", ("usr", "projects")),
66 + ("Skills", ("usr", "skills")),
67 + ("Agents", ("usr", "agents")),
68 + ("Downloads", ("usr", "downloads")),
69 +)
70 +URL_INTENT_MAX_ITEMS = 50
71 +URL_INTENT_MAX_LENGTH = 8192
72 +URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop"
73 +SHUTDOWN_HANDLER_DESKTOP_ID = "agent-zero-shutdown.desktop"
74 +SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID
75 +SHUTDOWN_CONFIRM_SECONDS = 8
76 +OOR_NS = "http://openoffice.org/2001/registry"
77 +XS_NS = "http://www.w3.org/2001/XMLSchema"
78 +XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
79 +
80 +
81 +@dataclass
82 +class DesktopSession:
83 + session_id: str
84 + file_id: str
85 + extension: str
86 + path: str
87 + title: str
88 + display: int
89 + xpra_port: int
90 + token: str
91 + url: str
92 + profile_dir: Path
93 + width: int = DEFAULT_SCREEN_WIDTH
94 + height: int = DEFAULT_SCREEN_HEIGHT
95 + processes: dict[str, subprocess.Popen[Any]] = field(default_factory=dict)
96 + process_ids: dict[str, int] = field(default_factory=dict)
97 + owns_processes: bool = True
98 + started_at: float = field(default_factory=time.time)
99 +
100 + def alive(self) -> bool:
101 + return _running(self.processes.get("xpra")) or _pid_is_running(self.process_ids.get("xpra", 0))
102 +
103 + def public(self, doc: dict[str, Any] | None = None) -> dict[str, Any]:
104 + title = str(doc.get("basename") or "") if doc else self.title
105 + path = str(doc.get("path") or "") if doc else self.path
106 + extension = str(doc.get("extension") or "") if doc else self.extension
107 + file_id = str(doc.get("file_id") or "") if doc else self.file_id
108 + return {
109 + "available": True,
110 + "session_id": self.session_id,
111 + "file_id": file_id,
112 + "extension": extension,
113 + "title": title,
114 + "path": document_store.display_path(path),
115 + "url": self.url,
116 + "token": self.token,
117 + "display": f":{self.display}",
118 + "desktop_path": virtual_desktop.SESSION_PATH,
119 + "width": self.width,
120 + "height": self.height,
121 + "started_at": self.started_at,
122 + }
123 +
124 +
125 +class DesktopSessionManager:
126 + def __init__(self) -> None:
127 + self._lock = threading.RLock()
128 + self._sessions: dict[str, DesktopSession] = {}
129 +
130 + def ensure_system_desktop(self) -> dict[str, Any]:
131 + try:
132 + with self._lock:
133 + self._reap_dead_locked()
134 + session = self._ensure_system_desktop_locked()
135 + return session.public()
136 + except Exception as exc:
137 + status = collect_desktop_status()
138 + return {
139 + "available": False,
140 + "error": str(exc),
141 + "status": status,
142 + }
143 +
144 + def open(self, doc: dict[str, Any], *, refresh: bool = False) -> dict[str, Any]:
145 + ext = str(doc.get("extension") or "").lower()
146 + if ext not in OFFICIAL_EXTENSIONS:
147 + return {"available": False, "reason": f".{ext} does not use the LibreOffice desktop surface."}
148 +
149 + with self._lock:
150 + self._reap_dead_locked()
151 + try:
152 + session = self._ensure_system_desktop_locked()
153 + except Exception as exc:
154 + status = collect_desktop_status()
155 + return {
156 + "available": False,
157 + "error": str(exc),
158 + "status": status,
159 + }
160 + refreshed = False
161 + try:
162 + if refresh and session.file_id == str(doc.get("file_id") or ""):
163 + refreshed = self._reload_document_locked(session, doc)
164 + else:
165 + self._open_document_locked(session, doc)
166 + except Exception as exc:
167 + return {
168 + "available": False,
169 + "error": str(exc),
170 + "status": collect_desktop_status(),
171 + }
172 + session.file_id = str(doc["file_id"])
173 + session.extension = ext
174 + session.path = str(doc["path"])
175 + session.title = str(doc["basename"])
176 + self._write_manifest(session)
177 + public = session.public(doc)
178 + public["refreshed"] = refreshed
179 + return public
180 +
181 + def refresh_document(self, file_id: str) -> dict[str, Any]:
182 + normalized = str(file_id or "").strip()
183 + if not normalized:
184 + return {"ok": True, "refreshed": False}
185 + try:
186 + doc = document_store.get_document(normalized)
187 + except Exception:
188 + return {"ok": False, "refreshed": False, "error": "Document not found."}
189 +
190 + ext = str(doc.get("extension") or "").lower()
191 + if ext not in OFFICIAL_EXTENSIONS:
192 + return {"ok": True, "refreshed": False}
193 +
194 + with self._lock:
195 + self._reap_dead_locked()
196 + session = self._find_by_file_id_locked(normalized)
197 + if not session:
198 + existing = self._load_system_desktop_from_manifest_locked()
199 + if existing:
200 + self._sessions[existing.session_id] = existing
201 + self._register_virtual_desktop(existing)
202 + if existing.file_id == normalized:
203 + session = existing
204 + if not session:
205 + return {"ok": True, "refreshed": False}
206 + refreshed = self._reload_document_locked(session, doc)
207 + session.file_id = str(doc["file_id"])
208 + session.extension = ext
209 + session.path = str(doc["path"])
210 + session.title = str(doc["basename"])
211 + self._write_manifest(session)
212 + return {"ok": True, "refreshed": refreshed, "desktop": session.public(doc)}
213 +
214 + def save(self, session_id: str, file_id: str = "") -> dict[str, Any]:
215 + session = self.require(session_id)
216 + doc = self._document_for_save(session, file_id)
217 + xdotool = shutil.which("xdotool")
218 + if not xdotool:
219 + updated = document_store.register_document(doc["path"]) if doc else None
220 + return {
221 + "ok": False,
222 + "error": "xdotool is not installed; use LibreOffice's Save control inside the canvas.",
223 + "document": _public_doc(updated) if updated else None,
224 + }
225 +
226 + result = subprocess.run(
227 + [xdotool, "key", "--clearmodifiers", "ctrl+s"],
228 + check=False,
229 + capture_output=True,
230 + text=True,
231 + timeout=8,
232 + env=self._display_env(session),
233 + )
234 + time.sleep(0.8)
235 + updated = document_store.register_document(doc["path"]) if doc else None
236 + if result.returncode != 0:
237 + detail = (result.stderr or result.stdout or "").strip()
238 + return {
239 + "ok": False,
240 + "error": detail or "LibreOffice desktop save shortcut failed.",
241 + "document": _public_doc(updated) if updated else None,
242 + }
243 + return {
244 + "ok": True,
245 + "session_id": session.session_id,
246 + "document": _public_doc(updated) if updated else None,
247 + }
248 +
249 + def sync(self, session_id: str = "", file_id: str = "") -> dict[str, Any]:
250 + session = self.get(session_id) if session_id else self._find_by_file_id(file_id)
251 + if not session:
252 + return {"ok": False, "error": "LibreOffice desktop session not found."}
253 + if not _url_bridge_script_path(session).exists():
254 + try:
255 + self._prepare_desktop_url_bridge(session)
256 + self._refresh_xfce_desktop(session)
257 + except Exception:
258 + pass
259 + url_intents = self.claim_url_intents(session.session_id)
260 + shutdown_request = self.claim_shutdown_request(session.session_id)
261 + if shutdown_request:
262 + return self.shutdown_system_desktop(
263 + save_first=True,
264 + source=str(shutdown_request.get("source") or "tray"),
265 + )
266 + doc = self._document_for_save(session, file_id)
267 + if not doc:
268 + return {
269 + "ok": True,
270 + "session_id": session.session_id,
271 + "desktop": session.public(),
272 + "url_intents": url_intents,
273 + }
274 + updated = document_store.register_document(doc["path"])
275 + return {
276 + "ok": True,
277 + "session_id": session.session_id,
278 + "document": _public_doc(updated),
279 + "url_intents": url_intents,
280 + }
281 +
282 + def state(self, *, include_screenshot: bool = False) -> dict[str, Any]:
283 + with self._lock:
284 + self._reap_dead_locked()
285 + return desktop_state.collect_state(include_screenshot=include_screenshot)
286 +
287 + def claim_url_intents(self, session_id: str = SYSTEM_SESSION_ID) -> list[dict[str, Any]]:
288 + session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
289 + if not session:
290 + return []
291 + return _claim_url_intents(session)
292 +
293 + def claim_shutdown_request(self, session_id: str = SYSTEM_SESSION_ID) -> dict[str, Any] | None:
294 + session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
295 + if not session:
296 + return None
297 + return _claim_shutdown_request(session)
298 +
299 + def shutdown_system_desktop(self, *, save_first: bool = True, source: str = "api") -> dict[str, Any]:
300 + with self._lock:
301 + session = self._sessions.get(SYSTEM_SESSION_ID)
302 + if not session:
303 + _remove_system_manifest()
304 + return {
305 + "ok": True,
306 + "closed": 0,
307 + "session_id": SYSTEM_SESSION_ID,
308 + "shutdown": True,
309 + "intentional_shutdown": True,
310 + "source": source,
311 + }
312 +
313 + save_result = None
314 + if save_first:
315 + try:
316 + save_result = self.save(session.session_id)
317 + except Exception as exc:
318 + save_result = {"ok": False, "error": str(exc)}
319 +
320 + with self._lock:
321 + if self._sessions.get(SYSTEM_SESSION_ID) is session:
322 + self._sessions.pop(SYSTEM_SESSION_ID, None)
323 + virtual_desktop.unregister_session(session.token)
324 + self._terminate_session(session, include_rehydrated=True)
325 + self._remove_manifest(session.session_id)
326 + _clear_shutdown_request(session)
327 + return {
328 + "ok": True,
329 + "closed": 1,
330 + "session_id": session.session_id,
331 + "shutdown": True,
332 + "intentional_shutdown": True,
333 + "source": source,
334 + "save": save_result,
335 + }
336 +
337 + def retarget_document(self, file_id: str, doc: dict[str, Any]) -> dict[str, Any]:
338 + session = self._find_by_file_id(file_id)
339 + if not session:
340 + return {"ok": True, "updated": False}
341 + with self._lock:
342 + session.path = str(doc["path"])
343 + session.title = str(doc["basename"])
344 + session.extension = str(doc["extension"])
345 + self._write_manifest(session)
346 + return {"ok": True, "updated": True, "desktop": session.public(doc)}
347 +
348 + def close(self, session_id: str, save_first: bool = True) -> dict[str, Any]:
349 + with self._lock:
350 + normalized = str(session_id or "").strip()
351 + session = self._sessions.get(normalized)
352 + if not session:
353 + return {"ok": True, "closed": 0}
354 + if session.session_id == SYSTEM_SESSION_ID:
355 + save_result = None
356 + if save_first:
357 + try:
358 + save_result = self.save(session.session_id)
359 + except Exception as exc:
360 + save_result = {"ok": False, "error": str(exc)}
361 + return {
362 + "ok": True,
363 + "closed": 0,
364 + "session_id": session.session_id,
365 + "persistent": True,
366 + "save": save_result,
367 + }
368 +
369 + save_result = None
370 + if save_first:
371 + try:
372 + save_result = self.save(session.session_id)
373 + except Exception as exc:
374 + save_result = {"ok": False, "error": str(exc)}
375 + with self._lock:
376 + self._sessions.pop(session.session_id, None)
377 + virtual_desktop.unregister_session(session.token)
378 + self._terminate_session(session, include_rehydrated=True)
379 + self._remove_manifest(session.session_id)
380 + return {"ok": True, "closed": 1, "session_id": session.session_id, "save": save_result}
381 +
382 + def close_file(self, file_id: str) -> int:
383 + return 0
384 +
385 + def resize(self, session_id: str, width: int, height: int) -> dict[str, Any]:
386 + session = self.get(session_id)
387 + if not session:
388 + return {"ok": False, "error": "LibreOffice desktop session not found."}
389 + is_system_desktop = session.session_id == SYSTEM_SESSION_ID and session.extension == "desktop"
390 + result = virtual_desktop.resize_display(
391 + display=session.display,
392 + width=width,
393 + height=height,
394 + max_width=MAX_SCREEN_WIDTH,
395 + max_height=MAX_SCREEN_HEIGHT,
396 + window_class="" if is_system_desktop else "libreoffice",
397 + keys=() if is_system_desktop else ("Escape",),
398 + xauthority=self._xauthority(session),
399 + home=str(session.profile_dir),
400 + )
401 + if result.get("ok"):
402 + session.width = int(result["width"])
403 + session.height = int(result["height"])
404 + if not is_system_desktop:
405 + self._dismiss_blocking_dialogs(session)
406 + return result
407 +
408 + def proxy_for_token(self, token: str) -> tuple[str, int] | None:
409 + normalized = str(token or "").strip()
410 + with self._lock:
411 + session = self._sessions.get(normalized)
412 + if not session:
413 + session = next((item for item in self._sessions.values() if item.token == normalized), None)
414 + if not session or not session.alive():
415 + return None
416 + return ("127.0.0.1", session.xpra_port)
417 +
418 + def resize_for_token(self, token: str, width: int, height: int) -> dict[str, Any]:
419 + normalized = str(token or "").strip()
420 + with self._lock:
421 + session = self._sessions.get(normalized)
422 + if not session:
423 + session = next((item for item in self._sessions.values() if item.token == normalized), None)
424 + if not session:
425 + return {"ok": False, "error": "LibreOffice desktop session not found."}
426 + return self.resize(session.session_id, width, height)
427 +
428 + def get(self, session_id: str) -> DesktopSession | None:
429 + with self._lock:
430 + session = self._sessions.get(str(session_id or "").strip())
431 + return session if session and session.alive() else None
432 +
433 + def require(self, session_id: str) -> DesktopSession:
434 + session = self.get(session_id)
435 + if not session:
436 + raise FileNotFoundError(f"LibreOffice desktop session not found: {session_id}")
437 + return session
438 +
439 + def shutdown(self) -> None:
440 + with self._lock:
441 + sessions = list(self._sessions.values())
442 + self._sessions.clear()
443 + for session in sessions:
444 + virtual_desktop.unregister_session(session.token)
445 + if session.owns_processes:
446 + self._terminate_session(session)
447 + self._remove_manifest(session.session_id)
448 +
449 + def _document_for_save(self, session: DesktopSession, file_id: str = "") -> dict[str, Any] | None:
450 + normalized = str(file_id or "").strip()
451 + if normalized == SYSTEM_FILE_ID:
452 + return None
453 + if normalized and normalized != SYSTEM_FILE_ID:
454 + return document_store.get_document(normalized)
455 + if session.file_id and session.file_id != SYSTEM_FILE_ID:
456 + try:
457 + return document_store.get_document(session.file_id)
458 + except Exception:
459 + path = Path(session.path)
460 + if path.is_file():
461 + return document_store.register_document(path)
462 + return None
463 +
464 + def _register_virtual_desktop(self, session: DesktopSession) -> None:
465 + virtual_desktop.register_session(
466 + token=session.token,
467 + host="127.0.0.1",
468 + port=session.xpra_port,
469 + owner="desktop",
470 + title=session.title,
471 + resize=lambda width, height, session_id=session.session_id: self.resize(session_id, width, height),
472 + )
473 +
474 + def _ensure_system_desktop_locked(self) -> DesktopSession:
475 + existing = self._sessions.get(SYSTEM_SESSION_ID)
476 + if existing and existing.alive():
477 + self._prepare_desktop_url_bridge(existing)
478 + self._refresh_xfce_desktop(existing)
479 + return existing
480 +
481 + existing = self._load_system_desktop_from_manifest_locked()
482 + if existing:
483 + self._sessions[existing.session_id] = existing
484 + self._register_virtual_desktop(existing)
485 + self._prepare_desktop_url_bridge(existing)
486 + self._refresh_xfce_desktop(existing)
487 + return existing
488 +
489 + status = collect_desktop_status()
490 + if not status["healthy"]:
491 + raise RuntimeError(status["message"])
492 +
493 + display, xpra_port = self._allocate_endpoint_locked()
494 + profile_dir = PROFILE_DIR / SYSTEM_SESSION_ID
495 + session = DesktopSession(
496 + session_id=SYSTEM_SESSION_ID,
497 + file_id=SYSTEM_FILE_ID,
498 + extension="desktop",
499 + path=str(document_store.document_binary_home()),
500 + title=SYSTEM_TITLE,
501 + display=display,
502 + xpra_port=xpra_port,
503 + token=SYSTEM_SESSION_ID,
504 + url=_xpra_url(SYSTEM_SESSION_ID),
505 + profile_dir=profile_dir,
506 + )
507 + try:
508 + self._prepare_profile(session)
509 + self._prepare_desktop_launchers(session)
510 + self._spawn_desktop_locked(session)
511 + except Exception:
512 + self._terminate_session(session)
513 + raise
514 + self._sessions[session.session_id] = session
515 + self._register_virtual_desktop(session)
516 + self._write_manifest(session)
517 + return session
518 +
519 + def _load_system_desktop_from_manifest_locked(self) -> DesktopSession | None:
520 + manifest = SESSION_DIR / f"{SYSTEM_SESSION_ID}.json"
521 + if not manifest.exists():
522 + return None
523 + try:
524 + payload = json.loads(manifest.read_text(encoding="utf-8"))
525 + display = int(payload.get("display") or 0)
526 + xpra_port = int(payload.get("xpra_port") or 0)
527 + process_ids = {
528 + str(name): pid
529 + for name, value in dict(payload.get("pids") or {}).items()
530 + if (pid := _coerce_pid(value))
531 + }
532 + if not display or not xpra_port:
533 + return None
534 + if not _pid_is_running(process_ids.get("xpra", 0)):
535 + return None
536 + if not _port_is_accepting("127.0.0.1", xpra_port):
537 + return None
538 +
539 + path = str(payload.get("path") or document_store.document_binary_home())
540 + file_id = str(payload.get("file_id") or SYSTEM_FILE_ID)
541 + extension = str(payload.get("extension") or "").lower()
542 + if not extension:
543 + extension = "desktop" if file_id == SYSTEM_FILE_ID else Path(path).suffix.lower().lstrip(".")
544 + title = str(payload.get("title") or "")
545 + if not title:
546 + title = SYSTEM_TITLE if file_id == SYSTEM_FILE_ID else Path(path).name or SYSTEM_TITLE
547 + return DesktopSession(
548 + session_id=SYSTEM_SESSION_ID,
549 + file_id=file_id,
550 + extension=extension,
551 + path=path,
552 + title=title,
553 + display=display,
554 + xpra_port=xpra_port,
555 + token=SYSTEM_SESSION_ID,
556 + url=_xpra_url(SYSTEM_SESSION_ID),
557 + profile_dir=Path(payload.get("profile_dir") or PROFILE_DIR / SYSTEM_SESSION_ID),
558 + width=int(payload.get("width") or DEFAULT_SCREEN_WIDTH),
559 + height=int(payload.get("height") or DEFAULT_SCREEN_HEIGHT),
560 + process_ids=process_ids,
561 + owns_processes=False,
562 + started_at=float(payload.get("started_at") or time.time()),
563 + )
564 + except Exception:
565 + return None
566 +
567 + def _spawn_desktop_locked(self, session: DesktopSession) -> None:
568 + STATE_DIR.mkdir(parents=True, exist_ok=True)
569 + SESSION_DIR.mkdir(parents=True, exist_ok=True)
570 + session.profile_dir.mkdir(parents=True, exist_ok=True)
571 +
572 + xpra = _require_binary("xpra")
573 + xvfb = _require_binary("Xvfb")
574 + _require_binary("xfce4-session")
575 + _require_binary("dbus-launch")
576 + xfce_launcher = self._prepare_xfce_launcher(session)
577 +
578 + session.processes["xvfb"] = subprocess.Popen(
579 + _xvfb_command(xvfb, session),
580 + stdin=subprocess.DEVNULL,
581 + stdout=subprocess.DEVNULL,
582 + stderr=subprocess.DEVNULL,
583 + env=self._session_env(session),
584 + )
585 + self._wait_for_display(session)
586 + self._set_display_size(session, session.width, session.height)
587 + self._prepare_root_window(session)
588 + session.processes["xfce"] = subprocess.Popen(
589 + [str(xfce_launcher)],
590 + stdin=subprocess.DEVNULL,
591 + stdout=subprocess.DEVNULL,
592 + stderr=subprocess.DEVNULL,
593 + env=self._display_env(session),
594 + )
595 + self._wait_for_xfce(session)
596 + session.processes["xpra"] = subprocess.Popen(
597 + _xpra_shadow_command(xpra, session),
598 + stdin=subprocess.DEVNULL,
599 + stdout=subprocess.DEVNULL,
600 + stderr=subprocess.DEVNULL,
601 + env=self._display_env(session),
602 + )
603 + _wait_for_port(
604 + "127.0.0.1",
605 + session.xpra_port,
606 + timeout=PORT_START_TIMEOUT_SECONDS,
607 + process=session.processes.get("xpra"),
608 + )
609 + self._refresh_xfce_desktop(session)
610 +
611 + def _restart_xpra_shadow(self, session: DesktopSession) -> None:
612 + xpra = _require_binary("xpra")
613 + process = session.processes.get("xpra")
614 + if process:
615 + _terminate_process(process)
616 + session.processes["xpra"] = subprocess.Popen(
617 + _xpra_shadow_command(xpra, session),
618 + stdin=subprocess.DEVNULL,
619 + stdout=subprocess.DEVNULL,
620 + stderr=subprocess.DEVNULL,
621 + env=self._display_env(session),
622 + )
623 + _wait_for_port(
624 + "127.0.0.1",
625 + session.xpra_port,
626 + timeout=PORT_START_TIMEOUT_SECONDS,
627 + process=session.processes.get("xpra"),
628 + )
629 +
630 + def _open_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> None:
631 + soffice = libreoffice.find_soffice()
632 + if not soffice:
633 + raise RuntimeError("LibreOffice is not installed in this runtime.")
634 + path = str(doc["path"])
635 + self._remove_stale_lock_file(session, path=path)
636 + process_key = f"soffice-{doc['file_id']}"
637 + session.processes[process_key] = subprocess.Popen(
638 + [
639 + soffice,
640 + "--norestore",
641 + "--nofirststartwizard",
642 + "--nolockcheck",
643 + f"-env:UserInstallation=file://{session.profile_dir}",
644 + path,
645 + ],
646 + cwd=str(Path(path).parent),
647 + stdin=subprocess.DEVNULL,
648 + stdout=subprocess.DEVNULL,
649 + stderr=subprocess.DEVNULL,
650 + env=self._display_env(session),
651 + )
652 + self._fit_office_window(session, process=session.processes[process_key])
653 + window_id = self._wait_for_office_window_locked(
654 + session,
655 + title=str(doc.get("basename") or ""),
656 + process=session.processes[process_key],
657 + )
658 + if not window_id:
659 + raise RuntimeError(
660 + f"LibreOffice did not show {doc.get('basename') or 'the document'} "
661 + f"on desktop :{session.display}.",
662 + )
663 + self._fit_office_window_id_locked(
664 + session,
665 + window_id,
666 + env=self._display_env(session),
667 + keys=("Escape",),
668 + )
669 +
670 + def _reload_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
671 + if self._close_document_window_locked(session, doc):
672 + self._open_document_locked(session, doc)
673 + return True
674 + if self._send_reload_shortcut_locked(session, doc):
675 + return True
676 + self._open_document_locked(session, doc)
677 + return False
678 +
679 + def _close_document_window_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
680 + xdotool = shutil.which("xdotool")
681 + if not xdotool:
682 + return False
683 + title = str(doc.get("basename") or Path(str(doc.get("path") or "")).name or "").strip()
684 + if not title:
685 + return False
686 + window_id = self._office_window_id_locked(session, title=title, fallback=False)
687 + if not window_id:
688 + return False
689 + env = self._display_env(session)
690 + self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
691 + for command in (
692 + [xdotool, "key", "--clearmodifiers", "alt+F4"],
693 + [xdotool, "windowclose", window_id],
694 + ):
695 + try:
696 + subprocess.run(
697 + command,
698 + check=False,
699 + stdout=subprocess.DEVNULL,
700 + stderr=subprocess.DEVNULL,
701 + timeout=3,
702 + env=env,
703 + )
704 + except (OSError, subprocess.TimeoutExpired):
705 + continue
706 + if self._wait_for_window_closed_locked(session, window_id):
707 + return True
708 + self._dismiss_blocking_dialogs(session)
709 + return self._wait_for_window_closed_locked(session, window_id, timeout_seconds=1.5)
710 +
711 + def _send_reload_shortcut_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
712 + xdotool = shutil.which("xdotool")
713 + if not xdotool:
714 + return False
715 + window_id = self._office_window_id_locked(
716 + session,
717 + title=str(doc.get("basename") or ""),
718 + )
719 + if not window_id:
720 + return False
721 + env = self._display_env(session)
722 + self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
723 + try:
724 + result = subprocess.run(
725 + [xdotool, "key", "--clearmodifiers", "ctrl+shift+r"],
726 + check=False,
727 + capture_output=True,
728 + text=True,
729 + timeout=4,
730 + env=env,
731 + )
732 + except (OSError, subprocess.TimeoutExpired):
733 + return False
734 + time.sleep(0.8)
735 + self._dismiss_blocking_dialogs(session)
736 + self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
737 + return result.returncode == 0
738 +
739 + def _office_window_id_locked(
740 + self,
741 + session: DesktopSession,
742 + *,
743 + title: str = "",
744 + fallback: bool = True,
745 + ) -> str:
746 + xdotool = shutil.which("xdotool")
747 + if not xdotool:
748 + return ""
749 + env = self._display_env(session)
750 + title = str(title or "").strip()
751 + searches: list[list[str]] = []
752 + if title:
753 + escaped_title = re.escape(title)
754 + searches.append([
755 + xdotool,
756 + "search",
757 + "--onlyvisible",
758 + "--name",
759 + escaped_title,
760 + ])
761 + for window_class in (
762 + "libreoffice",
763 + "libreoffice-writer",
764 + "libreoffice-calc",
765 + "libreoffice-impress",
766 + ):
767 + searches.append([
768 + xdotool,
769 + "search",
770 + "--onlyvisible",
771 + "--class",
772 + window_class,
773 + "--name",
774 + escaped_title,
775 + ])
776 + if fallback:
777 + for window_class in (
778 + "libreoffice",
779 + "libreoffice-writer",
780 + "libreoffice-calc",
781 + "libreoffice-impress",
782 + ):
783 + searches.append([xdotool, "search", "--onlyvisible", "--class", window_class])
784 + searches.append([xdotool, "search", "--onlyvisible", "--name", "LibreOffice"])
785 + for command in searches:
786 + try:
787 + result = subprocess.run(
788 + command,
789 + check=False,
790 + capture_output=True,
791 + text=True,
792 + timeout=2,
793 + env=env,
794 + )
795 + except (OSError, subprocess.TimeoutExpired):
796 + continue
797 + window_ids = [
798 + line.strip()
799 + for line in result.stdout.splitlines()
800 + if line.strip()
801 + ]
802 + if window_ids:
803 + return window_ids[-1]
804 + return ""
805 +
806 + def _wait_for_office_window_locked(
807 + self,
808 + session: DesktopSession,
809 + *,
810 + title: str = "",
811 + process: subprocess.Popen[Any] | None = None,
812 + timeout_seconds: float = 20.0,
813 + ) -> str:
814 + deadline = time.time() + timeout_seconds
815 + last_fallback = ""
816 + title = str(title or "").strip()
817 + while time.time() < deadline:
818 + window_id = self._office_window_id_locked(session, title=title, fallback=False)
819 + if window_id:
820 + return window_id
821 + last_fallback = self._office_window_id_locked(session, fallback=True) or last_fallback
822 + if last_fallback and not title:
823 + return last_fallback
824 + if process and process.poll() is not None and last_fallback:
825 + return last_fallback
826 + time.sleep(0.25)
827 + return self._office_window_id_locked(session, title=title, fallback=True) or last_fallback
828 +
829 + def _wait_for_window_closed_locked(
830 + self,
831 + session: DesktopSession,
832 + window_id: str,
833 + *,
834 + timeout_seconds: float = 6.0,
835 + ) -> bool:
836 + deadline = time.time() + timeout_seconds
837 + while time.time() < deadline:
838 + if not self._window_exists_locked(session, window_id):
839 + return True
840 + time.sleep(0.2)
841 + return not self._window_exists_locked(session, window_id)
842 +
843 + def _window_exists_locked(self, session: DesktopSession, window_id: str) -> bool:
844 + xdotool = shutil.which("xdotool")
845 + if not xdotool or not window_id:
846 + return False
847 + try:
848 + result = subprocess.run(
849 + [xdotool, "getwindowname", str(window_id)],
850 + check=False,
851 + stdout=subprocess.DEVNULL,
852 + stderr=subprocess.DEVNULL,
853 + timeout=2,
854 + env=self._display_env(session),
855 + )
856 + except (OSError, subprocess.TimeoutExpired):
857 + return False
858 + return result.returncode == 0
859 +
860 + def _fit_office_window_id_locked(
861 + self,
862 + session: DesktopSession,
863 + window_id: str,
864 + *,
865 + env: dict[str, str],
866 + keys: tuple[str, ...] = (),
867 + ) -> None:
868 + xdotool = shutil.which("xdotool")
869 + if not xdotool or not window_id:
870 + return
871 + for command in (
872 + [xdotool, "windowactivate", window_id],
873 + [
874 + xdotool,
875 + "windowmove",
876 + window_id,
877 + "0",
878 + "0",
879 + "windowsize",
880 + window_id,
881 + str(session.width),
882 + str(session.height),
883 + ],
884 + ):
885 + try:
886 + subprocess.run(
887 + command,
888 + check=False,
889 + stdout=subprocess.DEVNULL,
890 + stderr=subprocess.DEVNULL,
891 + timeout=2,
892 + env=env,
893 + )
894 + except (OSError, subprocess.TimeoutExpired):
895 + continue
896 + for key in keys:
897 + try:
898 + subprocess.run(
899 + [xdotool, "key", "--clearmodifiers", key],
900 + check=False,
901 + stdout=subprocess.DEVNULL,
902 + stderr=subprocess.DEVNULL,
903 + timeout=2,
904 + env=env,
905 + )
906 + except (OSError, subprocess.TimeoutExpired):
907 + continue
908 +
909 + def _prepare_profile(self, session: DesktopSession) -> None:
910 + user_dir = session.profile_dir / "user"
911 + user_dir.mkdir(parents=True, exist_ok=True)
912 + registry = user_dir / "registrymodifications.xcu"
913 + _write_libreoffice_registry_defaults(registry, document_store.document_home())
914 +
915 + def _prepare_desktop_launchers(self, session: DesktopSession) -> None:
916 + soffice = libreoffice.find_soffice()
917 + if not soffice:
918 + raise RuntimeError("LibreOffice is not installed in this runtime.")
919 + workdir_home = document_store.document_home()
920 + workdir_home.mkdir(parents=True, exist_ok=True)
921 + documents_home = document_store.document_binary_home()
922 + documents_home.mkdir(parents=True, exist_ok=True)
923 + downloads_home = Path(files.get_abs_path("usr", "downloads"))
924 + downloads_home.mkdir(parents=True, exist_ok=True)
925 +
926 + desktop_dir = session.profile_dir / "Desktop"
927 + desktop_dir.mkdir(parents=True, exist_ok=True)
928 + _install_desktop_readme(desktop_dir)
929 + _remove_path_if_owned(desktop_dir / "Browser.desktop")
930 + _remove_path_if_owned(desktop_dir / "Files.desktop")
931 + config_dir = session.profile_dir / ".config"
932 + config_dir.mkdir(parents=True, exist_ok=True)
933 + _remove_path_if_owned(config_dir / "xfce4" / "panel")
934 + data_dir = session.profile_dir / ".local" / "share"
935 + data_dir.mkdir(parents=True, exist_ok=True)
936 + applications_dir = data_dir / "applications"
937 + applications_dir.mkdir(parents=True, exist_ok=True)
938 + cache_dir = session.profile_dir / ".cache"
939 + cache_dir.mkdir(parents=True, exist_ok=True)
940 + (config_dir / "user-dirs.dirs").write_text(
941 + "\n".join(
942 + [
943 + 'XDG_DESKTOP_DIR="$HOME/Desktop"',
944 + f'XDG_DOCUMENTS_DIR="{workdir_home}"',
945 + f'XDG_DOWNLOAD_DIR="{downloads_home}"',
946 + f'XDG_TEMPLATES_DIR="{workdir_home}"',
947 + f'XDG_PUBLICSHARE_DIR="{workdir_home}"',
948 + f'XDG_MUSIC_DIR="{workdir_home}"',
949 + f'XDG_PICTURES_DIR="{downloads_home}"',
950 + f'XDG_VIDEOS_DIR="{workdir_home}"',
951 + "",
952 + ],
953 + ),
954 + encoding="utf-8",
955 + )
956 + xfce_conf_dir = config_dir / "xfce4" / "xfconf" / "xfce-perchannel-xml"
957 + xfce_conf_dir.mkdir(parents=True, exist_ok=True)
958 + (xfce_conf_dir / "xfce4-desktop.xml").write_text(
959 + f"""<?xml version="1.1" encoding="UTF-8"?>
960 +
961 +<channel name="xfce4-desktop" version="1.0">
962 + <property name="last-settings-migration-version" type="uint" value="1"/>
963 + <property name="backdrop" type="empty">
964 + <property name="screen0" type="empty">
965 + <property name="monitor0" type="empty">
966 + <property name="image-path" type="string" value="{_xml_attr(str(downloads_home))}"/>
967 + </property>
968 + </property>
969 + </property>
970 + <property name="desktop-icons" type="empty">
971 + <property name="style" type="int" value="2"/>
972 + <property name="file-icons" type="empty">
973 + <property name="show-home" type="bool" value="false"/>
974 + <property name="show-filesystem" type="bool" value="false"/>
975 + <property name="show-removable" type="bool" value="false"/>
976 + <property name="show-trash" type="bool" value="false"/>
977 + </property>
978 + </property>
979 +</channel>
980 +""",
981 + encoding="utf-8",
982 + )
983 + _write_thunar_defaults(xfce_conf_dir / "thunar.xml")
984 + self._hide_xpra_desktop_entries(applications_dir)
985 + self._hide_xfce_menu_entries(applications_dir)
986 + self._prepare_desktop_url_bridge(session)
987 +
988 + base_args = (
989 + soffice,
990 + "--norestore",
991 + "--nofirststartwizard",
992 + "--nolockcheck",
993 + f"-env:UserInstallation=file://{session.profile_dir}",
994 + )
995 + office_launchers = (
996 + ("LibreOffice Writer", "libreoffice-writer", "--writer", "Office;WordProcessor;"),
997 + ("LibreOffice Calc", "libreoffice-calc", "--calc", "Office;Spreadsheet;"),
998 + ("LibreOffice Impress", "libreoffice-impress", "--impress", "Office;Presentation;"),
999 + )
1000 + for name, icon, mode, categories in office_launchers:
1001 + _write_desktop_launcher(
1002 + desktop_dir / f"{name}.desktop",
1003 + name=name,
1004 + exec_line=_desktop_exec(*base_args, mode),
1005 + icon=icon,
1006 + categories=categories,
1007 + try_exec=soffice,
1008 + working_dir=workdir_home,
1009 + )
1010 +
1011 + terminal = shutil.which("xfce4-terminal") or "xfce4-terminal"
1012 + settings = shutil.which("xfce4-settings-manager") or "xfce4-settings-manager"
1013 + desktop_apps = (
1014 + {
1015 + "filename": "Terminal.desktop",
1016 + "name": "Terminal",
1017 + "exec": _desktop_exec(terminal, f"--working-directory={workdir_home}"),
1018 + "try_exec": terminal,
1019 + "icon": _desktop_icon(
1020 + "/usr/share/icons/hicolor/128x128/apps/org.xfce.terminal.png",
1021 + "/usr/share/icons/hicolor/scalable/apps/org.xfce.terminal.svg",
1022 + "org.xfce.terminal",
1023 + "utilities-terminal",
1024 + ),
1025 + "categories": "System;TerminalEmulator;",
1026 + },
1027 + {
1028 + "filename": "Settings.desktop",
1029 + "name": "Settings",
1030 + "exec": _desktop_exec(settings),
1031 + "try_exec": settings,
1032 + "icon": _desktop_icon(
1033 + "/usr/share/icons/hicolor/128x128/apps/org.xfce.settings.manager.png",
1034 + "/usr/share/icons/hicolor/scalable/apps/org.xfce.settings.manager.svg",
1035 + "org.xfce.settings.manager",
1036 + "preferences-system",
1037 + ),
1038 + "categories": "Settings;DesktopSettings;",
1039 + },
1040 + )
1041 + for app in desktop_apps:
1042 + _write_desktop_launcher(
1043 + desktop_dir / str(app["filename"]),
1044 + name=str(app["name"]),
1045 + exec_line=str(app["exec"]),
1046 + icon=str(app["icon"]),
1047 + categories=str(app["categories"]),
1048 + try_exec=str(app["try_exec"]),
1049 + )
1050 + _ensure_desktop_folder_link(desktop_dir, "Workdir", workdir_home)
1051 + for label, target_parts in DESKTOP_FOLDER_LINKS:
1052 + _ensure_desktop_folder_link(desktop_dir, label, Path(files.get_abs_path(*target_parts)))
1053 +
1054 + self._trust_desktop_launchers(session, desktop_dir)
1055 + self._prepare_xfce_panel_config(session)
1056 + self._prepare_xfce_profile_autostart(session)
1057 +
1058 + def _prepare_desktop_url_bridge(self, session: DesktopSession) -> None:
1059 + desktop_dir = session.profile_dir / "Desktop"
1060 + config_dir = session.profile_dir / ".config"
1061 + data_dir = session.profile_dir / ".local" / "share"
1062 + applications_dir = data_dir / "applications"
1063 + desktop_dir.mkdir(parents=True, exist_ok=True)
1064 + config_dir.mkdir(parents=True, exist_ok=True)
1065 + applications_dir.mkdir(parents=True, exist_ok=True)
1066 +
1067 + browser_bridge = _write_url_bridge_script(session)
1068 + shutdown_bridge = _write_shutdown_bridge_script(session)
1069 + helpers_rc = config_dir / "xfce4" / "helpers.rc"
1070 + helpers_rc.parent.mkdir(parents=True, exist_ok=True)
1071 + helpers_rc.write_text(
1072 + "\n".join(
1073 + [
1074 + "TerminalEmulator=xfce4-terminal",
1075 + "FileManager=thunar",
1076 + "WebBrowser=agent-zero-browser",
1077 + "",
1078 + ],
1079 + ),
1080 + encoding="utf-8",
1081 + )
1082 + _write_xfce_browser_helper(
1083 + config_dir / "xfce4" / "helpers" / "agent-zero-browser.desktop",
1084 + browser_bridge,
1085 + )
1086 + _write_mimeapps_defaults(config_dir / "mimeapps.list", URL_HANDLER_DESKTOP_ID)
1087 + _write_mimeapps_defaults(data_dir / "applications" / "mimeapps.list", URL_HANDLER_DESKTOP_ID)
1088 + _write_desktop_launcher(
1089 + applications_dir / URL_HANDLER_DESKTOP_ID,
1090 + name="Agent Zero Browser",
1091 + exec_line=_desktop_exec(browser_bridge, "%U"),
1092 + icon="web-browser",
1093 + categories="Network;WebBrowser;",
1094 + try_exec=str(browser_bridge),
1095 + mime_types=_url_handler_mime_types(),
1096 + no_display=True,
1097 + )
1098 + _write_desktop_launcher(
1099 + applications_dir / SHUTDOWN_HANDLER_DESKTOP_ID,
1100 + name="Shutdown Desktop",
1101 + exec_line=_desktop_exec(shutdown_bridge),
1102 + icon="system-shutdown",
1103 + categories="System;",
1104 + try_exec=str(shutdown_bridge),
1105 + no_display=True,
1106 + )
1107 + _write_desktop_launcher(
1108 + config_dir / "xfce4" / "panel" / "launcher-9" / SHUTDOWN_HANDLER_DESKTOP_ID,
1109 + name="Shutdown Desktop",
1110 + exec_line=_desktop_exec(shutdown_bridge),
1111 + icon="system-shutdown",
1112 + categories="System;",
1113 + try_exec=str(shutdown_bridge),
1114 + )
1115 + _write_desktop_launcher(
1116 + desktop_dir / "Browser.desktop",
1117 + name="Browser",
1118 + exec_line=_desktop_exec(browser_bridge),
1119 + icon="web-browser",
1120 + categories="Network;WebBrowser;",
1121 + try_exec=str(browser_bridge),
1122 + )
1123 + self._trust_desktop_launchers(session, desktop_dir)
1124 +
1125 + def _hide_xpra_desktop_entries(self, applications_dir: Path) -> None:
1126 + for filename in HIDDEN_XPRA_DESKTOP_ENTRIES:
1127 + _write_hidden_application_entry(applications_dir / filename, "Xpra")
1128 +
1129 + def _hide_xfce_menu_entries(self, applications_dir: Path) -> None:
1130 + for filename, name in HIDDEN_XFCE_MENU_ENTRIES:
1131 + _write_hidden_application_entry(applications_dir / filename, name)
1132 +
1133 + def _prepare_xfce_panel_config(self, session: DesktopSession) -> None:
1134 + panel_xml = (
1135 + session.profile_dir
1136 + / ".config"
1137 + / "xfce4"
1138 + / "xfconf"
1139 + / "xfce-perchannel-xml"
1140 + / "xfce4-panel.xml"
1141 + )
1142 + panel_xml.parent.mkdir(parents=True, exist_ok=True)
1143 +
1144 + root = ET.Element("channel", {"name": "xfce4-panel", "version": "1.0"})
1145 + ET.SubElement(root, "property", {"name": "configver", "type": "int", "value": "2"})
1146 +
1147 + panels = ET.SubElement(root, "property", {"name": "panels", "type": "array"})
1148 + ET.SubElement(panels, "value", {"type": "int", "value": "1"})
1149 + panel = ET.SubElement(panels, "property", {"name": "panel-1", "type": "empty"})
1150 + for name, prop_type, value in (
1151 + ("position", "string", "p=6;x=0;y=0"),
1152 + ("length", "uint", "100"),
1153 + ("position-locked", "bool", "true"),
1154 + ("size", "uint", "24"),
1155 + ("mode", "uint", "0"),
1156 + ("autohide-behavior", "uint", "0"),
1157 + ("disable-struts", "bool", "false"),
1158 + ("nrows", "uint", "1"),
1159 + ):
1160 + ET.SubElement(panel, "property", {"name": name, "type": prop_type, "value": value})
1161 + plugin_ids = ET.SubElement(panel, "property", {"name": "plugin-ids", "type": "array"})
1162 + for plugin_id in ("1", "2", "3", "4", "5", "6", "7", "8", "9"):
1163 + ET.SubElement(plugin_ids, "value", {"type": "int", "value": plugin_id})
1164 +
1165 + plugins = ET.SubElement(root, "property", {"name": "plugins", "type": "empty"})
1166 + ET.SubElement(plugins, "property", {"name": "plugin-1", "type": "string", "value": "applicationsmenu"})
1167 + ET.SubElement(plugins, "property", {"name": "plugin-2", "type": "string", "value": "tasklist"})
1168 + tasklist = _xfce_property(plugins, "plugin-2", "string", "tasklist")
1169 + ET.SubElement(tasklist, "property", {"name": "flat-buttons", "type": "bool", "value": "true"})
1170 + ET.SubElement(tasklist, "property", {"name": "show-handle", "type": "bool", "value": "false"})
1171 + ET.SubElement(tasklist, "property", {"name": "show-labels", "type": "bool", "value": "true"})
1172 + separator = ET.SubElement(plugins, "property", {"name": "plugin-3", "type": "string", "value": "separator"})
1173 + ET.SubElement(separator, "property", {"name": "expand", "type": "bool", "value": "true"})
1174 + ET.SubElement(separator, "property", {"name": "style", "type": "uint", "value": "0"})
1175 + ET.SubElement(plugins, "property", {"name": "plugin-4", "type": "string", "value": "pager"})
1176 + ET.SubElement(plugins, "property", {"name": "plugin-5", "type": "string", "value": "systray"})
1177 + ET.SubElement(plugins, "property", {"name": "plugin-6", "type": "string", "value": "separator"})
1178 + ET.SubElement(plugins, "property", {"name": "plugin-7", "type": "string", "value": "clock"})
1179 + ET.SubElement(plugins, "property", {"name": "plugin-8", "type": "string", "value": "separator"})
1180 + shutdown = ET.SubElement(plugins, "property", {"name": "plugin-9", "type": "string", "value": "launcher"})
1181 + shutdown_items = ET.SubElement(shutdown, "property", {"name": "items", "type": "array"})
1182 + ET.SubElement(shutdown_items, "value", {"type": "string", "value": SHUTDOWN_PANEL_LAUNCHER_ID})
1183 +
1184 + tree = ET.ElementTree(root)
1185 + try:
1186 + ET.indent(tree, space=" ")
1187 + except AttributeError:
1188 + pass
1189 + tree.write(panel_xml, encoding="utf-8", xml_declaration=True)
1190 +
1191 + def _prepare_xfce_profile_autostart(self, session: DesktopSession) -> None:
1192 + script = session.profile_dir / "prepare-xfce-profile.sh"
1193 + script.write_text(
1194 + """#!/bin/sh
1195 +set -eu
1196 +export HOME="${HOME:-%s}"
1197 +export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
1198 +export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
1199 +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
1200 +export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"
1201 +mkdir -p "$HOME/Desktop" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
1202 +if command -v xfconf-query >/dev/null 2>&1; then
1203 + xfconf-query -c thunar -p /last-show-hidden -n -t bool -s true >/dev/null 2>&1 || true
1204 + xfconf-query -c xfce4-desktop -p /desktop-icons/style -n -t int -s 2 >/dev/null 2>&1 || true
1205 + xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-home -n -t bool -s false >/dev/null 2>&1 || true
1206 + xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-filesystem -n -t bool -s false >/dev/null 2>&1 || true
1207 + xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-removable -n -t bool -s false >/dev/null 2>&1 || true
1208 + xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-trash -n -t bool -s false >/dev/null 2>&1 || true
1209 +fi
1210 +for launcher in "$HOME"/Desktop/*.desktop; do
1211 + [ -f "$launcher" ] || continue
1212 + chmod +x "$launcher" 2>/dev/null || true
1213 + if command -v gio >/dev/null 2>&1; then
1214 + checksum="$(sha256sum "$launcher" 2>/dev/null | cut -d " " -f 1)"
1215 + gio set "$launcher" metadata::trusted true >/dev/null 2>&1 || true
1216 + if [ -n "$checksum" ]; then
1217 + gio set -t string "$launcher" metadata::xfce-exe-checksum "$checksum" >/dev/null 2>&1 || true
1218 + fi
1219 + fi
1220 +done
1221 +if command -v xfdesktop >/dev/null 2>&1; then
1222 + timeout 4 xfdesktop --reload >/dev/null 2>&1 || true
1223 +fi
1224 +""" % str(session.profile_dir),
1225 + encoding="utf-8",
1226 + )
1227 + try:
1228 + script.chmod(0o700)
1229 + except OSError:
1230 + pass
1231 +
1232 + autostart_dir = session.profile_dir / ".config" / "autostart"
1233 + autostart_dir.mkdir(parents=True, exist_ok=True)
1234 + autostart = autostart_dir / "agent-zero-desktop.desktop"
1235 + autostart.write_text(
1236 + "\n".join(
1237 + [
1238 + "[Desktop Entry]",
1239 + "Type=Application",
1240 + "Name=Agent Zero desktop profile",
1241 + f"Exec={script}",
1242 + "Terminal=false",
1243 + "OnlyShowIn=XFCE;",
1244 + "X-GNOME-Autostart-enabled=true",
1245 + "",
1246 + ],
1247 + ),
1248 + encoding="utf-8",
1249 + )
1250 +
1251 + def _prepare_xfce_launcher(self, session: DesktopSession) -> Path:
1252 + launcher = session.profile_dir / "start-xfce.sh"
1253 + launcher.write_text(
1254 + "\n".join(
1255 + [
1256 + "#!/bin/sh",
1257 + 'export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"',
1258 + 'export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"',
1259 + 'export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"',
1260 + 'export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"',
1261 + (
1262 + "exec dbus-launch --exit-with-session sh -c "
1263 + f"'\"{session.profile_dir / 'prepare-xfce-profile.sh'}\" >/dev/null 2>&1 || true; exec xfce4-session'"
1264 + ),
1265 + "",
1266 + ],
1267 + ),
1268 + encoding="utf-8",
1269 + )
1270 + try:
1271 + launcher.chmod(0o700)
1272 + except OSError:
1273 + pass
1274 + return launcher
1275 +
1276 + def _prepare_root_window(self, session: DesktopSession) -> None:
1277 + xsetroot = shutil.which("xsetroot")
1278 + if not xsetroot:
1279 + return
1280 + subprocess.run(
1281 + [xsetroot, "-solid", "#20242a"],
1282 + check=False,
1283 + stdout=subprocess.DEVNULL,
1284 + stderr=subprocess.DEVNULL,
1285 + timeout=2,
1286 + env=self._display_env(session),
1287 + )
1288 +
1289 + def _fit_office_window(
1290 + self,
1291 + session: DesktopSession,
1292 + *,
1293 + process: subprocess.Popen[Any] | None = None,
1294 + ) -> None:
1295 + virtual_desktop.fit_window_until(
1296 + display=session.display,
1297 + width=session.width,
1298 + height=session.height,
1299 + window_class="libreoffice",
1300 + keys=("Escape",),
1301 + settle_seconds=4,
1302 + timeout_seconds=10,
1303 + process=process,
1304 + xauthority=self._xauthority(session),
1305 + home=str(session.profile_dir),
1306 + )
1307 + self._dismiss_blocking_dialogs(session)
1308 +
1309 + def _set_display_size(self, session: DesktopSession, width: int, height: int) -> dict[str, Any]:
1310 + result = virtual_desktop.resize_display(
1311 + display=session.display,
1312 + width=width,
1313 + height=height,
1314 + max_width=MAX_SCREEN_WIDTH,
1315 + max_height=MAX_SCREEN_HEIGHT,
1316 + window_class="",
1317 + keys=(),
1318 + xauthority=self._xauthority(session),
1319 + home=str(session.profile_dir),
1320 + )
1321 + if result.get("ok"):
1322 + session.width = int(result["width"])
1323 + session.height = int(result["height"])
1324 + return result
1325 +
1326 + def _dismiss_blocking_dialogs(self, session: DesktopSession) -> None:
1327 + virtual_desktop.close_windows(
1328 + display=session.display,
1329 + names=BLOCKING_DIALOG_TITLES,
1330 + xauthority=self._xauthority(session),
1331 + home=str(session.profile_dir),
1332 + )
1333 +
1334 + def _refresh_xfce_desktop(self, session: DesktopSession) -> None:
1335 + xfdesktop = shutil.which("xfdesktop")
1336 + if not xfdesktop:
1337 + return
1338 + env = self._xfce_process_env(session, "xfdesktop")
1339 + try:
1340 + subprocess.run(
1341 + [xfdesktop, "--reload"],
1342 + check=False,
1343 + stdout=subprocess.DEVNULL,
1344 + stderr=subprocess.DEVNULL,
1345 + timeout=4,
1346 + env=env,
1347 + )
1348 + except (OSError, subprocess.TimeoutExpired):
1349 + return
1350 +
1351 + def _trust_desktop_launchers(self, session: DesktopSession, desktop_dir: Path) -> None:
1352 + gio = shutil.which("gio")
1353 + if not gio:
1354 + return
1355 + env = self._xfce_process_env(session, "xfdesktop")
1356 + for launcher in desktop_dir.glob("*.desktop"):
1357 + try:
1358 + launcher.chmod(0o755)
1359 + checksum = hashlib.sha256(launcher.read_bytes()).hexdigest()
1360 + except OSError:
1361 + continue
1362 + for command in (
1363 + [gio, "set", str(launcher), "metadata::trusted", "true"],
1364 + [gio, "set", "-t", "string", str(launcher), "metadata::xfce-exe-checksum", checksum],
1365 + ):
1366 + try:
1367 + subprocess.run(
1368 + command,
1369 + check=False,
1370 + stdout=subprocess.DEVNULL,
1371 + stderr=subprocess.DEVNULL,
1372 + timeout=4,
1373 + env=env,
1374 + )
1375 + except (OSError, subprocess.TimeoutExpired):
1376 + continue
1377 +
1378 + def _xfce_process_env(self, session: DesktopSession, command_name: str) -> dict[str, str]:
1379 + env = self._display_env(session)
1380 + proc = Path("/proc")
1381 + for candidate in proc.iterdir():
1382 + if not candidate.name.isdigit():
1383 + continue
1384 + try:
1385 + if (candidate / "comm").read_text(encoding="utf-8").strip() != command_name:
1386 + continue
1387 + process_env = self._read_process_env(candidate)
1388 + except OSError:
1389 + continue
1390 + if process_env.get("HOME") != str(session.profile_dir):
1391 + continue
1392 + if process_env.get("DISPLAY") != f":{session.display}":
1393 + continue
1394 + for key, value in process_env.items():
1395 + if (
1396 + key in {"DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "HOME", "XAUTHORITY"}
1397 + or key.startswith("XDG_")
1398 + ):
1399 + env[key] = value
1400 + break
1401 + return env
1402 +
1403 + def _read_process_env(self, proc_dir: Path) -> dict[str, str]:
1404 + raw = (proc_dir / "environ").read_bytes()
1405 + env: dict[str, str] = {}
1406 + for item in raw.split(b"\0"):
1407 + if not item or b"=" not in item:
1408 + continue
1409 + key, value = item.split(b"=", 1)
1410 + env[key.decode("utf-8", errors="ignore")] = value.decode("utf-8", errors="ignore")
1411 + return env
1412 +
1413 + def _session_env(self, session: DesktopSession) -> dict[str, str]:
1414 + env = {
1415 + **os.environ,
1416 + "HOME": str(session.profile_dir),
1417 + "LANG": os.environ.get("LANG") or "C.UTF-8",
1418 + }
1419 + browser_bridge = _url_bridge_script_path(session)
1420 + if browser_bridge.exists():
1421 + env["BROWSER"] = str(browser_bridge)
1422 + env.setdefault("XDG_RUNTIME_DIR", str(STATE_DIR / "xdg-runtime"))
1423 + runtime_dir = Path(env["XDG_RUNTIME_DIR"])
1424 + runtime_dir.mkdir(parents=True, exist_ok=True)
1425 + try:
1426 + runtime_dir.chmod(0o700)
1427 + except OSError:
1428 + pass
1429 + return env
1430 +
1431 + def _display_env(self, session: DesktopSession) -> dict[str, str]:
1432 + env = {
1433 + **self._session_env(session),
1434 + "DISPLAY": f":{session.display}",
1435 + "SAL_USE_VCLPLUGIN": os.environ.get("SAL_USE_VCLPLUGIN") or "gtk3",
1436 + }
1437 + xauthority = self._xauthority(session)
1438 + if xauthority:
1439 + env["XAUTHORITY"] = xauthority
1440 + return env
1441 +
1442 + def _xauthority(self, session: DesktopSession) -> str:
1443 + path = session.profile_dir / ".Xauthority"
1444 + return str(path) if path.exists() else ""
1445 +
1446 + def _allocate_endpoint_locked(self) -> tuple[int, int]:
1447 + used_displays = {session.display for session in self._sessions.values()}
1448 + used_ports = {session.xpra_port for session in self._sessions.values()}
1449 + for offset in range(MAX_SESSIONS):
1450 + display = DISPLAY_BASE + offset
1451 + port = XPRA_PORT_BASE + offset
1452 + if display in used_displays or port in used_ports:
1453 + continue
1454 + if _port_is_free(port):
1455 + return display, port
1456 + raise RuntimeError("No LibreOffice desktop slots are available.")
1457 +
1458 + def _find_by_file_id_locked(self, file_id: str) -> DesktopSession | None:
1459 + for session in self._sessions.values():
1460 + if session.file_id == file_id and session.alive():
1461 + return session
1462 + return None
1463 +
1464 + def _find_by_file_id(self, file_id: str) -> DesktopSession | None:
1465 + with self._lock:
1466 + return self._find_by_file_id_locked(str(file_id or "").strip())
1467 +
1468 + def _reap_dead_locked(self) -> None:
1469 + for session_id, session in list(self._sessions.items()):
1470 + if not session.alive():
1471 + self._terminate_session(session)
1472 + virtual_desktop.unregister_session(session.token)
1473 + self._sessions.pop(session_id, None)
1474 + self._remove_manifest(session_id)
1475 +
1476 + def _wait_for_display(self, session: DesktopSession) -> None:
1477 + marker = Path(f"/tmp/.X11-unix/X{session.display}")
1478 + deadline = time.time() + DISPLAY_START_TIMEOUT_SECONDS
1479 + while time.time() < deadline:
1480 + process = session.processes.get("xvfb") or session.processes.get("xpra")
1481 + if process and process.poll() is not None:
1482 + raise RuntimeError("The LibreOffice X display exited before it was ready.")
1483 + if marker.exists():
1484 + return
1485 + time.sleep(0.1)
1486 + raise TimeoutError("Timed out waiting for the LibreOffice X display.")
1487 +
1488 + def _wait_for_xfce(self, session: DesktopSession) -> None:
1489 + deadline = time.time() + STARTUP_GRACE_SECONDS
1490 + while time.time() < deadline:
1491 + process = session.processes.get("xfce")
1492 + if process and process.poll() is not None:
1493 + return
1494 + if virtual_desktop.has_window(
1495 + display=session.display,
1496 + name="xfce4-panel",
1497 + xauthority=self._xauthority(session),
1498 + home=str(session.profile_dir),
1499 + ):
1500 + return
1501 + time.sleep(0.25)
1502 +
1503 + def _write_manifest(self, session: DesktopSession) -> None:
1504 + SESSION_DIR.mkdir(parents=True, exist_ok=True)
1505 + pids = dict(session.process_ids)
1506 + pids.update({name: process.pid for name, process in session.processes.items()})
1507 + payload = {
1508 + "session_id": session.session_id,
1509 + "file_id": session.file_id,
1510 + "extension": session.extension,
1511 + "path": session.path,
1512 + "title": session.title,
1513 + "display": session.display,
1514 + "xpra_port": session.xpra_port,
1515 + "profile_dir": str(session.profile_dir),
1516 + "width": session.width,
1517 + "height": session.height,
1518 + "started_at": session.started_at,
1519 + "owner_pid": os.getpid(),
1520 + "pids": pids,
1521 + }
1522 + (SESSION_DIR / f"{session.session_id}.json").write_text(json.dumps(payload), encoding="utf-8")
1523 +
1524 + def _remove_manifest(self, session_id: str) -> None:
1525 + (SESSION_DIR / f"{session_id}.json").unlink(missing_ok=True)
1526 +
1527 + def _terminate_session(self, session: DesktopSession, *, include_rehydrated: bool = False) -> None:
1528 + process_names = [name for name in session.processes if name.startswith("soffice")]
1529 + process_names.extend(["xfce", "xpra", "xvfb"])
1530 + terminated_pids: set[int] = set()
1531 + for name in process_names:
1532 + process = session.processes.get(name)
1533 + if not process:
1534 + continue
1535 + if process.pid:
1536 + terminated_pids.add(process.pid)
1537 + _terminate_process(process)
1538 + if session.owns_processes or include_rehydrated:
1539 + for name, pid in session.process_ids.items():
1540 + if pid in terminated_pids:
1541 + continue
1542 + if name.startswith("soffice") or name in {"xfce", "xpra", "xvfb"}:
1543 + _kill_pid(pid)
1544 + self._remove_stale_lock_file(session)
1545 +
1546 + def _remove_stale_lock_file(self, session: DesktopSession, *, path: str | Path | None = None) -> None:
1547 + path = Path(path or session.path)
1548 + if not path.name:
1549 + return
1550 + lock_file = path.with_name(f".~lock.{path.name}#")
1551 + try:
1552 + lock_file.unlink(missing_ok=True)
1553 + except OSError:
1554 + pass
1555 +
1556 +
1557 +def collect_desktop_status() -> dict[str, Any]:
1558 + desktop = virtual_desktop.collect_status()
1559 + binaries = {
1560 + **desktop["binaries"],
1561 + "soffice": libreoffice.find_soffice(),
1562 + "thunar": shutil.which("thunar") or "",
1563 + "xfce4-terminal": shutil.which("xfce4-terminal") or "",
1564 + "xfce4-settings-manager": shutil.which("xfce4-settings-manager") or "",
1565 + "gio": shutil.which("gio") or "",
1566 + }
1567 + missing = [
1568 + name
1569 + for name in (
1570 + "soffice",
1571 + "thunar",
1572 + "xfce4-terminal",
1573 + "xfce4-settings-manager",
1574 + "gio",
1575 + )
1576 + if not binaries[name]
1577 + ]
1578 + missing.extend(
1579 + name
1580 + for name in ("xpra", "Xvfb", "xfce4-session", "dbus-launch", "xrandr", "xdotool")
1581 + if not binaries.get(name)
1582 + )
1583 + if not desktop.get("xpra_html_root"):
1584 + missing.append("xpra-html5")
1585 + if desktop.get("binaries", {}).get("xpra") and desktop.get("packages", {}).get("xpra-x11") is False:
1586 + missing.append("xpra-x11")
1587 + healthy = not missing
1588 + return {
1589 + "ok": True,
1590 + "healthy": healthy,
1591 + "state": "healthy" if healthy else "missing",
1592 + "binaries": binaries,
1593 + "xpra_html_root": str(desktop.get("xpra_html_root") or ""),
1594 + "message": (
1595 + "Agent Zero Desktop sessions are available."
1596 + if healthy
1597 + else f"Agent Zero Desktop sessions need: {', '.join(missing)}."
1598 + ),
1599 + }
1600 +
1601 +
1602 +def cleanup_stale_runtime_state() -> dict[str, Any]:
1603 + killed: list[int] = []
1604 + errors: list[str] = []
1605 + seen_dirs: set[Path] = set()
1606 + for session_dir in (SESSION_DIR, *LEGACY_SESSION_DIRS):
1607 + if session_dir in seen_dirs:
1608 + continue
1609 + seen_dirs.add(session_dir)
1610 + if session_dir.exists():
1611 + for manifest in session_dir.glob("*.json"):
1612 + try:
1613 + payload = json.loads(manifest.read_text(encoding="utf-8"))
1614 + owner_pid = _coerce_pid(payload.get("owner_pid"))
1615 + if owner_pid and _pid_is_running(owner_pid):
1616 + continue
1617 + for pid in dict(payload.get("pids") or {}).values():
1618 + pid_int = _coerce_pid(pid)
1619 + if not pid_int:
1620 + continue
1621 + if _kill_pid(pid_int):
1622 + killed.append(pid_int)
1623 + manifest.unlink(missing_ok=True)
1624 + except Exception as exc:
1625 + errors.append(str(exc))
1626 + return {"ok": not errors, "killed": killed, "errors": errors}
1627 +
1628 +
1629 +def get_manager() -> DesktopSessionManager:
1630 + global _manager
1631 + try:
1632 + return _manager
1633 + except NameError:
1634 + _manager = DesktopSessionManager()
1635 + atexit.register(_manager.shutdown)
1636 + return _manager
1637 +
1638 +
1639 +def _xpra_url(token: str) -> str:
1640 + return virtual_desktop.session_url(token, title="Desktop")
1641 +
1642 +
1643 +def _xvfb_command(xvfb: str, session: DesktopSession) -> list[str]:
1644 + return [
1645 + xvfb,
1646 + f":{session.display}",
1647 + "-screen",
1648 + "0",
1649 + f"{MAX_SCREEN_WIDTH}x{MAX_SCREEN_HEIGHT}x24",
1650 + "+extension",
1651 + "GLX",
1652 + "+extension",
1653 + "RANDR",
1654 + "+extension",
1655 + "RENDER",
1656 + "+extension",
1657 + "Composite",
1658 + "-extension",
1659 + "DOUBLE-BUFFER",
1660 + "-nolisten",
1661 + "tcp",
1662 + "-noreset",
1663 + "-ac",
1664 + ]
1665 +
1666 +
1667 +def _xpra_shadow_command(xpra: str, session: DesktopSession) -> list[str]:
1668 + return [
1669 + xpra,
1670 + "shadow",
1671 + f":{session.display}",
1672 + "--daemon=no",
1673 + "--mdns=no",
1674 + "--html=on",
1675 + "--tray=no",
1676 + "--system-tray=no",
1677 + "--notifications=no",
1678 + "--clipboard=yes",
1679 + "--clipboard-direction=both",
1680 + "--file-transfer=yes",
1681 + "--open-files=no",
1682 + "--open-url=no",
1683 + "--printing=yes",
1684 + "--audio=no",
1685 + "--speaker=off",
1686 + "--microphone=off",
1687 + "--encoding=jpeg",
1688 + "--quality=85",
1689 + "--speed=80",
1690 + f"--bind-tcp=127.0.0.1:{session.xpra_port}",
1691 + "--resize-display=yes",
1692 + f"--log-dir={session.profile_dir}",
1693 + "--log-file=xpra.log",
1694 + ]
1695 +
1696 +
1697 +def _desktop_exec(*args: str | Path) -> str:
1698 + return " ".join(_desktop_exec_arg(str(arg)) for arg in args if str(arg))
1699 +
1700 +
1701 +def _desktop_icon(*candidates: str) -> str:
1702 + for candidate in candidates:
1703 + if candidate.startswith("/") and Path(candidate).exists():
1704 + return candidate
1705 + return next(
1706 + (candidate for candidate in candidates if not candidate.startswith("/")),
1707 + candidates[-1],
1708 + )
1709 +
1710 +
1711 +def _ensure_desktop_folder_link(desktop_dir: Path, label: str, target: Path) -> None:
1712 + target.mkdir(parents=True, exist_ok=True)
1713 + link = desktop_dir / label
1714 + try:
1715 + if link.is_symlink() or link.is_file():
1716 + link.unlink()
1717 + if not link.exists():
1718 + link.symlink_to(target, target_is_directory=True)
1719 + except OSError:
1720 + return
1721 +
1722 +
1723 +def _url_bridge_dir(session: DesktopSession) -> Path:
1724 + return session.profile_dir / ".agent-zero"
1725 +
1726 +
1727 +def _url_bridge_script_path(session: DesktopSession) -> Path:
1728 + return _url_bridge_dir(session) / "open-url"
1729 +
1730 +
1731 +def _url_bridge_queue_path(session: DesktopSession) -> Path:
1732 + return _url_bridge_dir(session) / "browser-url-intents.jsonl"
1733 +
1734 +
1735 +def _url_bridge_lock_path(session: DesktopSession) -> Path:
1736 + return _url_bridge_dir(session) / "browser-url-intents.lock"
1737 +
1738 +
1739 +def _shutdown_request_path(session: DesktopSession) -> Path:
1740 + return _url_bridge_dir(session) / "shutdown-request.json"
1741 +
1742 +
1743 +def _shutdown_arm_path(session: DesktopSession) -> Path:
1744 + return _url_bridge_dir(session) / "shutdown-request.arm.json"
1745 +
1746 +
1747 +def _shutdown_lock_path(session: DesktopSession) -> Path:
1748 + return _url_bridge_dir(session) / "shutdown-request.lock"
1749 +
1750 +
1751 +def _write_url_bridge_script(session: DesktopSession) -> Path:
1752 + bridge_dir = _url_bridge_dir(session)
1753 + bridge_dir.mkdir(parents=True, exist_ok=True)
1754 + script = _url_bridge_script_path(session)
1755 + queue = _url_bridge_queue_path(session)
1756 + lock = _url_bridge_lock_path(session)
1757 + script.write_text(
1758 + f"""#!/usr/bin/env python3
1759 +import fcntl
1760 +import json
1761 +import os
1762 +import sys
1763 +import time
1764 +
1765 +QUEUE_PATH = {str(queue)!r}
1766 +LOCK_PATH = {str(lock)!r}
1767 +MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH}
1768 +
1769 +
1770 +def main():
1771 + urls = [str(arg or "").strip()[:MAX_URL_LENGTH] for arg in sys.argv[1:] if str(arg or "").strip()]
1772 + if not urls:
1773 + urls = [""]
1774 + os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True)
1775 + with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
1776 + fcntl.flock(lock_file, fcntl.LOCK_EX)
1777 + with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file:
1778 + for url in urls:
1779 + queue_file.write(json.dumps({{
1780 + "url": url,
1781 + "created_at": time.time(),
1782 + "source": "desktop",
1783 + }}, ensure_ascii=True) + "\\n")
1784 + queue_file.flush()
1785 + os.fsync(queue_file.fileno())
1786 + fcntl.flock(lock_file, fcntl.LOCK_UN)
1787 +
1788 +
1789 +if __name__ == "__main__":
1790 + main()
1791 +""",
1792 + encoding="utf-8",
1793 + )
1794 + try:
1795 + script.chmod(0o755)
1796 + except OSError:
1797 + pass
1798 + return script
1799 +
1800 +
1801 +def _write_shutdown_bridge_script(session: DesktopSession) -> Path:
1802 + bridge_dir = _url_bridge_dir(session)
1803 + bridge_dir.mkdir(parents=True, exist_ok=True)
1804 + script = bridge_dir / "shutdown-desktop"
1805 + request = _shutdown_request_path(session)
1806 + arm = _shutdown_arm_path(session)
1807 + lock = _shutdown_lock_path(session)
1808 + script.write_text(
1809 + f"""#!/usr/bin/env python3
1810 +import fcntl
1811 +import json
1812 +import os
1813 +import shutil
1814 +import subprocess
1815 +import time
1816 +
1817 +REQUEST_PATH = {str(request)!r}
1818 +ARM_PATH = {str(arm)!r}
1819 +LOCK_PATH = {str(lock)!r}
1820 +CONFIRM_SECONDS = {SHUTDOWN_CONFIRM_SECONDS}
1821 +
1822 +
1823 +def notify(message, timeout=None):
1824 + if not os.environ.get("DISPLAY"):
1825 + return
1826 + xmessage = shutil.which("xmessage")
1827 + if not xmessage:
1828 + return
1829 + try:
1830 + subprocess.Popen(
1831 + [
1832 + xmessage,
1833 + "-buttons",
1834 + "",
1835 + "-timeout",
1836 + str(timeout or CONFIRM_SECONDS),
1837 + "-center",
1838 + message,
1839 + ],
1840 + stdin=subprocess.DEVNULL,
1841 + stdout=subprocess.DEVNULL,
1842 + stderr=subprocess.DEVNULL,
1843 + start_new_session=True,
1844 + )
1845 + except OSError:
1846 + pass
1847 +
1848 +
1849 +def read_arm(now):
1850 + try:
1851 + with open(ARM_PATH, "r", encoding="utf-8") as handle:
1852 + payload = json.load(handle)
1853 + except (OSError, json.JSONDecodeError):
1854 + return None
1855 + try:
1856 + created_at = float(payload.get("created_at"))
1857 + except (TypeError, ValueError):
1858 + return None
1859 + if now - created_at > CONFIRM_SECONDS:
1860 + return None
1861 + return created_at
1862 +
1863 +
1864 +def write_json_atomic(path, payload):
1865 + tmp_path = path + ".tmp"
1866 + with open(tmp_path, "w", encoding="utf-8") as handle:
1867 + json.dump(payload, handle, ensure_ascii=True)
1868 + handle.write("\\n")
1869 + handle.flush()
1870 + os.fsync(handle.fileno())
1871 + os.replace(tmp_path, path)
1872 +
1873 +
1874 +def main():
1875 + os.makedirs(os.path.dirname(REQUEST_PATH), exist_ok=True)
1876 + now = time.time()
1877 + with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
1878 + fcntl.flock(lock_file, fcntl.LOCK_EX)
1879 + armed_at = read_arm(now)
1880 + if armed_at is None:
1881 + write_json_atomic(ARM_PATH, {{"created_at": now, "source": "tray"}})
1882 + notify(
1883 + f"Shutdown Desktop armed. Click Shutdown Desktop again within {{CONFIRM_SECONDS}} seconds to close it.",
1884 + CONFIRM_SECONDS,
1885 + )
1886 + return
1887 + try:
1888 + os.unlink(ARM_PATH)
1889 + except OSError:
1890 + pass
1891 + payload = {{
1892 + "created_at": now,
1893 + "armed_at": armed_at,
1894 + "source": "tray",
1895 + }}
1896 + write_json_atomic(REQUEST_PATH, payload)
1897 + notify("Shutting down Agent Zero Desktop.", 2)
1898 +
1899 +
1900 +if __name__ == "__main__":
1901 + main()
1902 +""",
1903 + encoding="utf-8",
1904 + )
1905 + try:
1906 + script.chmod(0o755)
1907 + except OSError:
1908 + pass
1909 + return script
1910 +
1911 +
1912 +def _claim_url_intents(session: DesktopSession) -> list[dict[str, Any]]:
1913 + queue = _url_bridge_queue_path(session)
1914 + lock = _url_bridge_lock_path(session)
1915 + if not queue.exists():
1916 + return []
1917 + lock.parent.mkdir(parents=True, exist_ok=True)
1918 + try:
1919 + with open(lock, "a+", encoding="utf-8") as lock_file:
1920 + fcntl.flock(lock_file, fcntl.LOCK_EX)
1921 + try:
1922 + raw = queue.read_text(encoding="utf-8")
1923 + queue.write_text("", encoding="utf-8")
1924 + finally:
1925 + fcntl.flock(lock_file, fcntl.LOCK_UN)
1926 + except OSError:
1927 + return []
1928 +
1929 + intents: list[dict[str, Any]] = []
1930 + for line in raw.splitlines():
1931 + try:
1932 + payload = json.loads(line)
1933 + except json.JSONDecodeError:
1934 + continue
1935 + url = str(payload.get("url") or "").strip()
1936 + if len(url) > URL_INTENT_MAX_LENGTH:
1937 + url = url[:URL_INTENT_MAX_LENGTH]
1938 + created_at = payload.get("created_at")
1939 + try:
1940 + created_at = float(created_at)
1941 + except (TypeError, ValueError):
1942 + created_at = time.time()
1943 + intents.append(
1944 + {
1945 + "url": url,
1946 + "created_at": created_at,
1947 + "source": str(payload.get("source") or "desktop"),
1948 + },
1949 + )
1950 + if len(intents) >= URL_INTENT_MAX_ITEMS:
1951 + break
1952 + return intents
1953 +
1954 +
1955 +def _claim_shutdown_request(session: DesktopSession) -> dict[str, Any] | None:
1956 + request = _shutdown_request_path(session)
1957 + if not request.exists():
1958 + return None
1959 + try:
1960 + raw = request.read_text(encoding="utf-8")
1961 + request.unlink(missing_ok=True)
1962 + except OSError:
1963 + return None
1964 + try:
1965 + payload = json.loads(raw)
1966 + except json.JSONDecodeError:
1967 + payload = {}
1968 + created_at = payload.get("created_at")
1969 + try:
1970 + created_at = float(created_at)
1971 + except (TypeError, ValueError):
1972 + created_at = time.time()
1973 + return {
1974 + "created_at": created_at,
1975 + "source": str(payload.get("source") or "tray"),
1976 + }
1977 +
1978 +
1979 +def _clear_shutdown_request(session: DesktopSession) -> None:
1980 + request = _shutdown_request_path(session)
1981 + arm = _shutdown_arm_path(session)
1982 + lock = _shutdown_lock_path(session)
1983 + request.unlink(missing_ok=True)
1984 + request.with_suffix(request.suffix + ".tmp").unlink(missing_ok=True)
1985 + arm.unlink(missing_ok=True)
1986 + arm.with_suffix(arm.suffix + ".tmp").unlink(missing_ok=True)
1987 + lock.unlink(missing_ok=True)
1988 +
1989 +
1990 +def _remove_system_manifest() -> None:
1991 + (SESSION_DIR / f"{SYSTEM_SESSION_ID}.json").unlink(missing_ok=True)
1992 +
1993 +
1994 +def _url_handler_mime_types() -> tuple[str, ...]:
1995 + return (
1996 + "x-scheme-handler/http",
1997 + "x-scheme-handler/https",
1998 + "text/html",
1999 + "application/xhtml+xml",
2000 + )
2001 +
2002 +
2003 +def _write_mimeapps_defaults(path: Path, desktop_id: str) -> None:
2004 + associations = ";".join([desktop_id, ""])
2005 + lines = [
2006 + "[Default Applications]",
2007 + *(f"{mime_type}={desktop_id}" for mime_type in _url_handler_mime_types()),
2008 + "",
2009 + "[Added Associations]",
2010 + *(f"{mime_type}={associations}" for mime_type in _url_handler_mime_types()),
2011 + "",
2012 + ]
2013 + path.parent.mkdir(parents=True, exist_ok=True)
2014 + path.write_text("\n".join(lines), encoding="utf-8")
2015 +
2016 +
2017 +def _write_xfce_browser_helper(path: Path, bridge_script: Path) -> None:
2018 + command = _desktop_exec(bridge_script)
2019 + command_with_parameter = _desktop_exec(bridge_script, "%s")
2020 + path.parent.mkdir(parents=True, exist_ok=True)
2021 + path.write_text(
2022 + "\n".join(
2023 + [
2024 + "[Desktop Entry]",
2025 + "NoDisplay=true",
2026 + "Version=1.0",
2027 + "Type=X-XFCE-Helper",
2028 + "X-XFCE-Category=WebBrowser",
2029 + f"X-XFCE-Commands={command}",
2030 + f"X-XFCE-CommandsWithParameter={command_with_parameter}",
2031 + "Icon=web-browser",
2032 + "Name=Agent Zero Browser",
2033 + "",
2034 + ],
2035 + ),
2036 + encoding="utf-8",
2037 + )
2038 +
2039 +
2040 +def _remove_path_if_owned(path: Path) -> None:
2041 + try:
2042 + if path.is_symlink() or path.is_file():
2043 + path.unlink()
2044 + elif path.is_dir():
2045 + shutil.rmtree(path)
2046 + except OSError:
2047 + return
2048 +
2049 +
2050 +def _desktop_exec_arg(value: str) -> str:
2051 + if not any(char.isspace() or char in '"\\' for char in value):
2052 + return value
2053 + escaped = value.replace("\\", "\\\\").replace('"', '\\"')
2054 + return f'"{escaped}"'
2055 +
2056 +
2057 +def _xml_attr(value: str) -> str:
2058 + return (
2059 + str(value)
2060 + .replace("&", "&amp;")
2061 + .replace('"', "&quot;")
2062 + .replace("<", "&lt;")
2063 + .replace(">", "&gt;")
2064 + )
2065 +
2066 +
2067 +def _oor(name: str) -> str:
2068 + return f"{{{OOR_NS}}}{name}"
2069 +
2070 +
2071 +def _file_uri(path: str | Path) -> str:
2072 + return Path(path).resolve(strict=False).as_uri()
2073 +
2074 +
2075 +def _write_libreoffice_registry_defaults(registry: Path, workdir: str | Path) -> None:
2076 + Path(workdir).mkdir(parents=True, exist_ok=True)
2077 + ET.register_namespace("oor", OOR_NS)
2078 + ET.register_namespace("xs", XS_NS)
2079 + ET.register_namespace("xsi", XSI_NS)
2080 + root = _read_libreoffice_registry(registry)
2081 + workdir_uri = _file_uri(workdir)
2082 + for path, prop, value in (
2083 + ("/org.openoffice.Office.Common/Misc", "FirstRun", "false"),
2084 + ("/org.openoffice.Setup/Office", "ooSetupInstCompleted", "true"),
2085 + ("/org.openoffice.Setup/Office", "MigrationCompleted", "true"),
2086 + ("/org.openoffice.Setup/Office", "OfficeRestartInProgress", "false"),
2087 + ("/org.openoffice.Setup/L10N", "ooLocale", "en-US"),
2088 + ("/org.openoffice.Office.Paths/Variables", "Work", workdir_uri),
2089 + (
2090 + "/org.openoffice.Office.Paths/Paths/org.openoffice.Office.Paths:NamedPath['Work']",
2091 + "WritePath",
2092 + workdir_uri,
2093 + ),
2094 + ):
2095 + _set_registry_prop(root, path, prop, value)
2096 + registry.parent.mkdir(parents=True, exist_ok=True)
2097 + ET.ElementTree(root).write(registry, encoding="utf-8", xml_declaration=True)
2098 +
2099 +
2100 +def _read_libreoffice_registry(registry: Path) -> ET.Element:
2101 + if registry.exists():
2102 + try:
2103 + return ET.parse(registry).getroot()
2104 + except ET.ParseError:
2105 + pass
2106 + return ET.Element(
2107 + _oor("items"),
2108 + {
2109 + "xmlns:xs": XS_NS,
2110 + "xmlns:xsi": XSI_NS,
2111 + },
2112 + )
2113 +
2114 +
2115 +def _set_registry_prop(root: ET.Element, item_path: str, prop_name: str, value: str) -> None:
2116 + item = _find_registry_item(root, item_path)
2117 + if item is None:
2118 + item = ET.SubElement(root, "item", {_oor("path"): item_path})
2119 + prop = next((child for child in item.findall("prop") if child.get(_oor("name")) == prop_name), None)
2120 + if prop is None:
2121 + prop = ET.SubElement(item, "prop", {_oor("name"): prop_name, _oor("op"): "fuse"})
2122 + else:
2123 + prop.set(_oor("op"), "fuse")
2124 + value_node = prop.find("value")
2125 + if value_node is None:
2126 + value_node = ET.SubElement(prop, "value")
2127 + value_node.text = str(value)
2128 +
2129 +
2130 +def _find_registry_item(root: ET.Element, item_path: str) -> ET.Element | None:
2131 + for item in root.findall("item"):
2132 + if item.get(_oor("path")) == item_path:
2133 + return item
2134 + return None
2135 +
2136 +
2137 +def _write_desktop_launcher(
2138 + path: Path,
2139 + *,
2140 + name: str,
2141 + exec_line: str,
2142 + icon: str,
2143 + categories: str,
2144 + try_exec: str = "",
2145 + working_dir: str | Path | None = None,
2146 + mime_types: tuple[str, ...] = (),
2147 + no_display: bool = False,
2148 +) -> None:
2149 + path.parent.mkdir(parents=True, exist_ok=True)
2150 + lines = [
2151 + "[Desktop Entry]",
2152 + "Version=1.0",
2153 + "Type=Application",
2154 + f"Name={name}",
2155 + f"Exec={exec_line}",
2156 + ]
2157 + if try_exec:
2158 + lines.append(f"TryExec={try_exec}")
2159 + if working_dir:
2160 + lines.append(f"Path={working_dir}")
2161 + if mime_types:
2162 + lines.append(f"MimeType={';'.join(mime_types)};")
2163 + if no_display:
2164 + lines.append("NoDisplay=true")
2165 + lines.extend(
2166 + [
2167 + f"Icon={icon}",
2168 + "Terminal=false",
2169 + f"Categories={categories}",
2170 + "StartupNotify=true",
2171 + "X-XFCE-Trusted=true",
2172 + "",
2173 + ],
2174 + )
2175 + path.write_text("\n".join(lines), encoding="utf-8")
2176 + try:
2177 + path.chmod(0o755)
2178 + except OSError:
2179 + pass
2180 +
2181 +
2182 +def _write_hidden_application_entry(path: Path, name: str) -> None:
2183 + path.parent.mkdir(parents=True, exist_ok=True)
2184 + path.write_text(
2185 + "\n".join(
2186 + [
2187 + "[Desktop Entry]",
2188 + "Type=Application",
2189 + f"Name={name}",
2190 + "NoDisplay=true",
2191 + "Hidden=true",
2192 + "",
2193 + ],
2194 + ),
2195 + encoding="utf-8",
2196 + )
2197 +
2198 +
2199 +def _write_thunar_defaults(path: Path) -> None:
2200 + root = _read_xfce_channel(path, "thunar")
2201 + if _find_xfce_property(root, "last-view") is None:
2202 + _xfce_property(root, "last-view", "string", "ThunarIconView")
2203 + _xfce_property(root, "last-show-hidden", "bool", "true")
2204 + _write_xfce_channel(path, root)
2205 +
2206 +
2207 +def _read_xfce_channel(path: Path, channel_name: str) -> ET.Element:
2208 + if path.exists():
2209 + try:
2210 + root = ET.parse(path).getroot()
2211 + if root.tag == "channel" and root.get("name") == channel_name:
2212 + root.set("version", root.get("version") or "1.0")
2213 + return root
2214 + except (ET.ParseError, OSError):
2215 + pass
2216 + return ET.Element("channel", {"name": channel_name, "version": "1.0"})
2217 +
2218 +
2219 +def _write_xfce_channel(path: Path, root: ET.Element) -> None:
2220 + path.parent.mkdir(parents=True, exist_ok=True)
2221 + tree = ET.ElementTree(root)
2222 + try:
2223 + ET.indent(tree, space=" ")
2224 + except AttributeError:
2225 + pass
2226 + tree.write(path, encoding="utf-8", xml_declaration=True)
2227 +
2228 +
2229 +def _find_xfce_property(parent: ET.Element, name: str) -> ET.Element | None:
2230 + return next((child for child in parent.findall("property") if child.get("name") == name), None)
2231 +
2232 +
2233 +def _install_desktop_readme(desktop_dir: Path) -> None:
2234 + if not DESKTOP_README_SOURCE.exists():
2235 + return
2236 + target = desktop_dir / "README.md"
2237 + try:
2238 + content = DESKTOP_README_SOURCE.read_text(encoding="utf-8")
2239 + if target.exists() and target.read_text(encoding="utf-8") == content:
2240 + return
2241 + target.write_text(content, encoding="utf-8")
2242 + target.chmod(0o644)
2243 + except OSError:
2244 + return
2245 +
2246 +
2247 +def _xfce_property(parent: ET.Element, name: str, property_type: str, value: str | None = None) -> ET.Element:
2248 + for child in parent.findall("property"):
2249 + if child.get("name") == name:
2250 + child.set("type", property_type)
2251 + if value is None:
2252 + child.attrib.pop("value", None)
2253 + else:
2254 + child.set("value", value)
2255 + return child
2256 + attributes = {"name": name, "type": property_type}
2257 + if value is not None:
2258 + attributes["value"] = value
2259 + return ET.SubElement(parent, "property", attributes)
2260 +
2261 +
2262 +def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
2263 + return {
2264 + "file_id": doc["file_id"],
2265 + "path": document_store.display_path(doc["path"]),
2266 + "basename": doc["basename"],
2267 + "extension": doc["extension"],
2268 + "size": doc["size"],
2269 + "version": document_store.item_version(doc),
2270 + "last_modified": doc["last_modified"],
2271 + }
2272 +
2273 +
2274 +def _require_binary(name: str) -> str:
2275 + found = shutil.which(name)
2276 + if not found:
2277 + raise RuntimeError(f"{name} is required for official LibreOffice desktop sessions.")
2278 + return found
2279 +
2280 +
2281 +def _running(process: subprocess.Popen[Any] | None) -> bool:
2282 + return bool(process and process.poll() is None)
2283 +
2284 +
2285 +def _wait_for_port(
2286 + host: str,
2287 + port: int,
2288 + timeout: float = 15.0,
2289 + process: subprocess.Popen[Any] | None = None,
2290 +) -> None:
2291 + deadline = time.time() + timeout
2292 + while time.time() < deadline:
2293 + if process and process.poll() is not None:
2294 + raise RuntimeError(f"Xpra exited before port {port} was ready.")
2295 + try:
2296 + with socket.create_connection((host, port), timeout=0.2):
2297 + return
2298 + except OSError:
2299 + time.sleep(0.1)
2300 + raise TimeoutError(f"Timed out waiting for Xpra port {port}.")
2301 +
2302 +
2303 +def _port_is_free(port: int) -> bool:
2304 + try:
2305 + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
2306 + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
2307 + probe.bind(("127.0.0.1", port))
2308 + return True
2309 + except OSError:
2310 + return False
2311 +
2312 +
2313 +def _port_is_accepting(host: str, port: int) -> bool:
2314 + try:
2315 + with socket.create_connection((host, port), timeout=0.2):
2316 + return True
2317 + except OSError:
2318 + return False
2319 +
2320 +
2321 +def _terminate_process(process: subprocess.Popen[Any]) -> None:
2322 + if process.poll() is not None:
2323 + return
2324 + try:
2325 + process.terminate()
2326 + process.wait(timeout=2)
2327 + return
2328 + except Exception:
2329 + pass
2330 + try:
2331 + process.kill()
2332 + process.wait(timeout=2)
2333 + except Exception:
2334 + pass
2335 +
2336 +
2337 +def _kill_pid(pid: int) -> bool:
2338 + if pid <= 0:
2339 + return False
2340 + try:
2341 + os.kill(pid, 15)
2342 + return True
2343 + except ProcessLookupError:
2344 + return False
2345 + except PermissionError:
2346 + return False
2347 +
2348 +
2349 +def _coerce_pid(value: Any) -> int:
2350 + try:
2351 + pid = int(value)
2352 + except (TypeError, ValueError):
2353 + return 0
2354 + return pid if pid > 0 else 0
2355 +
2356 +
2357 +def _pid_is_running(pid: int) -> bool:
2358 + if pid <= 0:
2359 + return False
2360 + try:
2361 + os.kill(pid, 0)
2362 + return True
2363 + except ProcessLookupError:
2364 + return False
2365 + except PermissionError:
2366 + return True
plugins/_desktop/helpers/desktop_state.py renamed
+4 -4
@@ -15,7 +15,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[3]
15
16 SESSION_ID = "agent-zero-desktop"
17 BASE_DIR = Path(os.environ.get("A0_BASE_DIR") or ("/a0" if Path("/a0").exists() else PROJECT_ROOT))
18 -STATE_DIR = BASE_DIR / "tmp" / "_office" / "desktop"
18 +STATE_DIR = BASE_DIR / "usr" / "_desktop"
19 SESSION_DIR = STATE_DIR / "sessions"
20 PROFILE_DIR = STATE_DIR / "profiles"
21 SCREENSHOT_DIR = STATE_DIR / "screenshots"
@@ -40,7 +40,7 @@ def collect_state(*, include_screenshot: bool = False, screenshot_path: str | Pa
40 capabilities = collect_capabilities()
41 for name in ("xdotool", "xrandr", "xwininfo", "xprop"):
42 if not capabilities.get(name):
43 - errors.append(f"{name} is not installed; install Office runtime dependencies through the _office plugin hook.")
43 + errors.append(f"{name} is not installed; install Desktop runtime dependencies through the _desktop plugin hook.")
44
45 size = collect_display_size(env, capabilities, errors)
46 pointer = collect_pointer(env, capabilities, errors)
@@ -81,7 +81,7 @@ def capture_screenshot(
81
82 xwd = capabilities.get("xwd") or shutil.which("xwd") or ""
83 if not xwd:
84 - message = "xwd is not installed; install x11-apps through the _office plugin hook."
84 + message = "xwd is not installed; install x11-apps through the _desktop plugin hook."
85 local_errors.append(message)
86 return {"ok": False, "path": "", "format": "", "captured_at": "", "error": message}
87
@@ -559,7 +559,7 @@ def compact_prompt_context(state: dict[str, Any] | None = None) -> str:
559 if screenshot.get("recent") and screenshot.get("path"):
560 lines.append(f"- recent_screenshot={screenshot['path']}")
561 lines.append(
562 - "- next=plugins/_office/skills/linux-desktop/scripts/desktopctl.sh observe --json --screenshot "
562 + "- next=plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh observe --json --screenshot "
563 "before any coordinate action; prefer focus/key/paste/save/app-native helpers first."
564 )
565 lines.append(
plugins/_desktop/hooks.py new
+322
@@ -0,0 +1,322 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import shutil
5 +import subprocess
6 +import urllib.request
7 +from pathlib import Path
8 +from typing import Any
9 +
10 +
11 +XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources")
12 +XPRA_KEYRING_FILE = Path("/usr/share/keyrings/xpra.asc")
13 +XPRA_KEY_URL = "https://xpra.org/xpra.asc"
14 +RUNTIME_PACKAGES = (
15 + "xpra-server",
16 + "xpra-client",
17 + "xpra-client-gtk3",
18 + "xpra-x11",
19 + "xpra-html5",
20 + "xfce4-session",
21 + "xfwm4",
22 + "xfce4-panel",
23 + "xfdesktop4",
24 + "xfce4-settings",
25 + "thunar",
26 + "gvfs",
27 + "libglib2.0-bin",
28 + "xfce4-terminal",
29 + "x11-xserver-utils",
30 + "x11-utils",
31 + "x11-apps",
32 + "xdotool",
33 + "xclip",
34 + "xauth",
35 + "dbus-x11",
36 + "python3-pil",
37 + "fonts-dejavu",
38 + "fonts-liberation",
39 + "fonts-crosextra-caladea",
40 + "fonts-crosextra-carlito",
41 + "fonts-noto-core",
42 + "fonts-noto-cjk",
43 + "fonts-noto-color-emoji",
44 +)
45 +OPTIONAL_RUNTIME_PACKAGES = (
46 + "xpra-client",
47 + "xpra-client-gtk3",
48 +)
49 +RETIRED_RUNTIME_PACKAGES = (
50 + "firefox-esr",
51 +)
52 +
53 +
54 +def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
55 + """Prepare the Linux Desktop runtime and reap stale Desktop sessions."""
56 +
57 + installed: list[str] = []
58 + removed: list[str] = []
59 + errors: list[str] = []
60 +
61 + retired_packages = _installed_packages(RETIRED_RUNTIME_PACKAGES)
62 + if retired_packages:
63 + _purge_packages(removed, errors, installed_packages=retired_packages)
64 +
65 + _ensure_runtime_dependencies(installed, errors)
66 + _cleanup_desktop_sessions(errors)
67 +
68 + return {
69 + "ok": not errors,
70 + "skipped": False,
71 + "removed": removed,
72 + "installed": installed,
73 + "errors": errors,
74 + }
75 +
76 +
77 +def _installed_packages(packages: tuple[str, ...]) -> list[str]:
78 + if not shutil.which("dpkg-query"):
79 + return []
80 + return [package for package in packages if _package_installed(package)]
81 +
82 +
83 +def _package_installed(package: str) -> bool:
84 + result = subprocess.run(
85 + ["dpkg-query", "-W", "-f=${Status}", package],
86 + check=False,
87 + text=True,
88 + capture_output=True,
89 + timeout=8,
90 + )
91 + return result.returncode == 0 and "install ok installed" in result.stdout
92 +
93 +
94 +def _purge_packages(
95 + removed: list[str],
96 + errors: list[str],
97 + *,
98 + installed_packages: list[str] | None = None,
99 +) -> None:
100 + if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
101 + return
102 + installed = installed_packages if installed_packages is not None else []
103 + if not installed:
104 + return
105 + result = subprocess.run(
106 + ["apt-get", "purge", "-y", *installed],
107 + check=False,
108 + text=True,
109 + capture_output=True,
110 + timeout=180,
111 + env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
112 + )
113 + if result.returncode == 0:
114 + removed.extend(installed)
115 + return
116 + errors.append((result.stderr or result.stdout or "apt-get purge failed").strip())
117 +
118 +
119 +def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> None:
120 + if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
121 + return
122 + missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
123 + if not missing:
124 + return
125 +
126 + if not _apt_update(errors):
127 + return
128 +
129 + required_missing, optional_missing = _split_runtime_packages(missing)
130 + required_xpra_missing = [package for package in required_missing if package.startswith("xpra")]
131 + if required_xpra_missing and not _package_candidates_available(required_xpra_missing):
132 + previous_error_count = len(errors)
133 + _ensure_xpra_repository(installed, errors)
134 + if len(errors) > previous_error_count or not _apt_update(errors):
135 + return
136 + missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
137 + if not missing:
138 + return
139 + required_missing, optional_missing = _split_runtime_packages(missing)
140 +
141 + if required_missing and not _install_runtime_packages(required_missing, installed, errors):
142 + return
143 +
144 + if optional_missing:
145 + optional_xpra_missing = [package for package in optional_missing if package.startswith("xpra")]
146 + if optional_xpra_missing and not _package_candidates_available(optional_xpra_missing):
147 + return
148 + _install_runtime_packages(optional_missing, installed, errors, optional=True)
149 +
150 +
151 +def _split_runtime_packages(packages: list[str]) -> tuple[list[str], list[str]]:
152 + optional = [package for package in packages if package in OPTIONAL_RUNTIME_PACKAGES]
153 + required = [package for package in packages if package not in OPTIONAL_RUNTIME_PACKAGES]
154 + return required, optional
155 +
156 +
157 +def _install_runtime_packages(
158 + packages: list[str],
159 + installed: list[str],
160 + errors: list[str],
161 + *,
162 + optional: bool = False,
163 +) -> bool:
164 + result = subprocess.run(
165 + ["apt-get", "install", "-y", "--no-install-recommends", *packages],
166 + check=False,
167 + text=True,
168 + capture_output=True,
169 + timeout=900,
170 + env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
171 + )
172 + if result.returncode == 0:
173 + installed.extend(packages)
174 + return True
175 + output = (result.stderr or result.stdout or "apt-get install failed").strip()
176 + if optional and _is_xpra_codec_dependency_gap(output):
177 + return False
178 + errors.append(output)
179 + return False
180 +
181 +
182 +def _is_xpra_codec_dependency_gap(output: str) -> bool:
183 + normalized = output.lower()
184 + return "xpra-codecs" in normalized and "libvpx9" in normalized
185 +
186 +
187 +def _apt_update(errors: list[str]) -> bool:
188 + result = subprocess.run(
189 + ["apt-get", "update"],
190 + check=False,
191 + text=True,
192 + capture_output=True,
193 + timeout=300,
194 + env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
195 + )
196 + if result.returncode == 0:
197 + return True
198 + errors.append((result.stderr or result.stdout or "apt-get update failed").strip())
199 + return False
200 +
201 +
202 +def _package_candidate_available(package: str) -> bool:
203 + if not shutil.which("apt-cache"):
204 + return True
205 + result = subprocess.run(
206 + ["apt-cache", "policy", package],
207 + check=False,
208 + text=True,
209 + capture_output=True,
210 + timeout=15,
211 + )
212 + if result.returncode != 0:
213 + return True
214 + if not result.stdout.strip():
215 + return False
216 + return "Candidate: (none)" not in result.stdout
217 +
218 +
219 +def _package_candidates_available(packages: list[str]) -> bool:
220 + return all(_package_candidate_available(package) for package in packages)
221 +
222 +
223 +def _ensure_xpra_repository(installed: list[str], errors: list[str]) -> None:
224 + if not _package_installed("ca-certificates"):
225 + result = subprocess.run(
226 + ["apt-get", "install", "-y", "--no-install-recommends", "ca-certificates"],
227 + check=False,
228 + text=True,
229 + capture_output=True,
230 + timeout=180,
231 + env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
232 + )
233 + if result.returncode != 0:
234 + errors.append((result.stderr or result.stdout or "apt-get install ca-certificates failed").strip())
235 + return
236 + installed.append("ca-certificates")
237 +
238 + try:
239 + key = _download(XPRA_KEY_URL)
240 + XPRA_KEYRING_FILE.parent.mkdir(parents=True, exist_ok=True)
241 + if not XPRA_KEYRING_FILE.exists() or XPRA_KEYRING_FILE.read_bytes() != key:
242 + XPRA_KEYRING_FILE.write_bytes(key)
243 +
244 + XPRA_SOURCE_FILE.parent.mkdir(parents=True, exist_ok=True)
245 + source = _xpra_repository_source()
246 + if not XPRA_SOURCE_FILE.exists() or XPRA_SOURCE_FILE.read_text(encoding="utf-8") != source:
247 + XPRA_SOURCE_FILE.write_text(source, encoding="utf-8")
248 + except Exception as exc:
249 + errors.append(f"Xpra repository setup failed: {exc}")
250 +
251 +
252 +def _download(url: str) -> bytes:
253 + with urllib.request.urlopen(url, timeout=45) as response:
254 + return response.read()
255 +
256 +
257 +def _xpra_repository_source() -> str:
258 + os_release = _read_os_release()
259 + os_id = os_release.get("ID", "")
260 + codename = os_release.get("VERSION_CODENAME", "")
261 + arch = _dpkg_architecture()
262 +
263 + if os_id == "kali" and arch == "amd64":
264 + uri = "https://xpra.org/beta"
265 + suite = "sid"
266 + elif os_id == "kali":
267 + uri = "https://xpra.org"
268 + suite = "trixie"
269 + elif codename in {"sid", "forky"} and arch == "amd64":
270 + uri = "https://xpra.org/beta"
271 + suite = codename
272 + elif codename in {"sid", "forky"}:
273 + uri = "https://xpra.org"
274 + suite = "trixie"
275 + else:
276 + uri = "https://xpra.org"
277 + suite = codename or "trixie"
278 +
279 + return (
280 + f"Types: deb\n"
281 + f"URIs: {uri}\n"
282 + f"Suites: {suite}\n"
283 + f"Components: main\n"
284 + f"Signed-By: {XPRA_KEYRING_FILE}\n"
285 + f"Architectures: {arch}\n"
286 + )
287 +
288 +
289 +def _read_os_release() -> dict[str, str]:
290 + path = Path("/etc/os-release")
291 + if not path.exists():
292 + return {}
293 + values: dict[str, str] = {}
294 + for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
295 + if not line or line.startswith("#") or "=" not in line:
296 + continue
297 + key, value = line.split("=", 1)
298 + values[key] = value.strip().strip('"')
299 + return values
300 +
301 +
302 +def _dpkg_architecture() -> str:
303 + result = subprocess.run(
304 + ["dpkg", "--print-architecture"],
305 + check=False,
306 + text=True,
307 + capture_output=True,
308 + timeout=8,
309 + )
310 + if result.returncode == 0 and result.stdout.strip():
311 + return result.stdout.strip()
312 + return "amd64"
313 +
314 +
315 +def _cleanup_desktop_sessions(errors: list[str]) -> None:
316 + try:
317 + from plugins._desktop.helpers import desktop_session
318 +
319 + result = desktop_session.cleanup_stale_runtime_state()
320 + errors.extend(str(item) for item in result.get("errors") or [])
321 + except Exception as exc:
322 + errors.append(f"Desktop cleanup failed: {exc}")
plugins/_desktop/plugin.yaml new
+5
@@ -0,0 +1,5 @@
1 +name: _desktop
2 +title: Desktop
3 +description: Owns the Agent Zero Linux Desktop runtime, Xpra/Xfce sessions, and live Desktop surface.
4 +version: 1.0.0
5 +always_enabled: true
plugins/_desktop/skills/linux-desktop/SKILL.md renamed
+10 -10
@@ -19,7 +19,7 @@ allowed_tools:
19
20 # Linux Desktop Interface
21
22 -Use the Desktop as a full Linux GUI when the user explicitly needs a visual workflow, an installed desktop app, or manual layout polish that is awkward through structured file edits alone. Agent Zero may warm the persistent Desktop runtime during initial startup, but visible Desktop/canvas use remains opt-in. The Desktop is opt-in at the UI level: do not open the canvas just because the user asks for a document. Use structured tools first for deterministic content changes, then use the Desktop for inspection, GUI-only actions, and final visual confirmation.
22 +Use the Desktop as a full Linux GUI when the user explicitly needs a visual workflow, an installed desktop app, or manual layout polish that is awkward through structured file edits alone. Agent Zero may warm the persistent Desktop runtime during initial startup, but visible Desktop surface use remains opt-in. The Desktop is opt-in at the UI level: do not open a surface just because the user asks for a document. Use structured tools first for deterministic content changes, then use the Desktop for inspection, GUI-only actions, and final visual confirmation.
23
24 ## Operating Model
25
@@ -35,10 +35,10 @@ The Desktop is an observe-act-verify control surface. Use this decision hierarch
35 Keep these standing rules:
36
37 1. Treat Markdown as first-class. For writing, notes, reports, and drafts with no explicit binary Office requirement, create Markdown and use the custom Markdown editor when the user opens the canvas.
38 -2. Treat ODF as first-class for LibreOffice office work: ODT in Writer, ODS in Calc, ODP in Impress. Use DOCX/XLSX/PPTX only for explicit Microsoft compatibility.
38 +2. Treat ODF as first-class for LibreOffice office work: ODT in Writer, ODS in Calc, ODP in Impress. Use DOCX/XLSX/PPTX only for explicit OOXML compatibility.
39 3. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation.
40 -4. Never open the Desktop/canvas automatically from a tool result if the user has not opened it. Offer the explicit Open in canvas action instead.
41 -5. Launch common apps from the Desktop icons, the header buttons, or `/a0/plugins/_office/skills/linux-desktop/scripts/desktopctl.sh`.
40 +4. Never open the Desktop surface automatically from a tool result if the user has not opened it. Offer an explicit Open in Desktop action instead.
41 +5. Launch common apps from the Desktop icons, the header buttons, or `/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh`.
42 6. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version.
43 7. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success. If exact terminal text matters, load or inspect the screenshot path returned by the final observation, not a screenshot captured before the text appeared.
44
@@ -47,7 +47,7 @@ Keep these standing rules:
47 Use the helper script when the Desktop is already open and you need reliable app launches, clicks, keystrokes, or window checks from the agent shell. In the live Agent Zero runtime, prefer the absolute path so the command works from any current directory:
48
49 ```bash
50 -DESKTOP=/a0/plugins/_office/skills/linux-desktop/scripts/desktopctl.sh
50 +DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
51 $DESKTOP check
52 $DESKTOP state --json
53 $DESKTOP observe --json --screenshot
@@ -63,7 +63,7 @@ The script targets the persistent `agent-zero-desktop` X display, sets `DISPLAY`
63 For direct app launches without coordinates:
64
65 ```bash
66 -DESKTOP=/a0/plugins/_office/skills/linux-desktop/scripts/desktopctl.sh
66 +DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
67 $DESKTOP launch writer
68 $DESKTOP launch calc
69 $DESKTOP launch impress
@@ -78,7 +78,7 @@ $DESKTOP key ctrl+s
78 For live spreadsheet coworking, use the Calc helper instead of hand-written UNO snippets:
79
80 ```bash
81 -DESKTOP=/a0/plugins/_office/skills/linux-desktop/scripts/desktopctl.sh
81 +DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
82 $DESKTOP calc-set-cell /a0/usr/workdir/example.xlsx Sheet1 B2 "Cowork verified live"
83 ```
84
@@ -87,7 +87,7 @@ This opens the workbook in the visible Desktop Calc session if needed, changes t
87 For coordinate actions, clicks are explicitly last resort. First try `launch`, `open-path`, `wait-window`, `focus`, `key`, `paste-text`, `save`, or an app-native helper. If a coordinate action is still necessary, base it on a fresh screenshot observation and verify immediately afterward:
88
89 ```bash
90 -DESKTOP=/a0/plugins/_office/skills/linux-desktop/scripts/desktopctl.sh
90 +DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
91 $DESKTOP observe --json --screenshot
92 $DESKTOP click 120 180
93 $DESKTOP dblclick 120 180
@@ -101,7 +101,7 @@ $DESKTOP observe --json
101 When browser automation is available, the higher-level QA flow is:
102
103 1. Open `http://127.0.0.1:32080`.
104 -2. Open the Desktop canvas from the UI or with `Alpine.store("rightCanvas").open("office")`.
104 +2. Open the Desktop surface from the UI or with `Alpine.store("rightCanvas").open("desktop")`.
105 3. Use browser mouse events into the Xpra iframe for real user-path testing.
106 4. Cross-check with `desktopctl.sh location` and `desktopctl.sh windows PATTERN`.
107 5. Capture the browser screenshot as visual evidence.
@@ -129,7 +129,7 @@ Guard the boundary between the shell and the target CLI carefully:
129 Example for a nested CLI-agent smoke test:
130
131 ```bash
132 -DESKTOP=/a0/plugins/_office/skills/linux-desktop/scripts/desktopctl.sh
132 +DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
133 $DESKTOP focus "Terminal"
134 $DESKTOP paste-text 'TARGET_CLI="example-cli-agent"; FALLBACK_CMD=""; if command -v "$TARGET_CLI" >/dev/null 2>&1; then "$TARGET_CLI"; elif [ -n "$FALLBACK_CMD" ]; then sh -lc "$FALLBACK_CMD"; else echo "CLI agent not found: $TARGET_CLI"; fi'
135 $DESKTOP key Return
plugins/_desktop/skills/linux-desktop/scripts/calc_set_cell.py renamed
plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh renamed
+2 -2
@@ -3,8 +3,8 @@ set -euo pipefail
3
4 SESSION="${A0_DESKTOP_SESSION:-agent-zero-desktop}"
5 BASE_DIR="${A0_BASE_DIR:-/a0}"
6 -PROFILE_DIR="${A0_DESKTOP_PROFILE:-$BASE_DIR/tmp/_office/desktop/profiles/$SESSION}"
7 -MANIFEST="${A0_DESKTOP_MANIFEST:-$BASE_DIR/tmp/_office/desktop/sessions/$SESSION.json}"
6 +PROFILE_DIR="${A0_DESKTOP_PROFILE:-$BASE_DIR/usr/_desktop/profiles/$SESSION}"
7 +MANIFEST="${A0_DESKTOP_MANIFEST:-$BASE_DIR/usr/_desktop/sessions/$SESSION.json}"
8 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9 DESKTOP_STATE_HELPER="$SCRIPT_DIR/../../../helpers/desktop_state.py"
10 DESKTOP_STATE_PYTHON="${A0_DESKTOP_STATE_PYTHON:-$(command -v /usr/bin/python3 || command -v python3 || true)}"
plugins/_desktop/webui/desktop-panel.html new
+795
@@ -0,0 +1,795 @@
1 +<html>
2 +<head>
3 + <script type="module">
4 + import { store } from "/plugins/_desktop/webui/desktop-store.js";
5 + </script>
6 +</head>
7 +<body>
8 + <div x-data>
9 + <template x-if="$store.desktop">
10 + <div class="office-panel" x-create="$store.desktop.onMount($el, xAttrs($el) || {})" x-destroy="$store.desktop.cleanup()">
11 + <div class="office-shell">
12 + <div class="office-document-header" x-show="$store.desktop.hasActiveFile()" style="display: none;">
13 + <div class="office-document-title" :title="$store.desktop.tabLabel($store.desktop.session)">
14 + <span class="material-symbols-outlined office-document-icon" aria-hidden="true" x-text="$store.desktop.tabIcon($store.desktop.session)"></span>
15 + <span class="office-document-name" x-text="$store.desktop.tabTitle($store.desktop.session)"></span>
16 + <span class="office-document-dirty" x-show="$store.desktop.dirty" aria-hidden="true">*</span>
17 + </div>
18 +
19 + <button
20 + type="button"
21 + class="office-icon-button office-document-save-button"
22 + title="Save"
23 + aria-label="Save"
24 + :class="{ 'is-primary': $store.desktop.dirty }"
25 + :disabled="$store.desktop.saving"
26 + @click="$store.desktop.save()"
27 + >
28 + <span class="material-symbols-outlined" :class="{ spinning: $store.desktop.saving }" x-text="$store.desktop.saving ? 'progress_activity' : 'save'"></span>
29 + </button>
30 +
31 + <div class="office-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
32 + <button
33 + type="button"
34 + class="office-icon-button office-file-menu-button"
35 + title="File actions"
36 + aria-label="File actions"
37 + aria-haspopup="menu"
38 + :aria-expanded="open.toString()"
39 + :disabled="$store.desktop.saving"
40 + @click.stop="open = !open"
41 + >
42 + <span class="material-symbols-outlined">more_vert</span>
43 + </button>
44 + <div class="office-new-menu office-file-menu" role="menu" x-show="open" @click.stop>
45 + <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.desktop.saving" @click="open = false; $store.desktop.renameActiveFile()">
46 + <span class="material-symbols-outlined" aria-hidden="true">edit</span>
47 + <span>Rename</span>
48 + </button>
49 + <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.desktop.loading" @click="open = false; $store.desktop.closeActiveFile()">
50 + <span class="material-symbols-outlined" aria-hidden="true">close</span>
51 + <span>Close File</span>
52 + </button>
53 + </div>
54 + </div>
55 + </div>
56 +
57 + <div class="office-toolbar" x-show="$store.desktop.session && $store.desktop.isMarkdown()" style="display: none;">
58 + <div class="office-toolbar-row">
59 + <div class="office-tool-group office-editor-tools" x-show="$store.desktop.session && $store.desktop.isMarkdown()" style="display: none;">
60 + <button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.desktop.canUndo()" @click="$store.desktop.undo()">
61 + <span class="material-symbols-outlined">undo</span>
62 + </button>
63 + <button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.desktop.canRedo()" @click="$store.desktop.redo()">
64 + <span class="material-symbols-outlined">redo</span>
65 + </button>
66 + <button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.desktop.format('bold')">
67 + <span class="material-symbols-outlined">format_bold</span>
68 + </button>
69 + <button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.desktop.format('italic')">
70 + <span class="material-symbols-outlined">format_italic</span>
71 + </button>
72 + <button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.desktop.format('list')">
73 + <span class="material-symbols-outlined">format_list_bulleted</span>
74 + </button>
75 + <button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.desktop.format('numbered')">
76 + <span class="material-symbols-outlined">format_list_numbered</span>
77 + </button>
78 + <button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.desktop.format('table')">
79 + <span class="material-symbols-outlined">table</span>
80 + </button>
81 + </div>
82 +
83 + <span class="office-toolbar-spacer"></span>
84 + </div>
85 + </div>
86 +
87 + <div class="office-state-line" x-show="$store.desktop.message || $store.desktop.error || $store.desktop.loading" style="display: none;">
88 + <span class="material-symbols-outlined" :class="{ spinning: $store.desktop.loading }" x-text="$store.desktop.loading ? 'progress_activity' : ($store.desktop.error ? 'error' : 'check_circle')"></span>
89 + <span x-text="$store.desktop.error || $store.desktop.message || 'Working'"></span>
90 + </div>
91 +
92 + <div class="office-body" :class="{ 'is-source': $store.desktop.isMarkdown() }">
93 + <div class="office-editor-wrap" x-show="$store.desktop.session" style="display: none;">
94 + <div class="office-editor-scroll" :class="{ 'is-desktop': $store.desktop.hasOfficialOffice(), 'is-source': $store.desktop.isMarkdown() }" @click.self="$store.desktop.focusEditor()">
95 + <template x-if="$store.desktop.hasOfficialOffice()">
96 + <div
97 + class="office-desktop-wrap"
98 + data-office-desktop-host
99 + x-init="$nextTick(() => $store.desktop.mountDesktopFrameHost($el))"
100 + >
101 + </div>
102 + </template>
103 +
104 + <textarea
105 + class="office-source-editor"
106 + data-office-source
107 + aria-label="Markdown source"
108 + x-show="$store.desktop.isMarkdown()"
109 + x-model="$store.desktop.editorText"
110 + @input="$store.desktop.onSourceInput()"
111 + @blur="$store.desktop.flushInput()"
112 + spellcheck="true"
113 + style="display: none;"
114 + ></textarea>
115 +
116 + </div>
117 + </div>
118 +
119 + <div class="office-desktop-empty" x-show="$store.desktop.shouldShowDesktopEmptyState()" style="display: none;">
120 + <span class="material-symbols-outlined" aria-hidden="true">power_settings_new</span>
121 + <span class="office-desktop-empty-title">Desktop is shut down</span>
122 + <button type="button" class="office-icon-button office-command-button" @click="$store.desktop.restartDesktopSession()">
123 + <span class="material-symbols-outlined" aria-hidden="true">restart_alt</span>
124 + <span class="office-button-label">Restart Desktop</span>
125 + </button>
126 + </div>
127 + </div>
128 + </div>
129 + </div>
130 + </template>
131 + </div>
132 +
133 + <style>
134 + .office-panel,
135 + .office-shell {
136 + display: flex;
137 + flex: 1 1 auto;
138 + flex-direction: column;
139 + width: 100%;
140 + height: 100%;
141 + min-width: 0;
142 + min-height: 0;
143 + background: var(--color-background);
144 + color: var(--color-text);
145 + }
146 +
147 + .office-panel {
148 + container-type: inline-size;
149 + }
150 +
151 + .modal-inner.office-modal {
152 + box-sizing: border-box;
153 + width: min(1120px, calc(100vw - 32px));
154 + height: min(820px, calc(100vh - 32px));
155 + min-width: min(720px, calc(100vw - 16px));
156 + min-height: min(520px, calc(100vh - 16px));
157 + max-width: none;
158 + max-height: none;
159 + resize: none;
160 + overflow: hidden;
161 + will-change: width, height, left, top;
162 + }
163 +
164 + .modal-inner.office-modal.is-resizing,
165 + .modal-inner.office-modal.is-dragging {
166 + user-select: none;
167 + }
168 +
169 + .modal-inner.office-modal.is-focus-mode {
170 + border-radius: 6px;
171 + }
172 +
173 + .modal-inner.office-modal .modal-scroll {
174 + display: flex;
175 + flex: 1 1 auto;
176 + min-height: 0;
177 + max-height: none;
178 + overflow: hidden;
179 + padding: 0;
180 + }
181 +
182 + .modal-inner.office-modal .modal-header {
183 + grid-template-columns: minmax(0, 1fr) repeat(5, auto);
184 + }
185 +
186 + .office-modal-input-shield {
187 + position: absolute;
188 + inset: 42px 0 0 0;
189 + z-index: 4;
190 + display: none;
191 + background: transparent;
192 + }
193 +
194 + .office-modal-resizer {
195 + position: absolute;
196 + z-index: 5;
197 + display: block;
198 + touch-action: none;
199 + }
200 +
201 + .office-modal-resizer.is-right {
202 + top: 42px;
203 + right: -4px;
204 + bottom: 12px;
205 + width: 10px;
206 + cursor: ew-resize;
207 + }
208 +
209 + .office-modal-resizer.is-bottom {
210 + right: 12px;
211 + bottom: -4px;
212 + left: 0;
213 + height: 10px;
214 + cursor: ns-resize;
215 + }
216 +
217 + .office-modal-resizer.is-corner {
218 + right: 0;
219 + bottom: 0;
220 + width: 22px;
221 + height: 22px;
222 + cursor: nwse-resize;
223 + }
224 +
225 + .office-modal-resizer.is-corner::after {
226 + content: "";
227 + position: absolute;
228 + right: 6px;
229 + bottom: 6px;
230 + width: 9px;
231 + height: 9px;
232 + border-right: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
233 + border-bottom: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
234 + border-radius: 1px;
235 + }
236 +
237 + .modal-inner.office-modal.is-focus-mode .office-modal-resizer {
238 + display: none;
239 + }
240 +
241 + .modal-inner.office-modal .modal-bd.office-modal-body,
242 + .modal-inner.office-modal .modal-bd.office-modal-body > x-component,
243 + .modal-inner.office-modal .modal-bd.office-modal-body > x-component > div[x-data],
244 + .modal-inner.office-modal .modal-bd.office-modal-body > x-component > .office-panel,
245 + .modal-inner.office-modal .modal-bd.office-modal-body > x-component > div[x-data] > .office-panel {
246 + display: flex;
247 + flex: 1 1 auto;
248 + min-height: 0;
249 + min-width: 0;
250 + width: 100%;
251 + height: 100%;
252 + padding: 0;
253 + }
254 +
255 + .office-toolbar {
256 + display: flex;
257 + flex-direction: row;
258 + align-items: stretch;
259 + flex-wrap: nowrap;
260 + min-height: 0;
261 + padding: 6px 10px;
262 + overflow: hidden;
263 + border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 20%);
264 + background: color-mix(in srgb, var(--color-background), var(--color-panel) 48%);
265 + }
266 +
267 + .office-toolbar-row {
268 + display: flex;
269 + align-items: center;
270 + flex: 0 0 auto;
271 + flex-wrap: nowrap;
272 + gap: 6px;
273 + width: 100%;
274 + min-width: 0;
275 + min-height: 32px;
276 + overflow-x: auto;
277 + overflow-y: hidden;
278 + scrollbar-width: thin;
279 + }
280 +
281 + .office-tool-group {
282 + display: flex;
283 + align-items: center;
284 + flex-wrap: nowrap;
285 + flex: 0 0 auto;
286 + gap: 4px;
287 + min-width: 0;
288 + }
289 +
290 + .office-editor-tools {
291 + gap: 3px;
292 + }
293 +
294 + .office-tool-actions {
295 + justify-content: flex-end;
296 + }
297 +
298 + .office-toolbar-spacer {
299 + flex: 1 1 16px;
300 + min-width: 8px;
301 + }
302 +
303 + .office-toolbar-divider {
304 + flex: 0 0 auto;
305 + width: 1px;
306 + height: 22px;
307 + margin-inline: 2px;
308 + background: color-mix(in srgb, var(--color-border), transparent 18%);
309 + }
310 +
311 + .office-document-header {
312 + display: flex;
313 + align-items: center;
314 + gap: 10px;
315 + min-height: 38px;
316 + padding: 5px 10px 5px 12px;
317 + border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
318 + background: color-mix(in srgb, var(--color-panel), var(--color-background) 30%);
319 + }
320 +
321 + .office-document-title {
322 + display: flex;
323 + align-items: center;
324 + gap: 7px;
325 + min-width: 0;
326 + flex: 1 1 auto;
327 + color: var(--color-text);
328 + }
329 +
330 + .office-document-icon {
331 + flex: 0 0 auto;
332 + font-size: 19px;
333 + line-height: 1;
334 + }
335 +
336 + .office-document-name {
337 + min-width: 0;
338 + overflow: hidden;
339 + text-overflow: ellipsis;
340 + white-space: nowrap;
341 + font-size: 13px;
342 + font-weight: 750;
343 + letter-spacing: 0;
344 + line-height: 1.2;
345 + }
346 +
347 + .office-document-dirty {
348 + flex: 0 0 auto;
349 + color: #2ca58d;
350 + font-size: 14px;
351 + font-weight: 800;
352 + line-height: 1;
353 + }
354 +
355 + .office-file-actions {
356 + position: relative;
357 + display: inline-flex;
358 + align-items: center;
359 + flex: 0 0 auto;
360 + }
361 +
362 + .office-document-save-button,
363 + .office-file-menu-button {
364 + width: 30px;
365 + height: 30px;
366 + min-width: 30px;
367 + }
368 +
369 + .office-header-actions {
370 + position: relative;
371 + display: inline-flex;
372 + align-items: center;
373 + flex: 0 0 auto;
374 + }
375 +
376 + .office-header-new-button {
377 + appearance: none;
378 + display: inline-flex;
379 + align-items: center;
380 + justify-content: center;
381 + gap: 4px;
382 + height: 34px;
383 + min-height: 34px;
384 + padding: 0 9px 0 8px;
385 + border: 1px solid transparent;
386 + border-radius: 7px;
387 + background: transparent;
388 + color: var(--color-text);
389 + cursor: pointer;
390 + font: inherit;
391 + font-size: 12px;
392 + font-weight: 750;
393 + letter-spacing: 0;
394 + line-height: 1;
395 + opacity: 0.82;
396 + white-space: nowrap;
397 + transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
398 + }
399 +
400 + .office-header-new-button:hover,
401 + .office-header-actions.is-open .office-header-new-button {
402 + opacity: 1;
403 + border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
404 + background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
405 + }
406 +
407 + .office-header-new-button .material-symbols-outlined {
408 + font-size: 18px;
409 + line-height: 1;
410 + }
411 +
412 + .office-header-new-button .office-new-chevron {
413 + margin-left: -2px;
414 + font-size: 16px;
415 + opacity: 0.8;
416 + }
417 +
418 + .office-new-menu {
419 + position: absolute;
420 + top: calc(100% + 6px);
421 + right: 0;
422 + z-index: 100;
423 + min-width: 184px;
424 + padding: 5px;
425 + border: 1px solid color-mix(in srgb, var(--color-border), transparent 10%);
426 + border-radius: 8px;
427 + background: color-mix(in srgb, var(--color-panel), var(--color-background) 10%);
428 + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
429 + }
430 +
431 + .right-canvas-desktop-actions .office-new-menu {
432 + z-index: 4000;
433 + }
434 +
435 + .office-new-menu[hidden] {
436 + display: none;
437 + }
438 +
439 + .office-new-menu-item {
440 + appearance: none;
441 + display: flex;
442 + align-items: center;
443 + gap: 8px;
444 + width: 100%;
445 + height: 32px;
446 + padding: 0 8px;
447 + border: 1px solid transparent;
448 + border-radius: 6px;
449 + background: transparent;
450 + color: var(--color-text);
451 + cursor: pointer;
452 + font: inherit;
453 + font-size: 12px;
454 + font-weight: 650;
455 + letter-spacing: 0;
456 + line-height: 1;
457 + text-align: left;
458 + white-space: nowrap;
459 + }
460 +
461 + .office-new-menu-item:hover {
462 + border-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
463 + background: color-mix(in srgb, var(--color-background-hover) 76%, transparent);
464 + }
465 +
466 + .office-new-menu-item:disabled {
467 + cursor: default;
468 + opacity: 0.46;
469 + }
470 +
471 + .office-new-menu-item .material-symbols-outlined {
472 + flex: 0 0 auto;
473 + width: 18px;
474 + font-size: 18px;
475 + line-height: 1;
476 + text-align: center;
477 + }
478 +
479 + .office-icon-button,
480 + .office-tab,
481 + .office-tab-close {
482 + border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
483 + border-radius: 8px;
484 + background: color-mix(in srgb, var(--color-panel), var(--color-background) 16%);
485 + color: inherit;
486 + transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
487 + }
488 +
489 + .office-icon-button {
490 + display: inline-grid;
491 + place-items: center;
492 + width: 32px;
493 + height: 32px;
494 + min-width: 32px;
495 + padding: 0;
496 + }
497 +
498 + .office-command-button {
499 + display: inline-flex;
500 + align-items: center;
501 + justify-content: center;
502 + gap: 5px;
503 + width: auto;
504 + max-width: 126px;
505 + padding: 0 8px;
506 + white-space: nowrap;
507 + }
508 +
509 + .office-command-button .office-button-label {
510 + min-width: 0;
511 + overflow: hidden;
512 + text-overflow: ellipsis;
513 + font-size: 11px;
514 + font-weight: 700;
515 + line-height: 1;
516 + }
517 +
518 + .office-icon-button.is-primary {
519 + border-color: color-mix(in srgb, #2c7be5, var(--color-border) 20%);
520 + background: color-mix(in srgb, #2c7be5, var(--color-panel) 82%);
521 + }
522 +
523 + .office-icon-button.is-active {
524 + border-color: color-mix(in srgb, #2ca58d, var(--color-border) 24%);
525 + background: color-mix(in srgb, #2ca58d, var(--color-panel) 84%);
526 + }
527 +
528 + .office-icon-button:hover:not(:disabled),
529 + .office-tab:hover,
530 + .office-tab-close:hover {
531 + border-color: color-mix(in srgb, #2c7be5, var(--color-border) 45%);
532 + background: color-mix(in srgb, var(--color-panel), #2c7be5 8%);
533 + }
534 +
535 + .office-icon-button:disabled {
536 + cursor: default;
537 + opacity: 0.42;
538 + }
539 +
540 + .office-icon-button .material-symbols-outlined,
541 + .office-tab-icon {
542 + font-size: 19px;
543 + line-height: 1;
544 + }
545 +
546 + .office-tabs {
547 + display: flex;
548 + gap: 6px;
549 + min-height: 42px;
550 + padding: 7px 10px;
551 + overflow-x: auto;
552 + border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
553 + background: color-mix(in srgb, var(--color-panel), var(--color-background) 28%);
554 + }
555 +
556 + .office-tab-shell {
557 + display: grid;
558 + grid-template-columns: minmax(0, 1fr) 28px;
559 + align-items: center;
560 + min-width: 150px;
561 + max-width: 240px;
562 + }
563 +
564 + .office-tab-shell.is-system {
565 + grid-template-columns: minmax(0, 1fr);
566 + min-width: 172px;
567 + }
568 +
569 + .office-tab,
570 + .office-tab-close {
571 + height: 28px;
572 + min-height: 28px;
573 + border-radius: 7px;
574 + }
575 +
576 + .office-tab {
577 + display: flex;
578 + align-items: center;
579 + gap: 6px;
580 + min-width: 0;
581 + border-top-right-radius: 0;
582 + border-bottom-right-radius: 0;
583 + padding: 0 8px;
584 + text-align: left;
585 + }
586 +
587 + .office-tab-shell.is-system .office-tab {
588 + border-radius: 7px;
589 + }
590 +
591 + .office-tab-close {
592 + display: grid;
593 + place-items: center;
594 + border-left: 0;
595 + border-top-left-radius: 0;
596 + border-bottom-left-radius: 0;
597 + padding: 0;
598 + }
599 +
600 + .office-tab-close .material-symbols-outlined {
601 + font-size: 17px;
602 + }
603 +
604 + .office-tab-shell.is-active .office-tab,
605 + .office-tab-shell.is-active .office-tab-close {
606 + border-color: color-mix(in srgb, #2c7be5, var(--color-border) 36%);
607 + background: color-mix(in srgb, #2c7be5, var(--color-panel) 88%);
608 + }
609 +
610 + .office-tab-shell.is-dirty .office-tab-title::after {
611 + content: " *";
612 + color: #2ca58d;
613 + }
614 +
615 + .office-tab-title {
616 + min-width: 0;
617 + overflow: hidden;
618 + text-overflow: ellipsis;
619 + white-space: nowrap;
620 + font-size: 12px;
621 + line-height: 1;
622 + }
623 +
624 + .office-state-line {
625 + display: flex;
626 + align-items: center;
627 + flex: 0 0 auto;
628 + gap: 8px;
629 + width: 100%;
630 + min-width: 0;
631 + min-height: 34px;
632 + box-sizing: border-box;
633 + padding: 6px 12px;
634 + border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 28%);
635 + color: var(--color-text-secondary);
636 + font-size: 12px;
637 + }
638 +
639 + .office-state-line > .material-symbols-outlined {
640 + flex: 0 0 auto;
641 + }
642 +
643 + .office-state-line > span:not(.material-symbols-outlined) {
644 + min-width: 0;
645 + overflow: hidden;
646 + text-overflow: ellipsis;
647 + white-space: nowrap;
648 + }
649 +
650 + .office-body {
651 + position: relative;
652 + display: flex;
653 + flex: 1 1 auto;
654 + min-height: 0;
655 + overflow: hidden;
656 + background: var(--color-background);
657 + }
658 +
659 + .office-body.is-source {
660 + background: transparent;
661 + }
662 +
663 + .office-editor-wrap {
664 + display: flex;
665 + flex: 1 1 auto;
666 + flex-direction: column;
667 + min-width: 0;
668 + min-height: 0;
669 + }
670 +
671 + .office-editor-scroll {
672 + flex: 1 1 auto;
673 + min-height: 0;
674 + overflow: auto;
675 + padding: 30px 24px;
676 + }
677 +
678 + .office-editor-scroll.is-source {
679 + display: flex;
680 + overflow: hidden;
681 + padding: var(--spacing-md);
682 + background: transparent;
683 + }
684 +
685 + .office-editor-scroll.is-desktop {
686 + display: flex;
687 + overflow: hidden;
688 + padding: 0;
689 + background: #1f2329;
690 + }
691 +
692 + .office-desktop-wrap {
693 + display: flex;
694 + flex: 1 1 auto;
695 + width: 100%;
696 + height: 100%;
697 + min-width: 0;
698 + min-height: 0;
699 + aspect-ratio: auto;
700 + background: #1f2329;
701 + }
702 +
703 + .office-desktop-empty {
704 + display: grid;
705 + flex: 1 1 auto;
706 + place-items: center;
707 + align-content: center;
708 + gap: 12px;
709 + min-width: 0;
710 + min-height: 0;
711 + padding: 24px;
712 + color: var(--color-text-secondary);
713 + text-align: center;
714 + }
715 +
716 + .office-desktop-empty > .material-symbols-outlined {
717 + font-size: 32px;
718 + color: color-mix(in srgb, var(--color-text) 68%, transparent);
719 + }
720 +
721 + .office-desktop-empty-title {
722 + font-size: 13px;
723 + font-weight: 700;
724 + }
725 +
726 + .office-desktop-frame {
727 + flex: 1 1 auto;
728 + width: 100%;
729 + height: 100%;
730 + min-height: 0;
731 + aspect-ratio: auto;
732 + border: 0;
733 + background: #20242a;
734 + }
735 +
736 + .office-source-editor {
737 + box-sizing: border-box;
738 + flex: 1 1 auto;
739 + width: 100%;
740 + height: 100%;
741 + min-width: 0;
742 + min-height: 0;
743 + margin: 0;
744 + padding: 0;
745 + border: 0;
746 + outline: none;
747 + background: transparent;
748 + box-shadow: none;
749 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
750 + font-size: 13px;
751 + line-height: 1.65;
752 + resize: none;
753 + }
754 +
755 + textarea:focus {
756 + background: transparent;
757 + filter: brightness(1) !important;
758 + }
759 +
760 + .office-panel .spinning {
761 + animation: office-spin 0.8s linear infinite;
762 + }
763 +
764 + @keyframes office-spin {
765 + to { transform: rotate(360deg); }
766 + }
767 +
768 + @container (max-width: 680px) {
769 + .office-toolbar {
770 + padding-inline: 8px;
771 + }
772 +
773 + .office-toolbar-row {
774 + gap: 5px;
775 + }
776 +
777 + .office-command-button {
778 + width: 32px;
779 + max-width: 32px;
780 + padding-inline: 0;
781 + }
782 +
783 + .office-command-button .office-button-label {
784 + position: absolute;
785 + width: 1px;
786 + height: 1px;
787 + overflow: hidden;
788 + clip: rect(0 0 0 0);
789 + white-space: nowrap;
790 + }
791 +
792 + }
793 + </style>
794 +</body>
795 +</html>
plugins/_desktop/webui/desktop-store.js new
+2655
@@ -0,0 +1,2655 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +import { getNamespacedClient } from "/js/websocket.js";
4 +import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 +import { handleUrlIntent } from "/js/surfaces.js";
6 +
7 +const officeSocket = getNamespacedClient("/ws");
8 +officeSocket.addHandlers(["ws_webui"]);
9 +
10 +const SAVE_MESSAGE_MS = 1800;
11 +const INPUT_PUSH_DELAY_MS = 650;
12 +const DESKTOP_HEARTBEAT_MS = 3500;
13 +const DESKTOP_RESIZE_DELAY_MS = 80;
14 +const DESKTOP_START_MESSAGE = "Starting Agent Zero Desktop environment";
15 +const XPRA_DESKTOP_PRIME_INTERVAL_MS = 220;
16 +const XPRA_DESKTOP_PRIME_ATTEMPTS = 120;
17 +const SYSTEM_DESKTOP_FILE_ID = "system-desktop";
18 +const URL_INTENT_PANEL_TIMEOUT_MS = 5000;
19 +const DESKTOP_SHUTDOWN_STORAGE_KEY = "a0.desktop.shutdown";
20 +const MAX_HISTORY = 80;
21 +
22 +function currentContextId() {
23 + try {
24 + return globalThis.getContext?.() || "";
25 + } catch {
26 + return "";
27 + }
28 +}
29 +
30 +function basename(path = "") {
31 + const value = String(path || "").split("?")[0].split("#")[0];
32 + return value.split("/").filter(Boolean).pop() || "Untitled";
33 +}
34 +
35 +function extensionOf(path = "") {
36 + const name = basename(path).toLowerCase();
37 + const index = name.lastIndexOf(".");
38 + return index >= 0 ? name.slice(index + 1) : "";
39 +}
40 +
41 +function isOfficialExtension(extension = "") {
42 + return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(String(extension || "").toLowerCase());
43 +}
44 +
45 +function parentPath(path = "") {
46 + const normalized = String(path || "").split("?")[0].split("#")[0].replace(/\/+$/, "");
47 + const index = normalized.lastIndexOf("/");
48 + if (index <= 0) return "/";
49 + return normalized.slice(0, index);
50 +}
51 +
52 +function uniqueTabId(session = {}) {
53 + return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
54 +}
55 +
56 +function editorContainsFocus(element) {
57 + const active = document.activeElement;
58 + return Boolean(element && active && (element === active || element.contains(active)));
59 +}
60 +
61 +function isEditableInputTarget(target) {
62 + const element = target?.nodeType === 1 ? target : target?.parentElement;
63 + const editable = element?.closest?.("input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='textbox']");
64 + if (!editable) return false;
65 + if (editable.tagName !== "INPUT") return true;
66 + const type = String(editable.getAttribute("type") || "text").toLowerCase();
67 + return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(type);
68 +}
69 +
70 +function normalizeModalPath(path = "") {
71 + return String(path || "").replace(/^\/+/, "");
72 +}
73 +
74 +function isModalPathOpen(path = "") {
75 + const normalized = normalizeModalPath(path);
76 + return Boolean(
77 + globalThis.isModalOpen?.(path)
78 + || globalThis.isModalOpen?.(`/${normalized}`)
79 + || globalThis.isModalOpen?.(normalized)
80 + );
81 +}
82 +
83 +function waitForElementByPredicate(predicate, timeoutMs = URL_INTENT_PANEL_TIMEOUT_MS) {
84 + const found = predicate();
85 + if (found) return Promise.resolve(found);
86 + return new Promise((resolve) => {
87 + const timeout = globalThis.setTimeout(() => {
88 + observer.disconnect();
89 + resolve(predicate());
90 + }, timeoutMs);
91 + const observer = new MutationObserver(() => {
92 + const element = predicate();
93 + if (!element) return;
94 + globalThis.clearTimeout(timeout);
95 + observer.disconnect();
96 + resolve(element);
97 + });
98 + observer.observe(document.body, { childList: true, subtree: true });
99 + });
100 +}
101 +
102 +function browserPanelForMode(mode = "modal") {
103 + const panels = Array.from(document.querySelectorAll(".browser-panel"));
104 + if (mode === "canvas") {
105 + return panels.find((panel) => panel.closest?.('[data-surface-id="browser"]')) || null;
106 + }
107 + return panels.find((panel) => panel.closest?.(".modal")) || null;
108 +}
109 +
110 +function placeCaretAtEnd(element) {
111 + if (!element) return;
112 + if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") {
113 + const length = element.value?.length || 0;
114 + element.selectionStart = length;
115 + element.selectionEnd = length;
116 + return;
117 + }
118 + const selection = globalThis.getSelection?.();
119 + const range = document.createRange?.();
120 + if (!selection || !range) return;
121 + range.selectNodeContents(element);
122 + range.collapse(false);
123 + selection.removeAllRanges();
124 + selection.addRange(range);
125 +}
126 +
127 +function normalizeDocument(doc = {}) {
128 + const path = doc.path || "";
129 + const extension = String(doc.extension || extensionOf(path)).toLowerCase();
130 + return {
131 + ...doc,
132 + extension,
133 + title: doc.title || doc.basename || basename(path),
134 + basename: doc.basename || basename(path),
135 + path,
136 + };
137 +}
138 +
139 +function normalizeSession(payload = {}) {
140 + const document = normalizeDocument(payload.document || payload);
141 + const extension = String(payload.extension || document.extension || "").toLowerCase();
142 + return {
143 + ...payload,
144 + document,
145 + extension,
146 + file_id: payload.file_id || document.file_id || "",
147 + path: document.path || payload.path || "",
148 + title: payload.title || document.title || document.basename || basename(document.path),
149 + tab_id: uniqueTabId(payload),
150 + text: String(payload.text || ""),
151 + desktop: payload.desktop || null,
152 + desktop_session_id: payload.desktop_session_id || payload.desktop?.session_id || "",
153 + dirty: false,
154 + };
155 +}
156 +
157 +async function callOffice(action, payload = {}) {
158 + return await callJsonApi("/plugins/_office/office_session", {
159 + action,
160 + ctxid: currentContextId(),
161 + ...payload,
162 + });
163 +}
164 +
165 +async function callDesktop(action, payload = {}) {
166 + return await callJsonApi("/plugins/_desktop/desktop_session", {
167 + action,
168 + ctxid: currentContextId(),
169 + ...payload,
170 + });
171 +}
172 +
173 +async function requestOffice(eventType, payload = {}, timeoutMs = 5000) {
174 + const response = await officeSocket.request(eventType, {
175 + ctxid: currentContextId(),
176 + ...payload,
177 + }, { timeoutMs });
178 + const results = Array.isArray(response?.results) ? response.results : [];
179 + const first = results.find((item) => item?.ok === true && isOfficeSocketData(item?.data))
180 + || results.find((item) => item?.ok === true);
181 + if (!first) {
182 + const error = results.find((item) => item?.error)?.error;
183 + throw new Error(error?.error || error?.code || `${eventType} failed`);
184 + }
185 + if (first.data?.office_error) {
186 + const error = first.data.office_error;
187 + throw new Error(error.error || error.code || `${eventType} failed`);
188 + }
189 + return first.data || {};
190 +}
191 +
192 +function isOfficeSocketData(data) {
193 + if (!data || typeof data !== "object") return false;
194 + return (
195 + Object.prototype.hasOwnProperty.call(data, "office_error")
196 + || Object.prototype.hasOwnProperty.call(data, "ok")
197 + || Object.prototype.hasOwnProperty.call(data, "session_id")
198 + || Object.prototype.hasOwnProperty.call(data, "document")
199 + || Object.prototype.hasOwnProperty.call(data, "desktop")
200 + || Object.prototype.hasOwnProperty.call(data, "closed")
201 + );
202 +}
203 +
204 +const model = {
205 + status: null,
206 + tabs: [],
207 + activeTabId: "",
208 + session: null,
209 + loading: false,
210 + saving: false,
211 + dirty: false,
212 + error: "",
213 + message: "",
214 + editorText: "",
215 + _root: null,
216 + _mode: "canvas",
217 + _saveMessageTimer: null,
218 + _inputTimer: null,
219 + _history: [],
220 + _historyIndex: -1,
221 + _pendingFocus: false,
222 + _pendingFocusEnd: true,
223 + _focusAttempts: 0,
224 + _floatingCleanup: null,
225 + _desktopHeartbeatTimer: null,
226 + _desktopHeartbeatSessionId: "",
227 + _desktopHeartbeatTabId: "",
228 + _desktopHeartbeatMisses: 0,
229 + _desktopResizeCleanup: null,
230 + _desktopResizeTarget: null,
231 + _desktopResizeTimer: null,
232 + _desktopResizeKey: "",
233 + _desktopResizePendingKey: "",
234 + _desktopResizeSuspended: false,
235 + _desktopResizePending: false,
236 + _desktopViewportSyncTimers: [],
237 + _desktopHostVisible: false,
238 + _desktopPrimeTimer: null,
239 + _desktopPrimeAttempts: 0,
240 + _desktopKeyboardActive: false,
241 + _desktopFocusInProgress: false,
242 + _desktopBridgeReady: false,
243 + _desktopKeyboardCaptureState: { ready: false, active: false, capture: false, focused: false },
244 + _desktopLastState: null,
245 + _desktopKeyboardCleanup: null,
246 + _desktopClipboardCleanup: null,
247 + _desktopStarting: null,
248 + _desktopUrlIntentBusy: false,
249 + _desktopUrlIntentQueue: [],
250 + _desktopFrame: null,
251 + _desktopFrameHost: null,
252 + _desktopFrameLoadHandler: null,
253 + _desktopKeepaliveHost: null,
254 + _desktopIntentionalShutdown: false,
255 +
256 + async init(element = null) {
257 + this.restoreDesktopShutdownState();
258 + return await this.onMount(element, { mode: "canvas" });
259 + },
260 +
261 + async onMount(element = null, options = {}) {
262 + if (element) this._root = element;
263 + this._mode = options?.mode === "modal" ? "modal" : "canvas";
264 + if (this._mode === "modal") {
265 + this._desktopHostVisible = true;
266 + this.setupFloatingModal(element);
267 + await this.onOpen({ source: "modal" });
268 + return;
269 + }
270 + this.queueRender();
271 + },
272 +
273 + async onOpen(payload = {}) {
274 + this.restoreDesktopShutdownState();
275 + await this.refresh();
276 + if (payload?.path || payload?.file_id) {
277 + await this.openSession({
278 + path: payload.path || "",
279 + file_id: payload.file_id || "",
280 + refresh: payload.refresh === true,
281 + source: payload.source || "",
282 + });
283 + } else if (this._desktopIntentionalShutdown) {
284 + this.session = null;
285 + this.activeTabId = "";
286 + this.editorText = "";
287 + this.dirty = false;
288 + } else {
289 + await this.ensureDesktopSession({ select: !this.session });
290 + }
291 + this.restoreDesktopFrames();
292 + this.requestDesktopViewportSync({ force: true });
293 + },
294 +
295 + beforeHostHidden(options = {}) {
296 + this._desktopHostVisible = false;
297 + this.flushInput();
298 + this.clearDesktopViewportSyncTimers();
299 + this.stopDesktopMonitor();
300 + this.stopDesktopKeyboardBridge();
301 + this.stopDesktopClipboardBridge();
302 + this.unloadDesktopFrames();
303 + },
304 +
305 + cleanup() {
306 + this.flushInput();
307 + this.stopDesktopMonitor();
308 + this.stopDesktopResizeObserver();
309 + this.clearDesktopViewportSyncTimers();
310 + this.stopXpraDesktopPrime();
311 + this.stopDesktopKeyboardBridge();
312 + this.stopDesktopClipboardBridge();
313 + if (!this._desktopIntentionalShutdown) this.moveDesktopFrameToKeepalive();
314 + this._floatingCleanup?.();
315 + this._floatingCleanup = null;
316 + if (this._mode === "modal") this._root = null;
317 + },
318 +
319 + async refresh() {
320 + try {
321 + const status = await callDesktop("status");
322 + this.status = status || {};
323 + this.error = "";
324 + } catch (error) {
325 + this.error = error instanceof Error ? error.message : String(error);
326 + }
327 + },
328 +
329 + restoreDesktopShutdownState() {
330 + try {
331 + this._desktopIntentionalShutdown = localStorage.getItem(DESKTOP_SHUTDOWN_STORAGE_KEY) === "1";
332 + } catch {
333 + this._desktopIntentionalShutdown = Boolean(this._desktopIntentionalShutdown);
334 + }
335 + },
336 +
337 + persistDesktopShutdownState() {
338 + try {
339 + if (this._desktopIntentionalShutdown) {
340 + localStorage.setItem(DESKTOP_SHUTDOWN_STORAGE_KEY, "1");
341 + } else {
342 + localStorage.removeItem(DESKTOP_SHUTDOWN_STORAGE_KEY);
343 + }
344 + } catch {
345 + // Shutdown state is still correct for this page even without storage.
346 + }
347 + },
348 +
349 + setDesktopIntentionalShutdown(value) {
350 + this._desktopIntentionalShutdown = Boolean(value);
351 + this.persistDesktopShutdownState();
352 + },
353 +
354 + isDesktopShutdown() {
355 + return Boolean(this._desktopIntentionalShutdown);
356 + },
357 +
358 + shouldShowDesktopEmptyState() {
359 + return Boolean(this._desktopIntentionalShutdown && !this.session);
360 + },
361 +
362 + async restartDesktopSession() {
363 + this.error = "";
364 + const session = await this.ensureDesktopSession({
365 + force: true,
366 + restart: true,
367 + select: true,
368 + message: "Restarting Agent Zero Desktop environment",
369 + });
370 + if (!session) {
371 + this.setDesktopIntentionalShutdown(true);
372 + return null;
373 + }
374 + this.restoreDesktopFrames();
375 + this.requestDesktopViewportSync({ force: true });
376 + return session;
377 + },
378 +
379 + async shutdownDesktop(options = {}) {
380 + this.loading = options.progress !== false;
381 + this.message = this.loading ? "Shutting down Desktop" : this.message;
382 + this.error = "";
383 + try {
384 + const response = await callDesktop("shutdown", {
385 + save_first: options.saveFirst !== false,
386 + source: options.source || "ui",
387 + });
388 + await this.handleIntentionalDesktopShutdown(response);
389 + return response;
390 + } catch (error) {
391 + this.error = error instanceof Error ? error.message : String(error);
392 + return null;
393 + } finally {
394 + if (options.progress !== false) {
395 + this.loading = false;
396 + if (this.message === "Shutting down Desktop") this.message = "";
397 + }
398 + }
399 + },
400 +
401 + async handleIntentionalDesktopShutdown(response = {}) {
402 + this.setDesktopIntentionalShutdown(true);
403 + this.stopDesktopMonitor();
404 + this.stopDesktopResizeObserver();
405 + this.clearDesktopViewportSyncTimers();
406 + this.stopXpraDesktopPrime();
407 + this.stopDesktopKeyboardBridge();
408 + this.stopDesktopClipboardBridge();
409 + this.destroyDesktopFrame();
410 + const activeTabId = this.activeTabId;
411 + this.tabs = this.tabs.filter((tab) => !this.isDesktopSession(tab) && !this.hasOfficialOffice(tab));
412 + if (!this.tabs.some((tab) => tab.tab_id === activeTabId)) {
413 + this.session = null;
414 + this.activeTabId = "";
415 + this.editorText = "";
416 + this.dirty = false;
417 + this.resetHistory("");
418 + }
419 + this._desktopStarting = null;
420 + this._desktopHeartbeatMisses = 0;
421 + this.message = response?.source === "tray" ? "Desktop shut down from system tray" : "Desktop is shut down";
422 + await this.refresh();
423 + },
424 +
425 + async ensureDesktopSession(options = {}) {
426 + if (this._desktopIntentionalShutdown && options.restart !== true) {
427 + return null;
428 + }
429 + if (options.restart === true) {
430 + this.setDesktopIntentionalShutdown(false);
431 + this.destroyDesktopFrame();
432 + }
433 + const existing = this.tabs.find((tab) => this.isDesktopSession(tab));
434 + if (existing && !options.force) {
435 + if (options.select) this.selectTab(existing.tab_id, { focus: false });
436 + this.updateDesktopMonitor();
437 + return existing;
438 + }
439 + const showProgress = options.progress !== false;
440 + const progressMessage = String(options.message || DESKTOP_START_MESSAGE);
441 + if (this._desktopStarting) {
442 + if (showProgress) {
443 + this.loading = true;
444 + this.message = progressMessage;
445 + }
446 + return await this._desktopStarting;
447 + }
448 +
449 + this._desktopStarting = (async () => {
450 + try {
451 + if (showProgress) {
452 + this.loading = true;
453 + this.message = progressMessage;
454 + this.error = "";
455 + }
456 + const response = await callDesktop("desktop");
457 + if (response?.ok === false) throw new Error(response.error || "Desktop session could not be opened.");
458 + this.setDesktopIntentionalShutdown(false);
459 + const session = normalizeSession(response);
460 + const existingIndex = this.tabs.findIndex((tab) => this.isDesktopSession(tab));
461 + let desktopTabId = session.tab_id;
462 + if (existingIndex >= 0) {
463 + desktopTabId = this.tabs[existingIndex].tab_id;
464 + this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: desktopTabId });
465 + } else {
466 + this.tabs.unshift(session);
467 + }
468 + this.tabs = this.tabs.map((tab) => (
469 + this.hasOfficialOffice(tab)
470 + ? {
471 + ...tab,
472 + desktop: session.desktop,
473 + desktop_session_id: session.desktop_session_id,
474 + session_id: this.isDesktopSession(tab) ? session.session_id : tab.session_id,
475 + }
476 + : tab
477 + ));
478 + if (options.select || !this.session) {
479 + this.selectTab(desktopTabId, { focus: false });
480 + } else {
481 + this.updateDesktopMonitor();
482 + }
483 + this.restoreDesktopFrames();
484 + return { ...session, tab_id: desktopTabId };
485 + } catch (error) {
486 + this.error = error instanceof Error ? error.message : String(error);
487 + return null;
488 + } finally {
489 + if (showProgress) {
490 + this.loading = false;
491 + if (this.message === progressMessage) this.message = "";
492 + }
493 + this._desktopStarting = null;
494 + }
495 + })();
496 + return await this._desktopStarting;
497 + },
498 +
499 + async create(kind = "document", format = "") {
500 + const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "md")).toLowerCase();
501 + const title = this.defaultTitle(kind, fmt);
502 + this.loading = true;
503 + this.error = "";
504 + try {
505 + const response = await callOffice("create", {
506 + kind,
507 + format: fmt,
508 + title,
509 + open_in_desktop: isOfficialExtension(fmt),
510 + });
511 + if (response?.ok === false) {
512 + this.error = response.error || "Document could not be created.";
513 + return null;
514 + }
515 + const session = normalizeSession(response);
516 + this.installSession(session);
517 + await this.refresh();
518 + return session;
519 + } catch (error) {
520 + this.error = error instanceof Error ? error.message : String(error);
521 + return null;
522 + } finally {
523 + this.loading = false;
524 + }
525 + },
526 +
527 + async openFileBrowser() {
528 + let workdirPath = "/a0/usr/workdir";
529 + try {
530 + const response = await callJsonApi("settings_get", null);
531 + workdirPath = response?.settings?.workdir_path || workdirPath;
532 + } catch {
533 + try {
534 + const home = await callOffice("home");
535 + workdirPath = home?.path || workdirPath;
536 + } catch {
537 + // The file browser can still open with the static fallback.
538 + }
539 + }
540 + await fileBrowserStore.open(workdirPath);
541 + },
542 +
543 + async openPath(path) {
544 + await this.openSession({ path: String(path || "") });
545 + },
546 +
547 + async openSession(payload = {}) {
548 + this.loading = true;
549 + this.error = "";
550 + try {
551 + const response = await callDesktop("open_document", payload);
552 + if (response?.ok === false) {
553 + this.error = response.error || "Document could not be opened.";
554 + return null;
555 + }
556 + const session = normalizeSession(response);
557 + this.installSession(session);
558 + await this.refresh();
559 + return session;
560 + } catch (error) {
561 + this.error = error instanceof Error ? error.message : String(error);
562 + return null;
563 + } finally {
564 + this.loading = false;
565 + }
566 + },
567 +
568 + installSession(session) {
569 + if (this.isDesktopOfficeDocument(session)) {
570 + this.installDesktopDocumentSession(session);
571 + return;
572 + }
573 + const existingIndex = this.tabs.findIndex((tab) => (
574 + (session.file_id && tab.file_id === session.file_id)
575 + || (session.path && tab.path === session.path)
576 + ));
577 + if (existingIndex >= 0) {
578 + this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: this.tabs[existingIndex].tab_id });
579 + this.activeTabId = this.tabs[existingIndex].tab_id;
580 + } else {
581 + this.tabs.push(session);
582 + this.activeTabId = session.tab_id;
583 + }
584 + this.selectTab(this.activeTabId);
585 + },
586 +
587 + installDesktopDocumentSession(session) {
588 + this.setDesktopIntentionalShutdown(false);
589 + this.tabs = this.tabs.filter((tab) => !this.isDesktopOfficeDocument(tab));
590 + let desktopTab = this.tabs.find((tab) => this.isDesktopSession(tab));
591 + if (!desktopTab) {
592 + desktopTab = {
593 + ...session,
594 + tab_id: SYSTEM_DESKTOP_FILE_ID,
595 + file_id: SYSTEM_DESKTOP_FILE_ID,
596 + extension: "desktop",
597 + title: "Desktop",
598 + path: session.desktop?.desktop_path || "/desktop/session",
599 + mode: "desktop",
600 + document: {
601 + file_id: SYSTEM_DESKTOP_FILE_ID,
602 + path: session.desktop?.desktop_path || "/desktop/session",
603 + basename: "Desktop",
604 + title: "Desktop",
605 + extension: "desktop",
606 + },
607 + dirty: false,
608 + };
609 + this.tabs.unshift(desktopTab);
610 + }
611 + const documentSession = { ...session, tab_id: session.tab_id || uniqueTabId(session) };
612 + const existingIndex = this.tabs.findIndex((tab) => (
613 + (documentSession.file_id && tab.file_id === documentSession.file_id)
614 + || (documentSession.path && tab.path === documentSession.path)
615 + ));
616 + if (existingIndex >= 0) {
617 + this.tabs.splice(existingIndex, 1, documentSession);
618 + } else {
619 + this.tabs.push(documentSession);
620 + }
621 + this.session = documentSession;
622 + this.activeTabId = documentSession.tab_id;
623 + this.editorText = "";
624 + this.dirty = false;
625 + this.resetHistory("");
626 + this.queueRender({ focus: true });
627 + this.restoreDesktopFrames();
628 + this.requestDesktopViewportSync({ force: true });
629 + this.updateDesktopMonitor();
630 + },
631 +
632 + selectTab(tabId, options = {}) {
633 + const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
634 + if (this.hasOfficialOffice(this.session) && !this.hasOfficialOffice(tab)) {
635 + this.moveDesktopFrameToKeepalive();
636 + }
637 + this.session = tab;
638 + this.activeTabId = tab?.tab_id || "";
639 + this.editorText = String(tab?.text || "");
640 + this.dirty = Boolean(tab?.dirty);
641 + this.resetHistory(this.editorText);
642 + this.queueRender({ focus: Boolean(tab) && options.focus !== false });
643 + if (this.hasOfficialOffice(tab)) {
644 + this.restoreDesktopFrames();
645 + this.requestDesktopViewportSync({ force: true });
646 + }
647 + this.updateDesktopMonitor();
648 + },
649 +
650 + ensureActiveTab() {
651 + if (this.session && this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) return;
652 + if (this.tabs.length) this.selectTab(this.tabs[0].tab_id, { focus: false });
653 + },
654 +
655 + isActiveTab(tab) {
656 + return Boolean(tab && tab.tab_id === this.activeTabId);
657 + },
658 +
659 + async closeTab(tabId) {
660 + const tab = this.tabs.find((item) => item.tab_id === tabId);
661 + if (!tab) return;
662 + if (this.isDesktopSession(tab)) {
663 + this.selectTab(tab.tab_id, { focus: false });
664 + return;
665 + }
666 + if (!this.hasOfficialOffice(tab) && (tab.dirty || (this.isActiveTab(tab) && this.dirty))) {
667 + const shouldSave = globalThis.confirm?.("Save changes?") ?? true;
668 + if (shouldSave) await this.save();
669 + }
670 + try {
671 + if (this.hasOfficialOffice(tab)) {
672 + await callDesktop("save", {
673 + desktop_session_id: tab.desktop_session_id || tab.session_id,
674 + file_id: tab.file_id || "",
675 + }).catch(() => null);
676 + } else if (tab.session_id) {
677 + await requestOffice("office_close", { session_id: tab.session_id }, 2500).catch(() => null);
678 + }
679 + await callOffice("close", {
680 + session_id: tab.store_session_id || "",
681 + file_id: tab.file_id || "",
682 + });
683 + } catch (error) {
684 + console.warn("Document close skipped", error);
685 + }
686 + this.tabs = this.tabs.filter((item) => item.tab_id !== tabId);
687 + if (this.activeTabId === tabId) {
688 + this.session = null;
689 + this.activeTabId = "";
690 + this.editorText = "";
691 + this.dirty = false;
692 + this.ensureActiveTab();
693 + }
694 + this.updateDesktopMonitor();
695 + this.ensureActiveTab();
696 + await this.refresh();
697 + },
698 +
699 + async closeActiveFile() {
700 + if (!this.session || this.isDesktopSession() || this.loading) return;
701 + await this.closeTab(this.session.tab_id);
702 + },
703 +
704 + async save() {
705 + if (!this.session || this.saving) return;
706 + if (this.isDesktopSession()) return;
707 + if (this.hasOfficialOffice()) {
708 + this.saving = true;
709 + this.error = "";
710 + try {
711 + const response = await callDesktop("save", {
712 + desktop_session_id: this.session.desktop_session_id || this.session.session_id,
713 + file_id: this.session.file_id || "",
714 + });
715 + if (response?.ok === false) throw new Error(response.error || "Save failed.");
716 + const document = normalizeDocument(response.document || this.session.document || {});
717 + const updated = {
718 + ...this.session,
719 + dirty: false,
720 + document,
721 + path: document.path || this.session.path,
722 + file_id: document.file_id || this.session.file_id,
723 + version: document.version || response.version || this.session.version,
724 + };
725 + this.replaceActiveSession(updated);
726 + this.dirty = false;
727 + this.setMessage("Saved");
728 + await this.refresh();
729 + } catch (error) {
730 + this.error = error instanceof Error ? error.message : String(error);
731 + } finally {
732 + this.saving = false;
733 + }
734 + return;
735 + }
736 + this.syncEditorText();
737 + this.saving = true;
738 + this.error = "";
739 + try {
740 + let response;
741 + const payload = { session_id: this.session.session_id, text: this.editorText };
742 + try {
743 + response = await requestOffice("office_save", payload, 10000);
744 + } catch (_socketError) {
745 + response = await callOffice("save", payload);
746 + }
747 + if (response?.ok === false) throw new Error(response.error || "Save failed.");
748 + const document = normalizeDocument(response.document || this.session.document || {});
749 + const updated = {
750 + ...this.session,
751 + text: this.editorText,
752 + dirty: false,
753 + document,
754 + path: document.path || this.session.path,
755 + file_id: document.file_id || this.session.file_id,
756 + version: document.version || response.version || this.session.version,
757 + };
758 + this.replaceActiveSession(updated);
759 + this.dirty = false;
760 + this.setMessage("Saved");
761 + await this.refresh();
762 + } catch (error) {
763 + this.error = error instanceof Error ? error.message : String(error);
764 + } finally {
765 + this.saving = false;
766 + }
767 + },
768 +
769 + async renameActiveFile() {
770 + if (!this.session || this.isDesktopSession() || this.saving) return;
771 +
772 + const session = this.session;
773 + const path = session.path || session.document?.path || "";
774 + if (!path) {
775 + this.error = "This document does not have a file path to rename.";
776 + return;
777 + }
778 + const name = basename(path || session.title || "");
779 + const extension = extensionOf(name);
780 + await fileBrowserStore.openRenameModal(
781 + {
782 + name,
783 + path,
784 + is_dir: false,
785 + size: session.document?.size || 0,
786 + modified: session.document?.last_modified || "",
787 + type: "document",
788 + },
789 + {
790 + currentPath: parentPath(path),
791 + validateName: (newName) => {
792 + if (!extension) return true;
793 + return extensionOf(newName) === extension || `Keep the .${extension} extension for this open document.`;
794 + },
795 + performRename: async ({ path: renamedPath }) => {
796 + const payload = {
797 + file_id: session.file_id || "",
798 + path: renamedPath,
799 + };
800 + if (this.isMarkdown(session)) {
801 + this.syncEditorText();
802 + payload.text = this.session?.tab_id === session.tab_id ? this.editorText : session.text || "";
803 + }
804 + return await callOffice("renamed", payload);
805 + },
806 + onRenamed: async ({ path: renamedPath, response }) => {
807 + await this.handleActiveFileRenamed(session, renamedPath, response);
808 + },
809 + },
810 + );
811 + },
812 +
813 + async handleActiveFileRenamed(session, renamedPath, renameResponse = null) {
814 + const response = renameResponse || await callOffice("renamed", {
815 + file_id: session.file_id || "",
816 + path: renamedPath,
817 + });
818 + if (response?.ok === false) throw new Error(response.error || "Rename failed.");
819 +
820 + const document = normalizeDocument(response.document || session.document || {});
821 + const updated = {
822 + ...session,
823 + document,
824 + title: document.title || document.basename || basename(document.path),
825 + path: document.path || renamedPath,
826 + extension: document.extension || session.extension,
827 + file_id: document.file_id || session.file_id,
828 + version: document.version || response.version || session.version,
829 + desktop: response.desktop?.desktop || session.desktop,
830 + text: this.session?.tab_id === session.tab_id ? this.editorText : session.text,
831 + dirty: false,
832 + };
833 + this.replaceSession(session, updated);
834 + this.dirty = false;
835 + this.setMessage("Renamed");
836 + await this.refresh();
837 + },
838 +
839 + replaceActiveSession(next) {
840 + if (!this.session) return;
841 + this.replaceSession(this.session, next);
842 + },
843 +
844 + replaceSession(previous, next) {
845 + this.session = next;
846 + const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id));
847 + if (index >= 0) this.tabs.splice(index, 1, next);
848 + this.queueRender();
849 + this.updateDesktopMonitor();
850 + },
851 +
852 + setMessage(value) {
853 + this.message = value;
854 + if (this._saveMessageTimer) globalThis.clearTimeout(this._saveMessageTimer);
855 + this._saveMessageTimer = globalThis.setTimeout(() => {
856 + this.message = "";
857 + this._saveMessageTimer = null;
858 + }, SAVE_MESSAGE_MS);
859 + },
860 +
861 + resetHistory(text) {
862 + this._history = [String(text || "")];
863 + this._historyIndex = 0;
864 + },
865 +
866 + pushHistory(text) {
867 + const value = String(text || "");
868 + if (this._history[this._historyIndex] === value) return;
869 + this._history = this._history.slice(0, this._historyIndex + 1);
870 + this._history.push(value);
871 + if (this._history.length > MAX_HISTORY) this._history.shift();
872 + this._historyIndex = this._history.length - 1;
873 + },
874 +
875 + undo() {
876 + if (this._historyIndex <= 0) return;
877 + this._historyIndex -= 1;
878 + this.applyEditorText(this._history[this._historyIndex], true);
879 + },
880 +
881 + redo() {
882 + if (this._historyIndex >= this._history.length - 1) return;
883 + this._historyIndex += 1;
884 + this.applyEditorText(this._history[this._historyIndex], true);
885 + },
886 +
887 + canUndo() {
888 + return this._historyIndex > 0;
889 + },
890 +
891 + canRedo() {
892 + return this._historyIndex < this._history.length - 1;
893 + },
894 +
895 + applyEditorText(text, markDirty = false) {
896 + this.editorText = String(text || "");
897 + if (this.session) {
898 + this.session.text = this.editorText;
899 + this.session.dirty = markDirty || this.session.dirty;
900 + }
901 + if (markDirty) this.markDirty();
902 + this.queueRender({ force: true, focus: true });
903 + },
904 +
905 + markDirty() {
906 + this.dirty = true;
907 + if (this.session) this.session.dirty = true;
908 + },
909 +
910 + onSourceInput() {
911 + this.markDirty();
912 + this.pushHistory(this.editorText);
913 + this.scheduleInputPush();
914 + },
915 +
916 + syncEditorText() {
917 + if (!this.session) return;
918 + if (this.hasOfficialOffice()) return;
919 + this.session.text = this.editorText;
920 + },
921 +
922 + scheduleInputPush() {
923 + if (!this.session?.session_id) return;
924 + if (this._inputTimer) globalThis.clearTimeout(this._inputTimer);
925 + this._inputTimer = globalThis.setTimeout(() => {
926 + this._inputTimer = null;
927 + this.flushInput();
928 + }, INPUT_PUSH_DELAY_MS);
929 + },
930 +
931 + flushInput() {
932 + if (!this.session?.session_id) return;
933 + if (this.hasOfficialOffice()) return;
934 + this.syncEditorText();
935 + requestOffice("office_input", {
936 + session_id: this.session.session_id,
937 + text: this.editorText,
938 + }, 3000).catch(() => {});
939 + },
940 +
941 + format(command) {
942 + if (!this.session) return;
943 + if (!this.isMarkdown()) return;
944 + this.applySourceFormat(command);
945 + },
946 +
947 + applySourceFormat(command) {
948 + const textarea = this._root?.querySelector?.("[data-office-source]");
949 + if (!textarea) return;
950 + const start = textarea.selectionStart || 0;
951 + const end = textarea.selectionEnd || start;
952 + const selected = this.editorText.slice(start, end);
953 + let replacement = selected;
954 + if (command === "bold") replacement = `**${selected || "text"}**`;
955 + if (command === "italic") replacement = `*${selected || "text"}*`;
956 + if (command === "list") replacement = (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n");
957 + if (command === "numbered") replacement = (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n");
958 + if (command === "table") replacement = "| Column | Value |\n| --- | --- |\n| | |";
959 + if (replacement === selected) return;
960 + this.editorText = `${this.editorText.slice(0, start)}${replacement}${this.editorText.slice(end)}`;
961 + this.onSourceInput();
962 + globalThis.requestAnimationFrame?.(() => {
963 + textarea.focus();
964 + textarea.selectionStart = start;
965 + textarea.selectionEnd = start + replacement.length;
966 + });
967 + },
968 +
969 + queueRender(options = {}) {
970 + const force = Boolean(options.force);
971 + if (options.focus) {
972 + this._pendingFocus = true;
973 + this._pendingFocusEnd = options.end !== false;
974 + this._focusAttempts = 0;
975 + }
976 + const render = () => {
977 + if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) {
978 + this._pendingFocus = false;
979 + this._focusAttempts = 0;
980 + } else if (this._pendingFocus && this._focusAttempts < 6) {
981 + this._focusAttempts += 1;
982 + globalThis.setTimeout(render, 45);
983 + }
984 + };
985 + if (globalThis.requestAnimationFrame) {
986 + globalThis.requestAnimationFrame(render);
987 + } else {
988 + globalThis.setTimeout(render, 0);
989 + }
990 + },
991 +
992 + focusEditor(options = {}) {
993 + if (!this.session) return false;
994 + if (this.hasOfficialOffice()) {
995 + return this.focusDesktopFrame(this.desktopFrame(), { arm: true });
996 + }
997 + const source = this._root?.querySelector?.("[data-office-source]");
998 + if (!this.isMarkdown() || !source) return false;
999 + source.focus?.({ preventScroll: true });
1000 + if (!editorContainsFocus(source)) return false;
1001 + if (options.end !== false) placeCaretAtEnd(source);
1002 + return true;
1003 + },
1004 +
1005 + isMarkdown(tab = this.session) {
1006 + const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
1007 + return ext === "md";
1008 + },
1009 +
1010 + isBinaryOffice(tab = this.session) {
1011 + const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
1012 + return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(ext);
1013 + },
1014 +
1015 + hasOfficialOffice(tab = this.session) {
1016 + return Boolean(tab?.desktop?.available && tab.desktop.url);
1017 + },
1018 +
1019 + isDesktopSession(tab = this.session) {
1020 + return Boolean(
1021 + tab
1022 + && (
1023 + tab.file_id === SYSTEM_DESKTOP_FILE_ID
1024 + || tab.extension === "desktop"
1025 + || tab.mode === "desktop"
1026 + )
1027 + );
1028 + },
1029 +
1030 + isDesktopOfficeDocument(tab = this.session) {
1031 + return Boolean(tab && this.hasOfficialOffice(tab) && !this.isDesktopSession(tab) && this.isBinaryOffice(tab));
1032 + },
1033 +
1034 + hasActiveFile(tab = this.session) {
1035 + return Boolean(tab && !this.isDesktopSession(tab) && (this.isMarkdown(tab) || this.isDesktopOfficeDocument(tab)));
1036 + },
1037 +
1038 + isVisibleOfficeTab(tab = {}) {
1039 + return Boolean(this.hasActiveFile(tab));
1040 + },
1041 +
1042 + visibleTabs() {
1043 + return this.tabs.filter((tab) => this.isVisibleOfficeTab(tab));
1044 + },
1045 +
1046 + officialOfficeUrl(tab = this.session) {
1047 + const url = tab?.desktop?.url || "";
1048 + if (!url) return "";
1049 + try {
1050 + const parsed = new URL(url, window.location.href);
1051 + const secureContext = globalThis.isSecureContext === true;
1052 + parsed.searchParams.set("offscreen", secureContext ? "true" : "false");
1053 + parsed.searchParams.set("clipboard_poll", secureContext ? "true" : "false");
1054 + if (parsed.origin === window.location.origin) return `${parsed.pathname}${parsed.search}${parsed.hash}`;
1055 + return parsed.href;
1056 + } catch {
1057 + return url;
1058 + }
1059 + },
1060 +
1061 + isDesktopHostVisible() {
1062 + if (this._mode === "modal") return true;
1063 + const surface = this._root?.closest?.('[data-surface-id="desktop"]');
1064 + return Boolean(surface?.classList?.contains("is-mounted") || surface?.classList?.contains("is-active"));
1065 + },
1066 +
1067 + setDesktopHostVisible(visible) {
1068 + const next = Boolean(visible);
1069 + if (!next && this._mode === "modal") return;
1070 + if (this._desktopHostVisible === next) return;
1071 + this._desktopHostVisible = next;
1072 + if (next) {
1073 + this.afterDesktopHostShown({ source: "canvas-visibility" });
1074 + } else {
1075 + this.beforeHostHidden({ reason: "hidden" });
1076 + }
1077 + },
1078 +
1079 + desktopFrames() {
1080 + const frames = [];
1081 + if (this._desktopFrame) frames.push(this._desktopFrame);
1082 + for (const frame of Array.from(document.querySelectorAll("[data-office-desktop-frame]"))) {
1083 + if (!frames.includes(frame)) frames.push(frame);
1084 + }
1085 + return frames;
1086 + },
1087 +
1088 + isUsableDesktopFrame(frame) {
1089 + if (!frame?.contentWindow) return false;
1090 + const rect = frame.getBoundingClientRect?.();
1091 + return Boolean(rect && rect.width >= 120 && rect.height >= 80);
1092 + },
1093 +
1094 + desktopFrame(preferred = null) {
1095 + if (this.isUsableDesktopFrame(preferred)) return preferred;
1096 + const rootFrame = this._root?.querySelector?.("[data-office-desktop-frame]");
1097 + if (this.isUsableDesktopFrame(rootFrame)) return rootFrame;
1098 + const frames = this.desktopFrames();
1099 + return frames
1100 + .filter((frame) => this.isUsableDesktopFrame(frame))
1101 + .sort((left, right) => {
1102 + const leftRect = left.getBoundingClientRect();
1103 + const rightRect = right.getBoundingClientRect();
1104 + return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height);
1105 + })[0] || null;
1106 + },
1107 +
1108 + isUsableDesktopHost(host) {
1109 + if (!host?.appendChild) return false;
1110 + const rect = host.getBoundingClientRect?.();
1111 + return Boolean(rect && rect.width >= 120 && rect.height >= 80);
1112 + },
1113 +
1114 + desktopHost(preferred = null) {
1115 + if (preferred?.matches?.("[data-office-desktop-host]")) return preferred;
1116 + const rootHost = this._root?.querySelector?.("[data-office-desktop-host]");
1117 + if (this.isUsableDesktopHost(rootHost)) return rootHost;
1118 + const hosts = Array.from(document.querySelectorAll("[data-office-desktop-host]"));
1119 + return hosts
1120 + .filter((host) => this.isUsableDesktopHost(host))
1121 + .sort((left, right) => {
1122 + const leftRect = left.getBoundingClientRect();
1123 + const rightRect = right.getBoundingClientRect();
1124 + return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height);
1125 + })[0] || rootHost || hosts[0] || null;
1126 + },
1127 +
1128 + ensureDesktopKeepaliveHost() {
1129 + if (this._desktopKeepaliveHost?.isConnected) return this._desktopKeepaliveHost;
1130 + const host = document.createElement("div");
1131 + host.className = "office-desktop-keepalive";
1132 + host.dataset.officeDesktopKeepalive = "true";
1133 + Object.assign(host.style, {
1134 + position: "fixed",
1135 + left: "-10000px",
1136 + top: "-10000px",
1137 + width: "720px",
1138 + height: "480px",
1139 + overflow: "hidden",
1140 + pointerEvents: "none",
1141 + visibility: "hidden",
1142 + });
1143 + document.body?.appendChild(host);
1144 + this._desktopKeepaliveHost = host;
1145 + return host;
1146 + },
1147 +
1148 + rememberDesktopFrameSize() {
1149 + const frame = this._desktopFrame;
1150 + const rect = frame?.getBoundingClientRect?.();
1151 + const hostRect = this._desktopFrameHost?.getBoundingClientRect?.();
1152 + const width = Math.round(rect?.width || hostRect?.width || 720);
1153 + const height = Math.round(rect?.height || hostRect?.height || 480);
1154 + const keepalive = this.ensureDesktopKeepaliveHost();
1155 + keepalive.style.width = `${Math.max(320, width)}px`;
1156 + keepalive.style.height = `${Math.max(220, height)}px`;
1157 + return keepalive;
1158 + },
1159 +
1160 + ensureDesktopFrame() {
1161 + if (this._desktopFrame) return this._desktopFrame;
1162 + const frame = document.createElement("iframe");
1163 + frame.className = "office-desktop-frame";
1164 + frame.dataset.officeDesktopFrame = "true";
1165 + frame.dataset.officePersistentDesktopFrame = "true";
1166 + frame.setAttribute("tabindex", "0");
1167 + frame.setAttribute("aria-label", "Desktop");
1168 + frame.setAttribute("allow", "clipboard-read; clipboard-write; autoplay");
1169 + this._desktopFrameLoadHandler = (event) => this.onDesktopFrameLoaded(event);
1170 + frame.addEventListener("load", this._desktopFrameLoadHandler);
1171 + this._desktopFrame = frame;
1172 + return frame;
1173 + },
1174 +
1175 + desktopFrameSrcMatches(frame, url) {
1176 + const current = frame?.getAttribute?.("src") || frame?.src || "";
1177 + if (!current && !url) return true;
1178 + try {
1179 + return new URL(current, window.location.href).href === new URL(url, window.location.href).href;
1180 + } catch {
1181 + return current === url;
1182 + }
1183 + },
1184 +
1185 + attachDesktopFrame(host = null) {
1186 + if (!this.hasOfficialOffice()) return false;
1187 + const target = this.desktopHost(host);
1188 + if (!target) return false;
1189 + const frame = this.ensureDesktopFrame();
1190 + if (frame.parentElement !== target) {
1191 + frame.parentElement?.removeAttribute?.("data-office-desktop-attached");
1192 + target.appendChild(frame);
1193 + }
1194 + target.dataset.officeDesktopAttached = "true";
1195 + if (this._desktopFrameHost !== target) this._desktopFrameHost = target;
1196 + const url = this.officialOfficeUrl();
1197 + if (url && !this.desktopFrameSrcMatches(frame, url)) {
1198 + frame.setAttribute("src", url);
1199 + }
1200 + return true;
1201 + },
1202 +
1203 + mountDesktopFrameHost(host = null) {
1204 + const attached = this.attachDesktopFrame(host);
1205 + if (attached && this.isDesktopHostVisible()) {
1206 + this.requestDesktopViewportSync({ force: true, frame: this._desktopFrame, followup: true });
1207 + }
1208 + return attached;
1209 + },
1210 +
1211 + moveDesktopFrameToKeepalive() {
1212 + const frame = this._desktopFrame;
1213 + if (!frame) return false;
1214 + const keepalive = this.rememberDesktopFrameSize();
1215 + if (frame.parentElement !== keepalive) {
1216 + frame.parentElement?.removeAttribute?.("data-office-desktop-attached");
1217 + keepalive.appendChild(frame);
1218 + }
1219 + this._desktopFrameHost = keepalive;
1220 + this._desktopKeyboardActive = false;
1221 + this.updateDesktopKeyboardCaptureState(frame);
1222 + return true;
1223 + },
1224 +
1225 + destroyDesktopFrame() {
1226 + const frame = this._desktopFrame;
1227 + if (!frame) return;
1228 + if (this._desktopFrameLoadHandler) {
1229 + frame.removeEventListener("load", this._desktopFrameLoadHandler);
1230 + }
1231 + frame.setAttribute("src", "about:blank");
1232 + frame.remove();
1233 + this._desktopFrame = null;
1234 + this._desktopFrameHost = null;
1235 + this._desktopFrameLoadHandler = null;
1236 + this._desktopBridgeReady = false;
1237 + this.updateDesktopKeyboardCaptureState();
1238 + this._desktopKeepaliveHost?.remove?.();
1239 + this._desktopKeepaliveHost = null;
1240 + },
1241 +
1242 + unloadDesktopFrames() {
1243 + this.stopDesktopResizeObserver();
1244 + this.stopXpraDesktopPrime();
1245 + this.moveDesktopFrameToKeepalive();
1246 + },
1247 +
1248 + restoreDesktopFrames() {
1249 + if (!this.isDesktopHostVisible()) return;
1250 + this.attachDesktopFrame();
1251 + },
1252 +
1253 + afterDesktopHostShown() {
1254 + if (!this.hasOfficialOffice()) return;
1255 + this._desktopHostVisible = true;
1256 + this._desktopResizeKey = "";
1257 + this._desktopResizePendingKey = "";
1258 + this._desktopResizeSuspended = false;
1259 + this._desktopResizePending = false;
1260 + this.restoreDesktopFrames();
1261 + this.requestDesktopViewportSync({ force: true, frame: this.desktopFrame() });
1262 + },
1263 +
1264 + beforeDesktopHostHandoff() {
1265 + this.stopDesktopResizeObserver();
1266 + this.clearDesktopViewportSyncTimers();
1267 + this.stopXpraDesktopPrime();
1268 + this._desktopResizeKey = "";
1269 + this._desktopResizePendingKey = "";
1270 + this._desktopResizeSuspended = true;
1271 + this._desktopResizePending = true;
1272 + },
1273 +
1274 + cancelDesktopHostHandoff() {
1275 + this._desktopResizeSuspended = false;
1276 + this._desktopResizePending = false;
1277 + this.requestDesktopViewportSync({ force: true, frame: this.desktopFrame() });
1278 + },
1279 +
1280 + onDesktopFrameLoaded(event = null) {
1281 + if (event?.target?.getAttribute?.("src") === "about:blank") return;
1282 + if (!this.isDesktopHostVisible()) return;
1283 + this.error = "";
1284 + this.queueDesktopFrameFocus(event?.target || null);
1285 + this.requestDesktopViewportSync({ force: true, frame: event?.target || null });
1286 + },
1287 +
1288 + queueDesktopFrameFocus(frame = null) {
1289 + for (const delay of [0, 80, 260]) {
1290 + globalThis.setTimeout(() => {
1291 + if (!this.hasOfficialOffice()) return;
1292 + if (isEditableInputTarget(document.activeElement)) return;
1293 + this.focusDesktopFrame(frame || this.desktopFrame(), { arm: true });
1294 + }, delay);
1295 + }
1296 + },
1297 +
1298 + focusDesktopFrame(frame = null, options = {}) {
1299 + if (this._desktopFocusInProgress) return false;
1300 + const target = this.desktopFrame(frame);
1301 + if (!target) return false;
1302 + if (options.arm !== false) this._desktopKeyboardActive = true;
1303 + this._desktopFocusInProgress = true;
1304 + try {
1305 + target.setAttribute("tabindex", "0");
1306 + target.focus?.({ preventScroll: true });
1307 + target.contentWindow?.focus?.();
1308 + if (target.contentDocument?.body && !target.contentDocument.body.hasAttribute("tabindex")) {
1309 + target.contentDocument.body.tabIndex = -1;
1310 + }
1311 + target.contentDocument?.body?.focus?.({ preventScroll: true });
1312 + if (target.contentWindow?.client) target.contentWindow.client.capture_keyboard = true;
1313 + } catch {
1314 + target.focus?.({ preventScroll: true });
1315 + } finally {
1316 + this._desktopFocusInProgress = false;
1317 + }
1318 + const focused = Boolean(document.activeElement === target || target.contentDocument?.hasFocus?.());
1319 + this.updateDesktopKeyboardCaptureState(target);
1320 + return focused;
1321 + },
1322 +
1323 + updateDesktopMonitor() {
1324 + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) {
1325 + this.stopDesktopMonitor();
1326 + this.stopDesktopResizeObserver();
1327 + this._desktopKeyboardActive = false;
1328 + this._desktopBridgeReady = false;
1329 + this.updateDesktopKeyboardCaptureState();
1330 + return;
1331 + }
1332 + const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
1333 + const tabId = this.session?.tab_id || "";
1334 + if (
1335 + sessionId
1336 + && tabId
1337 + && this._desktopHeartbeatTimer
1338 + && this._desktopHeartbeatSessionId === sessionId
1339 + && this._desktopHeartbeatTabId === tabId
1340 + ) return;
1341 + this.startDesktopMonitor();
1342 + this.startDesktopResizeObserver();
1343 + },
1344 +
1345 + startDesktopResizeObserver() {
1346 + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) {
1347 + this.stopDesktopResizeObserver();
1348 + return;
1349 + }
1350 + const frame = this.desktopFrame();
1351 + const target = frame?.parentElement || frame;
1352 + if (!target) {
1353 + this.stopDesktopResizeObserver();
1354 + return;
1355 + }
1356 + if (this._desktopResizeCleanup && this._desktopResizeTarget === target) return;
1357 + this.stopDesktopResizeObserver();
1358 +
1359 + const resize = () => this.queueDesktopResize();
1360 + const resizeStart = () => this.suspendDesktopResize();
1361 + const resizeEnd = () => this.resumeDesktopResize();
1362 + const cleanup = [];
1363 + if (typeof ResizeObserver !== "undefined") {
1364 + const observer = new ResizeObserver(resize);
1365 + observer.observe(target);
1366 + cleanup.push(() => observer.disconnect());
1367 + }
1368 + globalThis.addEventListener?.("resize", resize);
1369 + cleanup.push(() => globalThis.removeEventListener?.("resize", resize));
1370 + globalThis.addEventListener?.("right-canvas-resize-start", resizeStart);
1371 + cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-start", resizeStart));
1372 + globalThis.addEventListener?.("right-canvas-resize-end", resizeEnd);
1373 + cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-end", resizeEnd));
1374 + this._desktopResizeTarget = target;
1375 + this._desktopResizeCleanup = () => cleanup.splice(0).reverse().forEach((entry) => entry());
1376 + resize();
1377 + },
1378 +
1379 + stopDesktopResizeObserver() {
1380 + if (this._desktopResizeTimer) {
1381 + globalThis.clearTimeout(this._desktopResizeTimer);
1382 + }
1383 + this._desktopResizeTimer = null;
1384 + this._desktopResizeCleanup?.();
1385 + this._desktopResizeCleanup = null;
1386 + this._desktopResizeTarget = null;
1387 + this._desktopResizeKey = "";
1388 + this._desktopResizePendingKey = "";
1389 + this._desktopResizeSuspended = false;
1390 + this._desktopResizePending = false;
1391 + },
1392 +
1393 + suspendDesktopResize() {
1394 + this._desktopResizeSuspended = true;
1395 + if (this._desktopResizeTimer) {
1396 + globalThis.clearTimeout(this._desktopResizeTimer);
1397 + this._desktopResizeTimer = null;
1398 + }
1399 + this._desktopResizePendingKey = "";
1400 + },
1401 +
1402 + resumeDesktopResize() {
1403 + const hadPendingResize = this._desktopResizePending;
1404 + this._desktopResizeSuspended = false;
1405 + this._desktopResizePending = false;
1406 + if (hadPendingResize || this.hasOfficialOffice()) {
1407 + this.queueDesktopResize({ force: true });
1408 + }
1409 + },
1410 +
1411 + shouldDeferDesktopResize() {
1412 + return Boolean(
1413 + this._desktopResizeSuspended
1414 + || document.body?.classList?.contains("right-canvas-resizing")
1415 + || document.querySelector?.(".modal-inner.office-modal.is-resizing")
1416 + );
1417 + },
1418 +
1419 + clearDesktopViewportSyncTimers() {
1420 + for (const timer of this._desktopViewportSyncTimers.splice(0)) {
1421 + globalThis.clearTimeout(timer);
1422 + }
1423 + },
1424 +
1425 + requestDesktopViewportSync(options = {}) {
1426 + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
1427 + if (options.force) this.clearDesktopViewportSyncTimers();
1428 + const run = (force = false) => {
1429 + this.syncDesktopViewport({ ...options, force });
1430 + };
1431 + if (globalThis.requestAnimationFrame) {
1432 + globalThis.requestAnimationFrame(() => run(Boolean(options.force)));
1433 + } else {
1434 + globalThis.setTimeout(() => run(Boolean(options.force)), 0);
1435 + }
1436 + if (options.followup === false) return;
1437 + const timer = globalThis.setTimeout(() => {
1438 + this._desktopViewportSyncTimers = this._desktopViewportSyncTimers.filter((item) => item !== timer);
1439 + run(false);
1440 + }, options.force ? 260 : 180);
1441 + this._desktopViewportSyncTimers.push(timer);
1442 + },
1443 +
1444 + syncDesktopViewport(options = {}) {
1445 + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return false;
1446 + const frame = this.desktopFrame(options.frame || null);
1447 + if (!frame) return false;
1448 + this.startDesktopResizeObserver();
1449 + this.primeXpraDesktopFrame({ reset: true, frame });
1450 + this.queueDesktopResize({
1451 + force: Boolean(options.force),
1452 + serverResize: options.serverResize !== false,
1453 + frame,
1454 + });
1455 + this.updateDesktopMonitor();
1456 + return true;
1457 + },
1458 +
1459 + primeXpraDesktopFrame(options = {}) {
1460 + if (options.reset) {
1461 + this.stopXpraDesktopPrime();
1462 + this._desktopPrimeAttempts = 0;
1463 + }
1464 + if (this.applyXpraDesktopFrameMode(options.frame || null)) return;
1465 + if (this._desktopPrimeAttempts >= XPRA_DESKTOP_PRIME_ATTEMPTS) return;
1466 + this._desktopPrimeAttempts += 1;
1467 + if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer);
1468 + this._desktopPrimeTimer = globalThis.setTimeout(() => {
1469 + this._desktopPrimeTimer = null;
1470 + this.primeXpraDesktopFrame();
1471 + }, XPRA_DESKTOP_PRIME_INTERVAL_MS);
1472 + },
1473 +
1474 + stopXpraDesktopPrime() {
1475 + if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer);
1476 + this._desktopPrimeTimer = null;
1477 + },
1478 +
1479 + applyXpraDesktopFrameMode(preferredFrame = null, options = {}) {
1480 + const frame = this.desktopFrame(preferredFrame);
1481 + const remoteWindow = frame?.contentWindow;
1482 + if (!remoteWindow) return false;
1483 + const requestServerResize = options.requestServerResize === true;
1484 + const requestRefresh = options.requestRefresh !== false;
1485 + try {
1486 + const remoteDocument = frame.contentDocument || remoteWindow.document;
1487 + this.installXpraDesktopFrameCss(remoteDocument);
1488 + this.installXpraDesktopFramePatches(remoteWindow, remoteDocument);
1489 + const client = remoteWindow.client;
1490 + if (!client) return false;
1491 + this.installXpraDesktopClientPatches(remoteWindow, client);
1492 + this.installXpraDesktopCursorPatches(remoteWindow, remoteDocument, client);
1493 + this.installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client);
1494 + this.installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client);
1495 + const container = client.container || remoteDocument?.querySelector?.("#screen");
1496 + if (!container) return false;
1497 +
1498 + client.server_is_desktop = true;
1499 + client.server_resize_exact = true;
1500 + remoteDocument?.body?.classList?.add("desktop");
1501 +
1502 + const windows = Object.values(client.id_to_window || {});
1503 + if (!client.connected || !windows.length) return false;
1504 +
1505 + const width = Math.round(container.clientWidth || remoteWindow.innerWidth || 0);
1506 + const height = Math.round(container.clientHeight || remoteWindow.innerHeight || 0);
1507 + if (width > 0 && height > 0) {
1508 + client.desktop_width = width;
1509 + client.desktop_height = height;
1510 + }
1511 + if (requestServerResize && width > 0 && height > 0 && typeof client._screen_resized === "function") {
1512 + client.desktop_width = 0;
1513 + client.desktop_height = 0;
1514 + client.__a0AllowScreenResize = true;
1515 + try {
1516 + client._screen_resized(new remoteWindow.Event("resize"));
1517 + } finally {
1518 + client.__a0AllowScreenResize = false;
1519 + }
1520 + }
1521 +
1522 + for (const xpraWindow of windows) {
1523 + this.normalizeXpraDesktopWindow(xpraWindow, width, height);
1524 + xpraWindow.screen_resized?.();
1525 + this.normalizeXpraDesktopWindow(xpraWindow, width, height);
1526 + xpraWindow.updateCSSGeometry?.();
1527 + this.fitXpraDesktopWindowElement(xpraWindow, width, height);
1528 + this.installXpraDesktopWheelBridge(remoteWindow, xpraWindow);
1529 + if (requestRefresh && xpraWindow.wid != null) client.request_refresh?.(xpraWindow.wid);
1530 + }
1531 + this.installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container);
1532 + return true;
1533 + } catch (error) {
1534 + console.warn("Xpra desktop viewport prime skipped", error);
1535 + return false;
1536 + }
1537 + },
1538 +
1539 + installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container) {
1540 + if (!frame || !remoteWindow || !remoteDocument || !client) return null;
1541 + const store = this;
1542 + const finite = (value, fallback = 0) => {
1543 + const number = Number(value);
1544 + return Number.isFinite(number) ? number : fallback;
1545 + };
1546 + const metrics = () => {
1547 + const desktopWidth = Math.max(1, finite(client.desktop_width || container?.clientWidth || remoteWindow.innerWidth, 1));
1548 + const desktopHeight = Math.max(1, finite(client.desktop_height || container?.clientHeight || remoteWindow.innerHeight, 1));
1549 + const clientWidth = Math.max(1, finite(container?.clientWidth || remoteWindow.innerWidth, desktopWidth));
1550 + const clientHeight = Math.max(1, finite(container?.clientHeight || remoteWindow.innerHeight, desktopHeight));
1551 + return {
1552 + desktopWidth,
1553 + desktopHeight,
1554 + clientWidth,
1555 + clientHeight,
1556 + scaleX: clientWidth / desktopWidth,
1557 + scaleY: clientHeight / desktopHeight,
1558 + };
1559 + };
1560 + const bridge = frame.__agentZeroDesktopBridge || {};
1561 + Object.assign(bridge, {
1562 + ready: true,
1563 + state: async (options = {}) => {
1564 + const result = await callDesktop("state", {
1565 + include_screenshot: options.includeScreenshot === true || options.include_screenshot === true,
1566 + });
1567 + store._desktopLastState = result;
1568 + return result;
1569 + },
1570 + focus: (options = {}) => store.focusDesktopFrame(frame, { ...options, arm: options.arm !== false }),
1571 + requestRefresh: () => {
1572 + for (const xpraWindow of Object.values(client.id_to_window || {})) {
1573 + if (xpraWindow?.wid != null) client.request_refresh?.(xpraWindow.wid);
1574 + }
1575 + return true;
1576 + },
1577 + desktopToClient: (x, y) => {
1578 + const value = metrics();
1579 + return {
1580 + x: Math.round(finite(x) * value.scaleX),
1581 + y: Math.round(finite(y) * value.scaleY),
1582 + scale_x: value.scaleX,
1583 + scale_y: value.scaleY,
1584 + };
1585 + },
1586 + clientToDesktop: (x, y) => {
1587 + const value = metrics();
1588 + return {
1589 + x: Math.round(finite(x) / value.scaleX),
1590 + y: Math.round(finite(y) / value.scaleY),
1591 + scale_x: value.scaleX,
1592 + scale_y: value.scaleY,
1593 + };
1594 + },
1595 + diagnostics: () => store.desktopBridgeDiagnostics(frame),
1596 + });
1597 + frame.agentZeroDesktop = bridge;
1598 + frame.__agentZeroDesktopBridge = bridge;
1599 + remoteWindow.agentZeroDesktop = bridge;
1600 + remoteWindow.__agentZeroDesktopBridge = bridge;
1601 + this._desktopBridgeReady = true;
1602 + this.updateDesktopKeyboardCaptureState(frame);
1603 + return bridge;
1604 + },
1605 +
1606 + desktopBridgeDiagnostics(frame = null) {
1607 + return {
1608 + ready: this._desktopBridgeReady,
1609 + keyboard: this.updateDesktopKeyboardCaptureState(frame),
1610 + lastStateOk: this._desktopLastState?.ok ?? null,
1611 + };
1612 + },
1613 +
1614 + updateDesktopKeyboardCaptureState(frame = null) {
1615 + const target = this.desktopFrame(frame);
1616 + const client = target?.contentWindow?.client;
1617 + const state = {
1618 + ready: Boolean(target?.__agentZeroDesktopBridge || target?.contentWindow?.__agentZeroDesktopBridge),
1619 + active: Boolean(this._desktopKeyboardActive),
1620 + capture: Boolean(client?.capture_keyboard),
1621 + focused: Boolean(target && (document.activeElement === target || target.contentDocument?.hasFocus?.())),
1622 + };
1623 + this._desktopKeyboardCaptureState = state;
1624 + return state;
1625 + },
1626 +
1627 + normalizeXpraDesktopWindow(xpraWindow, width, height) {
1628 + if (!xpraWindow) return;
1629 + const normalizedWidth = Math.max(1, Math.round(Number(width || 0)));
1630 + const normalizedHeight = Math.max(1, Math.round(Number(height || 0)));
1631 + xpraWindow.x = 0;
1632 + xpraWindow.y = 0;
1633 + xpraWindow.w = normalizedWidth;
1634 + xpraWindow.h = normalizedHeight;
1635 + xpraWindow.resizable = false;
1636 + xpraWindow.decorations = false;
1637 + xpraWindow.decorated = false;
1638 + xpraWindow.metadata = { ...(xpraWindow.metadata || {}), decorations: false };
1639 + xpraWindow._set_decorated?.(false);
1640 + xpraWindow.configure_border_class?.();
1641 + xpraWindow.leftoffset = 0;
1642 + xpraWindow.rightoffset = 0;
1643 + xpraWindow.topoffset = 0;
1644 + xpraWindow.bottomoffset = 0;
1645 + },
1646 +
1647 + fitXpraDesktopWindowElement(xpraWindow, width, height) {
1648 + const cssWidth = `${Math.max(1, Number(width || 0))}px`;
1649 + const cssHeight = `${Math.max(1, Number(height || 0))}px`;
1650 + const windowElement = xpraWindow?.div;
1651 + const canvas = xpraWindow?.canvas;
1652 + windowElement?.style?.setProperty("left", "0px", "important");
1653 + windowElement?.style?.setProperty("top", "0px", "important");
1654 + windowElement?.style?.setProperty("position", "absolute", "important");
1655 + windowElement?.style?.setProperty("width", cssWidth, "important");
1656 + windowElement?.style?.setProperty("height", cssHeight, "important");
1657 + windowElement?.style?.setProperty("transform", "none", "important");
1658 + windowElement?.style?.setProperty("margin", "0", "important");
1659 + canvas?.style?.setProperty("width", cssWidth, "important");
1660 + canvas?.style?.setProperty("height", cssHeight, "important");
1661 + canvas?.style?.setProperty("display", "block", "important");
1662 + canvas?.style?.setProperty("margin", "0", "important");
1663 + },
1664 +
1665 + installXpraDesktopWheelBridge(remoteWindow, xpraWindow) {
1666 + const canvas = xpraWindow?.canvas;
1667 + if (!remoteWindow || !canvas || canvas.__a0XpraWheelBridgeInstalled) return;
1668 + if (typeof xpraWindow.mouse_scroll_cb !== "function") return;
1669 + canvas.__a0XpraWheelBridgeInstalled = true;
1670 + canvas.addEventListener("wheel", (event) => {
1671 + event.stopImmediatePropagation?.();
1672 + event.stopPropagation?.();
1673 + event.preventDefault?.();
1674 + const normalizedEvent = this.xpraDesktopWheelEvent(remoteWindow, canvas, event);
1675 + xpraWindow.mouse_scroll_cb(normalizedEvent, xpraWindow);
1676 + }, { passive: false, capture: true });
1677 + },
1678 +
1679 + xpraDesktopWheelEvent(remoteWindow, canvas, event) {
1680 + const finite = (value, fallback = 0) => {
1681 + const number = Number(value);
1682 + return Number.isFinite(number) ? number : fallback;
1683 + };
1684 + const deltaMode = finite(event.deltaMode, 0);
1685 + const lineHeight = 16;
1686 + const pageHeight = Math.max(1, remoteWindow.innerHeight || canvas.clientHeight || 800);
1687 + const deltaScale = deltaMode === 1 ? lineHeight : deltaMode === 2 ? pageHeight : 1;
1688 + const deltaX = finite(event.deltaX) * deltaScale;
1689 + const deltaY = finite(event.deltaY) * deltaScale;
1690 + const deltaZ = finite(event.deltaZ) * deltaScale;
1691 + const wheelDeltaX = finite(event.wheelDeltaX, -deltaX);
1692 + const wheelDeltaY = finite(event.wheelDeltaY, -deltaY);
1693 + const wheelDelta = finite(event.wheelDelta, wheelDeltaY || wheelDeltaX);
1694 + const getModifierState = (key) => {
1695 + if (typeof event.getModifierState === "function") return event.getModifierState(key);
1696 + const normalizedKey = String(key || "").toLowerCase();
1697 + if (normalizedKey === "alt") return Boolean(event.altKey);
1698 + if (normalizedKey === "control") return Boolean(event.ctrlKey);
1699 + if (normalizedKey === "meta") return Boolean(event.metaKey);
1700 + if (normalizedKey === "shift") return Boolean(event.shiftKey);
1701 + return false;
1702 + };
1703 + const normalizedEvent = Object.create(event);
1704 + Object.defineProperties(normalizedEvent, {
1705 + target: { value: event.target || canvas },
1706 + currentTarget: { value: canvas },
1707 + clientX: { value: finite(event.clientX) },
1708 + clientY: { value: finite(event.clientY) },
1709 + pageX: { value: finite(event.pageX, finite(event.clientX)) },
1710 + pageY: { value: finite(event.pageY, finite(event.clientY)) },
1711 + screenX: { value: finite(event.screenX) },
1712 + screenY: { value: finite(event.screenY) },
1713 + offsetX: { value: finite(event.offsetX) },
1714 + offsetY: { value: finite(event.offsetY) },
1715 + movementX: { value: finite(event.movementX) },
1716 + movementY: { value: finite(event.movementY) },
1717 + button: { value: finite(event.button) },
1718 + buttons: { value: finite(event.buttons) },
1719 + which: { value: finite(event.which) },
1720 + detail: { value: finite(event.detail) },
1721 + deltaX: { value: deltaX },
1722 + deltaY: { value: deltaY },
1723 + deltaZ: { value: deltaZ },
1724 + deltaMode: { value: 0 },
1725 + wheelDeltaX: { value: wheelDeltaX },
1726 + wheelDeltaY: { value: wheelDeltaY },
1727 + wheelDelta: { value: wheelDelta },
1728 + altKey: { value: Boolean(event.altKey) },
1729 + ctrlKey: { value: Boolean(event.ctrlKey) },
1730 + metaKey: { value: Boolean(event.metaKey) },
1731 + shiftKey: { value: Boolean(event.shiftKey) },
1732 + getModifierState: { value: getModifierState },
1733 + preventDefault: { value: () => event.preventDefault?.() },
1734 + stopPropagation: { value: () => event.stopPropagation?.() },
1735 + stopImmediatePropagation: { value: () => event.stopImmediatePropagation?.() },
1736 + });
1737 + return normalizedEvent;
1738 + },
1739 +
1740 + installXpraDesktopFrameCss(remoteDocument) {
1741 + if (!remoteDocument || remoteDocument.getElementById("a0-xpra-desktop-frame-css")) return;
1742 + const style = remoteDocument.createElement("style");
1743 + style.id = "a0-xpra-desktop-frame-css";
1744 + style.textContent = `
1745 + html, body, #screen {
1746 + width: 100% !important;
1747 + height: 100% !important;
1748 + overflow: hidden !important;
1749 + }
1750 + #float_menu,
1751 + .windowhead,
1752 + .windowbuttons {
1753 + display: none !important;
1754 + }
1755 + #shadow_pointer {
1756 + display: none !important;
1757 + visibility: hidden !important;
1758 + opacity: 0 !important;
1759 + }
1760 + .window,
1761 + .window.border,
1762 + .window.desktop,
1763 + .undecorated,
1764 + .undecorated.border,
1765 + .undecorated.desktop {
1766 + left: 0 !important;
1767 + top: 0 !important;
1768 + position: absolute !important;
1769 + width: 100% !important;
1770 + height: 100% !important;
1771 + transform: none !important;
1772 + margin: 0 !important;
1773 + border: 0 !important;
1774 + border-radius: 0 !important;
1775 + box-shadow: none !important;
1776 + }
1777 + .window canvas,
1778 + .undecorated canvas {
1779 + display: block !important;
1780 + width: 100% !important;
1781 + height: 100% !important;
1782 + margin: 0 !important;
1783 + border: 0 !important;
1784 + border-radius: 0 !important;
1785 + box-shadow: none !important;
1786 + }
1787 + `;
1788 + remoteDocument.head?.appendChild(style);
1789 + },
1790 +
1791 + installXpraDesktopCursorPatches(remoteWindow, remoteDocument, client) {
1792 + if (!remoteWindow || !remoteDocument || !client) return;
1793 + const hideShadowPointer = () => {
1794 + const pointer = remoteDocument.getElementById?.("shadow_pointer");
1795 + pointer?.style?.setProperty("display", "none", "important");
1796 + pointer?.style?.setProperty("visibility", "hidden", "important");
1797 + pointer?.style?.setProperty("opacity", "0", "important");
1798 + };
1799 + hideShadowPointer();
1800 +
1801 + const pointerPacket = remoteWindow.PACKET_TYPES?.pointer_position || "pointer-position";
1802 + if (!client.__a0XpraDesktopCursorPatched) {
1803 + if (typeof client._process_pointer_position === "function") {
1804 + client.__a0OriginalProcessPointerPosition = client._process_pointer_position;
1805 + }
1806 + client._process_pointer_position = function patchedProcessPointerPosition(packet) {
1807 + hideShadowPointer();
1808 + this.__a0LastPointerPosition = packet;
1809 + return false;
1810 + };
1811 + client.__a0XpraDesktopCursorPatched = true;
1812 + }
1813 + if (client.packet_handlers && pointerPacket) {
1814 + client.packet_handlers[pointerPacket] = client._process_pointer_position;
1815 + }
1816 + },
1817 +
1818 + installXpraDesktopFramePatches(remoteWindow, remoteDocument) {
1819 + if (!remoteWindow || !remoteDocument) return;
1820 + remoteWindow.__a0XpraDesktopFramePatches ||= {};
1821 + const patches = remoteWindow.__a0XpraDesktopFramePatches;
1822 + const isBenignXpraWarning = (args = []) => {
1823 + const text = Array.from(args || []).map((value) => String(value || "")).join(" ");
1824 + return text.includes("window does not fit in canvas, offsets")
1825 + || (text.includes("decode error packet") && text.includes("not found"));
1826 + };
1827 + if (!patches.consoleWarn && typeof remoteWindow.console?.warn === "function") {
1828 + const originalConsoleWarn = remoteWindow.console.warn.bind(remoteWindow.console);
1829 + remoteWindow.console.warn = function patchedConsoleWarn(...args) {
1830 + if (isBenignXpraWarning(args)) return undefined;
1831 + return originalConsoleWarn(...args);
1832 + };
1833 + patches.consoleWarn = true;
1834 + }
1835 + if (!patches.noWindowList && typeof remoteWindow.noWindowList === "function") {
1836 + const originalNoWindowList = remoteWindow.noWindowList;
1837 + remoteWindow.noWindowList = function patchedNoWindowList(...args) {
1838 + if (!remoteDocument.querySelector("#open_windows")) return undefined;
1839 + return originalNoWindowList.apply(this, args);
1840 + };
1841 + patches.noWindowList = true;
1842 + }
1843 + if (!patches.addWindowListItem && typeof remoteWindow.addWindowListItem === "function") {
1844 + const originalAddWindowListItem = remoteWindow.addWindowListItem;
1845 + remoteWindow.addWindowListItem = function patchedAddWindowListItem(...args) {
1846 + if (!remoteDocument.querySelector("#open_windows_list")) return undefined;
1847 + return originalAddWindowListItem.apply(this, args);
1848 + };
1849 + patches.addWindowListItem = true;
1850 + }
1851 + },
1852 +
1853 + installXpraDesktopClientPatches(remoteWindow, client) {
1854 + if (!remoteWindow || !client) return;
1855 + if (!client.__a0XpraOffsetWarnPatched && typeof client.warn === "function") {
1856 + const originalClientWarn = client.warn.bind(client);
1857 + client.warn = function patchedClientWarn(...args) {
1858 + const text = Array.from(args || []).map((value) => String(value || "")).join(" ");
1859 + if (
1860 + text.includes("window does not fit in canvas, offsets")
1861 + || (text.includes("decode error packet") && text.includes("not found"))
1862 + ) {
1863 + return undefined;
1864 + }
1865 + return originalClientWarn(...args);
1866 + };
1867 + client.__a0XpraOffsetWarnPatched = true;
1868 + }
1869 + if (client.__a0XpraDesktopClientPatched) return;
1870 + if (typeof client._screen_resized === "function") {
1871 + const originalScreenResized = client._screen_resized.bind(client);
1872 + client.__a0OriginalScreenResized = originalScreenResized;
1873 + client._screen_resized = function patchedScreenResized(event) {
1874 + if (client.__a0AllowScreenResize === true) return originalScreenResized(event);
1875 + return false;
1876 + };
1877 + }
1878 + client.__a0XpraDesktopClientPatched = true;
1879 + },
1880 +
1881 + installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client) {
1882 + if (!frame || !remoteWindow || !remoteDocument || !client) return;
1883 + this.ensureDesktopClipboardBridge();
1884 + if (remoteWindow.__a0XpraDesktopClipboardBridgeInstalled) return;
1885 +
1886 + const onPaste = (event) => {
1887 + this.handleDesktopPasteEvent(event, frame, remoteWindow, client);
1888 + };
1889 + const onKeydown = (event) => {
1890 + if (this.isDesktopPasteShortcut(event)) {
1891 + void this.syncHostClipboardToDesktop(frame);
1892 + }
1893 + };
1894 + remoteWindow.addEventListener("paste", onPaste, true);
1895 + remoteDocument.addEventListener("paste", onPaste, true);
1896 + remoteWindow.addEventListener("keydown", onKeydown, true);
1897 + remoteDocument.addEventListener("keydown", onKeydown, true);
1898 + remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = true;
1899 + remoteWindow.__a0XpraDesktopClipboardBridgeCleanup = () => {
1900 + remoteWindow.removeEventListener("paste", onPaste, true);
1901 + remoteDocument.removeEventListener("paste", onPaste, true);
1902 + remoteWindow.removeEventListener("keydown", onKeydown, true);
1903 + remoteDocument.removeEventListener("keydown", onKeydown, true);
1904 + remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = false;
1905 + };
1906 + },
1907 +
1908 + ensureDesktopClipboardBridge() {
1909 + if (this._desktopClipboardCleanup) return;
1910 +
1911 + const onPaste = (event) => {
1912 + if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return;
1913 + if (isEditableInputTarget(event.target)) return;
1914 + const frame = this.desktopFrame();
1915 + const remoteWindow = frame?.contentWindow;
1916 + const client = remoteWindow?.client;
1917 + if (!frame || !remoteWindow || !client) return;
1918 + this.handleDesktopPasteEvent(event, frame, remoteWindow, client);
1919 + };
1920 +
1921 + document.addEventListener("paste", onPaste, true);
1922 + this._desktopClipboardCleanup = () => {
1923 + document.removeEventListener("paste", onPaste, true);
1924 + this._desktopClipboardCleanup = null;
1925 + };
1926 + },
1927 +
1928 + stopDesktopClipboardBridge() {
1929 + this._desktopClipboardCleanup?.();
1930 + },
1931 +
1932 + handleDesktopPasteEvent(event, frame, remoteWindow, client) {
1933 + const text = this.desktopClipboardTextFromEvent(event);
1934 + if (!text) return false;
1935 + if (!this.syncXpraClipboardText(client, text, remoteWindow)) return false;
1936 + event.preventDefault?.();
1937 + event.stopImmediatePropagation?.();
1938 + event.stopPropagation?.();
1939 + this.focusDesktopFrame(frame, { arm: true });
1940 + return true;
1941 + },
1942 +
1943 + desktopClipboardTextFromEvent(event) {
1944 + const data = (event?.originalEvent || event)?.clipboardData;
1945 + if (!data?.getData) return "";
1946 + for (const type of ["text/plain", "text", "Text", "STRING", "UTF8_STRING"]) {
1947 + const value = data.getData(type);
1948 + if (value) return value;
1949 + }
1950 + return "";
1951 + },
1952 +
1953 + syncXpraClipboardText(client, text, remoteWindow = null) {
1954 + const value = String(text ?? "");
1955 + if (!client || !value || typeof client.send_clipboard_token !== "function") return false;
1956 + const textPlain = remoteWindow?.TEXT_PLAIN || "text/plain";
1957 + const utf8String = remoteWindow?.UTF8_STRING || "UTF8_STRING";
1958 + const utilities = remoteWindow?.Utilities;
1959 + const payload = utilities?.StringToUint8 ? utilities.StringToUint8(value) : value;
1960 + client.clipboard_enabled = true;
1961 + client.clipboard_direction = "both";
1962 + client.clipboard_buffer = value;
1963 + client.clipboard_pending = false;
1964 + client.send_clipboard_token(payload, [textPlain, utf8String, "TEXT", "STRING"]);
1965 + return true;
1966 + },
1967 +
1968 + async syncHostClipboardToDesktop(frame = null) {
1969 + const target = this.desktopFrame(frame);
1970 + const remoteWindow = target?.contentWindow;
1971 + const client = remoteWindow?.client;
1972 + if (!client || !navigator.clipboard?.readText) return false;
1973 + try {
1974 + const text = await navigator.clipboard.readText();
1975 + return this.syncXpraClipboardText(client, text, remoteWindow);
1976 + } catch {
1977 + return false;
1978 + }
1979 + },
1980 +
1981 + isDesktopPasteShortcut(event) {
1982 + const key = String(event?.key || "").toLowerCase();
1983 + return key === "v" && (event?.ctrlKey || event?.metaKey) && !event?.altKey;
1984 + },
1985 +
1986 + installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client) {
1987 + if (!frame || !remoteWindow || !remoteDocument || !client) return;
1988 + this.ensureDesktopKeyboardBridge();
1989 + frame.setAttribute("tabindex", "0");
1990 + if (remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled) return;
1991 +
1992 + const activate = () => {
1993 + if (this._desktopFocusInProgress) return;
1994 + this.focusDesktopFrame(frame, { arm: true });
1995 + };
1996 + const events = ["pointerdown", "mousedown", "touchstart", "focusin"];
1997 + for (const eventName of events) {
1998 + remoteDocument.addEventListener(eventName, activate, true);
1999 + }
2000 + remoteWindow.addEventListener("focus", activate, true);
2001 + remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = true;
2002 + remoteWindow.__a0XpraDesktopKeyboardBridgeCleanup = () => {
2003 + for (const eventName of events) {
2004 + remoteDocument.removeEventListener(eventName, activate, true);
2005 + }
2006 + remoteWindow.removeEventListener("focus", activate, true);
2007 + remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = false;
2008 + };
2009 + },
2010 +
2011 + ensureDesktopKeyboardBridge() {
2012 + if (this._desktopKeyboardCleanup) return;
2013 +
2014 + const deactivateWhenOutsideDesktop = (event) => {
2015 + const target = event.target;
2016 + if (target?.closest?.(".office-desktop-wrap") || target?.matches?.("[data-office-desktop-frame]")) return;
2017 + this._desktopKeyboardActive = false;
2018 + };
2019 + const forwardKeyboardEvent = (event, pressed) => {
2020 + if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return;
2021 + if (event.defaultPrevented || isEditableInputTarget(event.target)) return;
2022 +
2023 + const frame = this.desktopFrame();
2024 + if (!frame || document.activeElement === frame) return;
2025 + const client = frame.contentWindow?.client;
2026 + const handler = pressed ? client?._keyb_onkeydown : client?._keyb_onkeyup;
2027 + if (!client?.capture_keyboard || typeof handler !== "function") return;
2028 + if (pressed && this.isDesktopPasteShortcut(event)) {
2029 + void this.syncHostClipboardToDesktop(frame);
2030 + }
2031 +
2032 + const allowDefault = handler.call(client, event);
2033 + if (!allowDefault) {
2034 + event.preventDefault();
2035 + event.stopPropagation();
2036 + }
2037 + };
2038 + const onKeydown = (event) => forwardKeyboardEvent(event, true);
2039 + const onKeyup = (event) => forwardKeyboardEvent(event, false);
2040 +
2041 + document.addEventListener("pointerdown", deactivateWhenOutsideDesktop, true);
2042 + document.addEventListener("keydown", onKeydown, true);
2043 + document.addEventListener("keyup", onKeyup, true);
2044 + this._desktopKeyboardCleanup = () => {
2045 + document.removeEventListener("pointerdown", deactivateWhenOutsideDesktop, true);
2046 + document.removeEventListener("keydown", onKeydown, true);
2047 + document.removeEventListener("keyup", onKeyup, true);
2048 + this._desktopKeyboardActive = false;
2049 + this._desktopKeyboardCleanup = null;
2050 + };
2051 + },
2052 +
2053 + stopDesktopKeyboardBridge() {
2054 + this._desktopKeyboardCleanup?.();
2055 + },
2056 +
2057 + queueDesktopResize(options = {}) {
2058 + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
2059 + const token = this.session?.desktop?.token || "";
2060 + const frame = this.desktopFrame(options.frame || null);
2061 + const target = frame?.parentElement || frame;
2062 + if (!token || !target) return;
2063 + const force = Boolean(options.force);
2064 + const serverResize = options.serverResize !== false;
2065 + const rect = target.getBoundingClientRect();
2066 + const width = Math.round(rect.width);
2067 + const height = Math.round(rect.height);
2068 + if (width < 320 || height < 220) return;
2069 + const key = `${token}:${width}x${height}`;
2070 + const refreshFrameOnly = () => {
2071 + this.applyXpraDesktopFrameMode(frame, { requestServerResize: false, requestRefresh: false });
2072 + };
2073 + if (!serverResize) {
2074 + refreshFrameOnly();
2075 + return;
2076 + }
2077 + if (key === this._desktopResizeKey || key === this._desktopResizePendingKey) {
2078 + refreshFrameOnly();
2079 + return;
2080 + }
2081 + refreshFrameOnly();
2082 + if (!force && this.shouldDeferDesktopResize()) {
2083 + this._desktopResizePending = true;
2084 + return;
2085 + }
2086 + if (this._desktopResizeTimer) globalThis.clearTimeout(this._desktopResizeTimer);
2087 + this._desktopResizePendingKey = key;
2088 + this._desktopResizeTimer = globalThis.setTimeout(async () => {
2089 + this._desktopResizeTimer = null;
2090 + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) {
2091 + if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = "";
2092 + return;
2093 + }
2094 + if (!force && this.shouldDeferDesktopResize()) {
2095 + if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = "";
2096 + this._desktopResizePending = true;
2097 + return;
2098 + }
2099 + try {
2100 + const params = new URLSearchParams({ token, width: String(width), height: String(height) });
2101 + const response = await fetch(`/desktop/resize?${params.toString()}`, { credentials: "same-origin" });
2102 + if (response.ok) {
2103 + const result = await response.json().catch(() => ({}));
2104 + this._desktopResizeKey = key;
2105 + const activeFrame = this.desktopFrame(frame);
2106 + const activeTarget = activeFrame?.parentElement || activeFrame;
2107 + const activeRect = activeTarget?.getBoundingClientRect?.();
2108 + const activeWidth = Math.round(activeRect?.width || 0);
2109 + const activeHeight = Math.round(activeRect?.height || 0);
2110 + if (activeWidth >= 320 && activeHeight >= 220) {
2111 + const activeKey = `${token}:${activeWidth}x${activeHeight}`;
2112 + if (activeKey !== key) {
2113 + this.queueDesktopResize({ force: true, serverResize: true, frame: activeFrame });
2114 + return;
2115 + }
2116 + }
2117 + if (result?.reload) this.reloadDesktopFrame(activeFrame || frame);
2118 + this.primeXpraDesktopFrame({ reset: true, frame: activeFrame || frame });
2119 + }
2120 + } catch (error) {
2121 + console.warn("Desktop resize skipped", error);
2122 + } finally {
2123 + if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = "";
2124 + }
2125 + }, DESKTOP_RESIZE_DELAY_MS);
2126 + },
2127 +
2128 + reloadDesktopFrame(frame = null) {
2129 + const target = this.desktopFrame(frame);
2130 + if (!target) return;
2131 + const current = target.getAttribute("src") || target.src || this.officialOfficeUrl();
2132 + if (!current) return;
2133 + try {
2134 + const url = new URL(current, window.location.href);
2135 + url.searchParams.set("a0_reload", String(Date.now()));
2136 + target.setAttribute("src", `${url.pathname}${url.search}`);
2137 + } catch {
2138 + target.setAttribute("src", current);
2139 + }
2140 + },
2141 +
2142 + async handleDesktopUrlIntents(intents = []) {
2143 + const incoming = Array.isArray(intents)
2144 + ? intents.filter((intent) => intent && typeof intent === "object")
2145 + : [];
2146 + if (!incoming.length) return;
2147 + this._desktopUrlIntentQueue.push(...incoming);
2148 + if (this._desktopUrlIntentBusy) return;
2149 +
2150 + this._desktopUrlIntentBusy = true;
2151 + try {
2152 + while (this._desktopUrlIntentQueue.length) {
2153 + const intent = this._desktopUrlIntentQueue.shift();
2154 + await this.openDesktopUrlIntent(intent);
2155 + }
2156 + } finally {
2157 + this._desktopUrlIntentBusy = false;
2158 + }
2159 + },
2160 +
2161 + async openDesktopUrlIntent(intent = {}) {
2162 + const url = String(intent?.url || "").trim();
2163 + const handled = await handleUrlIntent({ url, source: "desktop-url" });
2164 + this.setMessage(handled ? "Opened link in Browser" : "Browser is not available");
2165 + },
2166 +
2167 + browserDestinationForDesktopUrl() {
2168 + if (this.isDesktopInModal()) return "canvas";
2169 + return "modal";
2170 + },
2171 +
2172 + isDesktopInModal() {
2173 + const modalDesktop = Array.from(document.querySelectorAll(".office-panel"))
2174 + .some((panel) => panel.closest?.(".modal") && panel.querySelector?.("[data-office-desktop-frame]"));
2175 + if (modalDesktop) return true;
2176 + return this._mode === "modal";
2177 + },
2178 +
2179 + startDesktopMonitor() {
2180 + this.stopDesktopMonitor();
2181 + if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
2182 + const tabId = this.session?.tab_id || "";
2183 + const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
2184 + if (!tabId || !sessionId) return;
2185 + this._desktopHeartbeatSessionId = sessionId;
2186 + this._desktopHeartbeatTabId = tabId;
2187 + this._desktopHeartbeatMisses = 0;
2188 +
2189 + const tick = async () => {
2190 + if (!this.session || this.session.tab_id !== tabId || !this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
2191 + try {
2192 + const response = await callDesktop("sync", {
2193 + desktop_session_id: sessionId,
2194 + file_id: this.session.file_id || "",
2195 + });
2196 + if (response?.intentional_shutdown || response?.shutdown) {
2197 + await this.handleIntentionalDesktopShutdown(response);
2198 + return;
2199 + }
2200 + if (response?.ok === false) throw new Error(response.error || "Desktop session closed.");
2201 + this._desktopHeartbeatMisses = 0;
2202 + await this.handleDesktopUrlIntents(response?.url_intents);
2203 + if (response?.document) {
2204 + const document = normalizeDocument(response.document);
2205 + this.replaceActiveSession({
2206 + ...this.session,
2207 + document,
2208 + path: document.path || this.session.path,
2209 + file_id: document.file_id || this.session.file_id,
2210 + version: document.version || this.session.version,
2211 + });
2212 + }
2213 + } catch {
2214 + if (!this.session || this.session.tab_id !== tabId) return;
2215 + this._desktopHeartbeatMisses += 1;
2216 + if (this._desktopHeartbeatMisses >= 2) {
2217 + await this.handleOfficialOfficeClosed(tabId);
2218 + }
2219 + }
2220 + };
2221 +
2222 + this._desktopHeartbeatTimer = globalThis.setInterval(tick, DESKTOP_HEARTBEAT_MS);
2223 + globalThis.setTimeout(tick, Math.min(1200, DESKTOP_HEARTBEAT_MS));
2224 + },
2225 +
2226 + stopDesktopMonitor() {
2227 + if (this._desktopHeartbeatTimer) {
2228 + globalThis.clearInterval(this._desktopHeartbeatTimer);
2229 + }
2230 + this._desktopHeartbeatTimer = null;
2231 + this._desktopHeartbeatSessionId = "";
2232 + this._desktopHeartbeatTabId = "";
2233 + this._desktopHeartbeatMisses = 0;
2234 + },
2235 +
2236 + async handleOfficialOfficeClosed(tabId) {
2237 + if (this._desktopIntentionalShutdown) return;
2238 + const tab = this.tabs.find((item) => item.tab_id === tabId);
2239 + const hiddenDesktopDocument = !tab && this.session?.tab_id === tabId && this.isDesktopOfficeDocument(this.session)
2240 + ? this.session
2241 + : null;
2242 + const target = tab || hiddenDesktopDocument;
2243 + if (!target || target._desktopClosed) return;
2244 + target._desktopClosed = true;
2245 + this.stopDesktopMonitor();
2246 + this.stopDesktopResizeObserver();
2247 + this.stopXpraDesktopPrime();
2248 + this.message = "Desktop is restarting";
2249 + await this.ensureDesktopSession({
2250 + force: true,
2251 + select: this.activeTabId === tabId || Boolean(hiddenDesktopDocument),
2252 + message: "Desktop is restarting",
2253 + });
2254 + target._desktopClosed = false;
2255 + await this.refresh();
2256 + },
2257 +
2258 + defaultTitle(kind, fmt) {
2259 + const date = new Date().toISOString().slice(0, 10);
2260 + if (fmt === "md") return `Document ${date}`;
2261 + if (fmt === "odt") return `Writer ${date}`;
2262 + if (fmt === "docx") return `DOCX ${date}`;
2263 + if (kind === "spreadsheet") return `Spreadsheet ${date}`;
2264 + if (kind === "presentation") return `Presentation ${date}`;
2265 + return `Document ${date}`;
2266 + },
2267 +
2268 + tabTitle(tab = {}) {
2269 + tab = tab || {};
2270 + return tab.title || tab.document?.basename || basename(tab.path);
2271 + },
2272 +
2273 + tabLabel(tab = {}) {
2274 + tab = tab || {};
2275 + const title = this.tabTitle(tab);
2276 + return tab.dirty ? `${title} unsaved` : title;
2277 + },
2278 +
2279 + tabIcon(tab = {}) {
2280 + tab = tab || {};
2281 + const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
2282 + if (this.isDesktopSession(tab)) return "desktop_windows";
2283 + if (ext === "md") return "article";
2284 + if (ext === "odt" || ext === "docx") return "description";
2285 + if (ext === "ods" || ext === "xlsx") return "table_chart";
2286 + if (ext === "odp" || ext === "pptx") return "co_present";
2287 + return "draft";
2288 + },
2289 +
2290 + async runNewMenuAction(action = "") {
2291 + const normalized = String(action || "").trim().toLowerCase();
2292 + if (normalized === "open") return await this.openFileBrowser();
2293 + if (normalized === "markdown") return await this.create("document", "md");
2294 + if (normalized === "writer") return await this.create("document", "odt");
2295 + if (normalized === "spreadsheet") return await this.create("spreadsheet", "ods");
2296 + if (normalized === "presentation") return await this.create("presentation", "odp");
2297 + return null;
2298 + },
2299 +
2300 + installHeaderNewMenu(header = null) {
2301 + if (!header || header.querySelector(".office-header-actions")) return () => {};
2302 +
2303 + const root = globalThis.document.createElement("div");
2304 + root.className = "office-header-actions";
2305 + root.innerHTML = `
2306 + <button type="button" class="office-header-new-button" aria-haspopup="menu" aria-expanded="false">
2307 + <span class="material-symbols-outlined" aria-hidden="true">add</span>
2308 + <span>New</span>
2309 + <span class="material-symbols-outlined office-new-chevron" aria-hidden="true">expand_more</span>
2310 + </button>
2311 + <div class="office-new-menu" role="menu" hidden>
2312 + <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="open">
2313 + <span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
2314 + <span>Open</span>
2315 + </button>
2316 + <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="markdown">
2317 + <span class="material-symbols-outlined" aria-hidden="true">article</span>
2318 + <span>Markdown</span>
2319 + </button>
2320 + <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="writer">
2321 + <span class="material-symbols-outlined" aria-hidden="true">description</span>
2322 + <span>Writer</span>
2323 + </button>
2324 + <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="spreadsheet">
2325 + <span class="material-symbols-outlined" aria-hidden="true">table_chart</span>
2326 + <span>Spreadsheet</span>
2327 + </button>
2328 + <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="presentation">
2329 + <span class="material-symbols-outlined" aria-hidden="true">co_present</span>
2330 + <span>Presentation</span>
2331 + </button>
2332 + </div>
2333 + `;
2334 +
2335 + const button = root.querySelector(".office-header-new-button");
2336 + const menu = root.querySelector(".office-new-menu");
2337 + const setOpen = (open) => {
2338 + root.classList.toggle("is-open", open);
2339 + button?.setAttribute("aria-expanded", open.toString());
2340 + if (menu) menu.hidden = !open;
2341 + };
2342 + const onButtonClick = (event) => {
2343 + event.preventDefault();
2344 + event.stopPropagation();
2345 + setOpen(!root.classList.contains("is-open"));
2346 + };
2347 + const onDocumentClick = (event) => {
2348 + if (!root.contains(event.target)) setOpen(false);
2349 + };
2350 + const onDocumentKeydown = (event) => {
2351 + if (event.key === "Escape") setOpen(false);
2352 + };
2353 +
2354 + button?.addEventListener("click", onButtonClick);
2355 + for (const item of root.querySelectorAll("[data-office-new-action]")) {
2356 + item.addEventListener("click", async (event) => {
2357 + event.preventDefault();
2358 + event.stopPropagation();
2359 + const action = event.currentTarget?.dataset?.officeNewAction || "";
2360 + setOpen(false);
2361 + await this.runNewMenuAction(action);
2362 + });
2363 + }
2364 + globalThis.document.addEventListener("click", onDocumentClick);
2365 + globalThis.document.addEventListener("keydown", onDocumentKeydown);
2366 +
2367 + const firstHeaderAction = header.querySelector(
2368 + ".modal-surface-switcher, .modal-dock-button, .office-modal-focus-button, .modal-close",
2369 + );
2370 + if (firstHeaderAction) {
2371 + firstHeaderAction.insertAdjacentElement("beforebegin", root);
2372 + } else {
2373 + header.appendChild(root);
2374 + }
2375 +
2376 + setOpen(false);
2377 + return () => {
2378 + button?.removeEventListener("click", onButtonClick);
2379 + globalThis.document.removeEventListener("click", onDocumentClick);
2380 + globalThis.document.removeEventListener("keydown", onDocumentKeydown);
2381 + root.remove();
2382 + };
2383 + },
2384 +
2385 + setupFloatingModal(element = null) {
2386 + const root = element || globalThis.document?.querySelector(".office-panel");
2387 + const modal = root?.closest?.(".modal");
2388 + const inner = root?.closest?.(".modal-inner");
2389 + const body = root?.closest?.(".modal-bd");
2390 + const header = inner?.querySelector?.(".modal-header");
2391 + if (!inner || !body || !header || inner.dataset.officeModalReady === "1") return;
2392 +
2393 + inner.dataset.officeModalReady = "1";
2394 + modal?.classList?.add("surface-floating", "modal-floating", "modal-no-backdrop");
2395 + inner.classList.add("surface-modal", "office-modal", "modal-no-backdrop");
2396 + body.classList.add("office-modal-body");
2397 + header.style.cursor = "move";
2398 +
2399 + const inset = 8;
2400 + const minWidth = 720;
2401 + const minHeight = 520;
2402 + const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
2403 + const cleanup = [];
2404 + let beforeFocusBounds = null;
2405 + let dragging = false;
2406 + let resizing = false;
2407 + let pointerId = 0;
2408 + let startX = 0;
2409 + let startY = 0;
2410 + let startLeft = 0;
2411 + let startTop = 0;
2412 + let startWidth = 0;
2413 + let startHeight = 0;
2414 + let resizeMode = "";
2415 +
2416 + const newMenuCleanup = this.installHeaderNewMenu(header);
2417 +
2418 + const currentBounds = () => {
2419 + const rect = inner.getBoundingClientRect();
2420 + return {
2421 + left: rect.left,
2422 + top: rect.top,
2423 + width: rect.width,
2424 + height: rect.height,
2425 + };
2426 + };
2427 +
2428 + const normalizedBounds = (bounds) => {
2429 + const maxWidth = Math.max(320, globalThis.innerWidth - inset * 2);
2430 + const maxHeight = Math.max(320, globalThis.innerHeight - inset * 2);
2431 + const safeMinWidth = Math.min(minWidth, maxWidth);
2432 + const safeMinHeight = Math.min(minHeight, maxHeight);
2433 + const width = clamp(bounds.width, safeMinWidth, maxWidth);
2434 + const height = clamp(bounds.height, safeMinHeight, maxHeight);
2435 + return {
2436 + width,
2437 + height,
2438 + left: clamp(bounds.left, inset, Math.max(inset, globalThis.innerWidth - width - inset)),
2439 + top: clamp(bounds.top, inset, Math.max(inset, globalThis.innerHeight - height - inset)),
2440 + };
2441 + };
2442 +
2443 + const setBounds = (bounds) => {
2444 + const next = normalizedBounds(bounds);
2445 + inner.style.position = "fixed";
2446 + inner.style.transform = "none";
2447 + inner.style.left = `${Math.round(next.left)}px`;
2448 + inner.style.top = `${Math.round(next.top)}px`;
2449 + inner.style.width = `${Math.round(next.width)}px`;
2450 + inner.style.height = `${Math.round(next.height)}px`;
2451 + inner.style.right = "auto";
2452 + inner.style.bottom = "auto";
2453 + inner.style.margin = "0";
2454 + };
2455 +
2456 + const ensurePosition = () => {
2457 + setBounds(currentBounds());
2458 + };
2459 +
2460 + const shield = globalThis.document.createElement("div");
2461 + shield.className = "office-modal-input-shield";
2462 + inner.appendChild(shield);
2463 + cleanup.push(() => shield.remove());
2464 +
2465 + const setShield = (visible, cursor = "") => {
2466 + shield.style.display = visible ? "block" : "none";
2467 + shield.style.cursor = cursor;
2468 + };
2469 +
2470 + const focusButton = globalThis.document.createElement("button");
2471 + focusButton.type = "button";
2472 + focusButton.className = "modal-dock-button office-modal-focus-button";
2473 + focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
2474 + const updateFocusButton = (active) => {
2475 + const label = active ? "Restore size" : "Focus mode";
2476 + focusButton.setAttribute("aria-label", label);
2477 + focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
2478 + };
2479 + updateFocusButton(false);
2480 + const closeButton = inner.querySelector(".modal-close");
2481 + if (closeButton) {
2482 + closeButton.insertAdjacentElement("beforebegin", focusButton);
2483 + } else {
2484 + header.appendChild(focusButton);
2485 + }
2486 + cleanup.push(() => focusButton.remove());
2487 +
2488 + const setFocusMode = (enabled) => {
2489 + ensurePosition();
2490 + if (enabled) {
2491 + beforeFocusBounds = currentBounds();
2492 + inner.classList.add("is-focus-mode");
2493 + setBounds({
2494 + left: inset,
2495 + top: inset,
2496 + width: globalThis.innerWidth - inset * 2,
2497 + height: globalThis.innerHeight - inset * 2,
2498 + });
2499 + updateFocusButton(true);
2500 + return;
2501 + }
2502 + inner.classList.remove("is-focus-mode");
2503 + setBounds(beforeFocusBounds || currentBounds());
2504 + beforeFocusBounds = null;
2505 + updateFocusButton(false);
2506 + };
2507 +
2508 + const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
2509 + focusButton.addEventListener("click", onFocusClick);
2510 + cleanup.push(() => focusButton.removeEventListener("click", onFocusClick));
2511 +
2512 + const onPointerDown = (event) => {
2513 + if (event.button !== 0) return;
2514 + if (event.target?.closest?.("button,a,input,textarea,select")) return;
2515 + if (inner.classList.contains("is-focus-mode")) return;
2516 + ensurePosition();
2517 + const rect = inner.getBoundingClientRect();
2518 + dragging = true;
2519 + pointerId = event.pointerId;
2520 + startX = event.clientX;
2521 + startY = event.clientY;
2522 + startLeft = rect.left;
2523 + startTop = rect.top;
2524 + startWidth = rect.width;
2525 + startHeight = rect.height;
2526 + inner.classList.add("is-dragging");
2527 + setShield(true, "move");
2528 + header.setPointerCapture?.(pointerId);
2529 + event.preventDefault();
2530 + };
2531 +
2532 + const onPointerMove = (event) => {
2533 + if (!dragging || event.pointerId !== pointerId) return;
2534 + setBounds({
2535 + left: startLeft + event.clientX - startX,
2536 + top: startTop + event.clientY - startY,
2537 + width: startWidth,
2538 + height: startHeight,
2539 + });
2540 + };
2541 +
2542 + const onPointerUp = (event) => {
2543 + if (!dragging || event.pointerId !== pointerId) return;
2544 + dragging = false;
2545 + inner.classList.remove("is-dragging");
2546 + setShield(false);
2547 + header.releasePointerCapture?.(pointerId);
2548 + };
2549 +
2550 + const createResizeHandle = (mode) => {
2551 + const handle = globalThis.document.createElement("div");
2552 + handle.className = `office-modal-resizer is-${mode}`;
2553 + handle.dataset.officeResize = mode;
2554 + inner.appendChild(handle);
2555 + cleanup.push(() => handle.remove());
2556 + return handle;
2557 + };
2558 +
2559 + const onResizeDown = (event) => {
2560 + if (event.button !== 0 || inner.classList.contains("is-focus-mode")) return;
2561 + ensurePosition();
2562 + const rect = inner.getBoundingClientRect();
2563 + resizing = true;
2564 + resizeMode = event.currentTarget.dataset.officeResize || "";
2565 + pointerId = event.pointerId;
2566 + startX = event.clientX;
2567 + startY = event.clientY;
2568 + startLeft = rect.left;
2569 + startTop = rect.top;
2570 + startWidth = rect.width;
2571 + startHeight = rect.height;
2572 + inner.classList.add("is-resizing");
2573 + this.suspendDesktopResize();
2574 + setShield(true, resizeMode === "right" ? "ew-resize" : resizeMode === "bottom" ? "ns-resize" : "nwse-resize");
2575 + event.currentTarget.setPointerCapture?.(pointerId);
2576 + event.preventDefault();
2577 + event.stopPropagation();
2578 + };
2579 +
2580 + const onResizeMove = (event) => {
2581 + if (!resizing || event.pointerId !== pointerId) return;
2582 + const dx = event.clientX - startX;
2583 + const dy = event.clientY - startY;
2584 + setBounds({
2585 + left: startLeft,
2586 + top: startTop,
2587 + width: resizeMode === "bottom" ? startWidth : startWidth + dx,
2588 + height: resizeMode === "right" ? startHeight : startHeight + dy,
2589 + });
2590 + };
2591 +
2592 + const onResizeUp = (event) => {
2593 + if (!resizing || event.pointerId !== pointerId) return;
2594 + resizing = false;
2595 + resizeMode = "";
2596 + inner.classList.remove("is-resizing");
2597 + setShield(false);
2598 + event.currentTarget.releasePointerCapture?.(pointerId);
2599 + this.resumeDesktopResize();
2600 + };
2601 +
2602 + header.addEventListener("pointerdown", onPointerDown);
2603 + header.addEventListener("pointermove", onPointerMove);
2604 + header.addEventListener("pointerup", onPointerUp);
2605 + header.addEventListener("pointercancel", onPointerUp);
2606 + cleanup.push(() => header.removeEventListener("pointerdown", onPointerDown));
2607 + cleanup.push(() => header.removeEventListener("pointermove", onPointerMove));
2608 + cleanup.push(() => header.removeEventListener("pointerup", onPointerUp));
2609 + cleanup.push(() => header.removeEventListener("pointercancel", onPointerUp));
2610 +
2611 + for (const mode of ["right", "bottom", "corner"]) {
2612 + const handle = createResizeHandle(mode);
2613 + handle.addEventListener("pointerdown", onResizeDown);
2614 + handle.addEventListener("pointermove", onResizeMove);
2615 + handle.addEventListener("pointerup", onResizeUp);
2616 + handle.addEventListener("pointercancel", onResizeUp);
2617 + cleanup.push(() => handle.removeEventListener("pointerdown", onResizeDown));
2618 + cleanup.push(() => handle.removeEventListener("pointermove", onResizeMove));
2619 + cleanup.push(() => handle.removeEventListener("pointerup", onResizeUp));
2620 + cleanup.push(() => handle.removeEventListener("pointercancel", onResizeUp));
2621 + }
2622 +
2623 + const onWindowResize = () => {
2624 + if (inner.classList.contains("is-focus-mode")) {
2625 + setBounds({
2626 + left: inset,
2627 + top: inset,
2628 + width: globalThis.innerWidth - inset * 2,
2629 + height: globalThis.innerHeight - inset * 2,
2630 + });
2631 + return;
2632 + }
2633 + ensurePosition();
2634 + };
2635 + globalThis.addEventListener("resize", onWindowResize);
2636 + cleanup.push(() => globalThis.removeEventListener("resize", onWindowResize));
2637 +
2638 + if (globalThis.requestAnimationFrame) {
2639 + globalThis.requestAnimationFrame(ensurePosition);
2640 + } else {
2641 + globalThis.setTimeout(ensurePosition, 0);
2642 + }
2643 + this._floatingCleanup = () => {
2644 + newMenuCleanup?.();
2645 + cleanup.splice(0).reverse().forEach((entry) => entry());
2646 + modal?.classList?.remove("surface-floating", "modal-floating", "modal-no-backdrop");
2647 + inner.classList.remove("is-dragging", "is-resizing", "is-focus-mode");
2648 + this._desktopResizeSuspended = false;
2649 + this._desktopResizePending = false;
2650 + delete inner.dataset.officeModalReady;
2651 + };
2652 + },
2653 +};
2654 +
2655 +export const store = createStore("desktop", model);
plugins/_desktop/webui/main.html new
+21
@@ -0,0 +1,21 @@
1 +<html
2 + class="surface-modal office-modal modal-no-backdrop"
3 + data-surface-id="desktop"
4 + data-surface-modal-path="/plugins/_desktop/webui/main.html"
5 + data-surface-dock-title="Open Desktop in surface"
6 + data-surface-dock-icon="dock_to_right"
7 + data-canvas-surface="desktop"
8 + data-canvas-modal-path="/plugins/_desktop/webui/main.html"
9 + data-canvas-dock-title="Open Desktop in canvas"
10 + data-canvas-dock-icon="dock_to_right"
11 +>
12 +<head>
13 + <title>Desktop</title>
14 + <script type="module">
15 + import { store } from "/plugins/_desktop/webui/desktop-store.js";
16 + </script>
17 +</head>
18 +<body class="office-modal-body">
19 + <x-component path="/plugins/_desktop/webui/desktop-panel.html" mode="modal"></x-component>
20 +</body>
21 +</html>
plugins/_desktop/webui/thumbnail.jpg
Binary files /dev/null and b/plugins/_desktop/webui/thumbnail.jpg differ
plugins/_office/extensions/python/startup_migration/_20_office_routes.py
+4 -6
@@ -6,7 +6,6 @@ from typing import Any
6 from helpers.extension import Extension
7 from helpers.print_style import PrintStyle
8 from plugins._office import hooks
9 -from plugins._office.helpers import libreoffice_desktop_routes
9
10
11 _startup_preparation_thread: threading.Thread | None = None
@@ -14,7 +13,6 @@ _startup_preparation_thread: threading.Thread | None = None
13
14 class OfficeStartupCleanup(Extension):
15 def execute(self, **kwargs):
17 - libreoffice_desktop_routes.install_route_hooks()
16 _start_background_runtime_preparation()
17
18
@@ -26,7 +24,7 @@ def _start_background_runtime_preparation() -> threading.Thread:
24
25 _startup_preparation_thread = threading.Thread(
26 target=_prepare_runtime_safely,
29 - name="a0-office-runtime-preparation",
27 + name="a0-office-document-runtime-preparation",
28 daemon=True,
29 )
30 _startup_preparation_thread.start()
@@ -37,11 +35,11 @@ def _prepare_runtime_safely() -> None:
35 try:
36 _log_runtime_preparation_result(hooks.cleanup_stale_runtime_state())
37 except Exception as exc:
40 - PrintStyle.warning("Office runtime preparation failed:", exc)
38 + PrintStyle.warning("Office document runtime preparation failed:", exc)
39
40
41 def _log_runtime_preparation_result(result: dict[str, Any]) -> None:
42 if result.get("errors"):
45 - PrintStyle.warning("Office runtime preparation reported errors:", result["errors"])
43 + PrintStyle.warning("Office document runtime preparation reported errors:", result["errors"])
44 elif result.get("installed") or result.get("removed"):
47 - PrintStyle.info("Office runtime prepared:", result)
45 + PrintStyle.info("Office document runtime prepared:", result)
plugins/_office/extensions/webui/right-canvas-panels/office-panel.html deleted
-15
@@ -1,15 +0,0 @@
1 -<div
2 - class="right-canvas-surface-panel office-canvas-surface"
3 - data-surface-id="office"
4 - :class="{
5 - 'is-active': $store.rightCanvas?.isSurfaceVisible('office'),
6 - 'is-mounted': $store.rightCanvas?.isSurfaceRendered('office')
7 - }"
8 - :aria-hidden="(!$store.rightCanvas?.isSurfaceVisible('office')).toString()"
9 - x-effect="(() => {
10 - const visible = Boolean($store.rightCanvas?.isSurfaceRendered('office'));
11 - (globalThis.queueMicrotask || ((callback) => globalThis.setTimeout(callback, 0)))(() => $store.office?.setDesktopHostVisible?.(visible));
12 - })()"
13 ->
14 - <x-component path="/plugins/_office/webui/office-panel.html"></x-component>
15 -</div>
plugins/_office/extensions/webui/right-canvas-toolbar-start/office-new-menu.html deleted
-43
@@ -1,43 +0,0 @@
1 -<style>
2 - .right-canvas-toolbar > x-extension:has(.office-canvas-new-menu-host),
3 - .right-canvas-toolbar > x-extension > x-component:has(.office-canvas-new-menu-host) {
4 - display: contents;
5 - }
6 -
7 - .office-canvas-new-menu-host {
8 - display: inline-flex;
9 - align-items: center;
10 - }
11 -
12 - .office-canvas-new-menu-host[hidden] {
13 - display: none;
14 - }
15 -</style>
16 -
17 -<script type="module">
18 - import { store as officeStore } from "/plugins/_office/webui/office-store.js";
19 -
20 - function syncCanvasMenuVisibility(host) {
21 - if (!host?.isConnected) return;
22 - const rightCanvas = globalThis.Alpine?.store?.("rightCanvas");
23 - host.hidden = !(rightCanvas?.isOpen && rightCanvas?.activeSurfaceId === "office");
24 - globalThis.requestAnimationFrame?.(() => syncCanvasMenuVisibility(host));
25 - }
26 -
27 - function mountCanvasNewMenu() {
28 - const hosts = globalThis.document.querySelectorAll("[data-office-canvas-new-menu-host]:not([data-office-mounted])");
29 - for (const host of hosts) {
30 - host.dataset.officeMounted = "1";
31 - officeStore.installHeaderNewMenu(host);
32 - syncCanvasMenuVisibility(host);
33 - }
34 - }
35 -
36 - if (globalThis.requestAnimationFrame) {
37 - globalThis.requestAnimationFrame(mountCanvasNewMenu);
38 - } else {
39 - globalThis.setTimeout(mountCanvasNewMenu, 0);
40 - }
41 -</script>
42 -
43 -<div class="office-canvas-new-menu-host" data-office-canvas-new-menu-host hidden></div>
plugins/_office/extensions/webui/right_canvas_register_surfaces/register-office.js deleted
-56
@@ -1,56 +0,0 @@
1 -import { store as officeStore } from "/plugins/_office/webui/office-store.js";
2 -
3 -void officeStore;
4 -
5 -function waitForElement(selector, timeoutMs = 10000) {
6 - const found = document.querySelector(selector);
7 - if (found) return Promise.resolve(found);
8 - return new Promise((resolve) => {
9 - const timeout = globalThis.setTimeout(() => {
10 - observer.disconnect();
11 - resolve(document.querySelector(selector));
12 - }, timeoutMs);
13 - const observer = new MutationObserver(() => {
14 - const element = document.querySelector(selector);
15 - if (!element) return;
16 - globalThis.clearTimeout(timeout);
17 - observer.disconnect();
18 - resolve(element);
19 - });
20 - observer.observe(document.body, { childList: true, subtree: true });
21 - });
22 -}
23 -
24 -export default async function registerOfficeSurface(canvas) {
25 - canvas.registerSurface({
26 - id: "office",
27 - title: "Desktop",
28 - icon: "desktop_windows",
29 - order: 20,
30 - modalPath: "/plugins/_office/webui/main.html",
31 - async beginDockHandoff() {
32 - const office = globalThis.Alpine?.store?.("office");
33 - office?.beforeDesktopHostHandoff?.();
34 - },
35 - async finishDockHandoff(payload = {}) {
36 - const office = globalThis.Alpine?.store?.("office");
37 - if (payload.opened !== false) office?.afterDesktopHostShown?.({ source: "dock" });
38 - },
39 - async cancelDockHandoff() {
40 - const office = globalThis.Alpine?.store?.("office");
41 - office?.cancelDesktopHostHandoff?.();
42 - },
43 - async open(payload = {}) {
44 - const panel = await waitForElement('[data-surface-id="office"] .office-panel');
45 - if (!panel) throw new Error("Office canvas panel did not mount.");
46 - const office = globalThis.Alpine?.store?.("office");
47 - await office?.onMount?.(panel, { mode: "canvas" });
48 - await office?.onOpen?.(payload);
49 - office?.afterDesktopHostShown?.({ source: payload?.source || "canvas" });
50 - },
51 - async close(payload = {}) {
52 - const office = globalThis.Alpine?.store?.("office");
53 - office?.beforeHostHidden?.({ unloadDesktop: payload?.reason === "mobile" });
54 - },
55 - });
56 -}
plugins/_office/helpers/libreoffice_desktop.py
+3 -2355
@@ -1,2357 +1,5 @@
1 from __future__ import annotations
2
3 -import atexit
4 -import fcntl
5 -import hashlib
6 -import json
7 -import os
8 -import re
9 -import shutil
10 -import socket
11 -import subprocess
12 -import threading
13 -import time
14 -import uuid
15 -import xml.etree.ElementTree as ET
16 -from dataclasses import dataclass, field
17 -from pathlib import Path
18 -from typing import Any
19 -
20 -from helpers import files, virtual_desktop
21 -from plugins._office.helpers import desktop_state, document_store, libreoffice
22 -
23 -
24 -OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"}
25 -SYSTEM_SESSION_ID = "agent-zero-desktop"
26 -SYSTEM_FILE_ID = "system-desktop"
27 -SYSTEM_TITLE = "Desktop"
28 -STATE_DIR = Path(files.get_abs_path("tmp", "_office", "desktop"))
29 -SESSION_DIR = STATE_DIR / "sessions"
30 -PROFILE_DIR = STATE_DIR / "profiles"
31 -DISPLAY_BASE = 120
32 -XPRA_PORT_BASE = 14500
33 -MAX_SESSIONS = 12
34 -DEFAULT_SCREEN_WIDTH = virtual_desktop.DEFAULT_WIDTH
35 -DEFAULT_SCREEN_HEIGHT = virtual_desktop.DEFAULT_HEIGHT
36 -MAX_SCREEN_WIDTH = virtual_desktop.MAX_WIDTH
37 -MAX_SCREEN_HEIGHT = virtual_desktop.MAX_HEIGHT
38 -BLOCKING_DIALOG_TITLES = ("Remote Files", "File Services")
39 -DISPLAY_START_TIMEOUT_SECONDS = 30.0
40 -PORT_START_TIMEOUT_SECONDS = 30.0
41 -STARTUP_GRACE_SECONDS = 45
42 -HIDDEN_XPRA_DESKTOP_ENTRIES = (
43 - "xpra.desktop",
44 - "xpra-gui.desktop",
45 - "xpra-launcher.desktop",
46 - "xpra-shadow.desktop",
47 - "xpra-start.desktop",
48 -)
49 -HIDDEN_XFCE_MENU_ENTRIES = (
50 - ("exo-mail-reader.desktop", "Mail Reader"),
51 - ("exo-web-browser.desktop", "Web Browser"),
52 - ("xfce4-mail-reader.desktop", "Mail Reader"),
53 - ("xfce4-web-browser.desktop", "Web Browser"),
54 - ("xfce4-session-logout.desktop", "Log Out"),
55 - ("xfce4-lock-screen.desktop", "Lock Screen"),
56 - ("xflock4.desktop", "Lock Screen"),
57 - ("xfce4-switch-user.desktop", "Switch User"),
58 -)
59 -DESKTOP_README_SOURCE = Path(__file__).resolve().parents[1] / "assets" / "desktop" / "README.md"
60 -DESKTOP_FOLDER_LINKS = (
61 - ("Projects", ("usr", "projects")),
62 - ("Skills", ("usr", "skills")),
63 - ("Agents", ("usr", "agents")),
64 - ("Downloads", ("usr", "downloads")),
65 -)
66 -URL_INTENT_MAX_ITEMS = 50
67 -URL_INTENT_MAX_LENGTH = 8192
68 -URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop"
69 -SHUTDOWN_HANDLER_DESKTOP_ID = "agent-zero-shutdown.desktop"
70 -SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID
71 -SHUTDOWN_CONFIRM_SECONDS = 8
72 -OOR_NS = "http://openoffice.org/2001/registry"
73 -XS_NS = "http://www.w3.org/2001/XMLSchema"
74 -XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
75 -
76 -
77 -@dataclass
78 -class DesktopSession:
79 - session_id: str
80 - file_id: str
81 - extension: str
82 - path: str
83 - title: str
84 - display: int
85 - xpra_port: int
86 - token: str
87 - url: str
88 - profile_dir: Path
89 - width: int = DEFAULT_SCREEN_WIDTH
90 - height: int = DEFAULT_SCREEN_HEIGHT
91 - processes: dict[str, subprocess.Popen[Any]] = field(default_factory=dict)
92 - process_ids: dict[str, int] = field(default_factory=dict)
93 - owns_processes: bool = True
94 - started_at: float = field(default_factory=time.time)
95 -
96 - def alive(self) -> bool:
97 - return _running(self.processes.get("xpra")) or _pid_is_running(self.process_ids.get("xpra", 0))
98 -
99 - def public(self, doc: dict[str, Any] | None = None) -> dict[str, Any]:
100 - title = str(doc.get("basename") or "") if doc else self.title
101 - path = str(doc.get("path") or "") if doc else self.path
102 - extension = str(doc.get("extension") or "") if doc else self.extension
103 - file_id = str(doc.get("file_id") or "") if doc else self.file_id
104 - return {
105 - "available": True,
106 - "session_id": self.session_id,
107 - "file_id": file_id,
108 - "extension": extension,
109 - "title": title,
110 - "path": document_store.display_path(path),
111 - "url": self.url,
112 - "token": self.token,
113 - "display": f":{self.display}",
114 - "desktop_path": virtual_desktop.SESSION_PATH,
115 - "width": self.width,
116 - "height": self.height,
117 - "started_at": self.started_at,
118 - }
119 -
120 -
121 -class LibreOfficeDesktopManager:
122 - def __init__(self) -> None:
123 - self._lock = threading.RLock()
124 - self._sessions: dict[str, DesktopSession] = {}
125 -
126 - def ensure_system_desktop(self) -> dict[str, Any]:
127 - try:
128 - with self._lock:
129 - self._reap_dead_locked()
130 - session = self._ensure_system_desktop_locked()
131 - return session.public()
132 - except Exception as exc:
133 - status = collect_desktop_status()
134 - return {
135 - "available": False,
136 - "error": str(exc),
137 - "status": status,
138 - }
139 -
140 - def open(self, doc: dict[str, Any], *, refresh: bool = False) -> dict[str, Any]:
141 - ext = str(doc.get("extension") or "").lower()
142 - if ext not in OFFICIAL_EXTENSIONS:
143 - return {"available": False, "reason": f".{ext} does not use the LibreOffice desktop surface."}
144 -
145 - with self._lock:
146 - self._reap_dead_locked()
147 - try:
148 - session = self._ensure_system_desktop_locked()
149 - except Exception as exc:
150 - status = collect_desktop_status()
151 - return {
152 - "available": False,
153 - "error": str(exc),
154 - "status": status,
155 - }
156 - refreshed = False
157 - try:
158 - if refresh and session.file_id == str(doc.get("file_id") or ""):
159 - refreshed = self._reload_document_locked(session, doc)
160 - else:
161 - self._open_document_locked(session, doc)
162 - except Exception as exc:
163 - return {
164 - "available": False,
165 - "error": str(exc),
166 - "status": collect_desktop_status(),
167 - }
168 - session.file_id = str(doc["file_id"])
169 - session.extension = ext
170 - session.path = str(doc["path"])
171 - session.title = str(doc["basename"])
172 - self._write_manifest(session)
173 - public = session.public(doc)
174 - public["refreshed"] = refreshed
175 - return public
176 -
177 - def refresh_document(self, file_id: str) -> dict[str, Any]:
178 - normalized = str(file_id or "").strip()
179 - if not normalized:
180 - return {"ok": True, "refreshed": False}
181 - try:
182 - doc = document_store.get_document(normalized)
183 - except Exception:
184 - return {"ok": False, "refreshed": False, "error": "Document not found."}
185 -
186 - ext = str(doc.get("extension") or "").lower()
187 - if ext not in OFFICIAL_EXTENSIONS:
188 - return {"ok": True, "refreshed": False}
189 -
190 - with self._lock:
191 - self._reap_dead_locked()
192 - session = self._find_by_file_id_locked(normalized)
193 - if not session:
194 - existing = self._load_system_desktop_from_manifest_locked()
195 - if existing:
196 - self._sessions[existing.session_id] = existing
197 - self._register_virtual_desktop(existing)
198 - if existing.file_id == normalized:
199 - session = existing
200 - if not session:
201 - return {"ok": True, "refreshed": False}
202 - refreshed = self._reload_document_locked(session, doc)
203 - session.file_id = str(doc["file_id"])
204 - session.extension = ext
205 - session.path = str(doc["path"])
206 - session.title = str(doc["basename"])
207 - self._write_manifest(session)
208 - return {"ok": True, "refreshed": refreshed, "desktop": session.public(doc)}
209 -
210 - def save(self, session_id: str, file_id: str = "") -> dict[str, Any]:
211 - session = self.require(session_id)
212 - doc = self._document_for_save(session, file_id)
213 - xdotool = shutil.which("xdotool")
214 - if not xdotool:
215 - updated = document_store.register_document(doc["path"]) if doc else None
216 - return {
217 - "ok": False,
218 - "error": "xdotool is not installed; use LibreOffice's Save control inside the canvas.",
219 - "document": _public_doc(updated) if updated else None,
220 - }
221 -
222 - result = subprocess.run(
223 - [xdotool, "key", "--clearmodifiers", "ctrl+s"],
224 - check=False,
225 - capture_output=True,
226 - text=True,
227 - timeout=8,
228 - env=self._display_env(session),
229 - )
230 - time.sleep(0.8)
231 - updated = document_store.register_document(doc["path"]) if doc else None
232 - if result.returncode != 0:
233 - detail = (result.stderr or result.stdout or "").strip()
234 - return {
235 - "ok": False,
236 - "error": detail or "LibreOffice desktop save shortcut failed.",
237 - "document": _public_doc(updated) if updated else None,
238 - }
239 - return {
240 - "ok": True,
241 - "session_id": session.session_id,
242 - "document": _public_doc(updated) if updated else None,
243 - }
244 -
245 - def sync(self, session_id: str = "", file_id: str = "") -> dict[str, Any]:
246 - session = self.get(session_id) if session_id else self._find_by_file_id(file_id)
247 - if not session:
248 - return {"ok": False, "error": "LibreOffice desktop session not found."}
249 - if not _url_bridge_script_path(session).exists():
250 - try:
251 - self._prepare_desktop_url_bridge(session)
252 - self._refresh_xfce_desktop(session)
253 - except Exception:
254 - pass
255 - url_intents = self.claim_url_intents(session.session_id)
256 - shutdown_request = self.claim_shutdown_request(session.session_id)
257 - if shutdown_request:
258 - return self.shutdown_system_desktop(
259 - save_first=True,
260 - source=str(shutdown_request.get("source") or "tray"),
261 - )
262 - doc = self._document_for_save(session, file_id)
263 - if not doc:
264 - return {
265 - "ok": True,
266 - "session_id": session.session_id,
267 - "desktop": session.public(),
268 - "url_intents": url_intents,
269 - }
270 - updated = document_store.register_document(doc["path"])
271 - return {
272 - "ok": True,
273 - "session_id": session.session_id,
274 - "document": _public_doc(updated),
275 - "url_intents": url_intents,
276 - }
277 -
278 - def state(self, *, include_screenshot: bool = False) -> dict[str, Any]:
279 - with self._lock:
280 - self._reap_dead_locked()
281 - return desktop_state.collect_state(include_screenshot=include_screenshot)
282 -
283 - def claim_url_intents(self, session_id: str = SYSTEM_SESSION_ID) -> list[dict[str, Any]]:
284 - session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
285 - if not session:
286 - return []
287 - return _claim_url_intents(session)
288 -
289 - def claim_shutdown_request(self, session_id: str = SYSTEM_SESSION_ID) -> dict[str, Any] | None:
290 - session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
291 - if not session:
292 - return None
293 - return _claim_shutdown_request(session)
294 -
295 - def shutdown_system_desktop(self, *, save_first: bool = True, source: str = "api") -> dict[str, Any]:
296 - with self._lock:
297 - session = self._sessions.get(SYSTEM_SESSION_ID)
298 - if not session:
299 - _remove_system_manifest()
300 - return {
301 - "ok": True,
302 - "closed": 0,
303 - "session_id": SYSTEM_SESSION_ID,
304 - "shutdown": True,
305 - "intentional_shutdown": True,
306 - "source": source,
307 - }
308 -
309 - save_result = None
310 - if save_first:
311 - try:
312 - save_result = self.save(session.session_id)
313 - except Exception as exc:
314 - save_result = {"ok": False, "error": str(exc)}
315 -
316 - with self._lock:
317 - if self._sessions.get(SYSTEM_SESSION_ID) is session:
318 - self._sessions.pop(SYSTEM_SESSION_ID, None)
319 - virtual_desktop.unregister_session(session.token)
320 - self._terminate_session(session, include_rehydrated=True)
321 - self._remove_manifest(session.session_id)
322 - _clear_shutdown_request(session)
323 - return {
324 - "ok": True,
325 - "closed": 1,
326 - "session_id": session.session_id,
327 - "shutdown": True,
328 - "intentional_shutdown": True,
329 - "source": source,
330 - "save": save_result,
331 - }
332 -
333 - def retarget_document(self, file_id: str, doc: dict[str, Any]) -> dict[str, Any]:
334 - session = self._find_by_file_id(file_id)
335 - if not session:
336 - return {"ok": True, "updated": False}
337 - with self._lock:
338 - session.path = str(doc["path"])
339 - session.title = str(doc["basename"])
340 - session.extension = str(doc["extension"])
341 - self._write_manifest(session)
342 - return {"ok": True, "updated": True, "desktop": session.public(doc)}
343 -
344 - def close(self, session_id: str, save_first: bool = True) -> dict[str, Any]:
345 - with self._lock:
346 - normalized = str(session_id or "").strip()
347 - session = self._sessions.get(normalized)
348 - if not session:
349 - return {"ok": True, "closed": 0}
350 - if session.session_id == SYSTEM_SESSION_ID:
351 - save_result = None
352 - if save_first:
353 - try:
354 - save_result = self.save(session.session_id)
355 - except Exception as exc:
356 - save_result = {"ok": False, "error": str(exc)}
357 - return {
358 - "ok": True,
359 - "closed": 0,
360 - "session_id": session.session_id,
361 - "persistent": True,
362 - "save": save_result,
363 - }
364 -
365 - save_result = None
366 - if save_first:
367 - try:
368 - save_result = self.save(session.session_id)
369 - except Exception as exc:
370 - save_result = {"ok": False, "error": str(exc)}
371 - with self._lock:
372 - self._sessions.pop(session.session_id, None)
373 - virtual_desktop.unregister_session(session.token)
374 - self._terminate_session(session, include_rehydrated=True)
375 - self._remove_manifest(session.session_id)
376 - return {"ok": True, "closed": 1, "session_id": session.session_id, "save": save_result}
377 -
378 - def close_file(self, file_id: str) -> int:
379 - return 0
380 -
381 - def resize(self, session_id: str, width: int, height: int) -> dict[str, Any]:
382 - session = self.get(session_id)
383 - if not session:
384 - return {"ok": False, "error": "LibreOffice desktop session not found."}
385 - is_system_desktop = session.session_id == SYSTEM_SESSION_ID and session.extension == "desktop"
386 - result = virtual_desktop.resize_display(
387 - display=session.display,
388 - width=width,
389 - height=height,
390 - max_width=MAX_SCREEN_WIDTH,
391 - max_height=MAX_SCREEN_HEIGHT,
392 - window_class="" if is_system_desktop else "libreoffice",
393 - keys=() if is_system_desktop else ("Escape",),
394 - xauthority=self._xauthority(session),
395 - home=str(session.profile_dir),
396 - )
397 - if result.get("ok"):
398 - session.width = int(result["width"])
399 - session.height = int(result["height"])
400 - if not is_system_desktop:
401 - self._dismiss_blocking_dialogs(session)
402 - return result
403 -
404 - def proxy_for_token(self, token: str) -> tuple[str, int] | None:
405 - normalized = str(token or "").strip()
406 - with self._lock:
407 - session = self._sessions.get(normalized)
408 - if not session:
409 - session = next((item for item in self._sessions.values() if item.token == normalized), None)
410 - if not session or not session.alive():
411 - return None
412 - return ("127.0.0.1", session.xpra_port)
413 -
414 - def resize_for_token(self, token: str, width: int, height: int) -> dict[str, Any]:
415 - normalized = str(token or "").strip()
416 - with self._lock:
417 - session = self._sessions.get(normalized)
418 - if not session:
419 - session = next((item for item in self._sessions.values() if item.token == normalized), None)
420 - if not session:
421 - return {"ok": False, "error": "LibreOffice desktop session not found."}
422 - return self.resize(session.session_id, width, height)
423 -
424 - def get(self, session_id: str) -> DesktopSession | None:
425 - with self._lock:
426 - session = self._sessions.get(str(session_id or "").strip())
427 - return session if session and session.alive() else None
428 -
429 - def require(self, session_id: str) -> DesktopSession:
430 - session = self.get(session_id)
431 - if not session:
432 - raise FileNotFoundError(f"LibreOffice desktop session not found: {session_id}")
433 - return session
434 -
435 - def shutdown(self) -> None:
436 - with self._lock:
437 - sessions = list(self._sessions.values())
438 - self._sessions.clear()
439 - for session in sessions:
440 - virtual_desktop.unregister_session(session.token)
441 - if session.owns_processes:
442 - self._terminate_session(session)
443 - self._remove_manifest(session.session_id)
444 -
445 - def _document_for_save(self, session: DesktopSession, file_id: str = "") -> dict[str, Any] | None:
446 - normalized = str(file_id or "").strip()
447 - if normalized == SYSTEM_FILE_ID:
448 - return None
449 - if normalized and normalized != SYSTEM_FILE_ID:
450 - return document_store.get_document(normalized)
451 - if session.file_id and session.file_id != SYSTEM_FILE_ID:
452 - try:
453 - return document_store.get_document(session.file_id)
454 - except Exception:
455 - path = Path(session.path)
456 - if path.is_file():
457 - return document_store.register_document(path)
458 - return None
459 -
460 - def _register_virtual_desktop(self, session: DesktopSession) -> None:
461 - virtual_desktop.register_session(
462 - token=session.token,
463 - host="127.0.0.1",
464 - port=session.xpra_port,
465 - owner="libreoffice",
466 - title=session.title,
467 - resize=lambda width, height, session_id=session.session_id: self.resize(session_id, width, height),
468 - )
469 -
470 - def _ensure_system_desktop_locked(self) -> DesktopSession:
471 - existing = self._sessions.get(SYSTEM_SESSION_ID)
472 - if existing and existing.alive():
473 - self._prepare_desktop_url_bridge(existing)
474 - self._refresh_xfce_desktop(existing)
475 - return existing
476 -
477 - existing = self._load_system_desktop_from_manifest_locked()
478 - if existing:
479 - self._sessions[existing.session_id] = existing
480 - self._register_virtual_desktop(existing)
481 - self._prepare_desktop_url_bridge(existing)
482 - self._refresh_xfce_desktop(existing)
483 - return existing
484 -
485 - status = collect_desktop_status()
486 - if not status["healthy"]:
487 - raise RuntimeError(status["message"])
488 -
489 - display, xpra_port = self._allocate_endpoint_locked()
490 - profile_dir = PROFILE_DIR / SYSTEM_SESSION_ID
491 - session = DesktopSession(
492 - session_id=SYSTEM_SESSION_ID,
493 - file_id=SYSTEM_FILE_ID,
494 - extension="desktop",
495 - path=str(document_store.document_binary_home()),
496 - title=SYSTEM_TITLE,
497 - display=display,
498 - xpra_port=xpra_port,
499 - token=SYSTEM_SESSION_ID,
500 - url=_xpra_url(SYSTEM_SESSION_ID),
501 - profile_dir=profile_dir,
502 - )
503 - try:
504 - self._prepare_profile(session)
505 - self._prepare_desktop_launchers(session)
506 - self._spawn_desktop_locked(session)
507 - except Exception:
508 - self._terminate_session(session)
509 - raise
510 - self._sessions[session.session_id] = session
511 - self._register_virtual_desktop(session)
512 - self._write_manifest(session)
513 - return session
514 -
515 - def _load_system_desktop_from_manifest_locked(self) -> DesktopSession | None:
516 - manifest = SESSION_DIR / f"{SYSTEM_SESSION_ID}.json"
517 - if not manifest.exists():
518 - return None
519 - try:
520 - payload = json.loads(manifest.read_text(encoding="utf-8"))
521 - display = int(payload.get("display") or 0)
522 - xpra_port = int(payload.get("xpra_port") or 0)
523 - process_ids = {
524 - str(name): pid
525 - for name, value in dict(payload.get("pids") or {}).items()
526 - if (pid := _coerce_pid(value))
527 - }
528 - if not display or not xpra_port:
529 - return None
530 - if not _pid_is_running(process_ids.get("xpra", 0)):
531 - return None
532 - if not _port_is_accepting("127.0.0.1", xpra_port):
533 - return None
534 -
535 - path = str(payload.get("path") or document_store.document_binary_home())
536 - file_id = str(payload.get("file_id") or SYSTEM_FILE_ID)
537 - extension = str(payload.get("extension") or "").lower()
538 - if not extension:
539 - extension = "desktop" if file_id == SYSTEM_FILE_ID else Path(path).suffix.lower().lstrip(".")
540 - title = str(payload.get("title") or "")
541 - if not title:
542 - title = SYSTEM_TITLE if file_id == SYSTEM_FILE_ID else Path(path).name or SYSTEM_TITLE
543 - return DesktopSession(
544 - session_id=SYSTEM_SESSION_ID,
545 - file_id=file_id,
546 - extension=extension,
547 - path=path,
548 - title=title,
549 - display=display,
550 - xpra_port=xpra_port,
551 - token=SYSTEM_SESSION_ID,
552 - url=_xpra_url(SYSTEM_SESSION_ID),
553 - profile_dir=Path(payload.get("profile_dir") or PROFILE_DIR / SYSTEM_SESSION_ID),
554 - width=int(payload.get("width") or DEFAULT_SCREEN_WIDTH),
555 - height=int(payload.get("height") or DEFAULT_SCREEN_HEIGHT),
556 - process_ids=process_ids,
557 - owns_processes=False,
558 - started_at=float(payload.get("started_at") or time.time()),
559 - )
560 - except Exception:
561 - return None
562 -
563 - def _spawn_desktop_locked(self, session: DesktopSession) -> None:
564 - STATE_DIR.mkdir(parents=True, exist_ok=True)
565 - SESSION_DIR.mkdir(parents=True, exist_ok=True)
566 - session.profile_dir.mkdir(parents=True, exist_ok=True)
567 -
568 - xpra = _require_binary("xpra")
569 - xvfb = _require_binary("Xvfb")
570 - _require_binary("xfce4-session")
571 - _require_binary("dbus-launch")
572 - xfce_launcher = self._prepare_xfce_launcher(session)
573 -
574 - session.processes["xvfb"] = subprocess.Popen(
575 - _xvfb_command(xvfb, session),
576 - stdin=subprocess.DEVNULL,
577 - stdout=subprocess.DEVNULL,
578 - stderr=subprocess.DEVNULL,
579 - env=self._session_env(session),
580 - )
581 - self._wait_for_display(session)
582 - self._set_display_size(session, session.width, session.height)
583 - self._prepare_root_window(session)
584 - session.processes["xfce"] = subprocess.Popen(
585 - [str(xfce_launcher)],
586 - stdin=subprocess.DEVNULL,
587 - stdout=subprocess.DEVNULL,
588 - stderr=subprocess.DEVNULL,
589 - env=self._display_env(session),
590 - )
591 - self._wait_for_xfce(session)
592 - session.processes["xpra"] = subprocess.Popen(
593 - _xpra_shadow_command(xpra, session),
594 - stdin=subprocess.DEVNULL,
595 - stdout=subprocess.DEVNULL,
596 - stderr=subprocess.DEVNULL,
597 - env=self._display_env(session),
598 - )
599 - _wait_for_port(
600 - "127.0.0.1",
601 - session.xpra_port,
602 - timeout=PORT_START_TIMEOUT_SECONDS,
603 - process=session.processes.get("xpra"),
604 - )
605 - self._refresh_xfce_desktop(session)
606 -
607 - def _restart_xpra_shadow(self, session: DesktopSession) -> None:
608 - xpra = _require_binary("xpra")
609 - process = session.processes.get("xpra")
610 - if process:
611 - _terminate_process(process)
612 - session.processes["xpra"] = subprocess.Popen(
613 - _xpra_shadow_command(xpra, session),
614 - stdin=subprocess.DEVNULL,
615 - stdout=subprocess.DEVNULL,
616 - stderr=subprocess.DEVNULL,
617 - env=self._display_env(session),
618 - )
619 - _wait_for_port(
620 - "127.0.0.1",
621 - session.xpra_port,
622 - timeout=PORT_START_TIMEOUT_SECONDS,
623 - process=session.processes.get("xpra"),
624 - )
625 -
626 - def _open_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> None:
627 - soffice = libreoffice.find_soffice()
628 - if not soffice:
629 - raise RuntimeError("LibreOffice is not installed in this runtime.")
630 - path = str(doc["path"])
631 - self._remove_stale_lock_file(session, path=path)
632 - process_key = f"soffice-{doc['file_id']}"
633 - session.processes[process_key] = subprocess.Popen(
634 - [
635 - soffice,
636 - "--norestore",
637 - "--nofirststartwizard",
638 - "--nolockcheck",
639 - f"-env:UserInstallation=file://{session.profile_dir}",
640 - path,
641 - ],
642 - cwd=str(Path(path).parent),
643 - stdin=subprocess.DEVNULL,
644 - stdout=subprocess.DEVNULL,
645 - stderr=subprocess.DEVNULL,
646 - env=self._display_env(session),
647 - )
648 - self._fit_office_window(session, process=session.processes[process_key])
649 - window_id = self._wait_for_office_window_locked(
650 - session,
651 - title=str(doc.get("basename") or ""),
652 - process=session.processes[process_key],
653 - )
654 - if not window_id:
655 - raise RuntimeError(
656 - f"LibreOffice did not show {doc.get('basename') or 'the document'} "
657 - f"on desktop :{session.display}.",
658 - )
659 - self._fit_office_window_id_locked(
660 - session,
661 - window_id,
662 - env=self._display_env(session),
663 - keys=("Escape",),
664 - )
665 -
666 - def _reload_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
667 - if self._close_document_window_locked(session, doc):
668 - self._open_document_locked(session, doc)
669 - return True
670 - if self._send_reload_shortcut_locked(session, doc):
671 - return True
672 - self._open_document_locked(session, doc)
673 - return False
674 -
675 - def _close_document_window_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
676 - xdotool = shutil.which("xdotool")
677 - if not xdotool:
678 - return False
679 - title = str(doc.get("basename") or Path(str(doc.get("path") or "")).name or "").strip()
680 - if not title:
681 - return False
682 - window_id = self._office_window_id_locked(session, title=title, fallback=False)
683 - if not window_id:
684 - return False
685 - env = self._display_env(session)
686 - self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
687 - for command in (
688 - [xdotool, "key", "--clearmodifiers", "alt+F4"],
689 - [xdotool, "windowclose", window_id],
690 - ):
691 - try:
692 - subprocess.run(
693 - command,
694 - check=False,
695 - stdout=subprocess.DEVNULL,
696 - stderr=subprocess.DEVNULL,
697 - timeout=3,
698 - env=env,
699 - )
700 - except (OSError, subprocess.TimeoutExpired):
701 - continue
702 - if self._wait_for_window_closed_locked(session, window_id):
703 - return True
704 - self._dismiss_blocking_dialogs(session)
705 - return self._wait_for_window_closed_locked(session, window_id, timeout_seconds=1.5)
706 -
707 - def _send_reload_shortcut_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
708 - xdotool = shutil.which("xdotool")
709 - if not xdotool:
710 - return False
711 - window_id = self._office_window_id_locked(
712 - session,
713 - title=str(doc.get("basename") or ""),
714 - )
715 - if not window_id:
716 - return False
717 - env = self._display_env(session)
718 - self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
719 - try:
720 - result = subprocess.run(
721 - [xdotool, "key", "--clearmodifiers", "ctrl+shift+r"],
722 - check=False,
723 - capture_output=True,
724 - text=True,
725 - timeout=4,
726 - env=env,
727 - )
728 - except (OSError, subprocess.TimeoutExpired):
729 - return False
730 - time.sleep(0.8)
731 - self._dismiss_blocking_dialogs(session)
732 - self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
733 - return result.returncode == 0
734 -
735 - def _office_window_id_locked(
736 - self,
737 - session: DesktopSession,
738 - *,
739 - title: str = "",
740 - fallback: bool = True,
741 - ) -> str:
742 - xdotool = shutil.which("xdotool")
743 - if not xdotool:
744 - return ""
745 - env = self._display_env(session)
746 - title = str(title or "").strip()
747 - searches: list[list[str]] = []
748 - if title:
749 - escaped_title = re.escape(title)
750 - searches.append([
751 - xdotool,
752 - "search",
753 - "--onlyvisible",
754 - "--name",
755 - escaped_title,
756 - ])
757 - for window_class in (
758 - "libreoffice",
759 - "libreoffice-writer",
760 - "libreoffice-calc",
761 - "libreoffice-impress",
762 - ):
763 - searches.append([
764 - xdotool,
765 - "search",
766 - "--onlyvisible",
767 - "--class",
768 - window_class,
769 - "--name",
770 - escaped_title,
771 - ])
772 - if fallback:
773 - for window_class in (
774 - "libreoffice",
775 - "libreoffice-writer",
776 - "libreoffice-calc",
777 - "libreoffice-impress",
778 - ):
779 - searches.append([xdotool, "search", "--onlyvisible", "--class", window_class])
780 - searches.append([xdotool, "search", "--onlyvisible", "--name", "LibreOffice"])
781 - for command in searches:
782 - try:
783 - result = subprocess.run(
784 - command,
785 - check=False,
786 - capture_output=True,
787 - text=True,
788 - timeout=2,
789 - env=env,
790 - )
791 - except (OSError, subprocess.TimeoutExpired):
792 - continue
793 - window_ids = [
794 - line.strip()
795 - for line in result.stdout.splitlines()
796 - if line.strip()
797 - ]
798 - if window_ids:
799 - return window_ids[-1]
800 - return ""
801 -
802 - def _wait_for_office_window_locked(
803 - self,
804 - session: DesktopSession,
805 - *,
806 - title: str = "",
807 - process: subprocess.Popen[Any] | None = None,
808 - timeout_seconds: float = 20.0,
809 - ) -> str:
810 - deadline = time.time() + timeout_seconds
811 - last_fallback = ""
812 - title = str(title or "").strip()
813 - while time.time() < deadline:
814 - window_id = self._office_window_id_locked(session, title=title, fallback=False)
815 - if window_id:
816 - return window_id
817 - last_fallback = self._office_window_id_locked(session, fallback=True) or last_fallback
818 - if last_fallback and not title:
819 - return last_fallback
820 - if process and process.poll() is not None and last_fallback:
821 - return last_fallback
822 - time.sleep(0.25)
823 - return self._office_window_id_locked(session, title=title, fallback=True) or last_fallback
824 -
825 - def _wait_for_window_closed_locked(
826 - self,
827 - session: DesktopSession,
828 - window_id: str,
829 - *,
830 - timeout_seconds: float = 6.0,
831 - ) -> bool:
832 - deadline = time.time() + timeout_seconds
833 - while time.time() < deadline:
834 - if not self._window_exists_locked(session, window_id):
835 - return True
836 - time.sleep(0.2)
837 - return not self._window_exists_locked(session, window_id)
838 -
839 - def _window_exists_locked(self, session: DesktopSession, window_id: str) -> bool:
840 - xdotool = shutil.which("xdotool")
841 - if not xdotool or not window_id:
842 - return False
843 - try:
844 - result = subprocess.run(
845 - [xdotool, "getwindowname", str(window_id)],
846 - check=False,
847 - stdout=subprocess.DEVNULL,
848 - stderr=subprocess.DEVNULL,
849 - timeout=2,
850 - env=self._display_env(session),
851 - )
852 - except (OSError, subprocess.TimeoutExpired):
853 - return False
854 - return result.returncode == 0
855 -
856 - def _fit_office_window_id_locked(
857 - self,
858 - session: DesktopSession,
859 - window_id: str,
860 - *,
861 - env: dict[str, str],
862 - keys: tuple[str, ...] = (),
863 - ) -> None:
864 - xdotool = shutil.which("xdotool")
865 - if not xdotool or not window_id:
866 - return
867 - for command in (
868 - [xdotool, "windowactivate", window_id],
869 - [
870 - xdotool,
871 - "windowmove",
872 - window_id,
873 - "0",
874 - "0",
875 - "windowsize",
876 - window_id,
877 - str(session.width),
878 - str(session.height),
879 - ],
880 - ):
881 - try:
882 - subprocess.run(
883 - command,
884 - check=False,
885 - stdout=subprocess.DEVNULL,
886 - stderr=subprocess.DEVNULL,
887 - timeout=2,
888 - env=env,
889 - )
890 - except (OSError, subprocess.TimeoutExpired):
891 - continue
892 - for key in keys:
893 - try:
894 - subprocess.run(
895 - [xdotool, "key", "--clearmodifiers", key],
896 - check=False,
897 - stdout=subprocess.DEVNULL,
898 - stderr=subprocess.DEVNULL,
899 - timeout=2,
900 - env=env,
901 - )
902 - except (OSError, subprocess.TimeoutExpired):
903 - continue
904 -
905 - def _prepare_profile(self, session: DesktopSession) -> None:
906 - user_dir = session.profile_dir / "user"
907 - user_dir.mkdir(parents=True, exist_ok=True)
908 - registry = user_dir / "registrymodifications.xcu"
909 - _write_libreoffice_registry_defaults(registry, document_store.document_home())
910 -
911 - def _prepare_desktop_launchers(self, session: DesktopSession) -> None:
912 - soffice = libreoffice.find_soffice()
913 - if not soffice:
914 - raise RuntimeError("LibreOffice is not installed in this runtime.")
915 - workdir_home = document_store.document_home()
916 - workdir_home.mkdir(parents=True, exist_ok=True)
917 - documents_home = document_store.document_binary_home()
918 - documents_home.mkdir(parents=True, exist_ok=True)
919 - downloads_home = Path(files.get_abs_path("usr", "downloads"))
920 - downloads_home.mkdir(parents=True, exist_ok=True)
921 -
922 - desktop_dir = session.profile_dir / "Desktop"
923 - desktop_dir.mkdir(parents=True, exist_ok=True)
924 - _install_desktop_readme(desktop_dir)
925 - _remove_path_if_owned(desktop_dir / "Browser.desktop")
926 - _remove_path_if_owned(desktop_dir / "Files.desktop")
927 - config_dir = session.profile_dir / ".config"
928 - config_dir.mkdir(parents=True, exist_ok=True)
929 - _remove_path_if_owned(config_dir / "xfce4" / "panel")
930 - data_dir = session.profile_dir / ".local" / "share"
931 - data_dir.mkdir(parents=True, exist_ok=True)
932 - applications_dir = data_dir / "applications"
933 - applications_dir.mkdir(parents=True, exist_ok=True)
934 - cache_dir = session.profile_dir / ".cache"
935 - cache_dir.mkdir(parents=True, exist_ok=True)
936 - (config_dir / "user-dirs.dirs").write_text(
937 - "\n".join(
938 - [
939 - 'XDG_DESKTOP_DIR="$HOME/Desktop"',
940 - f'XDG_DOCUMENTS_DIR="{workdir_home}"',
941 - f'XDG_DOWNLOAD_DIR="{downloads_home}"',
942 - f'XDG_TEMPLATES_DIR="{workdir_home}"',
943 - f'XDG_PUBLICSHARE_DIR="{workdir_home}"',
944 - f'XDG_MUSIC_DIR="{workdir_home}"',
945 - f'XDG_PICTURES_DIR="{downloads_home}"',
946 - f'XDG_VIDEOS_DIR="{workdir_home}"',
947 - "",
948 - ],
949 - ),
950 - encoding="utf-8",
951 - )
952 - xfce_conf_dir = config_dir / "xfce4" / "xfconf" / "xfce-perchannel-xml"
953 - xfce_conf_dir.mkdir(parents=True, exist_ok=True)
954 - (xfce_conf_dir / "xfce4-desktop.xml").write_text(
955 - f"""<?xml version="1.1" encoding="UTF-8"?>
956 -
957 -<channel name="xfce4-desktop" version="1.0">
958 - <property name="last-settings-migration-version" type="uint" value="1"/>
959 - <property name="backdrop" type="empty">
960 - <property name="screen0" type="empty">
961 - <property name="monitor0" type="empty">
962 - <property name="image-path" type="string" value="{_xml_attr(str(downloads_home))}"/>
963 - </property>
964 - </property>
965 - </property>
966 - <property name="desktop-icons" type="empty">
967 - <property name="style" type="int" value="2"/>
968 - <property name="file-icons" type="empty">
969 - <property name="show-home" type="bool" value="false"/>
970 - <property name="show-filesystem" type="bool" value="false"/>
971 - <property name="show-removable" type="bool" value="false"/>
972 - <property name="show-trash" type="bool" value="false"/>
973 - </property>
974 - </property>
975 -</channel>
976 -""",
977 - encoding="utf-8",
978 - )
979 - _write_thunar_defaults(xfce_conf_dir / "thunar.xml")
980 - self._hide_xpra_desktop_entries(applications_dir)
981 - self._hide_xfce_menu_entries(applications_dir)
982 - self._prepare_desktop_url_bridge(session)
983 -
984 - base_args = (
985 - soffice,
986 - "--norestore",
987 - "--nofirststartwizard",
988 - "--nolockcheck",
989 - f"-env:UserInstallation=file://{session.profile_dir}",
990 - )
991 - office_launchers = (
992 - ("LibreOffice Writer", "libreoffice-writer", "--writer", "Office;WordProcessor;"),
993 - ("LibreOffice Calc", "libreoffice-calc", "--calc", "Office;Spreadsheet;"),
994 - ("LibreOffice Impress", "libreoffice-impress", "--impress", "Office;Presentation;"),
995 - )
996 - for name, icon, mode, categories in office_launchers:
997 - _write_desktop_launcher(
998 - desktop_dir / f"{name}.desktop",
999 - name=name,
1000 - exec_line=_desktop_exec(*base_args, mode),
1001 - icon=icon,
1002 - categories=categories,
1003 - try_exec=soffice,
1004 - working_dir=workdir_home,
1005 - )
1006 -
1007 - terminal = shutil.which("xfce4-terminal") or "xfce4-terminal"
1008 - settings = shutil.which("xfce4-settings-manager") or "xfce4-settings-manager"
1009 - desktop_apps = (
1010 - {
1011 - "filename": "Terminal.desktop",
1012 - "name": "Terminal",
1013 - "exec": _desktop_exec(terminal, f"--working-directory={workdir_home}"),
1014 - "try_exec": terminal,
1015 - "icon": _desktop_icon(
1016 - "/usr/share/icons/hicolor/128x128/apps/org.xfce.terminal.png",
1017 - "/usr/share/icons/hicolor/scalable/apps/org.xfce.terminal.svg",
1018 - "org.xfce.terminal",
1019 - "utilities-terminal",
1020 - ),
1021 - "categories": "System;TerminalEmulator;",
1022 - },
1023 - {
1024 - "filename": "Settings.desktop",
1025 - "name": "Settings",
1026 - "exec": _desktop_exec(settings),
1027 - "try_exec": settings,
1028 - "icon": _desktop_icon(
1029 - "/usr/share/icons/hicolor/128x128/apps/org.xfce.settings.manager.png",
1030 - "/usr/share/icons/hicolor/scalable/apps/org.xfce.settings.manager.svg",
1031 - "org.xfce.settings.manager",
1032 - "preferences-system",
1033 - ),
1034 - "categories": "Settings;DesktopSettings;",
1035 - },
1036 - )
1037 - for app in desktop_apps:
1038 - _write_desktop_launcher(
1039 - desktop_dir / str(app["filename"]),
1040 - name=str(app["name"]),
1041 - exec_line=str(app["exec"]),
1042 - icon=str(app["icon"]),
1043 - categories=str(app["categories"]),
1044 - try_exec=str(app["try_exec"]),
1045 - )
1046 - _ensure_desktop_folder_link(desktop_dir, "Workdir", workdir_home)
1047 - for label, target_parts in DESKTOP_FOLDER_LINKS:
1048 - _ensure_desktop_folder_link(desktop_dir, label, Path(files.get_abs_path(*target_parts)))
1049 -
1050 - self._trust_desktop_launchers(session, desktop_dir)
1051 - self._prepare_xfce_panel_config(session)
1052 - self._prepare_xfce_profile_autostart(session)
1053 -
1054 - def _prepare_desktop_url_bridge(self, session: DesktopSession) -> None:
1055 - desktop_dir = session.profile_dir / "Desktop"
1056 - config_dir = session.profile_dir / ".config"
1057 - data_dir = session.profile_dir / ".local" / "share"
1058 - applications_dir = data_dir / "applications"
1059 - desktop_dir.mkdir(parents=True, exist_ok=True)
1060 - config_dir.mkdir(parents=True, exist_ok=True)
1061 - applications_dir.mkdir(parents=True, exist_ok=True)
1062 -
1063 - browser_bridge = _write_url_bridge_script(session)
1064 - shutdown_bridge = _write_shutdown_bridge_script(session)
1065 - helpers_rc = config_dir / "xfce4" / "helpers.rc"
1066 - helpers_rc.parent.mkdir(parents=True, exist_ok=True)
1067 - helpers_rc.write_text(
1068 - "\n".join(
1069 - [
1070 - "TerminalEmulator=xfce4-terminal",
1071 - "FileManager=thunar",
1072 - "WebBrowser=agent-zero-browser",
1073 - "",
1074 - ],
1075 - ),
1076 - encoding="utf-8",
1077 - )
1078 - _write_xfce_browser_helper(
1079 - config_dir / "xfce4" / "helpers" / "agent-zero-browser.desktop",
1080 - browser_bridge,
1081 - )
1082 - _write_mimeapps_defaults(config_dir / "mimeapps.list", URL_HANDLER_DESKTOP_ID)
1083 - _write_mimeapps_defaults(data_dir / "applications" / "mimeapps.list", URL_HANDLER_DESKTOP_ID)
1084 - _write_desktop_launcher(
1085 - applications_dir / URL_HANDLER_DESKTOP_ID,
1086 - name="Agent Zero Browser",
1087 - exec_line=_desktop_exec(browser_bridge, "%U"),
1088 - icon="web-browser",
1089 - categories="Network;WebBrowser;",
1090 - try_exec=str(browser_bridge),
1091 - mime_types=_url_handler_mime_types(),
1092 - no_display=True,
1093 - )
1094 - _write_desktop_launcher(
1095 - applications_dir / SHUTDOWN_HANDLER_DESKTOP_ID,
1096 - name="Shutdown Desktop",
1097 - exec_line=_desktop_exec(shutdown_bridge),
1098 - icon="system-shutdown",
1099 - categories="System;",
1100 - try_exec=str(shutdown_bridge),
1101 - no_display=True,
1102 - )
1103 - _write_desktop_launcher(
1104 - config_dir / "xfce4" / "panel" / "launcher-9" / SHUTDOWN_HANDLER_DESKTOP_ID,
1105 - name="Shutdown Desktop",
1106 - exec_line=_desktop_exec(shutdown_bridge),
1107 - icon="system-shutdown",
1108 - categories="System;",
1109 - try_exec=str(shutdown_bridge),
1110 - )
1111 - _write_desktop_launcher(
1112 - desktop_dir / "Browser.desktop",
1113 - name="Browser",
1114 - exec_line=_desktop_exec(browser_bridge),
1115 - icon="web-browser",
1116 - categories="Network;WebBrowser;",
1117 - try_exec=str(browser_bridge),
1118 - )
1119 - self._trust_desktop_launchers(session, desktop_dir)
1120 -
1121 - def _hide_xpra_desktop_entries(self, applications_dir: Path) -> None:
1122 - for filename in HIDDEN_XPRA_DESKTOP_ENTRIES:
1123 - _write_hidden_application_entry(applications_dir / filename, "Xpra")
1124 -
1125 - def _hide_xfce_menu_entries(self, applications_dir: Path) -> None:
1126 - for filename, name in HIDDEN_XFCE_MENU_ENTRIES:
1127 - _write_hidden_application_entry(applications_dir / filename, name)
1128 -
1129 - def _prepare_xfce_panel_config(self, session: DesktopSession) -> None:
1130 - panel_xml = (
1131 - session.profile_dir
1132 - / ".config"
1133 - / "xfce4"
1134 - / "xfconf"
1135 - / "xfce-perchannel-xml"
1136 - / "xfce4-panel.xml"
1137 - )
1138 - panel_xml.parent.mkdir(parents=True, exist_ok=True)
1139 -
1140 - root = ET.Element("channel", {"name": "xfce4-panel", "version": "1.0"})
1141 - ET.SubElement(root, "property", {"name": "configver", "type": "int", "value": "2"})
1142 -
1143 - panels = ET.SubElement(root, "property", {"name": "panels", "type": "array"})
1144 - ET.SubElement(panels, "value", {"type": "int", "value": "1"})
1145 - panel = ET.SubElement(panels, "property", {"name": "panel-1", "type": "empty"})
1146 - for name, prop_type, value in (
1147 - ("position", "string", "p=6;x=0;y=0"),
1148 - ("length", "uint", "100"),
1149 - ("position-locked", "bool", "true"),
1150 - ("size", "uint", "24"),
1151 - ("mode", "uint", "0"),
1152 - ("autohide-behavior", "uint", "0"),
1153 - ("disable-struts", "bool", "false"),
1154 - ("nrows", "uint", "1"),
1155 - ):
1156 - ET.SubElement(panel, "property", {"name": name, "type": prop_type, "value": value})
1157 - plugin_ids = ET.SubElement(panel, "property", {"name": "plugin-ids", "type": "array"})
1158 - for plugin_id in ("1", "2", "3", "4", "5", "6", "7", "8", "9"):
1159 - ET.SubElement(plugin_ids, "value", {"type": "int", "value": plugin_id})
1160 -
1161 - plugins = ET.SubElement(root, "property", {"name": "plugins", "type": "empty"})
1162 - ET.SubElement(plugins, "property", {"name": "plugin-1", "type": "string", "value": "applicationsmenu"})
1163 - ET.SubElement(plugins, "property", {"name": "plugin-2", "type": "string", "value": "tasklist"})
1164 - tasklist = _xfce_property(plugins, "plugin-2", "string", "tasklist")
1165 - ET.SubElement(tasklist, "property", {"name": "flat-buttons", "type": "bool", "value": "true"})
1166 - ET.SubElement(tasklist, "property", {"name": "show-handle", "type": "bool", "value": "false"})
1167 - ET.SubElement(tasklist, "property", {"name": "show-labels", "type": "bool", "value": "true"})
1168 - separator = ET.SubElement(plugins, "property", {"name": "plugin-3", "type": "string", "value": "separator"})
1169 - ET.SubElement(separator, "property", {"name": "expand", "type": "bool", "value": "true"})
1170 - ET.SubElement(separator, "property", {"name": "style", "type": "uint", "value": "0"})
1171 - ET.SubElement(plugins, "property", {"name": "plugin-4", "type": "string", "value": "pager"})
1172 - ET.SubElement(plugins, "property", {"name": "plugin-5", "type": "string", "value": "systray"})
1173 - ET.SubElement(plugins, "property", {"name": "plugin-6", "type": "string", "value": "separator"})
1174 - ET.SubElement(plugins, "property", {"name": "plugin-7", "type": "string", "value": "clock"})
1175 - ET.SubElement(plugins, "property", {"name": "plugin-8", "type": "string", "value": "separator"})
1176 - shutdown = ET.SubElement(plugins, "property", {"name": "plugin-9", "type": "string", "value": "launcher"})
1177 - shutdown_items = ET.SubElement(shutdown, "property", {"name": "items", "type": "array"})
1178 - ET.SubElement(shutdown_items, "value", {"type": "string", "value": SHUTDOWN_PANEL_LAUNCHER_ID})
1179 -
1180 - tree = ET.ElementTree(root)
1181 - try:
1182 - ET.indent(tree, space=" ")
1183 - except AttributeError:
1184 - pass
1185 - tree.write(panel_xml, encoding="utf-8", xml_declaration=True)
1186 -
1187 - def _prepare_xfce_profile_autostart(self, session: DesktopSession) -> None:
1188 - script = session.profile_dir / "prepare-xfce-profile.sh"
1189 - script.write_text(
1190 - """#!/bin/sh
1191 -set -eu
1192 -export HOME="${HOME:-%s}"
1193 -export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
1194 -export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
1195 -export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
1196 -export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"
1197 -mkdir -p "$HOME/Desktop" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
1198 -if command -v xfconf-query >/dev/null 2>&1; then
1199 - xfconf-query -c thunar -p /last-show-hidden -n -t bool -s true >/dev/null 2>&1 || true
1200 - xfconf-query -c xfce4-desktop -p /desktop-icons/style -n -t int -s 2 >/dev/null 2>&1 || true
1201 - xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-home -n -t bool -s false >/dev/null 2>&1 || true
1202 - xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-filesystem -n -t bool -s false >/dev/null 2>&1 || true
1203 - xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-removable -n -t bool -s false >/dev/null 2>&1 || true
1204 - xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-trash -n -t bool -s false >/dev/null 2>&1 || true
1205 -fi
1206 -for launcher in "$HOME"/Desktop/*.desktop; do
1207 - [ -f "$launcher" ] || continue
1208 - chmod +x "$launcher" 2>/dev/null || true
1209 - if command -v gio >/dev/null 2>&1; then
1210 - checksum="$(sha256sum "$launcher" 2>/dev/null | cut -d " " -f 1)"
1211 - gio set "$launcher" metadata::trusted true >/dev/null 2>&1 || true
1212 - if [ -n "$checksum" ]; then
1213 - gio set -t string "$launcher" metadata::xfce-exe-checksum "$checksum" >/dev/null 2>&1 || true
1214 - fi
1215 - fi
1216 -done
1217 -if command -v xfdesktop >/dev/null 2>&1; then
1218 - timeout 4 xfdesktop --reload >/dev/null 2>&1 || true
1219 -fi
1220 -""" % str(session.profile_dir),
1221 - encoding="utf-8",
1222 - )
1223 - try:
1224 - script.chmod(0o700)
1225 - except OSError:
1226 - pass
1227 -
1228 - autostart_dir = session.profile_dir / ".config" / "autostart"
1229 - autostart_dir.mkdir(parents=True, exist_ok=True)
1230 - autostart = autostart_dir / "agent-zero-office-desktop.desktop"
1231 - autostart.write_text(
1232 - "\n".join(
1233 - [
1234 - "[Desktop Entry]",
1235 - "Type=Application",
1236 - "Name=Agent Zero desktop profile",
1237 - f"Exec={script}",
1238 - "Terminal=false",
1239 - "OnlyShowIn=XFCE;",
1240 - "X-GNOME-Autostart-enabled=true",
1241 - "",
1242 - ],
1243 - ),
1244 - encoding="utf-8",
1245 - )
1246 -
1247 - def _prepare_xfce_launcher(self, session: DesktopSession) -> Path:
1248 - launcher = session.profile_dir / "start-xfce.sh"
1249 - launcher.write_text(
1250 - "\n".join(
1251 - [
1252 - "#!/bin/sh",
1253 - 'export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"',
1254 - 'export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"',
1255 - 'export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"',
1256 - 'export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"',
1257 - (
1258 - "exec dbus-launch --exit-with-session sh -c "
1259 - f"'\"{session.profile_dir / 'prepare-xfce-profile.sh'}\" >/dev/null 2>&1 || true; exec xfce4-session'"
1260 - ),
1261 - "",
1262 - ],
1263 - ),
1264 - encoding="utf-8",
1265 - )
1266 - try:
1267 - launcher.chmod(0o700)
1268 - except OSError:
1269 - pass
1270 - return launcher
1271 -
1272 - def _prepare_root_window(self, session: DesktopSession) -> None:
1273 - xsetroot = shutil.which("xsetroot")
1274 - if not xsetroot:
1275 - return
1276 - subprocess.run(
1277 - [xsetroot, "-solid", "#20242a"],
1278 - check=False,
1279 - stdout=subprocess.DEVNULL,
1280 - stderr=subprocess.DEVNULL,
1281 - timeout=2,
1282 - env=self._display_env(session),
1283 - )
1284 -
1285 - def _fit_office_window(
1286 - self,
1287 - session: DesktopSession,
1288 - *,
1289 - process: subprocess.Popen[Any] | None = None,
1290 - ) -> None:
1291 - virtual_desktop.fit_window_until(
1292 - display=session.display,
1293 - width=session.width,
1294 - height=session.height,
1295 - window_class="libreoffice",
1296 - keys=("Escape",),
1297 - settle_seconds=4,
1298 - timeout_seconds=10,
1299 - process=process,
1300 - xauthority=self._xauthority(session),
1301 - home=str(session.profile_dir),
1302 - )
1303 - self._dismiss_blocking_dialogs(session)
1304 -
1305 - def _set_display_size(self, session: DesktopSession, width: int, height: int) -> dict[str, Any]:
1306 - result = virtual_desktop.resize_display(
1307 - display=session.display,
1308 - width=width,
1309 - height=height,
1310 - max_width=MAX_SCREEN_WIDTH,
1311 - max_height=MAX_SCREEN_HEIGHT,
1312 - window_class="",
1313 - keys=(),
1314 - xauthority=self._xauthority(session),
1315 - home=str(session.profile_dir),
1316 - )
1317 - if result.get("ok"):
1318 - session.width = int(result["width"])
1319 - session.height = int(result["height"])
1320 - return result
1321 -
1322 - def _dismiss_blocking_dialogs(self, session: DesktopSession) -> None:
1323 - virtual_desktop.close_windows(
1324 - display=session.display,
1325 - names=BLOCKING_DIALOG_TITLES,
1326 - xauthority=self._xauthority(session),
1327 - home=str(session.profile_dir),
1328 - )
1329 -
1330 - def _refresh_xfce_desktop(self, session: DesktopSession) -> None:
1331 - xfdesktop = shutil.which("xfdesktop")
1332 - if not xfdesktop:
1333 - return
1334 - env = self._xfce_process_env(session, "xfdesktop")
1335 - try:
1336 - subprocess.run(
1337 - [xfdesktop, "--reload"],
1338 - check=False,
1339 - stdout=subprocess.DEVNULL,
1340 - stderr=subprocess.DEVNULL,
1341 - timeout=4,
1342 - env=env,
1343 - )
1344 - except (OSError, subprocess.TimeoutExpired):
1345 - return
1346 -
1347 - def _trust_desktop_launchers(self, session: DesktopSession, desktop_dir: Path) -> None:
1348 - gio = shutil.which("gio")
1349 - if not gio:
1350 - return
1351 - env = self._xfce_process_env(session, "xfdesktop")
1352 - for launcher in desktop_dir.glob("*.desktop"):
1353 - try:
1354 - launcher.chmod(0o755)
1355 - checksum = hashlib.sha256(launcher.read_bytes()).hexdigest()
1356 - except OSError:
1357 - continue
1358 - for command in (
1359 - [gio, "set", str(launcher), "metadata::trusted", "true"],
1360 - [gio, "set", "-t", "string", str(launcher), "metadata::xfce-exe-checksum", checksum],
1361 - ):
1362 - try:
1363 - subprocess.run(
1364 - command,
1365 - check=False,
1366 - stdout=subprocess.DEVNULL,
1367 - stderr=subprocess.DEVNULL,
1368 - timeout=4,
1369 - env=env,
1370 - )
1371 - except (OSError, subprocess.TimeoutExpired):
1372 - continue
1373 -
1374 - def _xfce_process_env(self, session: DesktopSession, command_name: str) -> dict[str, str]:
1375 - env = self._display_env(session)
1376 - proc = Path("/proc")
1377 - for candidate in proc.iterdir():
1378 - if not candidate.name.isdigit():
1379 - continue
1380 - try:
1381 - if (candidate / "comm").read_text(encoding="utf-8").strip() != command_name:
1382 - continue
1383 - process_env = self._read_process_env(candidate)
1384 - except OSError:
1385 - continue
1386 - if process_env.get("HOME") != str(session.profile_dir):
1387 - continue
1388 - if process_env.get("DISPLAY") != f":{session.display}":
1389 - continue
1390 - for key, value in process_env.items():
1391 - if (
1392 - key in {"DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "HOME", "XAUTHORITY"}
1393 - or key.startswith("XDG_")
1394 - ):
1395 - env[key] = value
1396 - break
1397 - return env
1398 -
1399 - def _read_process_env(self, proc_dir: Path) -> dict[str, str]:
1400 - raw = (proc_dir / "environ").read_bytes()
1401 - env: dict[str, str] = {}
1402 - for item in raw.split(b"\0"):
1403 - if not item or b"=" not in item:
1404 - continue
1405 - key, value = item.split(b"=", 1)
1406 - env[key.decode("utf-8", errors="ignore")] = value.decode("utf-8", errors="ignore")
1407 - return env
1408 -
1409 - def _session_env(self, session: DesktopSession) -> dict[str, str]:
1410 - env = {
1411 - **os.environ,
1412 - "HOME": str(session.profile_dir),
1413 - "LANG": os.environ.get("LANG") or "C.UTF-8",
1414 - }
1415 - browser_bridge = _url_bridge_script_path(session)
1416 - if browser_bridge.exists():
1417 - env["BROWSER"] = str(browser_bridge)
1418 - env.setdefault("XDG_RUNTIME_DIR", str(STATE_DIR / "xdg-runtime"))
1419 - runtime_dir = Path(env["XDG_RUNTIME_DIR"])
1420 - runtime_dir.mkdir(parents=True, exist_ok=True)
1421 - try:
1422 - runtime_dir.chmod(0o700)
1423 - except OSError:
1424 - pass
1425 - return env
1426 -
1427 - def _display_env(self, session: DesktopSession) -> dict[str, str]:
1428 - env = {
1429 - **self._session_env(session),
1430 - "DISPLAY": f":{session.display}",
1431 - "SAL_USE_VCLPLUGIN": os.environ.get("SAL_USE_VCLPLUGIN") or "gtk3",
1432 - }
1433 - xauthority = self._xauthority(session)
1434 - if xauthority:
1435 - env["XAUTHORITY"] = xauthority
1436 - return env
1437 -
1438 - def _xauthority(self, session: DesktopSession) -> str:
1439 - path = session.profile_dir / ".Xauthority"
1440 - return str(path) if path.exists() else ""
1441 -
1442 - def _allocate_endpoint_locked(self) -> tuple[int, int]:
1443 - used_displays = {session.display for session in self._sessions.values()}
1444 - used_ports = {session.xpra_port for session in self._sessions.values()}
1445 - for offset in range(MAX_SESSIONS):
1446 - display = DISPLAY_BASE + offset
1447 - port = XPRA_PORT_BASE + offset
1448 - if display in used_displays or port in used_ports:
1449 - continue
1450 - if _port_is_free(port):
1451 - return display, port
1452 - raise RuntimeError("No LibreOffice desktop slots are available.")
1453 -
1454 - def _find_by_file_id_locked(self, file_id: str) -> DesktopSession | None:
1455 - for session in self._sessions.values():
1456 - if session.file_id == file_id and session.alive():
1457 - return session
1458 - return None
1459 -
1460 - def _find_by_file_id(self, file_id: str) -> DesktopSession | None:
1461 - with self._lock:
1462 - return self._find_by_file_id_locked(str(file_id or "").strip())
1463 -
1464 - def _reap_dead_locked(self) -> None:
1465 - for session_id, session in list(self._sessions.items()):
1466 - if not session.alive():
1467 - self._terminate_session(session)
1468 - virtual_desktop.unregister_session(session.token)
1469 - self._sessions.pop(session_id, None)
1470 - self._remove_manifest(session_id)
1471 -
1472 - def _wait_for_display(self, session: DesktopSession) -> None:
1473 - marker = Path(f"/tmp/.X11-unix/X{session.display}")
1474 - deadline = time.time() + DISPLAY_START_TIMEOUT_SECONDS
1475 - while time.time() < deadline:
1476 - process = session.processes.get("xvfb") or session.processes.get("xpra")
1477 - if process and process.poll() is not None:
1478 - raise RuntimeError("The LibreOffice X display exited before it was ready.")
1479 - if marker.exists():
1480 - return
1481 - time.sleep(0.1)
1482 - raise TimeoutError("Timed out waiting for the LibreOffice X display.")
1483 -
1484 - def _wait_for_xfce(self, session: DesktopSession) -> None:
1485 - deadline = time.time() + STARTUP_GRACE_SECONDS
1486 - while time.time() < deadline:
1487 - process = session.processes.get("xfce")
1488 - if process and process.poll() is not None:
1489 - return
1490 - if virtual_desktop.has_window(
1491 - display=session.display,
1492 - name="xfce4-panel",
1493 - xauthority=self._xauthority(session),
1494 - home=str(session.profile_dir),
1495 - ):
1496 - return
1497 - time.sleep(0.25)
1498 -
1499 - def _write_manifest(self, session: DesktopSession) -> None:
1500 - SESSION_DIR.mkdir(parents=True, exist_ok=True)
1501 - pids = dict(session.process_ids)
1502 - pids.update({name: process.pid for name, process in session.processes.items()})
1503 - payload = {
1504 - "session_id": session.session_id,
1505 - "file_id": session.file_id,
1506 - "extension": session.extension,
1507 - "path": session.path,
1508 - "title": session.title,
1509 - "display": session.display,
1510 - "xpra_port": session.xpra_port,
1511 - "profile_dir": str(session.profile_dir),
1512 - "width": session.width,
1513 - "height": session.height,
1514 - "started_at": session.started_at,
1515 - "owner_pid": os.getpid(),
1516 - "pids": pids,
1517 - }
1518 - (SESSION_DIR / f"{session.session_id}.json").write_text(json.dumps(payload), encoding="utf-8")
1519 -
1520 - def _remove_manifest(self, session_id: str) -> None:
1521 - (SESSION_DIR / f"{session_id}.json").unlink(missing_ok=True)
1522 -
1523 - def _terminate_session(self, session: DesktopSession, *, include_rehydrated: bool = False) -> None:
1524 - process_names = [name for name in session.processes if name.startswith("soffice")]
1525 - process_names.extend(["xfce", "xpra", "xvfb"])
1526 - terminated_pids: set[int] = set()
1527 - for name in process_names:
1528 - process = session.processes.get(name)
1529 - if not process:
1530 - continue
1531 - if process.pid:
1532 - terminated_pids.add(process.pid)
1533 - _terminate_process(process)
1534 - if session.owns_processes or include_rehydrated:
1535 - for name, pid in session.process_ids.items():
1536 - if pid in terminated_pids:
1537 - continue
1538 - if name.startswith("soffice") or name in {"xfce", "xpra", "xvfb"}:
1539 - _kill_pid(pid)
1540 - self._remove_stale_lock_file(session)
1541 -
1542 - def _remove_stale_lock_file(self, session: DesktopSession, *, path: str | Path | None = None) -> None:
1543 - path = Path(path or session.path)
1544 - if not path.name:
1545 - return
1546 - lock_file = path.with_name(f".~lock.{path.name}#")
1547 - try:
1548 - lock_file.unlink(missing_ok=True)
1549 - except OSError:
1550 - pass
1551 -
1552 -
1553 -def collect_desktop_status() -> dict[str, Any]:
1554 - desktop = virtual_desktop.collect_status()
1555 - binaries = {
1556 - **desktop["binaries"],
1557 - "soffice": libreoffice.find_soffice(),
1558 - "thunar": shutil.which("thunar") or "",
1559 - "xfce4-terminal": shutil.which("xfce4-terminal") or "",
1560 - "xfce4-settings-manager": shutil.which("xfce4-settings-manager") or "",
1561 - "gio": shutil.which("gio") or "",
1562 - }
1563 - missing = [
1564 - name
1565 - for name in (
1566 - "soffice",
1567 - "thunar",
1568 - "xfce4-terminal",
1569 - "xfce4-settings-manager",
1570 - "gio",
1571 - )
1572 - if not binaries[name]
1573 - ]
1574 - missing.extend(
1575 - name
1576 - for name in ("xpra", "Xvfb", "xfce4-session", "dbus-launch", "xrandr", "xdotool")
1577 - if not binaries.get(name)
1578 - )
1579 - if not desktop.get("xpra_html_root"):
1580 - missing.append("xpra-html5")
1581 - if desktop.get("binaries", {}).get("xpra") and desktop.get("packages", {}).get("xpra-x11") is False:
1582 - missing.append("xpra-x11")
1583 - healthy = not missing
1584 - return {
1585 - "ok": True,
1586 - "healthy": healthy,
1587 - "state": "healthy" if healthy else "missing",
1588 - "binaries": binaries,
1589 - "xpra_html_root": str(desktop.get("xpra_html_root") or ""),
1590 - "message": (
1591 - "Official LibreOffice desktop sessions are available."
1592 - if healthy
1593 - else f"Official LibreOffice desktop sessions need: {', '.join(missing)}."
1594 - ),
1595 - }
1596 -
1597 -
1598 -def cleanup_stale_runtime_state() -> dict[str, Any]:
1599 - killed: list[int] = []
1600 - errors: list[str] = []
1601 - if SESSION_DIR.exists():
1602 - for manifest in SESSION_DIR.glob("*.json"):
1603 - try:
1604 - payload = json.loads(manifest.read_text(encoding="utf-8"))
1605 - owner_pid = _coerce_pid(payload.get("owner_pid"))
1606 - if owner_pid and _pid_is_running(owner_pid):
1607 - continue
1608 - for pid in dict(payload.get("pids") or {}).values():
1609 - pid_int = _coerce_pid(pid)
1610 - if not pid_int:
1611 - continue
1612 - if _kill_pid(pid_int):
1613 - killed.append(pid_int)
1614 - manifest.unlink(missing_ok=True)
1615 - except Exception as exc:
1616 - errors.append(str(exc))
1617 - return {"ok": not errors, "killed": killed, "errors": errors}
1618 -
1619 -
1620 -def get_manager() -> LibreOfficeDesktopManager:
1621 - global _manager
1622 - try:
1623 - return _manager
1624 - except NameError:
1625 - _manager = LibreOfficeDesktopManager()
1626 - atexit.register(_manager.shutdown)
1627 - return _manager
1628 -
1629 -
1630 -def _xpra_url(token: str) -> str:
1631 - return virtual_desktop.session_url(token, title="Desktop")
1632 -
1633 -
1634 -def _xvfb_command(xvfb: str, session: DesktopSession) -> list[str]:
1635 - return [
1636 - xvfb,
1637 - f":{session.display}",
1638 - "-screen",
1639 - "0",
1640 - f"{MAX_SCREEN_WIDTH}x{MAX_SCREEN_HEIGHT}x24",
1641 - "+extension",
1642 - "GLX",
1643 - "+extension",
1644 - "RANDR",
1645 - "+extension",
1646 - "RENDER",
1647 - "+extension",
1648 - "Composite",
1649 - "-extension",
1650 - "DOUBLE-BUFFER",
1651 - "-nolisten",
1652 - "tcp",
1653 - "-noreset",
1654 - "-ac",
1655 - ]
1656 -
1657 -
1658 -def _xpra_shadow_command(xpra: str, session: DesktopSession) -> list[str]:
1659 - return [
1660 - xpra,
1661 - "shadow",
1662 - f":{session.display}",
1663 - "--daemon=no",
1664 - "--mdns=no",
1665 - "--html=on",
1666 - "--tray=no",
1667 - "--system-tray=no",
1668 - "--notifications=no",
1669 - "--clipboard=yes",
1670 - "--clipboard-direction=both",
1671 - "--file-transfer=yes",
1672 - "--open-files=no",
1673 - "--open-url=no",
1674 - "--printing=yes",
1675 - "--audio=no",
1676 - "--speaker=off",
1677 - "--microphone=off",
1678 - "--encoding=jpeg",
1679 - "--quality=85",
1680 - "--speed=80",
1681 - f"--bind-tcp=127.0.0.1:{session.xpra_port}",
1682 - "--resize-display=yes",
1683 - f"--log-dir={session.profile_dir}",
1684 - "--log-file=xpra.log",
1685 - ]
1686 -
1687 -
1688 -def _desktop_exec(*args: str | Path) -> str:
1689 - return " ".join(_desktop_exec_arg(str(arg)) for arg in args if str(arg))
1690 -
1691 -
1692 -def _desktop_icon(*candidates: str) -> str:
1693 - for candidate in candidates:
1694 - if candidate.startswith("/") and Path(candidate).exists():
1695 - return candidate
1696 - return next(
1697 - (candidate for candidate in candidates if not candidate.startswith("/")),
1698 - candidates[-1],
1699 - )
1700 -
1701 -
1702 -def _ensure_desktop_folder_link(desktop_dir: Path, label: str, target: Path) -> None:
1703 - target.mkdir(parents=True, exist_ok=True)
1704 - link = desktop_dir / label
1705 - try:
1706 - if link.is_symlink() or link.is_file():
1707 - link.unlink()
1708 - if not link.exists():
1709 - link.symlink_to(target, target_is_directory=True)
1710 - except OSError:
1711 - return
1712 -
1713 -
1714 -def _url_bridge_dir(session: DesktopSession) -> Path:
1715 - return session.profile_dir / ".agent-zero"
1716 -
1717 -
1718 -def _url_bridge_script_path(session: DesktopSession) -> Path:
1719 - return _url_bridge_dir(session) / "open-url"
1720 -
1721 -
1722 -def _url_bridge_queue_path(session: DesktopSession) -> Path:
1723 - return _url_bridge_dir(session) / "browser-url-intents.jsonl"
1724 -
1725 -
1726 -def _url_bridge_lock_path(session: DesktopSession) -> Path:
1727 - return _url_bridge_dir(session) / "browser-url-intents.lock"
1728 -
1729 -
1730 -def _shutdown_request_path(session: DesktopSession) -> Path:
1731 - return _url_bridge_dir(session) / "shutdown-request.json"
1732 -
1733 -
1734 -def _shutdown_arm_path(session: DesktopSession) -> Path:
1735 - return _url_bridge_dir(session) / "shutdown-request.arm.json"
1736 -
1737 -
1738 -def _shutdown_lock_path(session: DesktopSession) -> Path:
1739 - return _url_bridge_dir(session) / "shutdown-request.lock"
1740 -
1741 -
1742 -def _write_url_bridge_script(session: DesktopSession) -> Path:
1743 - bridge_dir = _url_bridge_dir(session)
1744 - bridge_dir.mkdir(parents=True, exist_ok=True)
1745 - script = _url_bridge_script_path(session)
1746 - queue = _url_bridge_queue_path(session)
1747 - lock = _url_bridge_lock_path(session)
1748 - script.write_text(
1749 - f"""#!/usr/bin/env python3
1750 -import fcntl
1751 -import json
1752 -import os
1753 -import sys
1754 -import time
1755 -
1756 -QUEUE_PATH = {str(queue)!r}
1757 -LOCK_PATH = {str(lock)!r}
1758 -MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH}
1759 -
1760 -
1761 -def main():
1762 - urls = [str(arg or "").strip()[:MAX_URL_LENGTH] for arg in sys.argv[1:] if str(arg or "").strip()]
1763 - if not urls:
1764 - urls = [""]
1765 - os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True)
1766 - with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
1767 - fcntl.flock(lock_file, fcntl.LOCK_EX)
1768 - with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file:
1769 - for url in urls:
1770 - queue_file.write(json.dumps({{
1771 - "url": url,
1772 - "created_at": time.time(),
1773 - "source": "desktop",
1774 - }}, ensure_ascii=True) + "\\n")
1775 - queue_file.flush()
1776 - os.fsync(queue_file.fileno())
1777 - fcntl.flock(lock_file, fcntl.LOCK_UN)
1778 -
1779 -
1780 -if __name__ == "__main__":
1781 - main()
1782 -""",
1783 - encoding="utf-8",
1784 - )
1785 - try:
1786 - script.chmod(0o755)
1787 - except OSError:
1788 - pass
1789 - return script
1790 -
1791 -
1792 -def _write_shutdown_bridge_script(session: DesktopSession) -> Path:
1793 - bridge_dir = _url_bridge_dir(session)
1794 - bridge_dir.mkdir(parents=True, exist_ok=True)
1795 - script = bridge_dir / "shutdown-desktop"
1796 - request = _shutdown_request_path(session)
1797 - arm = _shutdown_arm_path(session)
1798 - lock = _shutdown_lock_path(session)
1799 - script.write_text(
1800 - f"""#!/usr/bin/env python3
1801 -import fcntl
1802 -import json
1803 -import os
1804 -import shutil
1805 -import subprocess
1806 -import time
1807 -
1808 -REQUEST_PATH = {str(request)!r}
1809 -ARM_PATH = {str(arm)!r}
1810 -LOCK_PATH = {str(lock)!r}
1811 -CONFIRM_SECONDS = {SHUTDOWN_CONFIRM_SECONDS}
1812 -
1813 -
1814 -def notify(message, timeout=None):
1815 - if not os.environ.get("DISPLAY"):
1816 - return
1817 - xmessage = shutil.which("xmessage")
1818 - if not xmessage:
1819 - return
1820 - try:
1821 - subprocess.Popen(
1822 - [
1823 - xmessage,
1824 - "-buttons",
1825 - "",
1826 - "-timeout",
1827 - str(timeout or CONFIRM_SECONDS),
1828 - "-center",
1829 - message,
1830 - ],
1831 - stdin=subprocess.DEVNULL,
1832 - stdout=subprocess.DEVNULL,
1833 - stderr=subprocess.DEVNULL,
1834 - start_new_session=True,
1835 - )
1836 - except OSError:
1837 - pass
1838 -
1839 -
1840 -def read_arm(now):
1841 - try:
1842 - with open(ARM_PATH, "r", encoding="utf-8") as handle:
1843 - payload = json.load(handle)
1844 - except (OSError, json.JSONDecodeError):
1845 - return None
1846 - try:
1847 - created_at = float(payload.get("created_at"))
1848 - except (TypeError, ValueError):
1849 - return None
1850 - if now - created_at > CONFIRM_SECONDS:
1851 - return None
1852 - return created_at
1853 -
1854 -
1855 -def write_json_atomic(path, payload):
1856 - tmp_path = path + ".tmp"
1857 - with open(tmp_path, "w", encoding="utf-8") as handle:
1858 - json.dump(payload, handle, ensure_ascii=True)
1859 - handle.write("\\n")
1860 - handle.flush()
1861 - os.fsync(handle.fileno())
1862 - os.replace(tmp_path, path)
1863 -
1864 -
1865 -def main():
1866 - os.makedirs(os.path.dirname(REQUEST_PATH), exist_ok=True)
1867 - now = time.time()
1868 - with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
1869 - fcntl.flock(lock_file, fcntl.LOCK_EX)
1870 - armed_at = read_arm(now)
1871 - if armed_at is None:
1872 - write_json_atomic(ARM_PATH, {{"created_at": now, "source": "tray"}})
1873 - notify(
1874 - f"Shutdown Desktop armed. Click Shutdown Desktop again within {{CONFIRM_SECONDS}} seconds to close it.",
1875 - CONFIRM_SECONDS,
1876 - )
1877 - return
1878 - try:
1879 - os.unlink(ARM_PATH)
1880 - except OSError:
1881 - pass
1882 - payload = {{
1883 - "created_at": now,
1884 - "armed_at": armed_at,
1885 - "source": "tray",
1886 - }}
1887 - write_json_atomic(REQUEST_PATH, payload)
1888 - notify("Shutting down Agent Zero Desktop.", 2)
1889 -
1890 -
1891 -if __name__ == "__main__":
1892 - main()
1893 -""",
1894 - encoding="utf-8",
1895 - )
1896 - try:
1897 - script.chmod(0o755)
1898 - except OSError:
1899 - pass
1900 - return script
1901 -
1902 -
1903 -def _claim_url_intents(session: DesktopSession) -> list[dict[str, Any]]:
1904 - queue = _url_bridge_queue_path(session)
1905 - lock = _url_bridge_lock_path(session)
1906 - if not queue.exists():
1907 - return []
1908 - lock.parent.mkdir(parents=True, exist_ok=True)
1909 - try:
1910 - with open(lock, "a+", encoding="utf-8") as lock_file:
1911 - fcntl.flock(lock_file, fcntl.LOCK_EX)
1912 - try:
1913 - raw = queue.read_text(encoding="utf-8")
1914 - queue.write_text("", encoding="utf-8")
1915 - finally:
1916 - fcntl.flock(lock_file, fcntl.LOCK_UN)
1917 - except OSError:
1918 - return []
1919 -
1920 - intents: list[dict[str, Any]] = []
1921 - for line in raw.splitlines():
1922 - try:
1923 - payload = json.loads(line)
1924 - except json.JSONDecodeError:
1925 - continue
1926 - url = str(payload.get("url") or "").strip()
1927 - if len(url) > URL_INTENT_MAX_LENGTH:
1928 - url = url[:URL_INTENT_MAX_LENGTH]
1929 - created_at = payload.get("created_at")
1930 - try:
1931 - created_at = float(created_at)
1932 - except (TypeError, ValueError):
1933 - created_at = time.time()
1934 - intents.append(
1935 - {
1936 - "url": url,
1937 - "created_at": created_at,
1938 - "source": str(payload.get("source") or "desktop"),
1939 - },
1940 - )
1941 - if len(intents) >= URL_INTENT_MAX_ITEMS:
1942 - break
1943 - return intents
1944 -
1945 -
1946 -def _claim_shutdown_request(session: DesktopSession) -> dict[str, Any] | None:
1947 - request = _shutdown_request_path(session)
1948 - if not request.exists():
1949 - return None
1950 - try:
1951 - raw = request.read_text(encoding="utf-8")
1952 - request.unlink(missing_ok=True)
1953 - except OSError:
1954 - return None
1955 - try:
1956 - payload = json.loads(raw)
1957 - except json.JSONDecodeError:
1958 - payload = {}
1959 - created_at = payload.get("created_at")
1960 - try:
1961 - created_at = float(created_at)
1962 - except (TypeError, ValueError):
1963 - created_at = time.time()
1964 - return {
1965 - "created_at": created_at,
1966 - "source": str(payload.get("source") or "tray"),
1967 - }
1968 -
1969 -
1970 -def _clear_shutdown_request(session: DesktopSession) -> None:
1971 - request = _shutdown_request_path(session)
1972 - arm = _shutdown_arm_path(session)
1973 - lock = _shutdown_lock_path(session)
1974 - request.unlink(missing_ok=True)
1975 - request.with_suffix(request.suffix + ".tmp").unlink(missing_ok=True)
1976 - arm.unlink(missing_ok=True)
1977 - arm.with_suffix(arm.suffix + ".tmp").unlink(missing_ok=True)
1978 - lock.unlink(missing_ok=True)
1979 -
1980 -
1981 -def _remove_system_manifest() -> None:
1982 - (SESSION_DIR / f"{SYSTEM_SESSION_ID}.json").unlink(missing_ok=True)
1983 -
1984 -
1985 -def _url_handler_mime_types() -> tuple[str, ...]:
1986 - return (
1987 - "x-scheme-handler/http",
1988 - "x-scheme-handler/https",
1989 - "text/html",
1990 - "application/xhtml+xml",
1991 - )
1992 -
1993 -
1994 -def _write_mimeapps_defaults(path: Path, desktop_id: str) -> None:
1995 - associations = ";".join([desktop_id, ""])
1996 - lines = [
1997 - "[Default Applications]",
1998 - *(f"{mime_type}={desktop_id}" for mime_type in _url_handler_mime_types()),
1999 - "",
2000 - "[Added Associations]",
2001 - *(f"{mime_type}={associations}" for mime_type in _url_handler_mime_types()),
2002 - "",
2003 - ]
2004 - path.parent.mkdir(parents=True, exist_ok=True)
2005 - path.write_text("\n".join(lines), encoding="utf-8")
2006 -
2007 -
2008 -def _write_xfce_browser_helper(path: Path, bridge_script: Path) -> None:
2009 - command = _desktop_exec(bridge_script)
2010 - command_with_parameter = _desktop_exec(bridge_script, "%s")
2011 - path.parent.mkdir(parents=True, exist_ok=True)
2012 - path.write_text(
2013 - "\n".join(
2014 - [
2015 - "[Desktop Entry]",
2016 - "NoDisplay=true",
2017 - "Version=1.0",
2018 - "Type=X-XFCE-Helper",
2019 - "X-XFCE-Category=WebBrowser",
2020 - f"X-XFCE-Commands={command}",
2021 - f"X-XFCE-CommandsWithParameter={command_with_parameter}",
2022 - "Icon=web-browser",
2023 - "Name=Agent Zero Browser",
2024 - "",
2025 - ],
2026 - ),
2027 - encoding="utf-8",
2028 - )
2029 -
2030 -
2031 -def _remove_path_if_owned(path: Path) -> None:
2032 - try:
2033 - if path.is_symlink() or path.is_file():
2034 - path.unlink()
2035 - elif path.is_dir():
2036 - shutil.rmtree(path)
2037 - except OSError:
2038 - return
2039 -
2040 -
2041 -def _desktop_exec_arg(value: str) -> str:
2042 - if not any(char.isspace() or char in '"\\' for char in value):
2043 - return value
2044 - escaped = value.replace("\\", "\\\\").replace('"', '\\"')
2045 - return f'"{escaped}"'
2046 -
2047 -
2048 -def _xml_attr(value: str) -> str:
2049 - return (
2050 - str(value)
2051 - .replace("&", "&amp;")
2052 - .replace('"', "&quot;")
2053 - .replace("<", "&lt;")
2054 - .replace(">", "&gt;")
2055 - )
2056 -
2057 -
2058 -def _oor(name: str) -> str:
2059 - return f"{{{OOR_NS}}}{name}"
2060 -
2061 -
2062 -def _file_uri(path: str | Path) -> str:
2063 - return Path(path).resolve(strict=False).as_uri()
2064 -
2065 -
2066 -def _write_libreoffice_registry_defaults(registry: Path, workdir: str | Path) -> None:
2067 - Path(workdir).mkdir(parents=True, exist_ok=True)
2068 - ET.register_namespace("oor", OOR_NS)
2069 - ET.register_namespace("xs", XS_NS)
2070 - ET.register_namespace("xsi", XSI_NS)
2071 - root = _read_libreoffice_registry(registry)
2072 - workdir_uri = _file_uri(workdir)
2073 - for path, prop, value in (
2074 - ("/org.openoffice.Office.Common/Misc", "FirstRun", "false"),
2075 - ("/org.openoffice.Setup/Office", "ooSetupInstCompleted", "true"),
2076 - ("/org.openoffice.Setup/Office", "MigrationCompleted", "true"),
2077 - ("/org.openoffice.Setup/Office", "OfficeRestartInProgress", "false"),
2078 - ("/org.openoffice.Setup/L10N", "ooLocale", "en-US"),
2079 - ("/org.openoffice.Office.Paths/Variables", "Work", workdir_uri),
2080 - (
2081 - "/org.openoffice.Office.Paths/Paths/org.openoffice.Office.Paths:NamedPath['Work']",
2082 - "WritePath",
2083 - workdir_uri,
2084 - ),
2085 - ):
2086 - _set_registry_prop(root, path, prop, value)
2087 - registry.parent.mkdir(parents=True, exist_ok=True)
2088 - ET.ElementTree(root).write(registry, encoding="utf-8", xml_declaration=True)
2089 -
2090 -
2091 -def _read_libreoffice_registry(registry: Path) -> ET.Element:
2092 - if registry.exists():
2093 - try:
2094 - return ET.parse(registry).getroot()
2095 - except ET.ParseError:
2096 - pass
2097 - return ET.Element(
2098 - _oor("items"),
2099 - {
2100 - "xmlns:xs": XS_NS,
2101 - "xmlns:xsi": XSI_NS,
2102 - },
2103 - )
2104 -
2105 -
2106 -def _set_registry_prop(root: ET.Element, item_path: str, prop_name: str, value: str) -> None:
2107 - item = _find_registry_item(root, item_path)
2108 - if item is None:
2109 - item = ET.SubElement(root, "item", {_oor("path"): item_path})
2110 - prop = next((child for child in item.findall("prop") if child.get(_oor("name")) == prop_name), None)
2111 - if prop is None:
2112 - prop = ET.SubElement(item, "prop", {_oor("name"): prop_name, _oor("op"): "fuse"})
2113 - else:
2114 - prop.set(_oor("op"), "fuse")
2115 - value_node = prop.find("value")
2116 - if value_node is None:
2117 - value_node = ET.SubElement(prop, "value")
2118 - value_node.text = str(value)
2119 -
2120 -
2121 -def _find_registry_item(root: ET.Element, item_path: str) -> ET.Element | None:
2122 - for item in root.findall("item"):
2123 - if item.get(_oor("path")) == item_path:
2124 - return item
2125 - return None
2126 -
2127 -
2128 -def _write_desktop_launcher(
2129 - path: Path,
2130 - *,
2131 - name: str,
2132 - exec_line: str,
2133 - icon: str,
2134 - categories: str,
2135 - try_exec: str = "",
2136 - working_dir: str | Path | None = None,
2137 - mime_types: tuple[str, ...] = (),
2138 - no_display: bool = False,
2139 -) -> None:
2140 - path.parent.mkdir(parents=True, exist_ok=True)
2141 - lines = [
2142 - "[Desktop Entry]",
2143 - "Version=1.0",
2144 - "Type=Application",
2145 - f"Name={name}",
2146 - f"Exec={exec_line}",
2147 - ]
2148 - if try_exec:
2149 - lines.append(f"TryExec={try_exec}")
2150 - if working_dir:
2151 - lines.append(f"Path={working_dir}")
2152 - if mime_types:
2153 - lines.append(f"MimeType={';'.join(mime_types)};")
2154 - if no_display:
2155 - lines.append("NoDisplay=true")
2156 - lines.extend(
2157 - [
2158 - f"Icon={icon}",
2159 - "Terminal=false",
2160 - f"Categories={categories}",
2161 - "StartupNotify=true",
2162 - "X-XFCE-Trusted=true",
2163 - "",
2164 - ],
2165 - )
2166 - path.write_text("\n".join(lines), encoding="utf-8")
2167 - try:
2168 - path.chmod(0o755)
2169 - except OSError:
2170 - pass
2171 -
2172 -
2173 -def _write_hidden_application_entry(path: Path, name: str) -> None:
2174 - path.parent.mkdir(parents=True, exist_ok=True)
2175 - path.write_text(
2176 - "\n".join(
2177 - [
2178 - "[Desktop Entry]",
2179 - "Type=Application",
2180 - f"Name={name}",
2181 - "NoDisplay=true",
2182 - "Hidden=true",
2183 - "",
2184 - ],
2185 - ),
2186 - encoding="utf-8",
2187 - )
2188 -
2189 -
2190 -def _write_thunar_defaults(path: Path) -> None:
2191 - root = _read_xfce_channel(path, "thunar")
2192 - if _find_xfce_property(root, "last-view") is None:
2193 - _xfce_property(root, "last-view", "string", "ThunarIconView")
2194 - _xfce_property(root, "last-show-hidden", "bool", "true")
2195 - _write_xfce_channel(path, root)
2196 -
2197 -
2198 -def _read_xfce_channel(path: Path, channel_name: str) -> ET.Element:
2199 - if path.exists():
2200 - try:
2201 - root = ET.parse(path).getroot()
2202 - if root.tag == "channel" and root.get("name") == channel_name:
2203 - root.set("version", root.get("version") or "1.0")
2204 - return root
2205 - except (ET.ParseError, OSError):
2206 - pass
2207 - return ET.Element("channel", {"name": channel_name, "version": "1.0"})
2208 -
2209 -
2210 -def _write_xfce_channel(path: Path, root: ET.Element) -> None:
2211 - path.parent.mkdir(parents=True, exist_ok=True)
2212 - tree = ET.ElementTree(root)
2213 - try:
2214 - ET.indent(tree, space=" ")
2215 - except AttributeError:
2216 - pass
2217 - tree.write(path, encoding="utf-8", xml_declaration=True)
2218 -
2219 -
2220 -def _find_xfce_property(parent: ET.Element, name: str) -> ET.Element | None:
2221 - return next((child for child in parent.findall("property") if child.get("name") == name), None)
2222 -
2223 -
2224 -def _install_desktop_readme(desktop_dir: Path) -> None:
2225 - if not DESKTOP_README_SOURCE.exists():
2226 - return
2227 - target = desktop_dir / "README.md"
2228 - try:
2229 - content = DESKTOP_README_SOURCE.read_text(encoding="utf-8")
2230 - if target.exists() and target.read_text(encoding="utf-8") == content:
2231 - return
2232 - target.write_text(content, encoding="utf-8")
2233 - target.chmod(0o644)
2234 - except OSError:
2235 - return
2236 -
2237 -
2238 -def _xfce_property(parent: ET.Element, name: str, property_type: str, value: str | None = None) -> ET.Element:
2239 - for child in parent.findall("property"):
2240 - if child.get("name") == name:
2241 - child.set("type", property_type)
2242 - if value is None:
2243 - child.attrib.pop("value", None)
2244 - else:
2245 - child.set("value", value)
2246 - return child
2247 - attributes = {"name": name, "type": property_type}
2248 - if value is not None:
2249 - attributes["value"] = value
2250 - return ET.SubElement(parent, "property", attributes)
2251 -
2252 -
2253 -def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
2254 - return {
2255 - "file_id": doc["file_id"],
2256 - "path": document_store.display_path(doc["path"]),
2257 - "basename": doc["basename"],
2258 - "extension": doc["extension"],
2259 - "size": doc["size"],
2260 - "version": document_store.item_version(doc),
2261 - "last_modified": doc["last_modified"],
2262 - }
2263 -
2264 -
2265 -def _require_binary(name: str) -> str:
2266 - found = shutil.which(name)
2267 - if not found:
2268 - raise RuntimeError(f"{name} is required for official LibreOffice desktop sessions.")
2269 - return found
2270 -
2271 -
2272 -def _running(process: subprocess.Popen[Any] | None) -> bool:
2273 - return bool(process and process.poll() is None)
2274 -
2275 -
2276 -def _wait_for_port(
2277 - host: str,
2278 - port: int,
2279 - timeout: float = 15.0,
2280 - process: subprocess.Popen[Any] | None = None,
2281 -) -> None:
2282 - deadline = time.time() + timeout
2283 - while time.time() < deadline:
2284 - if process and process.poll() is not None:
2285 - raise RuntimeError(f"Xpra exited before port {port} was ready.")
2286 - try:
2287 - with socket.create_connection((host, port), timeout=0.2):
2288 - return
2289 - except OSError:
2290 - time.sleep(0.1)
2291 - raise TimeoutError(f"Timed out waiting for Xpra port {port}.")
2292 -
2293 -
2294 -def _port_is_free(port: int) -> bool:
2295 - try:
2296 - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
2297 - probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
2298 - probe.bind(("127.0.0.1", port))
2299 - return True
2300 - except OSError:
2301 - return False
2302 -
2303 -
2304 -def _port_is_accepting(host: str, port: int) -> bool:
2305 - try:
2306 - with socket.create_connection((host, port), timeout=0.2):
2307 - return True
2308 - except OSError:
2309 - return False
2310 -
2311 -
2312 -def _terminate_process(process: subprocess.Popen[Any]) -> None:
2313 - if process.poll() is not None:
2314 - return
2315 - try:
2316 - process.terminate()
2317 - process.wait(timeout=2)
2318 - return
2319 - except Exception:
2320 - pass
2321 - try:
2322 - process.kill()
2323 - process.wait(timeout=2)
2324 - except Exception:
2325 - pass
2326 -
2327 -
2328 -def _kill_pid(pid: int) -> bool:
2329 - if pid <= 0:
2330 - return False
2331 - try:
2332 - os.kill(pid, 15)
2333 - return True
2334 - except ProcessLookupError:
2335 - return False
2336 - except PermissionError:
2337 - return False
2338 -
2339 -
2340 -def _coerce_pid(value: Any) -> int:
2341 - try:
2342 - pid = int(value)
2343 - except (TypeError, ValueError):
2344 - return 0
2345 - return pid if pid > 0 else 0
2346 -
2347 -
2348 -def _pid_is_running(pid: int) -> bool:
2349 - if pid <= 0:
2350 - return False
2351 - try:
2352 - os.kill(pid, 0)
2353 - return True
2354 - except ProcessLookupError:
2355 - return False
2356 - except PermissionError:
2357 - return True
3 +# Compatibility facade for pre-split callers. New Desktop runtime ownership
4 +# lives in plugins._desktop.helpers.desktop_session.
5 +from plugins._desktop.helpers.desktop_session import * # noqa: F401,F403
plugins/_office/helpers/libreoffice_desktop_routes.py deleted
-14
@@ -1,14 +0,0 @@
1 -from __future__ import annotations
2 -
3 -from helpers.virtual_desktop_routes import (
4 - VirtualDesktopGateway as LibreOfficeDesktopGateway,
5 - install_route_hooks,
6 - is_installed,
7 -)
8 -
9 -
10 -__all__ = [
11 - "LibreOfficeDesktopGateway",
12 - "install_route_hooks",
13 - "is_installed",
14 -]
plugins/_office/hooks.py
+117 -214
@@ -3,26 +3,31 @@ from __future__ import annotations
3 import os
4 import shutil
5 import subprocess
6 -import urllib.request
6 from pathlib import Path
7 from typing import Any
8
9 +from helpers import files
10 +
11
12 PROJECT_ROOT = Path(__file__).resolve().parents[2]
12 -APT_SOURCE_FILE = Path("/etc/apt/sources.list.d/collaboraonline.sources")
13 -APT_KEYRING_FILE = Path("/etc/apt/keyrings/collaboraonline-release-keyring.gpg")
14 -XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources")
15 -XPRA_KEYRING_FILE = Path("/usr/share/keyrings/xpra.asc")
16 -XPRA_KEY_URL = "https://xpra.org/xpra.asc"
17 -SUPERVISOR_FILE = Path("/etc/supervisor/conf.d/a0_office_collabora.conf")
18 -SUPERVISOR_PROGRAM = "a0_office_collabora"
19 -RUNTIME_DIRS = [
13 +STATE_DIR = Path(files.get_abs_path("usr", "_office"))
14 +DOCUMENT_STATE_DIR = STATE_DIR / "documents"
15 +LEGACY_DOCUMENT_STATE_DIRS = [
16 + Path(files.get_abs_path("usr", "plugins", "_office", "documents")),
17 + Path(files.get_abs_path("usr", "state", "_office", "documents")),
18 + Path(files.get_abs_path("usr", "state", "office", "documents")),
19 +]
20 +RETIRED_WEB_APT_SOURCE_FILE = Path("/etc/apt/sources.list.d/collaboraonline.sources")
21 +RETIRED_WEB_APT_KEYRING_FILE = Path("/etc/apt/keyrings/collaboraonline-release-keyring.gpg")
22 +RETIRED_WEB_SUPERVISOR_FILE = Path("/etc/supervisor/conf.d/a0_office_collabora.conf")
23 +RETIRED_WEB_SUPERVISOR_PROGRAM = "a0_office_collabora"
24 +RETIRED_WEB_RUNTIME_DIRS = [
25 Path("/a0/tmp/_office/collabora"),
26 Path("/a0/usr/plugins/_office/collabora"),
27 PROJECT_ROOT / "tmp" / "_office" / "collabora",
28 PROJECT_ROOT / "usr" / "plugins" / "_office" / "collabora",
29 ]
25 -PACKAGES = (
30 +RETIRED_WEB_PACKAGES = (
31 "coolwsd",
32 "coolwsd-deprecated",
33 "code-brand",
@@ -45,28 +50,6 @@ RUNTIME_PACKAGES = (
50 "libreoffice-impress",
51 "libreoffice-gtk3",
52 "python3-uno",
48 - "xpra-server",
49 - "xpra-client",
50 - "xpra-client-gtk3",
51 - "xpra-x11",
52 - "xpra-html5",
53 - "xfce4-session",
54 - "xfwm4",
55 - "xfce4-panel",
56 - "xfdesktop4",
57 - "xfce4-settings",
58 - "thunar",
59 - "gvfs",
60 - "libglib2.0-bin",
61 - "xfce4-terminal",
62 - "x11-xserver-utils",
63 - "x11-utils",
64 - "x11-apps",
65 - "xdotool",
66 - "xclip",
67 - "xauth",
68 - "dbus-x11",
69 - "python3-pil",
53 "fonts-dejavu",
54 "fonts-liberation",
55 "fonts-crosextra-caladea",
@@ -75,17 +58,10 @@ RUNTIME_PACKAGES = (
58 "fonts-noto-cjk",
59 "fonts-noto-color-emoji",
60 )
78 -# The browser-hosted Desktop needs the server, X11, and html5 pieces. Local
79 -# Xpra GUI clients are useful extras, but can pull codec packages that are not
80 -# consistently available across architectures.
81 -OPTIONAL_RUNTIME_PACKAGES = (
82 - "xpra-client",
83 - "xpra-client-gtk3",
84 -)
61 RETIRED_RUNTIME_PACKAGES = (
62 "firefox-esr",
63 )
88 -CLEANUP_MARKER = PROJECT_ROOT / "usr" / "plugins" / "_office" / "stale-cleanup-v2.done"
64 +CLEANUP_MARKER = STATE_DIR / "stale-cleanup-v3.done"
65
66
67 def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
@@ -98,20 +74,34 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
74
75 removed: list[str] = []
76 installed: list[str] = []
77 + migrated: list[str] = []
78 + warnings: list[str] = []
79 errors: list[str] = []
80
103 - stale_paths = [
81 + _migrate_legacy_document_state(migrated, warnings, errors)
82 +
83 + retired_web_paths = [
84 path
105 - for path in [APT_SOURCE_FILE, APT_KEYRING_FILE, SUPERVISOR_FILE, *RUNTIME_DIRS]
85 + for path in [
86 + RETIRED_WEB_APT_SOURCE_FILE,
87 + RETIRED_WEB_APT_KEYRING_FILE,
88 + RETIRED_WEB_SUPERVISOR_FILE,
89 + *RETIRED_WEB_RUNTIME_DIRS,
90 + ]
91 if path.exists() or path.is_symlink()
92 ]
108 - stale_packages = _installed_packages(PACKAGES)
109 - cleanup_needed = force or not CLEANUP_MARKER.exists() or bool(stale_paths or stale_packages)
93 + retired_web_packages = _installed_packages(RETIRED_WEB_PACKAGES)
94 + cleanup_needed = force or not CLEANUP_MARKER.exists() or bool(retired_web_paths or retired_web_packages)
95
96 if cleanup_needed:
97 _kill_old_processes(errors)
98
114 - for path in [APT_SOURCE_FILE, APT_KEYRING_FILE, SUPERVISOR_FILE, *RUNTIME_DIRS]:
99 + for path in [
100 + RETIRED_WEB_APT_SOURCE_FILE,
101 + RETIRED_WEB_APT_KEYRING_FILE,
102 + RETIRED_WEB_SUPERVISOR_FILE,
103 + *RETIRED_WEB_RUNTIME_DIRS,
104 + ]:
105 try:
106 if _remove_path(path):
107 removed.append(str(path))
@@ -119,7 +109,7 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
109 errors.append(f"{path}: {exc}")
110
111 _retire_supervisor_program(errors)
122 - _purge_packages(removed, errors, installed_packages=stale_packages)
112 + _purge_packages(removed, errors, installed_packages=retired_web_packages)
113
114 try:
115 CLEANUP_MARKER.parent.mkdir(parents=True, exist_ok=True)
@@ -130,24 +120,94 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
120 retired_packages = [
121 package
122 for package in _installed_packages(RETIRED_RUNTIME_PACKAGES)
133 - if package not in stale_packages
123 + if package not in retired_web_packages
124 ]
125 if retired_packages:
126 _purge_packages(removed, errors, installed_packages=retired_packages)
127
128 _retire_supervisor_program(errors)
129 _ensure_runtime_dependencies(installed, errors)
140 - _cleanup_desktop_sessions(errors)
141 -
130 + _ensure_desktop_runtime_compat(installed, removed, warnings, errors)
131 return {
132 "ok": not errors,
133 "skipped": not cleanup_needed,
134 "removed": removed,
135 "installed": installed,
136 + "migrated": migrated,
137 + "warnings": warnings,
138 "errors": errors,
139 }
140
141
142 +def _migrate_legacy_document_state(
143 + migrated: list[str],
144 + warnings: list[str],
145 + errors: list[str],
146 +) -> None:
147 + legacy_dirs = [
148 + path
149 + for path in LEGACY_DOCUMENT_STATE_DIRS
150 + if path != DOCUMENT_STATE_DIR and path.exists()
151 + ]
152 + if not legacy_dirs:
153 + return
154 +
155 + if DOCUMENT_STATE_DIR.exists():
156 + warnings.extend(
157 + f"Legacy Office document state left in place because {DOCUMENT_STATE_DIR} already exists: {path}"
158 + for path in legacy_dirs
159 + )
160 + return
161 +
162 + source = legacy_dirs[0]
163 + try:
164 + DOCUMENT_STATE_DIR.parent.mkdir(parents=True, exist_ok=True)
165 + shutil.copytree(source, DOCUMENT_STATE_DIR, symlinks=True)
166 + migrated.append(f"{source} -> {DOCUMENT_STATE_DIR}")
167 + except Exception as exc:
168 + errors.append(f"Office document state migration failed from {source}: {exc}")
169 + return
170 +
171 + warnings.extend(
172 + f"Additional legacy Office document state left in place after migrating {source}: {path}"
173 + for path in legacy_dirs[1:]
174 + )
175 +
176 +
177 +def _ensure_desktop_runtime_compat(
178 + installed: list[str],
179 + removed: list[str],
180 + warnings: list[str],
181 + errors: list[str],
182 +) -> None:
183 + """Keep self-update compatibility for managers that only invoke _office/hooks.py.
184 +
185 + Agent Zero 1.10-1.13 self-update managers call the Office cleanup hook
186 + directly before starting the updated UI. Desktop runtime ownership now lives
187 + in _desktop, so this temporary delegate preserves the old pre-launch cleanup
188 + and package-preparation behavior for users updating from those releases.
189 + """
190 +
191 + try:
192 + from plugins._desktop import hooks as desktop_hooks
193 + except Exception as exc:
194 + warnings.append(f"Desktop runtime compatibility hook unavailable: {exc}")
195 + return
196 +
197 + try:
198 + result = desktop_hooks.cleanup_stale_runtime_state()
199 + except Exception as exc:
200 + errors.append(f"Desktop runtime compatibility hook failed: {exc}")
201 + return
202 +
203 + if not isinstance(result, dict):
204 + return
205 + installed.extend(str(item) for item in result.get("installed") or [])
206 + removed.extend(str(item) for item in result.get("removed") or [])
207 + warnings.extend(str(item) for item in result.get("warnings") or [])
208 + errors.extend(str(item) for item in result.get("errors") or [])
209 +
210 +
211 def _remove_path(path: Path) -> bool:
212 if path.is_symlink() or path.is_file():
213 path.unlink(missing_ok=True)
@@ -175,24 +235,24 @@ def _kill_old_processes(errors: list[str]) -> None:
235 def _retire_supervisor_program(errors: list[str]) -> None:
236 if not shutil.which("supervisorctl"):
237 return
178 - status = _supervisorctl("status", SUPERVISOR_PROGRAM)
238 + status = _supervisorctl("status", RETIRED_WEB_SUPERVISOR_PROGRAM)
239 status_output = _supervisor_output(status)
240 if status.returncode != 0:
241 if _supervisor_absent(status_output):
242 return
183 - errors.append(status_output or f"supervisorctl status {SUPERVISOR_PROGRAM} failed")
243 + errors.append(status_output or f"supervisorctl status {RETIRED_WEB_SUPERVISOR_PROGRAM} failed")
244 return
245
186 - stopped = _supervisorctl("stop", SUPERVISOR_PROGRAM)
246 + stopped = _supervisorctl("stop", RETIRED_WEB_SUPERVISOR_PROGRAM)
247 stopped_output = _supervisor_output(stopped)
248 if stopped.returncode != 0 and not _supervisor_absent(stopped_output):
189 - errors.append(stopped_output or f"supervisorctl stop {SUPERVISOR_PROGRAM} failed")
249 + errors.append(stopped_output or f"supervisorctl stop {RETIRED_WEB_SUPERVISOR_PROGRAM} failed")
250 return
251
192 - removed = _supervisorctl("remove", SUPERVISOR_PROGRAM)
252 + removed = _supervisorctl("remove", RETIRED_WEB_SUPERVISOR_PROGRAM)
253 removed_output = _supervisor_output(removed)
254 if removed.returncode != 0 and not _supervisor_absent(removed_output):
195 - errors.append(removed_output or f"supervisorctl remove {SUPERVISOR_PROGRAM} failed")
255 + errors.append(removed_output or f"supervisorctl remove {RETIRED_WEB_SUPERVISOR_PROGRAM} failed")
256 return
257
258 for command in (("reread",), ("update",)):
@@ -242,7 +302,7 @@ def _purge_packages(
302 ) -> None:
303 if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
304 return
245 - installed = installed_packages if installed_packages is not None else _installed_packages(PACKAGES)
305 + installed = installed_packages if installed_packages is not None else _installed_packages(RETIRED_WEB_PACKAGES)
306 if not installed:
307 return
308 result = subprocess.run(
@@ -280,40 +340,13 @@ def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> Non
340 if not _apt_update(errors):
341 return
342
283 - required_missing, optional_missing = _split_runtime_packages(missing)
284 - required_xpra_missing = [package for package in required_missing if package.startswith("xpra")]
285 - if required_xpra_missing and not _package_candidates_available(required_xpra_missing):
286 - previous_error_count = len(errors)
287 - _ensure_xpra_repository(installed, errors)
288 - if len(errors) > previous_error_count or not _apt_update(errors):
289 - return
290 - missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
291 - if not missing:
292 - return
293 - required_missing, optional_missing = _split_runtime_packages(missing)
294 -
295 - if required_missing and not _install_runtime_packages(required_missing, installed, errors):
296 - return
297 -
298 - if optional_missing:
299 - optional_xpra_missing = [package for package in optional_missing if package.startswith("xpra")]
300 - if optional_xpra_missing and not _package_candidates_available(optional_xpra_missing):
301 - return
302 - _install_runtime_packages(optional_missing, installed, errors, optional=True)
303 -
304 -
305 -def _split_runtime_packages(packages: list[str]) -> tuple[list[str], list[str]]:
306 - optional = [package for package in packages if package in OPTIONAL_RUNTIME_PACKAGES]
307 - required = [package for package in packages if package not in OPTIONAL_RUNTIME_PACKAGES]
308 - return required, optional
343 + _install_runtime_packages(missing, installed, errors)
344
345
346 def _install_runtime_packages(
347 packages: list[str],
348 installed: list[str],
349 errors: list[str],
315 - *,
316 - optional: bool = False,
350 ) -> bool:
351 result = subprocess.run(
352 ["apt-get", "install", "-y", "--no-install-recommends", *packages],
@@ -327,17 +360,10 @@ def _install_runtime_packages(
360 installed.extend(packages)
361 return True
362 output = (result.stderr or result.stdout or "apt-get install failed").strip()
330 - if optional and _is_xpra_codec_dependency_gap(output):
331 - return False
363 errors.append(output)
364 return False
365
366
336 -def _is_xpra_codec_dependency_gap(output: str) -> bool:
337 - normalized = output.lower()
338 - return "xpra-codecs" in normalized and "libvpx9" in normalized
339 -
340 -
367 def _apt_update(errors: list[str]) -> bool:
368 result = subprocess.run(
369 ["apt-get", "update"],
@@ -351,126 +377,3 @@ def _apt_update(errors: list[str]) -> bool:
377 return True
378 errors.append((result.stderr or result.stdout or "apt-get update failed").strip())
379 return False
354 -
355 -
356 -def _package_candidate_available(package: str) -> bool:
357 - if not shutil.which("apt-cache"):
358 - return True
359 - result = subprocess.run(
360 - ["apt-cache", "policy", package],
361 - check=False,
362 - text=True,
363 - capture_output=True,
364 - timeout=15,
365 - )
366 - if result.returncode != 0:
367 - return True
368 - if not result.stdout.strip():
369 - return False
370 - return "Candidate: (none)" not in result.stdout
371 -
372 -
373 -def _package_candidates_available(packages: list[str]) -> bool:
374 - return all(_package_candidate_available(package) for package in packages)
375 -
376 -
377 -def _ensure_xpra_repository(installed: list[str], errors: list[str]) -> None:
378 - if not _package_installed("ca-certificates"):
379 - result = subprocess.run(
380 - ["apt-get", "install", "-y", "--no-install-recommends", "ca-certificates"],
381 - check=False,
382 - text=True,
383 - capture_output=True,
384 - timeout=180,
385 - env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
386 - )
387 - if result.returncode != 0:
388 - errors.append((result.stderr or result.stdout or "apt-get install ca-certificates failed").strip())
389 - return
390 - installed.append("ca-certificates")
391 -
392 - try:
393 - key = _download(XPRA_KEY_URL)
394 - XPRA_KEYRING_FILE.parent.mkdir(parents=True, exist_ok=True)
395 - if not XPRA_KEYRING_FILE.exists() or XPRA_KEYRING_FILE.read_bytes() != key:
396 - XPRA_KEYRING_FILE.write_bytes(key)
397 -
398 - XPRA_SOURCE_FILE.parent.mkdir(parents=True, exist_ok=True)
399 - source = _xpra_repository_source()
400 - if not XPRA_SOURCE_FILE.exists() or XPRA_SOURCE_FILE.read_text(encoding="utf-8") != source:
401 - XPRA_SOURCE_FILE.write_text(source, encoding="utf-8")
402 - except Exception as exc:
403 - errors.append(f"Xpra repository setup failed: {exc}")
404 -
405 -
406 -def _download(url: str) -> bytes:
407 - with urllib.request.urlopen(url, timeout=45) as response:
408 - return response.read()
409 -
410 -
411 -def _xpra_repository_source() -> str:
412 - os_release = _read_os_release()
413 - os_id = os_release.get("ID", "")
414 - codename = os_release.get("VERSION_CODENAME", "")
415 - arch = _dpkg_architecture()
416 -
417 - if os_id == "kali" and arch == "amd64":
418 - uri = "https://xpra.org/beta"
419 - suite = "sid"
420 - elif os_id == "kali":
421 - uri = "https://xpra.org"
422 - suite = "trixie"
423 - elif codename in {"sid", "forky"} and arch == "amd64":
424 - uri = "https://xpra.org/beta"
425 - suite = codename
426 - elif codename in {"sid", "forky"}:
427 - uri = "https://xpra.org"
428 - suite = "trixie"
429 - else:
430 - uri = "https://xpra.org"
431 - suite = codename or "trixie"
432 -
433 - return (
434 - f"Types: deb\n"
435 - f"URIs: {uri}\n"
436 - f"Suites: {suite}\n"
437 - f"Components: main\n"
438 - f"Signed-By: {XPRA_KEYRING_FILE}\n"
439 - f"Architectures: {arch}\n"
440 - )
441 -
442 -
443 -def _read_os_release() -> dict[str, str]:
444 - path = Path("/etc/os-release")
445 - if not path.exists():
446 - return {}
447 - values: dict[str, str] = {}
448 - for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
449 - if not line or line.startswith("#") or "=" not in line:
450 - continue
451 - key, value = line.split("=", 1)
452 - values[key] = value.strip().strip('"')
453 - return values
454 -
455 -
456 -def _dpkg_architecture() -> str:
457 - result = subprocess.run(
458 - ["dpkg", "--print-architecture"],
459 - check=False,
460 - text=True,
461 - capture_output=True,
462 - timeout=8,
463 - )
464 - if result.returncode == 0 and result.stdout.strip():
465 - return result.stdout.strip()
466 - return "amd64"
467 -
468 -
469 -def _cleanup_desktop_sessions(errors: list[str]) -> None:
470 - try:
471 - from plugins._office.helpers import libreoffice_desktop
472 -
473 - result = libreoffice_desktop.cleanup_stale_runtime_state()
474 - errors.extend(str(item) for item in result.get("errors") or [])
475 - except Exception as exc:
476 - errors.append(f"LibreOffice desktop cleanup failed: {exc}")