Show Desktop install progress during runtime prep

Track active Desktop runtime preparation in the _desktop hook, expose installing status through Desktop session status, and have the Desktop frontend poll with a neutral install message instead of showing missing dependencies while packages are still being installed after an update.

Alessandro committed May 7, 2026 at 03:22 UTC 28a0b35ad4558a155858f34ff94ce50ee31b6451
6 files changed +174 -21
plugins/_desktop/api/desktop_session.py
+1
@@ -32,6 +32,7 @@ class DesktopSession(ApiHandler):
32 return {
33 "ok": False,
34 "error": desktop.get("error") or "Desktop session is unavailable.",
35 + "status": desktop.get("status") or {},
36 "desktop": desktop,
37 "libreoffice": libreoffice.collect_status(),
38 }
plugins/_desktop/helpers/desktop_session.py
+21 -1
@@ -43,6 +43,10 @@ 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 +RUNTIME_INSTALL_MESSAGE = (
47 + "Installing Agent Zero Desktop runtime dependencies. "
48 + "This can take a few minutes after an update."
49 +)
50 HIDDEN_XPRA_DESKTOP_ENTRIES = (
51 "xpra.desktop",
52 "xpra-gui.desktop",
@@ -1585,20 +1589,36 @@ def collect_desktop_status() -> dict[str, Any]:
1589 if desktop.get("binaries", {}).get("xpra") and desktop.get("packages", {}).get("xpra-x11") is False:
1590 missing.append("xpra-x11")
1591 healthy = not missing
1592 + preparation = _runtime_preparation_status()
1593 + installing = bool(preparation.get("preparing")) and not healthy
1594 return {
1595 "ok": True,
1596 "healthy": healthy,
1591 - "state": "healthy" if healthy else "missing",
1597 + "state": "healthy" if healthy else "installing" if installing else "missing",
1598 + "installing": installing,
1599 + "missing": missing,
1600 + "preparation": preparation,
1601 "binaries": binaries,
1602 "xpra_html_root": str(desktop.get("xpra_html_root") or ""),
1603 "message": (
1604 "Agent Zero Desktop sessions are available."
1605 if healthy
1606 + else RUNTIME_INSTALL_MESSAGE
1607 + if installing
1608 else f"Agent Zero Desktop sessions need: {', '.join(missing)}."
1609 ),
1610 }
1611
1612
1613 +def _runtime_preparation_status() -> dict[str, Any]:
1614 + try:
1615 + from plugins._desktop import hooks
1616 +
1617 + return hooks.runtime_preparation_status()
1618 + except Exception:
1619 + return {"preparing": False, "active_count": 0, "started_at": 0.0, "completed_at": 0.0}
1620 +
1621 +
1622 def cleanup_stale_runtime_state() -> dict[str, Any]:
1623 killed: list[int] = []
1624 errors: list[str] = []
plugins/_desktop/hooks.py
+80 -18
@@ -3,6 +3,8 @@ from __future__ import annotations
3 import os
4 import shutil
5 import subprocess
6 +import threading
7 +import time
8 import urllib.request
9 from pathlib import Path
10 from typing import Any
@@ -59,29 +61,89 @@ OPTIONAL_RUNTIME_PACKAGES = (
61 RETIRED_RUNTIME_PACKAGES = (
62 "firefox-esr",
63 )
64 +_preparation_lock = threading.RLock()
65 +_preparation_state: dict[str, Any] = {
66 + "preparing": False,
67 + "active_count": 0,
68 + "started_at": 0.0,
69 + "completed_at": 0.0,
70 + "result": None,
71 + "error": "",
72 +}
73
74
75 def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
76 """Prepare the Linux Desktop runtime and reap stale Desktop sessions."""
77
67 - installed: list[str] = []
68 - removed: list[str] = []
69 - errors: list[str] = []
70 -
71 - retired_packages = _installed_packages(RETIRED_RUNTIME_PACKAGES)
72 - if retired_packages:
73 - _purge_packages(removed, errors, installed_packages=retired_packages)
74 -
75 - _ensure_runtime_dependencies(installed, errors)
76 - _cleanup_desktop_sessions(errors)
77 -
78 - return {
79 - "ok": not errors,
80 - "skipped": False,
81 - "removed": removed,
82 - "installed": installed,
83 - "errors": errors,
84 - }
78 + _begin_runtime_preparation()
79 + result: dict[str, Any] | None = None
80 + error = ""
81 + try:
82 + installed: list[str] = []
83 + removed: list[str] = []
84 + errors: list[str] = []
85 +
86 + retired_packages = _installed_packages(RETIRED_RUNTIME_PACKAGES)
87 + if retired_packages:
88 + _purge_packages(removed, errors, installed_packages=retired_packages)
89 +
90 + _ensure_runtime_dependencies(installed, errors)
91 + _cleanup_desktop_sessions(errors)
92 +
93 + result = {
94 + "ok": not errors,
95 + "skipped": False,
96 + "removed": removed,
97 + "installed": installed,
98 + "errors": errors,
99 + }
100 + return result
101 + except Exception as exc:
102 + error = str(exc)
103 + raise
104 + finally:
105 + _finish_runtime_preparation(result=result, error=error)
106 +
107 +
108 +def runtime_preparation_status() -> dict[str, Any]:
109 + with _preparation_lock:
110 + return {
111 + "preparing": bool(_preparation_state["preparing"]),
112 + "active_count": int(_preparation_state["active_count"]),
113 + "started_at": float(_preparation_state["started_at"]),
114 + "completed_at": float(_preparation_state["completed_at"]),
115 + "result": _preparation_state["result"],
116 + "error": str(_preparation_state["error"]),
117 + }
118 +
119 +
120 +def _begin_runtime_preparation() -> None:
121 + with _preparation_lock:
122 + if not _preparation_state["active_count"]:
123 + _preparation_state["preparing"] = True
124 + _preparation_state["started_at"] = time.time()
125 + _preparation_state["completed_at"] = 0.0
126 + _preparation_state["result"] = None
127 + _preparation_state["error"] = ""
128 + _preparation_state["active_count"] = int(_preparation_state["active_count"]) + 1
129 +
130 +
131 +def _finish_runtime_preparation(
132 + *,
133 + result: dict[str, Any] | None = None,
134 + error: str = "",
135 +) -> None:
136 + with _preparation_lock:
137 + active_count = max(0, int(_preparation_state["active_count"]) - 1)
138 + _preparation_state["active_count"] = active_count
139 + if result is not None:
140 + _preparation_state["result"] = result
141 + if error:
142 + _preparation_state["error"] = error
143 + if active_count:
144 + return
145 + _preparation_state["preparing"] = False
146 + _preparation_state["completed_at"] = time.time()
147
148
149 def _installed_packages(packages: tuple[str, ...]) -> list[str]:
plugins/_desktop/webui/desktop-store.js
+40 -2
@@ -12,6 +12,9 @@ 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 DESKTOP_RUNTIME_INSTALL_MESSAGE = "Installing Agent Zero Desktop runtime dependencies. This can take a few minutes after an update.";
16 +const DESKTOP_RUNTIME_INSTALL_POLL_MS = 4000;
17 +const DESKTOP_RUNTIME_INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
18 const XPRA_DESKTOP_PRIME_INTERVAL_MS = 220;
19 const XPRA_DESKTOP_PRIME_ATTEMPTS = 120;
20 const SYSTEM_DESKTOP_FILE_ID = "system-desktop";
@@ -124,6 +127,10 @@ function placeCaretAtEnd(element) {
127 selection.addRange(range);
128 }
129
130 +function sleep(ms) {
131 + return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
132 +}
133 +
134 function normalizeDocument(doc = {}) {
135 const path = doc.path || "";
136 const extension = String(doc.extension || extensionOf(path)).toLowerCase();
@@ -453,7 +460,7 @@ const model = {
460 this.message = progressMessage;
461 this.error = "";
462 }
456 - const response = await callDesktop("desktop");
463 + const response = await this.openDesktopWhenRuntimeReady(showProgress);
464 if (response?.ok === false) throw new Error(response.error || "Desktop session could not be opened.");
465 this.setDesktopIntentionalShutdown(false);
466 const session = normalizeSession(response);
@@ -488,7 +495,7 @@ const model = {
495 } finally {
496 if (showProgress) {
497 this.loading = false;
491 - if (this.message === progressMessage) this.message = "";
498 + if (this.message === progressMessage || this.message === DESKTOP_RUNTIME_INSTALL_MESSAGE) this.message = "";
499 }
500 this._desktopStarting = null;
501 }
@@ -496,6 +503,37 @@ const model = {
503 return await this._desktopStarting;
504 },
505
506 + async openDesktopWhenRuntimeReady(showProgress = true) {
507 + const startedAt = Date.now();
508 + let response = await callDesktop("desktop");
509 + while (response?.ok === false && this.isDesktopRuntimeInstalling(response)) {
510 + if (showProgress) {
511 + this.loading = true;
512 + this.error = "";
513 + this.message = this.desktopRuntimeInstallMessage(response);
514 + }
515 + if (Date.now() - startedAt > DESKTOP_RUNTIME_INSTALL_TIMEOUT_MS) {
516 + return {
517 + ...response,
518 + error: "Agent Zero Desktop runtime installation is still running. Please try again in a moment.",
519 + };
520 + }
521 + await sleep(DESKTOP_RUNTIME_INSTALL_POLL_MS);
522 + response = await callDesktop("desktop");
523 + }
524 + return response;
525 + },
526 +
527 + isDesktopRuntimeInstalling(response = {}) {
528 + const status = response?.status || response?.desktop?.status || response?.libreoffice?.desktop || {};
529 + return Boolean(status.installing || status.state === "installing" || status.preparation?.preparing);
530 + },
531 +
532 + desktopRuntimeInstallMessage(response = {}) {
533 + const status = response?.status || response?.desktop?.status || response?.libreoffice?.desktop || {};
534 + return String(status.message || DESKTOP_RUNTIME_INSTALL_MESSAGE);
535 + },
536 +
537 async create(kind = "document", format = "") {
538 const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "md")).toLowerCase();
539 const title = this.defaultTitle(kind, fmt);
tests/test_office_canvas_setup.py
+5
@@ -166,10 +166,15 @@ def test_desktop_plugin_owns_routes_runtime_surface_and_state_paths():
166
167 assert "virtual_desktop_routes.install_route_hooks()" in desktop_startup
168 assert 'action in {"open_document", "document"}' in desktop_api
169 + assert '"status": desktop.get("status") or {}' in desktop_api
170 assert 'callJsonApi("/plugins/_desktop/desktop_session"' in desktop_store
171 assert 'callDesktop("open_document"' in desktop_store
172 assert 'callOffice("create"' in desktop_store
173 assert "open_in_desktop: isOfficialExtension(fmt)" in desktop_store
174 + assert "DESKTOP_RUNTIME_INSTALL_MESSAGE" in desktop_store
175 + assert "openDesktopWhenRuntimeReady" in desktop_store
176 + assert "isDesktopRuntimeInstalling" in desktop_store
177 + assert "Installing Agent Zero Desktop runtime dependencies" in desktop_session
178 assert "__a0XpraOffsetWarnPatched" in desktop_store
179 assert "window does not fit in canvas, offsets" in desktop_store
180 assert "decode error packet" in desktop_store
tests/test_office_document_store.py
+27
@@ -505,6 +505,33 @@ def test_official_desktop_session_status_and_url_contract(tmp_path, monkeypatch)
505 assert "printing=true" in url
506
507
508 +def test_desktop_status_reports_installing_during_runtime_preparation(monkeypatch):
509 + monkeypatch.setattr(
510 + desktop_session.virtual_desktop,
511 + "collect_status",
512 + lambda: {
513 + "binaries": {},
514 + "packages": {},
515 + "xpra_html_root": "",
516 + },
517 + )
518 + monkeypatch.setattr(desktop_session.libreoffice, "find_soffice", lambda: "")
519 + monkeypatch.setattr(desktop_session.shutil, "which", lambda _name: "")
520 + monkeypatch.setattr(
521 + desktop_session,
522 + "_runtime_preparation_status",
523 + lambda: {"preparing": True, "active_count": 1, "started_at": 123.0},
524 + )
525 +
526 + status = desktop_session.collect_desktop_status()
527 +
528 + assert status["healthy"] is False
529 + assert status["installing"] is True
530 + assert status["state"] == "installing"
531 + assert status["message"].startswith("Installing Agent Zero Desktop runtime dependencies")
532 + assert "soffice" in status["missing"]
533 +
534 +
535 def test_desktop_gateway_patches_xpra_menu_script():
536 source = (PROJECT_ROOT / "helpers" / "virtual_desktop_routes.py").read_text(encoding="utf-8")
537