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("&", "&")
1199
+ .replace('"', """)
1200
+ .replace("<", "<")
1201
+ .replace(">", ">")
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"