Improve Office desktop integration

Route binary Office documents through the persistent Desktop surface while keeping Markdown in the custom tabbed editor. Harden Xpra clipboard bridging and explicit clipboard flags so host paste can reach the desktop session. Align XFCE and LibreOffice profile paths with Agent Zero locations: downloads for wallpapers, configured workdir for default saves and the Workdir shortcut, and trusted metadata for generated launchers.

Alessandro committed May 2, 2026 at 18:39 UTC 739c0a18a31e858e19e08723515ea7befab0c5a3
7 files changed +392 -48
helpers/virtual_desktop.py
+3
@@ -122,6 +122,9 @@ def session_url(token: str, *, title: str = "Desktop") -> str:
122 "title": title,
123 "sharing": "true",
124 "clipboard": "true",
125 + "clipboard_direction": "both",
126 + "clipboard_poll": "true",
127 + "clipboard_preferred_format": "text/plain",
128 "printing": "true",
129 "file_transfer": "true",
130 "sound": "true",
plugins/_office/helpers/libreoffice_desktop.py
+140 -25
@@ -1,6 +1,7 @@
1 from __future__ import annotations
2
3 import atexit
4 +import hashlib
5 import json
6 import os
7 import shutil
@@ -44,12 +45,14 @@ HIDDEN_XPRA_DESKTOP_ENTRIES = (
45 "xpra-start.desktop",
46 )
47 DESKTOP_FOLDER_LINKS = (
47 - ("Workdir", ("usr", "workdir")),
48 ("Projects", ("usr", "projects")),
49 ("Skills", ("usr", "skills")),
50 ("Agents", ("usr", "agents")),
51 ("Downloads", ("usr", "downloads")),
52 )
53 +OOR_NS = "http://openoffice.org/2001/registry"
54 +XS_NS = "http://www.w3.org/2001/XMLSchema"
55 +XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
56
57
58 @dataclass
@@ -431,27 +434,18 @@ class LibreOfficeDesktopManager:
434 user_dir = session.profile_dir / "user"
435 user_dir.mkdir(parents=True, exist_ok=True)
436 registry = user_dir / "registrymodifications.xcu"
434 - if registry.exists():
435 - return
436 - registry.write_text(
437 - """<?xml version="1.0" encoding="UTF-8"?>
438 -<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
439 -<item oor:path="/org.openoffice.Office.Common/Misc"><prop oor:name="FirstRun" oor:op="fuse"><value>false</value></prop></item>
440 -<item oor:path="/org.openoffice.Setup/Office"><prop oor:name="ooSetupInstCompleted" oor:op="fuse"><value>true</value></prop></item>
441 -<item oor:path="/org.openoffice.Setup/Office"><prop oor:name="MigrationCompleted" oor:op="fuse"><value>true</value></prop></item>
442 -<item oor:path="/org.openoffice.Setup/Office"><prop oor:name="OfficeRestartInProgress" oor:op="fuse"><value>false</value></prop></item>
443 -<item oor:path="/org.openoffice.Setup/L10N"><prop oor:name="ooLocale" oor:op="fuse"><value>en-US</value></prop></item>
444 -</oor:items>
445 -""",
446 - encoding="utf-8",
447 - )
437 + _write_libreoffice_registry_defaults(registry, document_store.document_home())
438
439 def _prepare_desktop_launchers(self, session: DesktopSession) -> None:
440 soffice = libreoffice.find_soffice()
441 if not soffice:
442 raise RuntimeError("LibreOffice is not installed in this runtime.")
443 + workdir_home = document_store.document_home()
444 + workdir_home.mkdir(parents=True, exist_ok=True)
445 documents_home = document_store.document_binary_home()
446 documents_home.mkdir(parents=True, exist_ok=True)
447 + downloads_home = Path(files.get_abs_path("usr", "downloads"))
448 + downloads_home.mkdir(parents=True, exist_ok=True)
449
450 desktop_dir = session.profile_dir / "Desktop"
451 desktop_dir.mkdir(parents=True, exist_ok=True)
@@ -470,13 +464,13 @@ class LibreOfficeDesktopManager:
464 "\n".join(
465 [
466 'XDG_DESKTOP_DIR="$HOME/Desktop"',
473 - f'XDG_DOCUMENTS_DIR="{documents_home}"',
474 - f'XDG_DOWNLOAD_DIR="{files.get_abs_path("usr", "downloads")}"',
475 - f'XDG_TEMPLATES_DIR="{documents_home}"',
476 - f'XDG_PUBLICSHARE_DIR="{document_store.document_home()}"',
477 - f'XDG_MUSIC_DIR="{document_store.document_home()}"',
478 - f'XDG_PICTURES_DIR="{document_store.document_home()}"',
479 - f'XDG_VIDEOS_DIR="{document_store.document_home()}"',
467 + f'XDG_DOCUMENTS_DIR="{workdir_home}"',
468 + f'XDG_DOWNLOAD_DIR="{downloads_home}"',
469 + f'XDG_TEMPLATES_DIR="{workdir_home}"',
470 + f'XDG_PUBLICSHARE_DIR="{workdir_home}"',
471 + f'XDG_MUSIC_DIR="{workdir_home}"',
472 + f'XDG_PICTURES_DIR="{downloads_home}"',
473 + f'XDG_VIDEOS_DIR="{workdir_home}"',
474 "",
475 ],
476 ),
@@ -485,10 +479,17 @@ class LibreOfficeDesktopManager:
479 xfce_conf_dir = config_dir / "xfce4" / "xfconf" / "xfce-perchannel-xml"
480 xfce_conf_dir.mkdir(parents=True, exist_ok=True)
481 (xfce_conf_dir / "xfce4-desktop.xml").write_text(
488 - """<?xml version="1.1" encoding="UTF-8"?>
482 + f"""<?xml version="1.1" encoding="UTF-8"?>
483
484 <channel name="xfce4-desktop" version="1.0">
485 <property name="last-settings-migration-version" type="uint" value="1"/>
486 + <property name="backdrop" type="empty">
487 + <property name="screen0" type="empty">
488 + <property name="monitor0" type="empty">
489 + <property name="image-path" type="string" value="{_xml_attr(str(downloads_home))}"/>
490 + </property>
491 + </property>
492 + </property>
493 <property name="desktop-icons" type="empty">
494 <property name="style" type="int" value="2"/>
495 <property name="file-icons" type="empty">
@@ -536,16 +537,16 @@ class LibreOfficeDesktopManager:
537 icon=icon,
538 categories=categories,
539 try_exec=soffice,
540 + working_dir=workdir_home,
541 )
542
543 terminal = shutil.which("xfce4-terminal") or "xfce4-terminal"
544 settings = shutil.which("xfce4-settings-manager") or "xfce4-settings-manager"
543 - workdir = document_store.document_home()
545 desktop_apps = (
546 {
547 "filename": "Terminal.desktop",
548 "name": "Terminal",
548 - "exec": _desktop_exec(terminal, f"--working-directory={workdir}"),
549 + "exec": _desktop_exec(terminal, f"--working-directory={workdir_home}"),
550 "try_exec": terminal,
551 "icon": _desktop_icon(
552 "/usr/share/icons/hicolor/128x128/apps/org.xfce.terminal.png",
@@ -578,9 +579,11 @@ class LibreOfficeDesktopManager:
579 categories=str(app["categories"]),
580 try_exec=str(app["try_exec"]),
581 )
582 + _ensure_desktop_folder_link(desktop_dir, "Workdir", workdir_home)
583 for label, target_parts in DESKTOP_FOLDER_LINKS:
584 _ensure_desktop_folder_link(desktop_dir, label, Path(files.get_abs_path(*target_parts)))
585
586 + self._trust_desktop_launchers(session, desktop_dir)
587 self._prepare_xfce_panel_config(session)
588 self._prepare_xfce_profile_autostart(session)
589
@@ -823,6 +826,33 @@ fi
826 except (OSError, subprocess.TimeoutExpired):
827 return
828
829 + def _trust_desktop_launchers(self, session: DesktopSession, desktop_dir: Path) -> None:
830 + gio = shutil.which("gio")
831 + if not gio:
832 + return
833 + env = self._xfce_process_env(session, "xfdesktop")
834 + for launcher in desktop_dir.glob("*.desktop"):
835 + try:
836 + launcher.chmod(0o755)
837 + checksum = hashlib.sha256(launcher.read_bytes()).hexdigest()
838 + except OSError:
839 + continue
840 + for command in (
841 + [gio, "set", str(launcher), "metadata::trusted", "true"],
842 + [gio, "set", "-t", "string", str(launcher), "metadata::xfce-exe-checksum", checksum],
843 + ):
844 + try:
845 + subprocess.run(
846 + command,
847 + check=False,
848 + stdout=subprocess.DEVNULL,
849 + stderr=subprocess.DEVNULL,
850 + timeout=4,
851 + env=env,
852 + )
853 + except (OSError, subprocess.TimeoutExpired):
854 + continue
855 +
856 def _xfce_process_env(self, session: DesktopSession, command_name: str) -> dict[str, str]:
857 env = self._display_env(session)
858 proc = Path("/proc")
@@ -1102,6 +1132,8 @@ def _xpra_shadow_command(xpra: str, session: DesktopSession) -> list[str]:
1132 "--tray=no",
1133 "--system-tray=no",
1134 "--notifications=no",
1135 + "--clipboard=yes",
1136 + "--clipboard-direction=both",
1137 "--file-transfer=yes",
1138 "--open-files=no",
1139 "--open-url=no",
@@ -1160,6 +1192,86 @@ def _desktop_exec_arg(value: str) -> str:
1192 return f'"{escaped}"'
1193
1194
1195 +def _xml_attr(value: str) -> str:
1196 + return (
1197 + str(value)
1198 + .replace("&", "&amp;")
1199 + .replace('"', "&quot;")
1200 + .replace("<", "&lt;")
1201 + .replace(">", "&gt;")
1202 + )
1203 +
1204 +
1205 +def _oor(name: str) -> str:
1206 + return f"{{{OOR_NS}}}{name}"
1207 +
1208 +
1209 +def _file_uri(path: str | Path) -> str:
1210 + return Path(path).resolve(strict=False).as_uri()
1211 +
1212 +
1213 +def _write_libreoffice_registry_defaults(registry: Path, workdir: str | Path) -> None:
1214 + Path(workdir).mkdir(parents=True, exist_ok=True)
1215 + ET.register_namespace("oor", OOR_NS)
1216 + ET.register_namespace("xs", XS_NS)
1217 + ET.register_namespace("xsi", XSI_NS)
1218 + root = _read_libreoffice_registry(registry)
1219 + workdir_uri = _file_uri(workdir)
1220 + for path, prop, value in (
1221 + ("/org.openoffice.Office.Common/Misc", "FirstRun", "false"),
1222 + ("/org.openoffice.Setup/Office", "ooSetupInstCompleted", "true"),
1223 + ("/org.openoffice.Setup/Office", "MigrationCompleted", "true"),
1224 + ("/org.openoffice.Setup/Office", "OfficeRestartInProgress", "false"),
1225 + ("/org.openoffice.Setup/L10N", "ooLocale", "en-US"),
1226 + ("/org.openoffice.Office.Paths/Variables", "Work", workdir_uri),
1227 + (
1228 + "/org.openoffice.Office.Paths/Paths/org.openoffice.Office.Paths:NamedPath['Work']",
1229 + "WritePath",
1230 + workdir_uri,
1231 + ),
1232 + ):
1233 + _set_registry_prop(root, path, prop, value)
1234 + registry.parent.mkdir(parents=True, exist_ok=True)
1235 + ET.ElementTree(root).write(registry, encoding="utf-8", xml_declaration=True)
1236 +
1237 +
1238 +def _read_libreoffice_registry(registry: Path) -> ET.Element:
1239 + if registry.exists():
1240 + try:
1241 + return ET.parse(registry).getroot()
1242 + except ET.ParseError:
1243 + pass
1244 + return ET.Element(
1245 + _oor("items"),
1246 + {
1247 + "xmlns:xs": XS_NS,
1248 + "xmlns:xsi": XSI_NS,
1249 + },
1250 + )
1251 +
1252 +
1253 +def _set_registry_prop(root: ET.Element, item_path: str, prop_name: str, value: str) -> None:
1254 + item = _find_registry_item(root, item_path)
1255 + if item is None:
1256 + item = ET.SubElement(root, "item", {_oor("path"): item_path})
1257 + prop = next((child for child in item.findall("prop") if child.get(_oor("name")) == prop_name), None)
1258 + if prop is None:
1259 + prop = ET.SubElement(item, "prop", {_oor("name"): prop_name, _oor("op"): "fuse"})
1260 + else:
1261 + prop.set(_oor("op"), "fuse")
1262 + value_node = prop.find("value")
1263 + if value_node is None:
1264 + value_node = ET.SubElement(prop, "value")
1265 + value_node.text = str(value)
1266 +
1267 +
1268 +def _find_registry_item(root: ET.Element, item_path: str) -> ET.Element | None:
1269 + for item in root.findall("item"):
1270 + if item.get(_oor("path")) == item_path:
1271 + return item
1272 + return None
1273 +
1274 +
1275 def _write_desktop_launcher(
1276 path: Path,
1277 *,
@@ -1168,6 +1280,7 @@ def _write_desktop_launcher(
1280 icon: str,
1281 categories: str,
1282 try_exec: str = "",
1283 + working_dir: str | Path | None = None,
1284 ) -> None:
1285 path.parent.mkdir(parents=True, exist_ok=True)
1286 lines = [
@@ -1179,6 +1292,8 @@ def _write_desktop_launcher(
1292 ]
1293 if try_exec:
1294 lines.append(f"TryExec={try_exec}")
1295 + if working_dir:
1296 + lines.append(f"Path={working_dir}")
1297 lines.extend(
1298 [
1299 f"Icon={icon}",
plugins/_office/skills/linux-desktop/SKILL.md
+2 -2
@@ -85,7 +85,7 @@ When browser automation is available, the higher-level QA flow is:
85
86 The Desktop exposes stable folders for common user work:
87
88 -- `Workdir` -> `/a0/usr/workdir`
88 +- `Workdir` -> configured Agent Zero workdir (default `/a0/usr/workdir`)
89 - `Projects` -> `/a0/usr/projects`
90 - `Skills` -> `/a0/usr/skills`
91 - `Agents` -> `/a0/usr/agents`
@@ -98,7 +98,7 @@ Use these folders when the user asks to inspect or manipulate project files, ski
98 - `LibreOffice Writer`: word processing and DOCX layout.
99 - `LibreOffice Calc`: spreadsheets, formulas, tables, charts.
100 - `LibreOffice Impress`: presentations and slide polish.
101 -- `Workdir`: graphical file management with Thunar at `/a0/usr/workdir`.
101 +- `Workdir`: graphical file management with Thunar at the configured Agent Zero workdir (default `/a0/usr/workdir`).
102 - `Terminal`: shell work inside the Agent Zero runtime.
103 - `Settings`: XFCE system settings.
104
plugins/_office/webui/office-panel.html
+2 -2
@@ -105,8 +105,8 @@
105 </div>
106 </div>
107
108 - <div class="office-tabs" x-show="$store.office.tabs.length" role="tablist" aria-label="Open documents" style="display: none;">
109 - <template x-for="tab in $store.office.tabs" :key="tab.tab_id">
108 + <div class="office-tabs" x-show="$store.office.visibleTabs().length" role="tablist" aria-label="Open documents" style="display: none;">
109 + <template x-for="tab in $store.office.visibleTabs()" :key="tab.tab_id">
110 <div class="office-tab-shell" :class="{ 'is-active': $store.office.isActiveTab(tab), 'is-dirty': tab.dirty, 'is-system': $store.office.isDesktopSession(tab) }">
111 <button type="button" class="office-tab" role="tab" :aria-selected="$store.office.isActiveTab(tab).toString()" :title="$store.office.tabLabel(tab)" @click="$store.office.selectTab(tab.tab_id)">
112 <span class="material-symbols-outlined office-tab-icon" aria-hidden="true" x-text="$store.office.tabIcon(tab)"></span>
plugins/_office/webui/office-store.js
+208 -10
@@ -356,6 +356,7 @@ const model = {
356 _desktopPrimeAttempts: 0,
357 _desktopKeyboardActive: false,
358 _desktopKeyboardCleanup: null,
359 + _desktopClipboardCleanup: null,
360 _desktopStarting: null,
361
362 async init(element = null) {
@@ -397,6 +398,7 @@ const model = {
398 this.stopDesktopResizeObserver();
399 this.stopXpraDesktopPrime();
400 this.stopDesktopKeyboardBridge();
401 + this.stopDesktopClipboardBridge();
402 this._floatingCleanup?.();
403 this._floatingCleanup = null;
404 if (this._mode === "modal") this._root = null;
@@ -522,6 +524,10 @@ const model = {
524 },
525
526 installSession(session) {
527 + if (this.isDesktopOfficeDocument(session)) {
528 + this.installDesktopDocumentSession(session);
529 + return;
530 + }
531 const existingIndex = this.tabs.findIndex((tab) => (
532 (session.file_id && tab.file_id === session.file_id)
533 || (session.path && tab.path === session.path)
@@ -536,6 +542,41 @@ const model = {
542 this.selectTab(this.activeTabId);
543 },
544
545 + installDesktopDocumentSession(session) {
546 + this.tabs = this.tabs.filter((tab) => this.isVisibleOfficeTab(tab));
547 + let desktopTab = this.tabs.find((tab) => this.isDesktopSession(tab));
548 + if (!desktopTab) {
549 + desktopTab = {
550 + ...session,
551 + tab_id: SYSTEM_DESKTOP_FILE_ID,
552 + file_id: SYSTEM_DESKTOP_FILE_ID,
553 + extension: "desktop",
554 + title: "Desktop",
555 + path: session.desktop?.desktop_path || "/desktop/session",
556 + mode: "desktop",
557 + document: {
558 + file_id: SYSTEM_DESKTOP_FILE_ID,
559 + path: session.desktop?.desktop_path || "/desktop/session",
560 + basename: "Desktop",
561 + title: "Desktop",
562 + extension: "desktop",
563 + preview: {},
564 + },
565 + dirty: false,
566 + };
567 + this.tabs.unshift(desktopTab);
568 + }
569 + const desktopTabId = desktopTab.tab_id;
570 + this.session = { ...session, tab_id: session.tab_id || uniqueTabId(session) };
571 + this.activeTabId = desktopTabId;
572 + this.sourceMode = false;
573 + this.editorText = "";
574 + this.dirty = false;
575 + this.resetHistory("");
576 + this.queueRender({ focus: true });
577 + this.updateDesktopMonitor();
578 + },
579 +
580 selectTab(tabId, options = {}) {
581 const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
582 this.session = tab;
@@ -559,9 +600,39 @@ const model = {
600
601 async closeFile() {
602 if (!this.session) return;
603 + if (this.isDesktopOfficeDocument(this.session) && !this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) {
604 + await this.closeDesktopDocumentSession(this.session);
605 + return;
606 + }
607 await this.closeTab(this.session.tab_id);
608 },
609
610 + async closeDesktopDocumentSession(session) {
611 + try {
612 + await callOffice("desktop_save", {
613 + desktop_session_id: session.desktop_session_id || session.session_id,
614 + file_id: session.file_id || "",
615 + }).catch(() => null);
616 + await callOffice("close", {
617 + session_id: session.store_session_id || "",
618 + file_id: session.file_id || "",
619 + });
620 + } catch (error) {
621 + console.warn("Desktop document close skipped", error);
622 + }
623 + this.session = null;
624 + this.activeTabId = "";
625 + this.editorText = "";
626 + this.dirty = false;
627 + const desktopTab = this.tabs.find((tab) => this.isDesktopSession(tab));
628 + if (desktopTab) {
629 + this.selectTab(desktopTab.tab_id, { focus: false });
630 + } else {
631 + await this.ensureDesktopSession({ select: true });
632 + }
633 + await this.refresh();
634 + },
635 +
636 async closeTab(tabId) {
637 const tab = this.tabs.find((item) => item.tab_id === tabId);
638 if (!tab) return;
@@ -695,7 +766,7 @@ const model = {
766 if (!this.session) return;
767 this.session = next;
768 const index = this.tabs.findIndex((tab) => tab.tab_id === next.tab_id);
698 - if (index >= 0) this.tabs.splice(index, 1, next);
769 + if (index >= 0 && this.isVisibleOfficeTab(next)) this.tabs.splice(index, 1, next);
770 this.queueRender();
771 this.updateDesktopMonitor();
772 },
@@ -996,12 +1067,14 @@ const model = {
1067 return true;
1068 },
1069
999 - isMarkdown() {
1000 - return this.session?.extension === "md";
1070 + isMarkdown(tab = this.session) {
1071 + const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
1072 + return ext === "md";
1073 },
1074
1003 - isDocx() {
1004 - return this.session?.extension === "docx";
1075 + isDocx(tab = this.session) {
1076 + const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
1077 + return ext === "docx";
1078 },
1079
1080 isBinaryOffice(tab = this.session) {
@@ -1024,6 +1097,18 @@ const model = {
1097 );
1098 },
1099
1100 + isDesktopOfficeDocument(tab = this.session) {
1101 + return Boolean(tab && this.hasOfficialOffice(tab) && !this.isDesktopSession(tab) && this.isBinaryOffice(tab));
1102 + },
1103 +
1104 + isVisibleOfficeTab(tab = {}) {
1105 + return Boolean(this.isDesktopSession(tab) || this.isMarkdown(tab));
1106 + },
1107 +
1108 + visibleTabs() {
1109 + return this.tabs.filter((tab) => this.isVisibleOfficeTab(tab));
1110 + },
1111 +
1112 officialOfficeUrl(tab = this.session) {
1113 return tab?.desktop?.url || "";
1114 },
@@ -1299,6 +1384,7 @@ const model = {
1384 this.installXpraDesktopClientPatches(remoteWindow, client);
1385 this.installXpraDesktopCursorPatches(remoteWindow, remoteDocument, client);
1386 this.installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client);
1387 + this.installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client);
1388 const container = client.container || remoteDocument?.querySelector?.("#screen");
1389 if (!container) return false;
1390
@@ -1568,6 +1654,111 @@ const model = {
1654 client.__a0XpraDesktopClientPatched = true;
1655 },
1656
1657 + installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client) {
1658 + if (!frame || !remoteWindow || !remoteDocument || !client) return;
1659 + this.ensureDesktopClipboardBridge();
1660 + if (remoteWindow.__a0XpraDesktopClipboardBridgeInstalled) return;
1661 +
1662 + const onPaste = (event) => {
1663 + this.handleDesktopPasteEvent(event, frame, remoteWindow, client);
1664 + };
1665 + const onKeydown = (event) => {
1666 + if (this.isDesktopPasteShortcut(event)) {
1667 + void this.syncHostClipboardToDesktop(frame);
1668 + }
1669 + };
1670 + remoteWindow.addEventListener("paste", onPaste, true);
1671 + remoteDocument.addEventListener("paste", onPaste, true);
1672 + remoteWindow.addEventListener("keydown", onKeydown, true);
1673 + remoteDocument.addEventListener("keydown", onKeydown, true);
1674 + remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = true;
1675 + remoteWindow.__a0XpraDesktopClipboardBridgeCleanup = () => {
1676 + remoteWindow.removeEventListener("paste", onPaste, true);
1677 + remoteDocument.removeEventListener("paste", onPaste, true);
1678 + remoteWindow.removeEventListener("keydown", onKeydown, true);
1679 + remoteDocument.removeEventListener("keydown", onKeydown, true);
1680 + remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = false;
1681 + };
1682 + },
1683 +
1684 + ensureDesktopClipboardBridge() {
1685 + if (this._desktopClipboardCleanup) return;
1686 +
1687 + const onPaste = (event) => {
1688 + if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return;
1689 + if (isEditableInputTarget(event.target)) return;
1690 + const frame = this.desktopFrame();
1691 + const remoteWindow = frame?.contentWindow;
1692 + const client = remoteWindow?.client;
1693 + if (!frame || !remoteWindow || !client) return;
1694 + this.handleDesktopPasteEvent(event, frame, remoteWindow, client);
1695 + };
1696 +
1697 + document.addEventListener("paste", onPaste, true);
1698 + this._desktopClipboardCleanup = () => {
1699 + document.removeEventListener("paste", onPaste, true);
1700 + this._desktopClipboardCleanup = null;
1701 + };
1702 + },
1703 +
1704 + stopDesktopClipboardBridge() {
1705 + this._desktopClipboardCleanup?.();
1706 + },
1707 +
1708 + handleDesktopPasteEvent(event, frame, remoteWindow, client) {
1709 + const text = this.desktopClipboardTextFromEvent(event);
1710 + if (!text) return false;
1711 + if (!this.syncXpraClipboardText(client, text, remoteWindow)) return false;
1712 + event.preventDefault?.();
1713 + event.stopImmediatePropagation?.();
1714 + event.stopPropagation?.();
1715 + this.focusDesktopFrame(frame, { arm: true });
1716 + return true;
1717 + },
1718 +
1719 + desktopClipboardTextFromEvent(event) {
1720 + const data = (event?.originalEvent || event)?.clipboardData;
1721 + if (!data?.getData) return "";
1722 + for (const type of ["text/plain", "text", "Text", "STRING", "UTF8_STRING"]) {
1723 + const value = data.getData(type);
1724 + if (value) return value;
1725 + }
1726 + return "";
1727 + },
1728 +
1729 + syncXpraClipboardText(client, text, remoteWindow = null) {
1730 + const value = String(text ?? "");
1731 + if (!client || !value || typeof client.send_clipboard_token !== "function") return false;
1732 + const textPlain = remoteWindow?.TEXT_PLAIN || "text/plain";
1733 + const utf8String = remoteWindow?.UTF8_STRING || "UTF8_STRING";
1734 + const utilities = remoteWindow?.Utilities;
1735 + const payload = utilities?.StringToUint8 ? utilities.StringToUint8(value) : value;
1736 + client.clipboard_enabled = true;
1737 + client.clipboard_direction = "both";
1738 + client.clipboard_buffer = value;
1739 + client.clipboard_pending = false;
1740 + client.send_clipboard_token(payload, [textPlain, utf8String, "TEXT", "STRING"]);
1741 + return true;
1742 + },
1743 +
1744 + async syncHostClipboardToDesktop(frame = null) {
1745 + const target = this.desktopFrame(frame);
1746 + const remoteWindow = target?.contentWindow;
1747 + const client = remoteWindow?.client;
1748 + if (!client || !navigator.clipboard?.readText) return false;
1749 + try {
1750 + const text = await navigator.clipboard.readText();
1751 + return this.syncXpraClipboardText(client, text, remoteWindow);
1752 + } catch {
1753 + return false;
1754 + }
1755 + },
1756 +
1757 + isDesktopPasteShortcut(event) {
1758 + const key = String(event?.key || "").toLowerCase();
1759 + return key === "v" && (event?.ctrlKey || event?.metaKey) && !event?.altKey;
1760 + },
1761 +
1762 installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client) {
1763 if (!frame || !remoteWindow || !remoteDocument || !client) return;
1764 this.ensureDesktopKeyboardBridge();
@@ -1607,6 +1798,9 @@ const model = {
1798 const client = frame.contentWindow?.client;
1799 const handler = pressed ? client?._keyb_onkeydown : client?._keyb_onkeyup;
1800 if (!client?.capture_keyboard || typeof handler !== "function") return;
1801 + if (pressed && this.isDesktopPasteShortcut(event)) {
1802 + void this.syncHostClipboardToDesktop(frame);
1803 + }
1804
1805 const allowDefault = handler.call(client, event);
1806 if (!allowDefault) {
@@ -1755,14 +1949,18 @@ const model = {
1949
1950 async handleOfficialOfficeClosed(tabId) {
1951 const tab = this.tabs.find((item) => item.tab_id === tabId);
1758 - if (!tab || tab._desktopClosed) return;
1759 - tab._desktopClosed = true;
1952 + const hiddenDesktopDocument = !tab && this.session?.tab_id === tabId && this.isDesktopOfficeDocument(this.session)
1953 + ? this.session
1954 + : null;
1955 + const target = tab || hiddenDesktopDocument;
1956 + if (!target || target._desktopClosed) return;
1957 + target._desktopClosed = true;
1958 this.stopDesktopMonitor();
1959 this.stopDesktopResizeObserver();
1960 this.stopXpraDesktopPrime();
1961 this.setMessage("Desktop is restarting");
1764 - await this.ensureDesktopSession({ force: true, select: this.activeTabId === tabId });
1765 - tab._desktopClosed = false;
1962 + await this.ensureDesktopSession({ force: true, select: this.activeTabId === tabId || Boolean(hiddenDesktopDocument) });
1963 + target._desktopClosed = false;
1964 await this.refresh();
1965 },
1966
@@ -1921,7 +2119,7 @@ const model = {
2119 },
2120
2121 openCards() {
1924 - return this.tabs
2122 + return this.visibleTabs()
2123 .filter((tab) => !this.isDesktopSession(tab))
2124 .map((tab) => normalizeDocument({
2125 ...tab.document,
tests/test_office_canvas_setup.py
+6 -1
@@ -112,7 +112,7 @@ def test_desktop_xpra_canvas_scroll_is_forwarded_to_the_remote_session():
112 assert "getModifierState: { value: getModifierState }" in store
113
114
115 -def test_office_dashboard_uses_cards_and_visible_tabs_for_open_files():
115 +def test_office_dashboard_uses_cards_and_filters_tabs_to_desktop_and_markdown():
116 panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text(
117 encoding="utf-8",
118 )
@@ -122,11 +122,16 @@ def test_office_dashboard_uses_cards_and_visible_tabs_for_open_files():
122
123 assert "office-card-grid" in panel
124 assert "office-document-card" in panel
125 + assert "visibleTabs()" in panel
126 assert "openCards()" in panel
127 assert "recentCards()" in panel
128 assert "office-editor-head" not in panel
129 assert "office-recent-row" not in panel
130 assert "open_documents" in store
131 + assert "installDesktopDocumentSession" in store
132 + assert "isDesktopOfficeDocument" in store
133 + assert "isVisibleOfficeTab" in store
134 + assert "return this.tabs.filter((tab) => this.isVisibleOfficeTab(tab));" in store
135
136
137 def test_right_canvas_surface_is_branded_as_desktop():
tests/test_office_document_store.py
+31 -8
@@ -430,11 +430,17 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
430 assert payload["extension"] == "xlsx"
431 assert payload["url"].startswith("/desktop/session/")
432 registry = tmp_path / "desktop" / "profiles" / payload["session_id"] / "user" / "registrymodifications.xcu"
433 - assert "ooSetupInstCompleted" in registry.read_text(encoding="utf-8")
434 - assert "FirstRun" in registry.read_text(encoding="utf-8")
433 + registry_text = registry.read_text(encoding="utf-8")
434 + assert "ooSetupInstCompleted" in registry_text
435 + assert "FirstRun" in registry_text
436 + assert "Office.Paths/Variables" in registry_text
437 + assert "Office.Paths:NamedPath['Work']" in registry_text
438 + assert office_state.workdir.as_uri() in registry_text
439 writer_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "LibreOffice Writer.desktop"
436 - assert "--writer" in writer_launcher.read_text(encoding="utf-8")
437 - assert "X-XFCE-Trusted=true" in writer_launcher.read_text(encoding="utf-8")
440 + writer_text = writer_launcher.read_text(encoding="utf-8")
441 + assert "--writer" in writer_text
442 + assert f"Path={office_state.workdir}" in writer_text
443 + assert "X-XFCE-Trusted=true" in writer_text
444 terminal_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Terminal.desktop"
445 files_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Files.desktop"
446 settings_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Settings.desktop"
@@ -446,13 +452,16 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
452 assert not (tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Browser.desktop").exists()
453 assert "xfce4-settings-manager" in settings_text
454 assert "org.xfce.settings.manager" in settings_text
449 - for link_name, target in {
450 - "Workdir": "usr/workdir",
455 + link_targets = {
456 "Projects": "usr/projects",
457 "Skills": "usr/skills",
458 "Agents": "usr/agents",
459 "Downloads": "usr/downloads",
455 - }.items():
460 + }
461 + workdir_link = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Workdir"
462 + assert workdir_link.is_symlink()
463 + assert workdir_link.resolve() == office_state.workdir
464 + for link_name, target in link_targets.items():
465 link = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / link_name
466 assert link.is_symlink()
467 assert str(link.resolve()).endswith(target)
@@ -478,7 +487,21 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state,
487 / "xfce-perchannel-xml"
488 / "xfce4-desktop.xml"
489 )
481 - assert "desktop-icons" in desktop_profile.read_text(encoding="utf-8")
490 + desktop_profile_text = desktop_profile.read_text(encoding="utf-8")
491 + assert "desktop-icons" in desktop_profile_text
492 + assert "image-path" in desktop_profile_text
493 + assert "usr/downloads" in desktop_profile_text
494 + user_dirs = (
495 + tmp_path
496 + / "desktop"
497 + / "profiles"
498 + / payload["session_id"]
499 + / ".config"
500 + / "user-dirs.dirs"
501 + ).read_text(encoding="utf-8")
502 + assert 'XDG_PICTURES_DIR="' in user_dirs
503 + assert "usr/downloads" in user_dirs
504 + assert f'XDG_DOCUMENTS_DIR="{office_state.workdir}"' in user_dirs
505 panel_profile = (
506 tmp_path
507 / "desktop"