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()