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