Agent Zero AI framework
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()