Persist Agent Zero Desktop lifecycle

Keep one Xpra Desktop iframe alive across canvas, modal, and keepalive hosts instead of unloading it during normal UI handoffs. Add intentional shutdown/restart state so explicit shutdown is treated as closed, not crashed. Add the desktop_shutdown Office API path, backend system-desktop shutdown cleanup, and an XFCE panel Shutdown Desktop launcher that requires a second click before writing the shutdown request marker. Hide unsafe logout, lock, and switch-user affordances and cover the lifecycle with focused tests.

Alessandro committed May 5, 2026 at 12:20 UTC 9390e42bcc5781f58e3f2d6aed95b5d50970405c
7 files changed +788 -47
plugins/_office/api/office_session.py
+9
@@ -63,6 +63,8 @@ class OfficeSession(ApiHandler):
63 return self._desktop_sync(input)
64 if action == "desktop_state":
65 return self._desktop_state(input)
66 + if action == "desktop_shutdown":
67 + return self._desktop_shutdown(input)
68 return {"ok": False, "error": f"Unsupported office session action: {action}"}
69
70 async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
@@ -196,6 +198,13 @@ class OfficeSession(ApiHandler):
198 include_screenshot = bool(input.get("include_screenshot") is True)
199 return libreoffice_desktop.get_manager().state(include_screenshot=include_screenshot)
200
201 + def _desktop_shutdown(self, input: dict) -> dict:
202 + save_first = input.get("save_first") is not False
203 + return libreoffice_desktop.get_manager().shutdown_system_desktop(
204 + save_first=save_first,
205 + source=str(input.get("source") or "api"),
206 + )
207 +
208 def _origin(self, request: Request) -> str:
209 origin = request.headers.get("Origin") or request.host_url.rstrip("/")
210 return origin.rstrip("/")
plugins/_office/extensions/webui/right-canvas-panels/office-panel.html
+1 -1
@@ -2,7 +2,7 @@
2 class="right-canvas-surface-panel office-canvas-surface"
3 data-surface-id="office"
4 x-show="$store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'office'"
5 - x-effect="$store.office?.setDesktopHostVisible?.($store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'office')"
5 + x-effect="(() => { const visible = Boolean($store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'office'); (globalThis.queueMicrotask || ((callback) => globalThis.setTimeout(callback, 0)))(() => $store.office?.setDesktopHostVisible?.(visible)); })()"
6 style="display: none;"
7 >
8 <x-component path="/plugins/_office/webui/office-panel.html"></x-component>
plugins/_office/helpers/libreoffice_desktop.py
+240 -9
@@ -50,6 +50,10 @@ HIDDEN_XFCE_MENU_ENTRIES = (
50 ("exo-web-browser.desktop", "Web Browser"),
51 ("xfce4-mail-reader.desktop", "Mail Reader"),
52 ("xfce4-web-browser.desktop", "Web Browser"),
53 + ("xfce4-session-logout.desktop", "Log Out"),
54 + ("xfce4-lock-screen.desktop", "Lock Screen"),
55 + ("xflock4.desktop", "Lock Screen"),
56 + ("xfce4-switch-user.desktop", "Switch User"),
57 )
58 DESKTOP_README_SOURCE = Path(__file__).resolve().parents[1] / "assets" / "desktop" / "README.md"
59 DESKTOP_FOLDER_LINKS = (
@@ -61,6 +65,9 @@ DESKTOP_FOLDER_LINKS = (
65 URL_INTENT_MAX_ITEMS = 50
66 URL_INTENT_MAX_LENGTH = 8192
67 URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop"
68 +SHUTDOWN_HANDLER_DESKTOP_ID = "agent-zero-shutdown.desktop"
69 +SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID
70 +SHUTDOWN_CONFIRM_SECONDS = 8
71 OOR_NS = "http://openoffice.org/2001/registry"
72 XS_NS = "http://www.w3.org/2001/XMLSchema"
73 XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
@@ -197,6 +204,12 @@ class LibreOfficeDesktopManager:
204 except Exception:
205 pass
206 url_intents = self.claim_url_intents(session.session_id)
207 + shutdown_request = self.claim_shutdown_request(session.session_id)
208 + if shutdown_request:
209 + return self.shutdown_system_desktop(
210 + save_first=True,
211 + source=str(shutdown_request.get("source") or "tray"),
212 + )
213 doc = self._document_for_save(session, file_id)
214 if not doc:
215 return {
@@ -224,6 +237,50 @@ class LibreOfficeDesktopManager:
237 return []
238 return _claim_url_intents(session)
239
240 + def claim_shutdown_request(self, session_id: str = SYSTEM_SESSION_ID) -> dict[str, Any] | None:
241 + session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
242 + if not session:
243 + return None
244 + return _claim_shutdown_request(session)
245 +
246 + def shutdown_system_desktop(self, *, save_first: bool = True, source: str = "api") -> dict[str, Any]:
247 + with self._lock:
248 + session = self._sessions.get(SYSTEM_SESSION_ID)
249 + if not session:
250 + _remove_system_manifest()
251 + return {
252 + "ok": True,
253 + "closed": 0,
254 + "session_id": SYSTEM_SESSION_ID,
255 + "shutdown": True,
256 + "intentional_shutdown": True,
257 + "source": source,
258 + }
259 +
260 + save_result = None
261 + if save_first:
262 + try:
263 + save_result = self.save(session.session_id)
264 + except Exception as exc:
265 + save_result = {"ok": False, "error": str(exc)}
266 +
267 + with self._lock:
268 + if self._sessions.get(SYSTEM_SESSION_ID) is session:
269 + self._sessions.pop(SYSTEM_SESSION_ID, None)
270 + virtual_desktop.unregister_session(session.token)
271 + self._terminate_session(session)
272 + self._remove_manifest(session.session_id)
273 + _clear_shutdown_request(session)
274 + return {
275 + "ok": True,
276 + "closed": 1,
277 + "session_id": session.session_id,
278 + "shutdown": True,
279 + "intentional_shutdown": True,
280 + "source": source,
281 + "save": save_result,
282 + }
283 +
284 def retarget_document(self, file_id: str, doc: dict[str, Any]) -> dict[str, Any]:
285 session = self._find_by_file_id(file_id)
286 if not session:
@@ -643,6 +700,7 @@ class LibreOfficeDesktopManager:
700 applications_dir.mkdir(parents=True, exist_ok=True)
701
702 browser_bridge = _write_url_bridge_script(session)
703 + shutdown_bridge = _write_shutdown_bridge_script(session)
704 helpers_rc = config_dir / "xfce4" / "helpers.rc"
705 helpers_rc.parent.mkdir(parents=True, exist_ok=True)
706 helpers_rc.write_text(
@@ -672,6 +730,23 @@ class LibreOfficeDesktopManager:
730 mime_types=_url_handler_mime_types(),
731 no_display=True,
732 )
733 + _write_desktop_launcher(
734 + applications_dir / SHUTDOWN_HANDLER_DESKTOP_ID,
735 + name="Shutdown Desktop",
736 + exec_line=_desktop_exec(shutdown_bridge),
737 + icon="system-shutdown",
738 + categories="System;",
739 + try_exec=str(shutdown_bridge),
740 + no_display=True,
741 + )
742 + _write_desktop_launcher(
743 + config_dir / "xfce4" / "panel" / "launcher-9" / SHUTDOWN_HANDLER_DESKTOP_ID,
744 + name="Shutdown Desktop",
745 + exec_line=_desktop_exec(shutdown_bridge),
746 + icon="system-shutdown",
747 + categories="System;",
748 + try_exec=str(shutdown_bridge),
749 + )
750 _write_desktop_launcher(
751 desktop_dir / "Browser.desktop",
752 name="Browser",
@@ -737,7 +812,9 @@ class LibreOfficeDesktopManager:
812 ET.SubElement(plugins, "property", {"name": "plugin-6", "type": "string", "value": "separator"})
813 ET.SubElement(plugins, "property", {"name": "plugin-7", "type": "string", "value": "clock"})
814 ET.SubElement(plugins, "property", {"name": "plugin-8", "type": "string", "value": "separator"})
740 - ET.SubElement(plugins, "property", {"name": "plugin-9", "type": "string", "value": "actions"})
815 + shutdown = ET.SubElement(plugins, "property", {"name": "plugin-9", "type": "string", "value": "launcher"})
816 + shutdown_items = ET.SubElement(shutdown, "property", {"name": "items", "type": "array"})
817 + ET.SubElement(shutdown_items, "value", {"type": "string", "value": SHUTDOWN_PANEL_LAUNCHER_ID})
818
819 tree = ET.ElementTree(root)
820 try:
@@ -764,15 +841,7 @@ if command -v xfconf-query >/dev/null 2>&1; then
841 xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-filesystem -n -t bool -s false >/dev/null 2>&1 || true
842 xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-removable -n -t bool -s false >/dev/null 2>&1 || true
843 xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-trash -n -t bool -s false >/dev/null 2>&1 || true
767 - xfconf-query -c xfce4-panel -p /panels -n -a -t int -s 1 >/dev/null 2>&1 || true
768 - xfconf-query -c xfce4-panel -p /panels/panel-2 -r -R >/dev/null 2>&1 || true
769 - xfconf-query -c xfce4-panel -p /panels/panel-1/plugin-ids -r -R >/dev/null 2>&1 || true
770 - xfconf-query -c xfce4-panel -p /panels/panel-1/plugin-ids -n -a -t int -s 1 -t int -s 2 -t int -s 3 -t int -s 4 -t int -s 5 -t int -s 6 -t int -s 7 -t int -s 8 -t int -s 9 >/dev/null 2>&1 || true
771 - for plugin_id in $(seq 10 30); do
772 - xfconf-query -c xfce4-panel -p "/plugins/plugin-${plugin_id}" -r -R >/dev/null 2>&1 || true
773 - done
844 fi
775 -rm -rf "$XDG_CONFIG_HOME"/xfce4/panel/launcher-* 2>/dev/null || true
845 for launcher in "$HOME"/Desktop/*.desktop; do
846 [ -f "$launcher" ] || continue
847 chmod +x "$launcher" 2>/dev/null || true
@@ -1281,6 +1350,18 @@ def _url_bridge_lock_path(session: DesktopSession) -> Path:
1350 return _url_bridge_dir(session) / "browser-url-intents.lock"
1351
1352
1353 +def _shutdown_request_path(session: DesktopSession) -> Path:
1354 + return _url_bridge_dir(session) / "shutdown-request.json"
1355 +
1356 +
1357 +def _shutdown_arm_path(session: DesktopSession) -> Path:
1358 + return _url_bridge_dir(session) / "shutdown-request.arm.json"
1359 +
1360 +
1361 +def _shutdown_lock_path(session: DesktopSession) -> Path:
1362 + return _url_bridge_dir(session) / "shutdown-request.lock"
1363 +
1364 +
1365 def _write_url_bridge_script(session: DesktopSession) -> Path:
1366 bridge_dir = _url_bridge_dir(session)
1367 bridge_dir.mkdir(parents=True, exist_ok=True)
@@ -1331,6 +1412,117 @@ if __name__ == "__main__":
1412 return script
1413
1414
1415 +def _write_shutdown_bridge_script(session: DesktopSession) -> Path:
1416 + bridge_dir = _url_bridge_dir(session)
1417 + bridge_dir.mkdir(parents=True, exist_ok=True)
1418 + script = bridge_dir / "shutdown-desktop"
1419 + request = _shutdown_request_path(session)
1420 + arm = _shutdown_arm_path(session)
1421 + lock = _shutdown_lock_path(session)
1422 + script.write_text(
1423 + f"""#!/usr/bin/env python3
1424 +import fcntl
1425 +import json
1426 +import os
1427 +import shutil
1428 +import subprocess
1429 +import time
1430 +
1431 +REQUEST_PATH = {str(request)!r}
1432 +ARM_PATH = {str(arm)!r}
1433 +LOCK_PATH = {str(lock)!r}
1434 +CONFIRM_SECONDS = {SHUTDOWN_CONFIRM_SECONDS}
1435 +
1436 +
1437 +def notify(message, timeout=None):
1438 + if not os.environ.get("DISPLAY"):
1439 + return
1440 + xmessage = shutil.which("xmessage")
1441 + if not xmessage:
1442 + return
1443 + try:
1444 + subprocess.Popen(
1445 + [
1446 + xmessage,
1447 + "-buttons",
1448 + "",
1449 + "-timeout",
1450 + str(timeout or CONFIRM_SECONDS),
1451 + "-center",
1452 + message,
1453 + ],
1454 + stdin=subprocess.DEVNULL,
1455 + stdout=subprocess.DEVNULL,
1456 + stderr=subprocess.DEVNULL,
1457 + start_new_session=True,
1458 + )
1459 + except OSError:
1460 + pass
1461 +
1462 +
1463 +def read_arm(now):
1464 + try:
1465 + with open(ARM_PATH, "r", encoding="utf-8") as handle:
1466 + payload = json.load(handle)
1467 + except (OSError, json.JSONDecodeError):
1468 + return None
1469 + try:
1470 + created_at = float(payload.get("created_at"))
1471 + except (TypeError, ValueError):
1472 + return None
1473 + if now - created_at > CONFIRM_SECONDS:
1474 + return None
1475 + return created_at
1476 +
1477 +
1478 +def write_json_atomic(path, payload):
1479 + tmp_path = path + ".tmp"
1480 + with open(tmp_path, "w", encoding="utf-8") as handle:
1481 + json.dump(payload, handle, ensure_ascii=True)
1482 + handle.write("\\n")
1483 + handle.flush()
1484 + os.fsync(handle.fileno())
1485 + os.replace(tmp_path, path)
1486 +
1487 +
1488 +def main():
1489 + os.makedirs(os.path.dirname(REQUEST_PATH), exist_ok=True)
1490 + now = time.time()
1491 + with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
1492 + fcntl.flock(lock_file, fcntl.LOCK_EX)
1493 + armed_at = read_arm(now)
1494 + if armed_at is None:
1495 + write_json_atomic(ARM_PATH, {{"created_at": now, "source": "tray"}})
1496 + notify(
1497 + f"Shutdown Desktop armed. Click Shutdown Desktop again within {{CONFIRM_SECONDS}} seconds to close it.",
1498 + CONFIRM_SECONDS,
1499 + )
1500 + return
1501 + try:
1502 + os.unlink(ARM_PATH)
1503 + except OSError:
1504 + pass
1505 + payload = {{
1506 + "created_at": now,
1507 + "armed_at": armed_at,
1508 + "source": "tray",
1509 + }}
1510 + write_json_atomic(REQUEST_PATH, payload)
1511 + notify("Shutting down Agent Zero Desktop.", 2)
1512 +
1513 +
1514 +if __name__ == "__main__":
1515 + main()
1516 +""",
1517 + encoding="utf-8",
1518 + )
1519 + try:
1520 + script.chmod(0o755)
1521 + except OSError:
1522 + pass
1523 + return script
1524 +
1525 +
1526 def _claim_url_intents(session: DesktopSession) -> list[dict[str, Any]]:
1527 queue = _url_bridge_queue_path(session)
1528 lock = _url_bridge_lock_path(session)
@@ -1374,6 +1566,45 @@ def _claim_url_intents(session: DesktopSession) -> list[dict[str, Any]]:
1566 return intents
1567
1568
1569 +def _claim_shutdown_request(session: DesktopSession) -> dict[str, Any] | None:
1570 + request = _shutdown_request_path(session)
1571 + if not request.exists():
1572 + return None
1573 + try:
1574 + raw = request.read_text(encoding="utf-8")
1575 + request.unlink(missing_ok=True)
1576 + except OSError:
1577 + return None
1578 + try:
1579 + payload = json.loads(raw)
1580 + except json.JSONDecodeError:
1581 + payload = {}
1582 + created_at = payload.get("created_at")
1583 + try:
1584 + created_at = float(created_at)
1585 + except (TypeError, ValueError):
1586 + created_at = time.time()
1587 + return {
1588 + "created_at": created_at,
1589 + "source": str(payload.get("source") or "tray"),
1590 + }
1591 +
1592 +
1593 +def _clear_shutdown_request(session: DesktopSession) -> None:
1594 + request = _shutdown_request_path(session)
1595 + arm = _shutdown_arm_path(session)
1596 + lock = _shutdown_lock_path(session)
1597 + request.unlink(missing_ok=True)
1598 + request.with_suffix(request.suffix + ".tmp").unlink(missing_ok=True)
1599 + arm.unlink(missing_ok=True)
1600 + arm.with_suffix(arm.suffix + ".tmp").unlink(missing_ok=True)
1601 + lock.unlink(missing_ok=True)
1602 +
1603 +
1604 +def _remove_system_manifest() -> None:
1605 + (SESSION_DIR / f"{SYSTEM_SESSION_ID}.json").unlink(missing_ok=True)
1606 +
1607 +
1608 def _url_handler_mime_types() -> tuple[str, ...]:
1609 return (
1610 "x-scheme-handler/http",
plugins/_office/webui/office-panel.html
+38 -14
@@ -103,16 +103,11 @@
103 <div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
104 <div class="office-editor-scroll" :class="{ 'is-desktop': $store.office.hasOfficialOffice(), 'is-source': $store.office.isMarkdown() }" @click.self="$store.office.focusEditor()">
105 <template x-if="$store.office.hasOfficialOffice()">
106 - <div class="office-desktop-wrap">
107 - <iframe
108 - class="office-desktop-frame"
109 - data-office-desktop-frame
110 - tabindex="0"
111 - :src="$store.office.officialOfficeUrl()"
112 - aria-label="Desktop"
113 - allow="clipboard-read; clipboard-write; autoplay"
114 - @load="$store.office.onDesktopFrameLoaded($event)"
115 - ></iframe>
106 + <div
107 + class="office-desktop-wrap"
108 + data-office-desktop-host
109 + x-init="$nextTick(() => $store.office.mountDesktopFrameHost($el))"
110 + >
111 </div>
112 </template>
113
@@ -130,6 +125,15 @@
125
126 </div>
127 </div>
128 +
129 + <div class="office-desktop-empty" x-show="$store.office.shouldShowDesktopEmptyState()" style="display: none;">
130 + <span class="material-symbols-outlined" aria-hidden="true">power_settings_new</span>
131 + <span class="office-desktop-empty-title">Desktop is shut down</span>
132 + <button type="button" class="office-icon-button office-command-button" @click="$store.office.restartDesktopSession()">
133 + <span class="material-symbols-outlined" aria-hidden="true">restart_alt</span>
134 + <span class="office-button-label">Restart Desktop</span>
135 + </button>
136 + </div>
137 </div>
138 </div>
139 </template>
@@ -460,10 +464,7 @@
464 flex: 1 1 auto;
465 min-height: 0;
466 overflow: hidden;
463 - background:
464 - linear-gradient(90deg, rgba(44, 123, 229, 0.05), transparent 38%),
465 - linear-gradient(180deg, rgba(44, 165, 141, 0.04), transparent 46%),
466 - #eef2f7;
467 + background: var(--color-background);
468 }
469
470 .office-body.is-source {
@@ -510,6 +511,29 @@
511 background: #1f2329;
512 }
513
514 + .office-desktop-empty {
515 + display: grid;
516 + flex: 1 1 auto;
517 + place-items: center;
518 + align-content: center;
519 + gap: 12px;
520 + min-width: 0;
521 + min-height: 0;
522 + padding: 24px;
523 + color: var(--color-text-secondary);
524 + text-align: center;
525 + }
526 +
527 + .office-desktop-empty > .material-symbols-outlined {
528 + font-size: 32px;
529 + color: color-mix(in srgb, var(--color-text) 68%, transparent);
530 + }
531 +
532 + .office-desktop-empty-title {
533 + font-size: 13px;
534 + font-weight: 700;
535 + }
536 +
537 .office-desktop-frame {
538 flex: 1 1 auto;
539 width: 100%;
plugins/_office/webui/office-store.js
+276 -19
@@ -19,6 +19,7 @@ const SYSTEM_DESKTOP_FILE_ID = "system-desktop";
19 const BROWSER_MODAL_PATH = "/plugins/_browser/webui/main.html";
20 const OFFICE_MODAL_PATH = "/plugins/_office/webui/main.html";
21 const URL_INTENT_PANEL_TIMEOUT_MS = 5000;
22 +const DESKTOP_SHUTDOWN_STORAGE_KEY = "a0.office.desktopShutdown";
23 const MAX_HISTORY = 80;
24
25 function currentContextId() {
@@ -236,8 +237,14 @@ const model = {
237 _desktopStarting: null,
238 _desktopUrlIntentBusy: false,
239 _desktopUrlIntentQueue: [],
240 + _desktopFrame: null,
241 + _desktopFrameHost: null,
242 + _desktopFrameLoadHandler: null,
243 + _desktopKeepaliveHost: null,
244 + _desktopIntentionalShutdown: false,
245
246 async init(element = null) {
247 + this.restoreDesktopShutdownState();
248 return await this.onMount(element, { mode: "canvas" });
249 },
250
@@ -254,12 +261,18 @@ const model = {
261 },
262
263 async onOpen(payload = {}) {
264 + this.restoreDesktopShutdownState();
265 await this.refresh();
266 if (payload?.path || payload?.file_id) {
267 await this.openSession({
268 path: payload.path || "",
269 file_id: payload.file_id || "",
270 });
271 + } else if (this._desktopIntentionalShutdown) {
272 + this.session = null;
273 + this.activeTabId = "";
274 + this.editorText = "";
275 + this.dirty = false;
276 } else {
277 await this.ensureDesktopSession({ select: !this.session });
278 }
@@ -271,6 +284,9 @@ const model = {
284 this._desktopHostVisible = false;
285 this.flushInput();
286 this.clearDesktopViewportSyncTimers();
287 + this.stopDesktopMonitor();
288 + this.stopDesktopKeyboardBridge();
289 + this.stopDesktopClipboardBridge();
290 this.unloadDesktopFrames();
291 },
292
@@ -282,6 +298,7 @@ const model = {
298 this.stopXpraDesktopPrime();
299 this.stopDesktopKeyboardBridge();
300 this.stopDesktopClipboardBridge();
301 + if (!this._desktopIntentionalShutdown) this.moveDesktopFrameToKeepalive();
302 this._floatingCleanup?.();
303 this._floatingCleanup = null;
304 if (this._mode === "modal") this._root = null;
@@ -297,7 +314,110 @@ const model = {
314 }
315 },
316
317 + restoreDesktopShutdownState() {
318 + try {
319 + this._desktopIntentionalShutdown = localStorage.getItem(DESKTOP_SHUTDOWN_STORAGE_KEY) === "1";
320 + } catch {
321 + this._desktopIntentionalShutdown = Boolean(this._desktopIntentionalShutdown);
322 + }
323 + },
324 +
325 + persistDesktopShutdownState() {
326 + try {
327 + if (this._desktopIntentionalShutdown) {
328 + localStorage.setItem(DESKTOP_SHUTDOWN_STORAGE_KEY, "1");
329 + } else {
330 + localStorage.removeItem(DESKTOP_SHUTDOWN_STORAGE_KEY);
331 + }
332 + } catch {
333 + // Shutdown state is still correct for this page even without storage.
334 + }
335 + },
336 +
337 + setDesktopIntentionalShutdown(value) {
338 + this._desktopIntentionalShutdown = Boolean(value);
339 + this.persistDesktopShutdownState();
340 + },
341 +
342 + isDesktopShutdown() {
343 + return Boolean(this._desktopIntentionalShutdown);
344 + },
345 +
346 + shouldShowDesktopEmptyState() {
347 + return Boolean(this._desktopIntentionalShutdown && !this.session);
348 + },
349 +
350 + async restartDesktopSession() {
351 + this.error = "";
352 + const session = await this.ensureDesktopSession({
353 + force: true,
354 + restart: true,
355 + select: true,
356 + message: "Restarting Agent Zero Desktop environment",
357 + });
358 + if (!session) {
359 + this.setDesktopIntentionalShutdown(true);
360 + return null;
361 + }
362 + this.restoreDesktopFrames();
363 + this.requestDesktopViewportSync({ force: true });
364 + return session;
365 + },
366 +
367 + async shutdownDesktop(options = {}) {
368 + this.loading = options.progress !== false;
369 + this.message = this.loading ? "Shutting down Desktop" : this.message;
370 + this.error = "";
371 + try {
372 + const response = await callOffice("desktop_shutdown", {
373 + save_first: options.saveFirst !== false,
374 + source: options.source || "ui",
375 + });
376 + await this.handleIntentionalDesktopShutdown(response);
377 + return response;
378 + } catch (error) {
379 + this.error = error instanceof Error ? error.message : String(error);
380 + return null;
381 + } finally {
382 + if (options.progress !== false) {
383 + this.loading = false;
384 + if (this.message === "Shutting down Desktop") this.message = "";
385 + }
386 + }
387 + },
388 +
389 + async handleIntentionalDesktopShutdown(response = {}) {
390 + this.setDesktopIntentionalShutdown(true);
391 + this.stopDesktopMonitor();
392 + this.stopDesktopResizeObserver();
393 + this.clearDesktopViewportSyncTimers();
394 + this.stopXpraDesktopPrime();
395 + this.stopDesktopKeyboardBridge();
396 + this.stopDesktopClipboardBridge();
397 + this.destroyDesktopFrame();
398 + const activeTabId = this.activeTabId;
399 + this.tabs = this.tabs.filter((tab) => !this.isDesktopSession(tab) && !this.hasOfficialOffice(tab));
400 + if (!this.tabs.some((tab) => tab.tab_id === activeTabId)) {
401 + this.session = null;
402 + this.activeTabId = "";
403 + this.editorText = "";
404 + this.dirty = false;
405 + this.resetHistory("");
406 + }
407 + this._desktopStarting = null;
408 + this._desktopHeartbeatMisses = 0;
409 + this.message = response?.source === "tray" ? "Desktop shut down from system tray" : "Desktop is shut down";
410 + await this.refresh();
411 + },
412 +
413 async ensureDesktopSession(options = {}) {
414 + if (this._desktopIntentionalShutdown && options.restart !== true) {
415 + return null;
416 + }
417 + if (options.restart === true) {
418 + this.setDesktopIntentionalShutdown(false);
419 + this.destroyDesktopFrame();
420 + }
421 const existing = this.tabs.find((tab) => this.isDesktopSession(tab));
422 if (existing && !options.force) {
423 if (options.select) this.selectTab(existing.tab_id, { focus: false });
@@ -323,6 +443,7 @@ const model = {
443 }
444 const response = await callOffice("desktop");
445 if (response?.ok === false) throw new Error(response.error || "Desktop session could not be opened.");
446 + this.setDesktopIntentionalShutdown(false);
447 const session = normalizeSession(response);
448 const existingIndex = this.tabs.findIndex((tab) => this.isDesktopSession(tab));
449 let desktopTabId = session.tab_id;
@@ -347,6 +468,7 @@ const model = {
468 } else {
469 this.updateDesktopMonitor();
470 }
471 + this.restoreDesktopFrames();
472 return { ...session, tab_id: desktopTabId };
473 } catch (error) {
474 this.error = error instanceof Error ? error.message : String(error);
@@ -434,6 +556,7 @@ const model = {
556 },
557
558 installDesktopDocumentSession(session) {
559 + this.setDesktopIntentionalShutdown(false);
560 this.tabs = this.tabs.filter((tab) => this.isVisibleOfficeTab(tab));
561 let desktopTab = this.tabs.find((tab) => this.isDesktopSession(tab));
562 if (!desktopTab) {
@@ -468,12 +591,19 @@ const model = {
591
592 selectTab(tabId, options = {}) {
593 const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
594 + if (this.hasOfficialOffice(this.session) && !this.hasOfficialOffice(tab)) {
595 + this.moveDesktopFrameToKeepalive();
596 + }
597 this.session = tab;
598 this.activeTabId = tab?.tab_id || "";
599 this.editorText = String(tab?.text || "");
600 this.dirty = Boolean(tab?.dirty);
601 this.resetHistory(this.editorText);
602 this.queueRender({ focus: Boolean(tab) && options.focus !== false });
603 + if (this.hasOfficialOffice(tab)) {
604 + this.restoreDesktopFrames();
605 + this.requestDesktopViewportSync({ force: true });
606 + }
607 this.updateDesktopMonitor();
608 },
609
@@ -898,9 +1028,11 @@ const model = {
1028 },
1029
1030 desktopFrames() {
901 - const frames = Array.from(document.querySelectorAll("[data-office-desktop-frame]"));
902 - const rootFrame = this._root?.querySelector?.("[data-office-desktop-frame]");
903 - if (rootFrame && !frames.includes(rootFrame)) frames.push(rootFrame);
1031 + const frames = [];
1032 + if (this._desktopFrame) frames.push(this._desktopFrame);
1033 + for (const frame of Array.from(document.querySelectorAll("[data-office-desktop-frame]"))) {
1034 + if (!frames.includes(frame)) frames.push(frame);
1035 + }
1036 return frames;
1037 },
1038
@@ -924,29 +1056,149 @@ const model = {
1056 })[0] || null;
1057 },
1058
1059 + isUsableDesktopHost(host) {
1060 + if (!host?.appendChild) return false;
1061 + const rect = host.getBoundingClientRect?.();
1062 + return Boolean(rect && rect.width >= 120 && rect.height >= 80);
1063 + },
1064 +
1065 + desktopHost(preferred = null) {
1066 + if (preferred?.matches?.("[data-office-desktop-host]")) return preferred;
1067 + const rootHost = this._root?.querySelector?.("[data-office-desktop-host]");
1068 + if (this.isUsableDesktopHost(rootHost)) return rootHost;
1069 + const hosts = Array.from(document.querySelectorAll("[data-office-desktop-host]"));
1070 + return hosts
1071 + .filter((host) => this.isUsableDesktopHost(host))
1072 + .sort((left, right) => {
1073 + const leftRect = left.getBoundingClientRect();
1074 + const rightRect = right.getBoundingClientRect();
1075 + return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height);
1076 + })[0] || rootHost || hosts[0] || null;
1077 + },
1078 +
1079 + ensureDesktopKeepaliveHost() {
1080 + if (this._desktopKeepaliveHost?.isConnected) return this._desktopKeepaliveHost;
1081 + const host = document.createElement("div");
1082 + host.className = "office-desktop-keepalive";
1083 + host.dataset.officeDesktopKeepalive = "true";
1084 + Object.assign(host.style, {
1085 + position: "fixed",
1086 + left: "-10000px",
1087 + top: "-10000px",
1088 + width: "720px",
1089 + height: "480px",
1090 + overflow: "hidden",
1091 + pointerEvents: "none",
1092 + visibility: "hidden",
1093 + });
1094 + document.body?.appendChild(host);
1095 + this._desktopKeepaliveHost = host;
1096 + return host;
1097 + },
1098 +
1099 + rememberDesktopFrameSize() {
1100 + const frame = this._desktopFrame;
1101 + const rect = frame?.getBoundingClientRect?.();
1102 + const hostRect = this._desktopFrameHost?.getBoundingClientRect?.();
1103 + const width = Math.round(rect?.width || hostRect?.width || 720);
1104 + const height = Math.round(rect?.height || hostRect?.height || 480);
1105 + const keepalive = this.ensureDesktopKeepaliveHost();
1106 + keepalive.style.width = `${Math.max(320, width)}px`;
1107 + keepalive.style.height = `${Math.max(220, height)}px`;
1108 + return keepalive;
1109 + },
1110 +
1111 + ensureDesktopFrame() {
1112 + if (this._desktopFrame) return this._desktopFrame;
1113 + const frame = document.createElement("iframe");
1114 + frame.className = "office-desktop-frame";
1115 + frame.dataset.officeDesktopFrame = "true";
1116 + frame.dataset.officePersistentDesktopFrame = "true";
1117 + frame.setAttribute("tabindex", "0");
1118 + frame.setAttribute("aria-label", "Desktop");
1119 + frame.setAttribute("allow", "clipboard-read; clipboard-write; autoplay");
1120 + this._desktopFrameLoadHandler = (event) => this.onDesktopFrameLoaded(event);
1121 + frame.addEventListener("load", this._desktopFrameLoadHandler);
1122 + this._desktopFrame = frame;
1123 + return frame;
1124 + },
1125 +
1126 + desktopFrameSrcMatches(frame, url) {
1127 + const current = frame?.getAttribute?.("src") || frame?.src || "";
1128 + if (!current && !url) return true;
1129 + try {
1130 + return new URL(current, window.location.href).href === new URL(url, window.location.href).href;
1131 + } catch {
1132 + return current === url;
1133 + }
1134 + },
1135 +
1136 + attachDesktopFrame(host = null) {
1137 + if (!this.hasOfficialOffice()) return false;
1138 + const target = this.desktopHost(host);
1139 + if (!target) return false;
1140 + const frame = this.ensureDesktopFrame();
1141 + if (frame.parentElement !== target) {
1142 + frame.parentElement?.removeAttribute?.("data-office-desktop-attached");
1143 + target.appendChild(frame);
1144 + }
1145 + target.dataset.officeDesktopAttached = "true";
1146 + if (this._desktopFrameHost !== target) this._desktopFrameHost = target;
1147 + const url = this.officialOfficeUrl();
1148 + if (url && !this.desktopFrameSrcMatches(frame, url)) {
1149 + frame.setAttribute("src", url);
1150 + }
1151 + return true;
1152 + },
1153 +
1154 + mountDesktopFrameHost(host = null) {
1155 + const attached = this.attachDesktopFrame(host);
1156 + if (attached && this.isDesktopHostVisible()) {
1157 + this.requestDesktopViewportSync({ force: true, frame: this._desktopFrame, followup: true });
1158 + }
1159 + return attached;
1160 + },
1161 +
1162 + moveDesktopFrameToKeepalive() {
1163 + const frame = this._desktopFrame;
1164 + if (!frame) return false;
1165 + const keepalive = this.rememberDesktopFrameSize();
1166 + if (frame.parentElement !== keepalive) {
1167 + frame.parentElement?.removeAttribute?.("data-office-desktop-attached");
1168 + keepalive.appendChild(frame);
1169 + }
1170 + this._desktopFrameHost = keepalive;
1171 + this._desktopKeyboardActive = false;
1172 + this.updateDesktopKeyboardCaptureState(frame);
1173 + return true;
1174 + },
1175 +
1176 + destroyDesktopFrame() {
1177 + const frame = this._desktopFrame;
1178 + if (!frame) return;
1179 + if (this._desktopFrameLoadHandler) {
1180 + frame.removeEventListener("load", this._desktopFrameLoadHandler);
1181 + }
1182 + frame.setAttribute("src", "about:blank");
1183 + frame.remove();
1184 + this._desktopFrame = null;
1185 + this._desktopFrameHost = null;
1186 + this._desktopFrameLoadHandler = null;
1187 + this._desktopBridgeReady = false;
1188 + this.updateDesktopKeyboardCaptureState();
1189 + this._desktopKeepaliveHost?.remove?.();
1190 + this._desktopKeepaliveHost = null;
1191 + },
1192 +
1193 unloadDesktopFrames() {
1194 this.stopDesktopResizeObserver();
1195 this.stopXpraDesktopPrime();
930 - for (const frame of this.desktopFrames()) {
931 - if (!frame?.getAttribute) continue;
932 - const current = frame.getAttribute("src") || "";
933 - if (!current || current === "about:blank") continue;
934 - frame.dataset.officeDesktopUnloaded = "true";
935 - frame.setAttribute("src", "about:blank");
936 - }
1196 + this.moveDesktopFrameToKeepalive();
1197 },
1198
1199 restoreDesktopFrames() {
1200 if (!this.isDesktopHostVisible()) return;
941 - const url = this.officialOfficeUrl();
942 - if (!url) return;
943 - for (const frame of this.desktopFrames()) {
944 - if (!frame?.getAttribute) continue;
945 - const current = frame.getAttribute("src") || "";
946 - if (current && current !== "about:blank" && frame.dataset.officeDesktopUnloaded !== "true") continue;
947 - delete frame.dataset.officeDesktopUnloaded;
948 - frame.setAttribute("src", url);
949 - }
1201 + this.attachDesktopFrame();
1202 },
1203
1204 afterDesktopHostShown() {
@@ -1908,6 +2160,10 @@ const model = {
2160 desktop_session_id: sessionId,
2161 file_id: this.session.file_id || "",
2162 });
2163 + if (response?.intentional_shutdown || response?.shutdown) {
2164 + await this.handleIntentionalDesktopShutdown(response);
2165 + return;
2166 + }
2167 if (response?.ok === false) throw new Error(response.error || "Desktop session closed.");
2168 this._desktopHeartbeatMisses = 0;
2169 await this.handleDesktopUrlIntents(response?.url_intents);
@@ -1945,6 +2201,7 @@ const model = {
2201 },
2202
2203 async handleOfficialOfficeClosed(tabId) {
2204 + if (this._desktopIntentionalShutdown) return;
2205 const tab = this.tabs.find((item) => item.tab_id === tabId);
2206 const hiddenDesktopDocument = !tab && this.session?.tab_id === tabId && this.isDesktopOfficeDocument(this.session)
2207 ? this.session
tests/test_office_canvas_setup.py
+31 -3
@@ -22,9 +22,12 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
22 assert "office-rich-editor" not in panel
23 assert "office-docx-pages" not in panel
24 assert "office-desktop-frame" in panel
25 - assert "data-office-desktop-frame" in panel
25 + assert "data-office-desktop-host" in panel
26 + assert 'x-init="$nextTick(() => $store.office.mountDesktopFrameHost($el))"' in panel
27 + assert 'x-effect="$store.office.attachDesktopFrame($el)"' not in panel
28 + assert "data-office-desktop-frame" in store
29 assert 'title="LibreOffice desktop"' not in panel
27 - assert 'aria-label="Desktop"' in panel
30 + assert 'frame.setAttribute("aria-label", "Desktop")' in store
31 assert "office-command-button" in panel
32 assert "office-button-label" in panel
33 assert "grid-template-columns: minmax(0, 1fr) auto auto auto" in panel
@@ -32,7 +35,7 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
35 assert ".modal-inner.office-modal .modal-scroll" in panel
36 assert "office-modal-resizer" in panel
37 assert "resize: both" not in panel
35 - assert 'tabindex="0"' in panel
38 + assert 'frame.setAttribute("tabindex", "0")' in store
39 assert "format_underlined" not in panel
40 assert "format_align_center" not in panel
41 assert "is-native-tile" not in panel
@@ -55,6 +58,7 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
58 assert "isDesktopHostVisible" in store
59 assert "clearDesktopViewportSyncTimers" in store
60 assert "setDesktopHostVisible" in canvas_panel
61 + assert "queueMicrotask" in canvas_panel
62 assert "Starting Agent Zero Desktop environment" in store
63 assert "handleOfficialOfficeClosed" in store
64 assert "ResizeObserver" in store
@@ -67,8 +71,22 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
71 assert "right-canvas-resize-end" in store
72 assert "isDesktopSession" in store
73 assert "desktopFrame" in store
74 + assert "attachDesktopFrame" in store
75 + assert "mountDesktopFrameHost" in store
76 + assert "desktopFrameSrcMatches" in store
77 + assert "moveDesktopFrameToKeepalive" in store
78 + assert "destroyDesktopFrame" in store
79 + assert "office-desktop-keepalive" in store
80 + assert "DESKTOP_SHUTDOWN_STORAGE_KEY" in store
81 + assert 'callOffice("desktop_shutdown"' in store
82 + assert "intentional_shutdown" in store
83 + assert "restartDesktopSession" in store
84 + assert "shouldShowDesktopEmptyState" in store
85 + assert "Restart Desktop" in panel
86 + assert "office-desktop-empty" in panel
87 assert "unloadDesktopFrames" in store
88 assert "restoreDesktopFrames" in store
89 + assert "officeDesktopUnloaded" not in store
90 assert "primeXpraDesktopFrame" in store
91 assert "normalizeXpraDesktopWindow" in store
92 assert "installXpraDesktopWheelBridge" in store
@@ -306,11 +324,21 @@ def test_official_libreoffice_desktop_route_and_packages_are_declared():
324 assert "DESKTOP_FOLDER_LINKS" in desktop
325 assert "HIDDEN_XPRA_DESKTOP_ENTRIES" in desktop
326 assert "HIDDEN_XFCE_MENU_ENTRIES" in desktop
327 + assert "SHUTDOWN_HANDLER_DESKTOP_ID" in desktop
328 + assert "SHUTDOWN_PANEL_LAUNCHER_ID" in desktop
329 + assert "SHUTDOWN_CONFIRM_SECONDS" in desktop
330 + assert "Shutdown Desktop" in desktop
331 + assert "shutdown-request.json" in desktop
332 + assert "shutdown-request.arm.json" in desktop
333 + assert "shutdown_system_desktop" in desktop
334 + assert "claim_shutdown_request" in desktop
335 assert "last-show-hidden" in desktop
336 assert "exo-mail-reader.desktop" in desktop
337 assert "exo-web-browser.desktop" in desktop
338 assert "xfce4-mail-reader.desktop" in desktop
339 assert "xfce4-web-browser.desktop" in desktop
340 + assert "xfce4-session-logout.desktop" in desktop
341 + assert "agent-zero-shutdown.desktop" in desktop
342 assert "libreoffice-gtk3" in install
343 assert "libreofficekit" not in install
344 assert "gir1.2-lokdocview" not in install
tests/test_office_document_store.py
+193 -1
@@ -4,6 +4,7 @@ import asyncio
4 import importlib.util
5 import json
6 import os
7 +import subprocess
8 import sys
9 import types
10 import zipfile
@@ -554,6 +555,51 @@ def test_office_session_desktop_state_action_defaults_without_screenshot(monkeyp
555 monkeypatch.delattr(api_package, "office_session", raising=False)
556
557
558 +def test_office_session_desktop_shutdown_action_calls_manager(monkeypatch):
559 + api_module = types.ModuleType("helpers.api")
560 +
561 + class ApiHandler:
562 + def __init__(self, app=None, thread_lock=None):
563 + self.app = app
564 + self.thread_lock = thread_lock
565 +
566 + api_module.ApiHandler = ApiHandler
567 + api_module.Request = object
568 + monkeypatch.setitem(sys.modules, "helpers.api", api_module)
569 + monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
570 +
571 + from plugins._office.api import office_session
572 +
573 + calls = []
574 +
575 + class FakeManager:
576 + def shutdown_system_desktop(self, *, save_first=True, source="api"):
577 + calls.append({"save_first": save_first, "source": source})
578 + return {
579 + "ok": True,
580 + "closed": 1,
581 + "shutdown": True,
582 + "intentional_shutdown": True,
583 + "source": source,
584 + }
585 +
586 + monkeypatch.setattr(office_session.libreoffice_desktop, "get_manager", lambda: FakeManager())
587 + handler = office_session.OfficeSession(app=None, thread_lock=None)
588 + request = types.SimpleNamespace(headers={}, host_url="http://localhost:32080")
589 +
590 + result = asyncio.run(
591 + handler.process({"action": "desktop_shutdown", "save_first": False, "source": "ui"}, request),
592 + )
593 +
594 + assert result["ok"] is True
595 + assert result["intentional_shutdown"] is True
596 + assert calls == [{"save_first": False, "source": "ui"}]
597 + monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
598 + api_package = sys.modules.get("plugins._office.api")
599 + if api_package is not None:
600 + monkeypatch.delattr(api_package, "office_session", raising=False)
601 +
602 +
603 def test_official_libreoffice_desktop_manager_opens_binary_session(office_state, tmp_path, monkeypatch):
604 class FakeProcess:
605 pid = 4242
@@ -704,7 +750,47 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
750 ).read_text(encoding="utf-8")
751 assert "panel-1" in panel_profile
752 assert "panel-2" not in panel_profile
707 - assert "launcher" not in panel_profile
753 + assert 'value="actions"' not in panel_profile
754 + assert 'value="launcher"' in panel_profile
755 + assert "agent-zero-shutdown.desktop" in panel_profile
756 + shutdown_app = (
757 + tmp_path
758 + / "desktop"
759 + / "profiles"
760 + / payload["session_id"]
761 + / ".local"
762 + / "share"
763 + / "applications"
764 + / "agent-zero-shutdown.desktop"
765 + ).read_text(encoding="utf-8")
766 + assert "Shutdown Desktop" in shutdown_app
767 + assert "shutdown-desktop" in shutdown_app
768 + shutdown_panel_launcher = (
769 + tmp_path
770 + / "desktop"
771 + / "profiles"
772 + / payload["session_id"]
773 + / ".config"
774 + / "xfce4"
775 + / "panel"
776 + / "launcher-9"
777 + / "agent-zero-shutdown.desktop"
778 + ).read_text(encoding="utf-8")
779 + assert "Shutdown Desktop" in shutdown_panel_launcher
780 + assert "shutdown-desktop" in shutdown_panel_launcher
781 + shutdown_script = (
782 + tmp_path
783 + / "desktop"
784 + / "profiles"
785 + / payload["session_id"]
786 + / ".agent-zero"
787 + / "shutdown-desktop"
788 + ).read_text(encoding="utf-8")
789 + assert "CONFIRM_SECONDS" in shutdown_script
790 + assert "ARM_PATH" in shutdown_script
791 + assert "Click Shutdown Desktop again" in shutdown_script
792 + assert "xmessage" in shutdown_script
793 + assert '"-buttons",' in shutdown_script
794 desktop_helper = (
795 PROJECT_ROOT / "plugins" / "_office" / "helpers" / "libreoffice_desktop.py"
796 ).read_text(encoding="utf-8")
@@ -731,11 +817,17 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
817 assert "agent-zero-settings.desktop" not in profile_script
818 assert "metadata::xfce-exe-checksum" in profile_script
819 assert "xfconf-query -c thunar -p /last-show-hidden" in profile_script
820 + assert "xfconf-query -c xfce4-panel" not in profile_script
821 + assert "launcher-*" not in profile_script
822 for filename in (
823 "exo-mail-reader.desktop",
824 "exo-web-browser.desktop",
825 "xfce4-mail-reader.desktop",
826 "xfce4-web-browser.desktop",
827 + "xfce4-session-logout.desktop",
828 + "xfce4-lock-screen.desktop",
829 + "xflock4.desktop",
830 + "xfce4-switch-user.desktop",
831 ):
832 entry = (
833 tmp_path
@@ -754,6 +846,106 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
846 assert manager.close(payload["session_id"], save_first=False)["persistent"] is True
847
848
849 +def test_shutdown_panel_launcher_requires_second_click(tmp_path):
850 + profile_dir = tmp_path / "desktop" / "profiles" / libreoffice_desktop.SYSTEM_SESSION_ID
851 + profile_dir.mkdir(parents=True)
852 + desktop_path = tmp_path / "workdir"
853 + desktop_path.mkdir()
854 + session = libreoffice_desktop.DesktopSession(
855 + session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
856 + file_id=libreoffice_desktop.SYSTEM_FILE_ID,
857 + extension="desktop",
858 + path=str(desktop_path),
859 + title=libreoffice_desktop.SYSTEM_TITLE,
860 + display=libreoffice_desktop.DISPLAY_BASE,
861 + xpra_port=libreoffice_desktop.XPRA_PORT_BASE,
862 + token=libreoffice_desktop.SYSTEM_SESSION_ID,
863 + url="/desktop/session/agent-zero-desktop/index.html",
864 + profile_dir=profile_dir,
865 + )
866 + script = libreoffice_desktop._write_shutdown_bridge_script(session)
867 + request = libreoffice_desktop._shutdown_request_path(session)
868 + arm = libreoffice_desktop._shutdown_arm_path(session)
869 + env = dict(os.environ)
870 + env.pop("DISPLAY", None)
871 +
872 + subprocess.run([sys.executable, str(script)], check=True, env=env)
873 +
874 + assert arm.exists()
875 + assert not request.exists()
876 +
877 + subprocess.run([sys.executable, str(script)], check=True, env=env)
878 +
879 + payload = json.loads(request.read_text(encoding="utf-8"))
880 + assert payload["source"] == "tray"
881 + assert payload["armed_at"] <= payload["created_at"]
882 + assert not arm.exists()
883 +
884 +
885 +def test_libreoffice_desktop_sync_consumes_shutdown_marker(tmp_path, monkeypatch):
886 + class FakeProcess:
887 + pid = 5252
888 + terminated = False
889 +
890 + def poll(self):
891 + return None if not self.terminated else 0
892 +
893 + def terminate(self):
894 + self.terminated = True
895 +
896 + def wait(self, timeout=None):
897 + self.terminated = True
898 + return 0
899 +
900 + def kill(self):
901 + self.terminated = True
902 +
903 + monkeypatch.setattr(libreoffice_desktop, "STATE_DIR", tmp_path / "desktop")
904 + monkeypatch.setattr(libreoffice_desktop, "SESSION_DIR", tmp_path / "desktop" / "sessions")
905 + monkeypatch.setattr(libreoffice_desktop, "PROFILE_DIR", tmp_path / "desktop" / "profiles")
906 +
907 + profile_dir = tmp_path / "desktop" / "profiles" / libreoffice_desktop.SYSTEM_SESSION_ID
908 + profile_dir.mkdir(parents=True)
909 + desktop_path = tmp_path / "workdir"
910 + desktop_path.mkdir()
911 + session = libreoffice_desktop.DesktopSession(
912 + session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
913 + file_id=libreoffice_desktop.SYSTEM_FILE_ID,
914 + extension="desktop",
915 + path=str(desktop_path),
916 + title=libreoffice_desktop.SYSTEM_TITLE,
917 + display=libreoffice_desktop.DISPLAY_BASE,
918 + xpra_port=libreoffice_desktop.XPRA_PORT_BASE,
919 + token=libreoffice_desktop.SYSTEM_SESSION_ID,
920 + url="/desktop/session/agent-zero-desktop/index.html",
921 + profile_dir=profile_dir,
922 + processes={"xpra": FakeProcess()},
923 + )
924 + manager = libreoffice_desktop.LibreOfficeDesktopManager()
925 + manager._sessions[session.session_id] = session
926 + manager._write_manifest(session)
927 + libreoffice_desktop._write_url_bridge_script(session)
928 + shutdown_request = libreoffice_desktop._shutdown_request_path(session)
929 + shutdown_request.write_text('{"source": "tray", "created_at": 123.0}\n', encoding="utf-8")
930 + save_calls = []
931 + monkeypatch.setattr(
932 + manager,
933 + "save",
934 + lambda session_id, file_id="": save_calls.append((session_id, file_id)) or {"ok": True},
935 + )
936 +
937 + result = manager.sync(session_id=session.session_id)
938 +
939 + assert result["ok"] is True
940 + assert result["intentional_shutdown"] is True
941 + assert result["source"] == "tray"
942 + assert result["closed"] == 1
943 + assert save_calls == [(libreoffice_desktop.SYSTEM_SESSION_ID, "")]
944 + assert not shutdown_request.exists()
945 + assert not (libreoffice_desktop.SESSION_DIR / f"{session.session_id}.json").exists()
946 + assert manager.get(session.session_id) is None
947 +
948 +
949 def test_libreoffice_desktop_cleanup_preserves_live_owner_manifest(tmp_path, monkeypatch):
950 session_dir = tmp_path / "sessions"
951 session_dir.mkdir()