feat(office): replace Collabora with LibreOffice document runtime

Remove the Collabora/WOPI runtime and route stack, including the old status APIs, proxy helpers, bootstrap extensions, and WOPI store tests. Add the Markdown-first document store, LibreOffice status/conversion helpers, LibreOfficeKit session bridge, and reusable Xpra virtual desktop gateway used by the new document runtime. Update image and self-update bootstrap paths so existing containers can acquire the LibreOffice, XFCE, Xpra, and desktop-control dependencies through the normal install hooks instead of an ad hoc manual install.

Alessandro committed May 2, 2026 at 12:20 UTC 10a6cd28c60e147e73f7898a9b974fdb552a0986
40 files changed +5319 -1949
README.md
+3 -7
@@ -74,19 +74,15 @@ The important idea is not a fixed list of buttons. The important idea is that th
74
75 ## Universal Canvas
76
77 -Agent Zero is becoming more visual and collaborative. The right-side Universal Canvas gives agents and humans shared working surfaces for browser sessions, Office files, workspace history, and other plugin panels.
77 +Agent Zero is becoming more visual and shared. The right-side Universal Canvas gives agents and humans working surfaces for browser sessions, documents, workspace history, and other plugin panels.
78
79 The canvas makes agent work visible. You can watch it browse, inspect what changed, open files, cowork on deliverables, and intervene before a small mistake becomes a large one.
80
81 -## Cowork on Office Documents
81 +## Cowork on Documents
82
83 Create, open, and cowork with the AI on documents, spreadsheets, and presentation decks.
84
85 -
86 -<img width="1280" height="656" alt="Agent Zero Collabora Office" src="https://github.com/user-attachments/assets/23893e10-cee9-4ee1-8f92-66193c25f0cd" />
87 -<br>
88 -
89 -The Office canvas supports editable artifacts with Collabora Online and WOPI, including DOCX, XLSX, and PPTX workflows. Agents can create substantial deliverables, read their contents, apply precise saved edits, preserve version history, and generate native XLSX charts directly inside spreadsheets.
85 +The document canvas supports Markdown by default, with LibreOffice-backed DOCX, XLSX, and PPTX workflows when binary artifacts are needed. Agents can create substantial deliverables, read their contents, apply precise saved edits, preserve version history, and generate native XLSX charts directly inside spreadsheets.
86
87 ## Native Browser With Annotations and Extensions
88
docker/run/fs/exe/self_update_manager.py
+28
@@ -2,6 +2,7 @@
2 from __future__ import annotations
3
4 import argparse
5 +import importlib.util
6 import json
7 import os
8 import re
@@ -680,6 +681,8 @@ def restore_git_state(
681
682
683 def launch_ui_process(repo_dir: Path, logger: AttemptLogger) -> subprocess.Popen[bytes]:
684 + run_office_cleanup_hook(repo_dir, logger)
685 +
686 prepare_script = repo_dir / "prepare.py"
687 if prepare_script.exists():
688 logger.log("Running prepare.py before UI start")
@@ -700,6 +703,31 @@ def launch_ui_process(repo_dir: Path, logger: AttemptLogger) -> subprocess.Popen
703 )
704
705
706 +def run_office_cleanup_hook(repo_dir: Path, logger: AttemptLogger) -> None:
707 + hook_path = repo_dir / "plugins" / "_office" / "hooks.py"
708 + if not hook_path.exists():
709 + return
710 + try:
711 + if str(repo_dir) not in sys.path:
712 + sys.path.insert(0, str(repo_dir))
713 + spec = importlib.util.spec_from_file_location("a0_office_hooks", hook_path)
714 + if spec is None or spec.loader is None:
715 + logger.log("Office cleanup hook could not be loaded.")
716 + return
717 + module = importlib.util.module_from_spec(spec)
718 + spec.loader.exec_module(module)
719 + cleanup = getattr(module, "cleanup_stale_runtime_state", None)
720 + if not callable(cleanup):
721 + return
722 + result = cleanup()
723 + if isinstance(result, dict) and result.get("errors"):
724 + logger.log(f"Office cleanup hook reported errors: {result.get('errors')}")
725 + else:
726 + logger.log("Office cleanup hook completed.")
727 + except Exception as exc:
728 + logger.log(f"Office cleanup hook skipped after error: {exc}")
729 +
730 +
731 def wait_for_health(
732 process: subprocess.Popen[bytes],
733 *,
docker/run/fs/ins/install_additional.sh
+84 -4
@@ -7,7 +7,87 @@ set -e
7 # searxng - moved to base image
8 # bash /ins/install_searxng.sh "$@"
9
10 -# Collabora CODE for future images. Existing containers still self-heal through the
11 -# Office plugin runtime bootstrap, so this is an optimization rather than a release
12 -# prerequisite.
13 -bash /ins/install_collabora_code.sh "$@"
10 +if ! command -v apt-get >/dev/null 2>&1; then
11 + echo "apt-get unavailable; skipping LibreOffice install"
12 + exit 0
13 +fi
14 +
15 +install_xpra_repo() {
16 + local os_id=""
17 + local codename=""
18 + local uri="https://xpra.org"
19 + local suite="trixie"
20 + local arch
21 +
22 + arch="$(dpkg --print-architecture 2>/dev/null || echo amd64)"
23 +
24 + if [ -r /etc/os-release ]; then
25 + # shellcheck disable=SC1091
26 + . /etc/os-release
27 + os_id="${ID:-}"
28 + codename="${VERSION_CODENAME:-}"
29 + fi
30 +
31 + if [ "$os_id" = "kali" ]; then
32 + uri="https://xpra.org/beta"
33 + suite="sid"
34 + elif [ "$codename" = "sid" ] || [ "$codename" = "forky" ]; then
35 + uri="https://xpra.org/beta"
36 + suite="$codename"
37 + elif [ -n "$codename" ]; then
38 + suite="$codename"
39 + fi
40 +
41 + apt-get update
42 + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates wget
43 + wget -O /usr/share/keyrings/xpra.asc https://xpra.org/xpra.asc
44 + cat >/etc/apt/sources.list.d/xpra.sources <<EOF
45 +Types: deb
46 +URIs: ${uri}
47 +Suites: ${suite}
48 +Components: main
49 +Signed-By: /usr/share/keyrings/xpra.asc
50 +Architectures: ${arch}
51 +EOF
52 +}
53 +
54 +install_xpra_repo
55 +apt-get update
56 +DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
57 + libreoffice-core \
58 + libreoffice-writer \
59 + libreoffice-calc \
60 + libreoffice-impress \
61 + libreoffice-gtk3 \
62 + libreofficekit-data \
63 + libreofficekit-dev \
64 + gir1.2-lokdocview-0.1 \
65 + python3-gi \
66 + python3-uno \
67 + xpra \
68 + xpra-x11 \
69 + xpra-html5 \
70 + xfce4-session \
71 + xfwm4 \
72 + xfce4-panel \
73 + xfdesktop4 \
74 + xfce4-settings \
75 + thunar \
76 + gvfs \
77 + libglib2.0-bin \
78 + xfce4-terminal \
79 + pulseaudio \
80 + pulseaudio-utils \
81 + x11-xserver-utils \
82 + xdotool \
83 + xauth \
84 + dbus-x11 \
85 + fonts-dejavu \
86 + fonts-liberation \
87 + fonts-crosextra-caladea \
88 + fonts-crosextra-carlito \
89 + fonts-noto-core \
90 + fonts-noto-cjk \
91 + fonts-noto-color-emoji
92 +
93 +rm -rf /var/lib/apt/lists/*
docker/run/fs/ins/install_collabora_code.sh deleted
-24
@@ -1,24 +0,0 @@
1 -#!/bin/bash
2 -set -e
3 -
4 -if ! command -v apt-get >/dev/null 2>&1; then
5 - echo "apt-get unavailable; skipping Collabora CODE install"
6 - exit 0
7 -fi
8 -
9 -install -d -m 0755 /etc/apt/keyrings
10 -if [ ! -f /etc/apt/keyrings/collaboraonline-release-keyring.gpg ]; then
11 - wget -O /etc/apt/keyrings/collaboraonline-release-keyring.gpg \
12 - https://collaboraoffice.com/downloads/gpg/collaboraonline-release-keyring.gpg
13 -fi
14 -
15 -cat >/etc/apt/sources.list.d/collaboraonline.sources <<'EOF'
16 -Types: deb
17 -URIs: https://www.collaboraoffice.com/repos/CollaboraOnline/CODE-deb
18 -Suites: ./
19 -Signed-By: /etc/apt/keyrings/collaboraonline-release-keyring.gpg
20 -EOF
21 -
22 -apt-get update
23 -DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends coolwsd coolwsd-deprecated code-brand
24 -rm -rf /var/lib/apt/lists/*
helpers/virtual_desktop.py new
+565
@@ -0,0 +1,565 @@
1 +from __future__ import annotations
2 +
3 +import math
4 +import os
5 +import re
6 +import shutil
7 +import subprocess
8 +import threading
9 +import time
10 +from dataclasses import dataclass
11 +from pathlib import Path
12 +from typing import Any, Callable
13 +from urllib.parse import quote, urlencode
14 +
15 +from helpers import files
16 +
17 +
18 +STATE_DIR = Path(files.get_abs_path("tmp", "virtual_desktop"))
19 +DEFAULT_WIDTH = 1440
20 +DEFAULT_HEIGHT = 900
21 +MAX_WIDTH = 1920
22 +MAX_HEIGHT = 1080
23 +MIN_WIDTH = 360
24 +MIN_HEIGHT = 240
25 +SESSION_PATH = "/desktop/session"
26 +XPRA_HTML_ROOT_CANDIDATES = (
27 + Path("/usr/share/xpra/www"),
28 +)
29 +
30 +
31 +ResizeCallback = Callable[[int, int], dict[str, Any]]
32 +
33 +
34 +@dataclass
35 +class VirtualDesktopEndpoint:
36 + token: str
37 + host: str
38 + port: int
39 + owner: str = "desktop"
40 + title: str = "Desktop"
41 + resize: ResizeCallback | None = None
42 +
43 +
44 +class VirtualDesktopRegistry:
45 + def __init__(self) -> None:
46 + self._lock = threading.RLock()
47 + self._endpoints: dict[str, VirtualDesktopEndpoint] = {}
48 +
49 + def register(self, endpoint: VirtualDesktopEndpoint) -> None:
50 + with self._lock:
51 + self._endpoints[str(endpoint.token)] = endpoint
52 +
53 + def unregister(self, token: str) -> None:
54 + with self._lock:
55 + self._endpoints.pop(str(token), None)
56 +
57 + def proxy_for_token(self, token: str) -> VirtualDesktopEndpoint | None:
58 + with self._lock:
59 + endpoint = self._endpoints.get(str(token or ""))
60 + if not endpoint:
61 + return None
62 + return endpoint
63 +
64 + def resize(self, token: str, width: int, height: int) -> dict[str, Any]:
65 + with self._lock:
66 + endpoint = self._endpoints.get(str(token or ""))
67 + if not endpoint:
68 + return {"ok": False, "error": "Virtual desktop session not found."}
69 + if not endpoint.resize:
70 + return {"ok": True, "resized": False, "reason": "Session does not expose resize."}
71 + return endpoint.resize(width, height)
72 +
73 +
74 +def register_session(
75 + *,
76 + token: str,
77 + host: str,
78 + port: int,
79 + owner: str = "desktop",
80 + title: str = "Desktop",
81 + resize: ResizeCallback | None = None,
82 +) -> None:
83 + get_registry().register(
84 + VirtualDesktopEndpoint(
85 + token=str(token),
86 + host=str(host),
87 + port=int(port),
88 + owner=str(owner),
89 + title=str(title),
90 + resize=resize,
91 + ),
92 + )
93 +
94 +
95 +def unregister_session(token: str) -> None:
96 + get_registry().unregister(token)
97 +
98 +
99 +def proxy_for_token(token: str) -> VirtualDesktopEndpoint | None:
100 + return get_registry().proxy_for_token(token)
101 +
102 +
103 +def resize_session(token: str, width: int, height: int) -> dict[str, Any]:
104 + return get_registry().resize(token, width, height)
105 +
106 +
107 +def get_registry() -> VirtualDesktopRegistry:
108 + global _registry
109 + try:
110 + return _registry
111 + except NameError:
112 + _registry = VirtualDesktopRegistry()
113 + return _registry
114 +
115 +
116 +def session_url(token: str, *, title: str = "Desktop") -> str:
117 + quoted_token = quote(str(token), safe="")
118 + base_path = f"{SESSION_PATH}/{quoted_token}/"
119 + query = urlencode(
120 + {
121 + "path": base_path,
122 + "title": title,
123 + "sharing": "true",
124 + "clipboard": "true",
125 + "printing": "true",
126 + "file_transfer": "true",
127 + "sound": "true",
128 + "offscreen": "false",
129 + "floating_menu": "false",
130 + "xpramenu": "false",
131 + },
132 + )
133 + return f"{base_path}index.html?{query}"
134 +
135 +
136 +def collect_status() -> dict[str, Any]:
137 + binaries = {
138 + "xpra": shutil.which("xpra") or "",
139 + "Xvfb": shutil.which("Xvfb") or "",
140 + "xfce4-session": shutil.which("xfce4-session") or "",
141 + "dbus-launch": shutil.which("dbus-launch") or "",
142 + "xrandr": shutil.which("xrandr") or "",
143 + "xdotool": shutil.which("xdotool") or "",
144 + "xsetroot": shutil.which("xsetroot") or "",
145 + }
146 + packages = {
147 + "xpra-x11": _package_installed("xpra-x11") if binaries["xpra"] else False,
148 + }
149 + xpra_html_root = find_xpra_html_root()
150 + missing = [
151 + name
152 + for name in ("xpra", "Xvfb", "xfce4-session", "dbus-launch", "xrandr", "xdotool")
153 + if not binaries[name]
154 + ]
155 + if binaries["xpra"] and not packages["xpra-x11"]:
156 + missing.append("xpra-x11")
157 + if not xpra_html_root:
158 + missing.append("xpra-html5")
159 + healthy = not missing
160 + return {
161 + "ok": True,
162 + "healthy": healthy,
163 + "state": "healthy" if healthy else "missing",
164 + "binaries": binaries,
165 + "packages": packages,
166 + "xpra_html_root": str(xpra_html_root) if xpra_html_root else "",
167 + "message": (
168 + "Virtual desktop sessions are available."
169 + if healthy
170 + else f"Virtual desktop sessions need: {', '.join(missing)}."
171 + ),
172 + }
173 +
174 +
175 +def find_xpra_html_root() -> Path | None:
176 + for root in XPRA_HTML_ROOT_CANDIDATES:
177 + if (root / "index.html").exists() or (root / "connect.html").exists():
178 + return root
179 + return None
180 +
181 +
182 +def _package_installed(package: str) -> bool:
183 + if not shutil.which("dpkg-query"):
184 + return True
185 + result = subprocess.run(
186 + ["dpkg-query", "-W", "-f=${Status}", package],
187 + check=False,
188 + text=True,
189 + capture_output=True,
190 + timeout=8,
191 + )
192 + return result.returncode == 0 and "install ok installed" in result.stdout
193 +
194 +
195 +def normalize_size(
196 + width: int | float | str,
197 + height: int | float | str,
198 + *,
199 + max_width: int = MAX_WIDTH,
200 + max_height: int = MAX_HEIGHT,
201 + min_width: int = MIN_WIDTH,
202 + min_height: int = MIN_HEIGHT,
203 +) -> tuple[int, int]:
204 + requested_width = max(1, int(float(width or DEFAULT_WIDTH)))
205 + requested_height = max(1, int(float(height or DEFAULT_HEIGHT)))
206 + scale = min(max_width / requested_width, max_height / requested_height, 1.0)
207 + if scale < 1.0:
208 + requested_width = max(1, math.floor(requested_width * scale))
209 + requested_height = max(1, math.floor(requested_height * scale))
210 + return (
211 + max(min_width, min(max_width, requested_width)),
212 + max(min_height, min(max_height, requested_height)),
213 + )
214 +
215 +
216 +def resize_display(
217 + *,
218 + display: int,
219 + width: int,
220 + height: int,
221 + max_width: int = MAX_WIDTH,
222 + max_height: int = MAX_HEIGHT,
223 + window_class: str = "",
224 + keys: tuple[str, ...] = (),
225 + xauthority: str = "",
226 + home: str = "",
227 +) -> dict[str, Any]:
228 + target_width, target_height = normalize_size(width, height, max_width=max_width, max_height=max_height)
229 + xrandr = shutil.which("xrandr")
230 + if not xrandr:
231 + return {"ok": False, "error": "xrandr is not installed."}
232 +
233 + env = _display_env(display, xauthority=xauthority, home=home)
234 + current_before = current_display_size(display, xauthority=xauthority, home=home)
235 + if current_before == (target_width, target_height):
236 + if window_class:
237 + fit_window(
238 + display=display,
239 + width=target_width,
240 + height=target_height,
241 + window_class=window_class,
242 + keys=keys,
243 + xauthority=xauthority,
244 + home=home,
245 + )
246 + return {"ok": True, "width": target_width, "height": target_height, "resized": False}
247 +
248 + _ensure_xrandr_mode(env, target_width, target_height)
249 + result = _select_xrandr_mode(env, target_width, target_height)
250 + if result.returncode != 0:
251 + result = subprocess.run(
252 + [xrandr, "--fb", f"{target_width}x{target_height}"],
253 + check=False,
254 + capture_output=True,
255 + text=True,
256 + timeout=4,
257 + env=env,
258 + )
259 + time.sleep(0.15)
260 + current = current_display_size(display, xauthority=xauthority, home=home)
261 + ok = current == (target_width, target_height)
262 + if ok:
263 + if window_class:
264 + fit_window(
265 + display=display,
266 + width=target_width,
267 + height=target_height,
268 + window_class=window_class,
269 + keys=keys,
270 + xauthority=xauthority,
271 + home=home,
272 + )
273 + return {"ok": True, "width": target_width, "height": target_height, "resized": True}
274 + detail = (result.stderr or result.stdout or "xrandr resize failed").strip()
275 + return {
276 + "ok": False,
277 + "error": detail,
278 + "width": current[0] if current else target_width,
279 + "height": current[1] if current else target_height,
280 + }
281 +
282 +
283 +def _ensure_xrandr_mode(env: dict[str, str], width: int, height: int) -> None:
284 + xrandr = shutil.which("xrandr")
285 + if not xrandr:
286 + return
287 + output, existing_modes = _xrandr_output_modes(env)
288 + if not output:
289 + return
290 + mode = f"{width}x{height}"
291 + if mode not in existing_modes:
292 + subprocess.run(
293 + [xrandr, "--newmode", mode, "0", str(width), "0", "0", "0", str(height), "0", "0", "0"],
294 + check=False,
295 + stdout=subprocess.DEVNULL,
296 + stderr=subprocess.DEVNULL,
297 + timeout=2,
298 + env=env,
299 + )
300 + subprocess.run(
301 + [xrandr, "--addmode", output, mode],
302 + check=False,
303 + stdout=subprocess.DEVNULL,
304 + stderr=subprocess.DEVNULL,
305 + timeout=2,
306 + env=env,
307 + )
308 +
309 +
310 +def _select_xrandr_mode(env: dict[str, str], width: int, height: int) -> subprocess.CompletedProcess[str]:
311 + xrandr = shutil.which("xrandr")
312 + if not xrandr:
313 + return subprocess.CompletedProcess([], 1, "", "xrandr is not installed.")
314 + output, _ = _xrandr_output_modes(env)
315 + if not output:
316 + return subprocess.CompletedProcess([], 1, "", "No connected XRandR output found.")
317 + mode = f"{width}x{height}"
318 + return subprocess.run(
319 + [xrandr, "--output", output, "--mode", mode],
320 + check=False,
321 + capture_output=True,
322 + text=True,
323 + timeout=4,
324 + env=env,
325 + )
326 +
327 +
328 +def _xrandr_output_modes(env: dict[str, str]) -> tuple[str, set[str]]:
329 + xrandr = shutil.which("xrandr")
330 + if not xrandr:
331 + return "", set()
332 + result = subprocess.run(
333 + [xrandr, "-q"],
334 + check=False,
335 + capture_output=True,
336 + text=True,
337 + timeout=4,
338 + env=env,
339 + )
340 + output = ""
341 + modes: set[str] = set()
342 + for line in result.stdout.splitlines():
343 + output_match = re.match(r"^(\S+)\s+connected\b", line)
344 + if output_match:
345 + output = output_match.group(1)
346 + continue
347 + if output:
348 + mode_match = re.match(r"^\s+(\d+x\d+)\b", line)
349 + if mode_match:
350 + modes.add(mode_match.group(1))
351 + return output, modes
352 +
353 +
354 +def current_display_size(display: int, *, xauthority: str = "", home: str = "") -> tuple[int, int] | None:
355 + xrandr = shutil.which("xrandr")
356 + if not xrandr:
357 + return None
358 + result = subprocess.run(
359 + [xrandr, "-q"],
360 + check=False,
361 + capture_output=True,
362 + text=True,
363 + timeout=4,
364 + env=_display_env(display, xauthority=xauthority, home=home),
365 + )
366 + match = re.search(r"\bcurrent\s+(\d+)\s+x\s+(\d+)", result.stdout)
367 + if not match:
368 + return None
369 + return int(match.group(1)), int(match.group(2))
370 +
371 +
372 +def fit_window_until(
373 + *,
374 + display: int,
375 + width: int,
376 + height: int,
377 + window_class: str = "",
378 + keys: tuple[str, ...] = (),
379 + settle_seconds: float = 4.0,
380 + timeout_seconds: float = 10.0,
381 + process: subprocess.Popen[Any] | None = None,
382 + xauthority: str = "",
383 + home: str = "",
384 +) -> None:
385 + xdotool = shutil.which("xdotool")
386 + if not xdotool:
387 + return
388 + deadline = time.time() + timeout_seconds
389 + settle_until = 0.0
390 + while time.time() < deadline:
391 + window_id = _find_window(display, window_class=window_class, xauthority=xauthority, home=home)
392 + if window_id:
393 + if not settle_until:
394 + settle_until = time.time() + settle_seconds
395 + fit_window(
396 + display=display,
397 + width=width,
398 + height=height,
399 + window_class=window_class,
400 + keys=keys,
401 + xauthority=xauthority,
402 + home=home,
403 + )
404 + if time.time() >= settle_until:
405 + return
406 + time.sleep(0.5)
407 + continue
408 + if process and process.poll() is not None:
409 + return
410 + time.sleep(0.25)
411 +
412 +
413 +def fit_window(
414 + *,
415 + display: int,
416 + width: int,
417 + height: int,
418 + window_class: str = "",
419 + keys: tuple[str, ...] = (),
420 + xauthority: str = "",
421 + home: str = "",
422 +) -> bool:
423 + xdotool = shutil.which("xdotool")
424 + if not xdotool:
425 + return False
426 + env = _display_env(display, xauthority=xauthority, home=home)
427 + window_id = _find_window(display, window_class=window_class, xauthority=xauthority, home=home)
428 + if not window_id:
429 + return False
430 + subprocess.run(
431 + [xdotool, "windowactivate", window_id],
432 + check=False,
433 + stdout=subprocess.DEVNULL,
434 + stderr=subprocess.DEVNULL,
435 + timeout=2,
436 + env=env,
437 + )
438 + subprocess.run(
439 + [xdotool, "windowmove", window_id, "0", "0", "windowsize", window_id, str(width), str(height)],
440 + check=False,
441 + stdout=subprocess.DEVNULL,
442 + stderr=subprocess.DEVNULL,
443 + timeout=2,
444 + env=env,
445 + )
446 + for key in keys:
447 + subprocess.run(
448 + [xdotool, "key", "--clearmodifiers", key],
449 + check=False,
450 + stdout=subprocess.DEVNULL,
451 + stderr=subprocess.DEVNULL,
452 + timeout=2,
453 + env=env,
454 + )
455 + return True
456 +
457 +
458 +def has_window(
459 + display: int,
460 + *,
461 + window_class: str = "",
462 + name: str = "",
463 + xauthority: str = "",
464 + home: str = "",
465 +) -> bool:
466 + return bool(find_window(display, window_class=window_class, name=name, xauthority=xauthority, home=home))
467 +
468 +
469 +def find_window(
470 + display: int,
471 + *,
472 + window_class: str = "",
473 + name: str = "",
474 + xauthority: str = "",
475 + home: str = "",
476 +) -> str:
477 + return _find_window(display, window_class=window_class, name=name, xauthority=xauthority, home=home)
478 +
479 +
480 +def close_windows(
481 + display: int,
482 + *,
483 + names: tuple[str, ...] = (),
484 + window_class: str = "",
485 + xauthority: str = "",
486 + home: str = "",
487 +) -> int:
488 + xdotool = shutil.which("xdotool")
489 + if not xdotool:
490 + return 0
491 + closed = 0
492 + env = _display_env(display, xauthority=xauthority, home=home)
493 + for pattern in names:
494 + command = [xdotool, "search", "--onlyvisible"]
495 + if window_class:
496 + command.extend(["--class", window_class])
497 + command.extend(["--name", pattern])
498 + result = subprocess.run(
499 + command,
500 + check=False,
501 + capture_output=True,
502 + text=True,
503 + timeout=2,
504 + env=env,
505 + )
506 + for window_id in [line.strip() for line in result.stdout.splitlines() if line.strip()]:
507 + subprocess.run(
508 + [xdotool, "windowclose", window_id],
509 + check=False,
510 + stdout=subprocess.DEVNULL,
511 + stderr=subprocess.DEVNULL,
512 + timeout=2,
513 + env=env,
514 + )
515 + closed += 1
516 + return closed
517 +
518 +
519 +def _find_window(
520 + display: int,
521 + *,
522 + window_class: str = "",
523 + name: str = "",
524 + xauthority: str = "",
525 + home: str = "",
526 +) -> str:
527 + xdotool = shutil.which("xdotool")
528 + if not xdotool:
529 + return ""
530 + command = [xdotool, "search", "--onlyvisible"]
531 + if window_class:
532 + command.extend(["--class", window_class])
533 + if name:
534 + command.extend(["--name", name])
535 + if not window_class and not name:
536 + command.extend(["--name", "."])
537 + result = subprocess.run(
538 + command,
539 + check=False,
540 + capture_output=True,
541 + text=True,
542 + timeout=2,
543 + env=_display_env(display, xauthority=xauthority, home=home),
544 + )
545 + window_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
546 + return window_ids[-1] if window_ids else ""
547 +
548 +
549 +def _display_env(display: int, *, xauthority: str = "", home: str = "") -> dict[str, str]:
550 + runtime_dir = STATE_DIR / "xdg-runtime"
551 + runtime_dir.mkdir(parents=True, exist_ok=True)
552 + try:
553 + runtime_dir.chmod(0o700)
554 + except OSError:
555 + pass
556 + env = {
557 + **os.environ,
558 + "DISPLAY": f":{display}",
559 + "XDG_RUNTIME_DIR": str(runtime_dir),
560 + }
561 + if home:
562 + env["HOME"] = home
563 + if xauthority:
564 + env["XAUTHORITY"] = xauthority
565 + return env
helpers/virtual_desktop_routes.py new
+407
@@ -0,0 +1,407 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +import http.client
5 +from http.cookies import SimpleCookie
6 +from urllib.parse import parse_qs, quote, unquote, urlsplit
7 +
8 +from flask.sessions import SecureCookieSessionInterface
9 +from starlette.requests import Request
10 +from starlette.responses import JSONResponse, PlainTextResponse, RedirectResponse, Response
11 +from starlette.types import Receive, Scope, Send
12 +from starlette.websockets import WebSocket
13 +from wsproto import ConnectionType, WSConnection
14 +from wsproto.events import (
15 + AcceptConnection,
16 + BytesMessage,
17 + CloseConnection,
18 + Ping,
19 + RejectConnection,
20 + Request as WebSocketRequest,
21 + TextMessage,
22 +)
23 +
24 +from helpers import login, virtual_desktop
25 +
26 +
27 +HOP_BY_HOP_HEADERS = {
28 + "connection",
29 + "content-length",
30 + "cookie",
31 + "host",
32 + "keep-alive",
33 + "proxy-authenticate",
34 + "proxy-authorization",
35 + "te",
36 + "trailer",
37 + "transfer-encoding",
38 + "upgrade",
39 +}
40 +
41 +
42 +class VirtualDesktopGateway:
43 + def __init__(self, flask_app=None, mount_path: str = "/desktop") -> None:
44 + self.flask_app = flask_app
45 + self.mount_path = "/" + mount_path.strip("/")
46 +
47 + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
48 + if scope["type"] == "websocket":
49 + await self.websocket(scope, receive, send)
50 + return
51 + if scope["type"] == "http":
52 + await self.http(scope, receive, send)
53 + return
54 + await PlainTextResponse("Unsupported scope", status_code=500)(scope, receive, send)
55 +
56 + async def http(self, scope: Scope, receive: Receive, send: Send) -> None:
57 + if not self.is_authorized(scope):
58 + await PlainTextResponse("Authentication required", status_code=401)(scope, receive, send)
59 + return
60 +
61 + path = self.relative_path(scope)
62 + if path in {"", "/"}:
63 + await RedirectResponse(f"{self.mount_path}/health")(scope, receive, send)
64 + return
65 + if path == "/health":
66 + await JSONResponse(virtual_desktop.collect_status())(scope, receive, send)
67 + return
68 + if path == "/resize":
69 + await self.resize(scope, receive, send)
70 + return
71 +
72 + session_request = self.session_request(path)
73 + if not session_request:
74 + await PlainTextResponse("Desktop session not found.", status_code=404)(scope, receive, send)
75 + return
76 + token, upstream_path = session_request
77 + if upstream_path in {"", "/"}:
78 + await RedirectResponse(self.session_index_url(token))(scope, receive, send)
79 + return
80 + await self.proxy_http(scope, receive, send, token, upstream_path)
81 +
82 + async def resize(self, scope: Scope, receive: Receive, send: Send) -> None:
83 + query = self.query(scope)
84 + payload: dict[str, object] = {}
85 + if scope.get("method") == "POST":
86 + try:
87 + payload = await Request(scope, receive).json()
88 + except Exception:
89 + payload = {}
90 + token = str(payload.get("token") or query.get("token", [""])[0])
91 + width = payload.get("width") or query.get("width", [0])[0]
92 + height = payload.get("height") or query.get("height", [0])[0]
93 + try:
94 + result = virtual_desktop.resize_session(token, int(float(width)), int(float(height)))
95 + except (TypeError, ValueError):
96 + result = {"ok": False, "error": "Invalid virtual desktop size."}
97 + await JSONResponse(result, status_code=200 if result.get("ok") else 400)(scope, receive, send)
98 +
99 + async def proxy_http(
100 + self,
101 + scope: Scope,
102 + receive: Receive,
103 + send: Send,
104 + token: str,
105 + upstream_path: str,
106 + ) -> None:
107 + endpoint = virtual_desktop.proxy_for_token(token)
108 + if not endpoint:
109 + await PlainTextResponse("Desktop session not found.", status_code=404)(scope, receive, send)
110 + return
111 +
112 + body = await Request(scope, receive).body()
113 + try:
114 + status, headers, content = await asyncio.to_thread(
115 + self.fetch_http,
116 + endpoint,
117 + upstream_path,
118 + scope.get("query_string", b"").decode("latin-1"),
119 + str(scope.get("method") or "GET"),
120 + self.proxy_request_headers(scope),
121 + body,
122 + )
123 + await Response(
124 + content,
125 + status_code=status,
126 + headers=self.proxy_response_headers(headers, token),
127 + )(scope, receive, send)
128 + except (http.client.HTTPException, OSError, asyncio.TimeoutError):
129 + await PlainTextResponse("Desktop proxy is unavailable.", status_code=502)(scope, receive, send)
130 +
131 + async def websocket(self, scope: Scope, receive: Receive, send: Send) -> None:
132 + websocket = WebSocket(scope, receive=receive, send=send)
133 + if not self.is_authorized(scope):
134 + await websocket.close(code=1008)
135 + return
136 +
137 + session_request = self.session_request(self.relative_path(scope))
138 + if not session_request:
139 + await websocket.close(code=1008)
140 + return
141 + token, upstream_path = session_request
142 + endpoint = virtual_desktop.proxy_for_token(token)
143 + if not endpoint:
144 + await websocket.close(code=1008)
145 + return
146 +
147 + target = self.upstream_target(upstream_path or "/", scope.get("query_string", b""))
148 + try:
149 + reader, writer, upstream, subprotocol = await self.open_websocket(
150 + endpoint,
151 + target,
152 + tuple(scope.get("subprotocols") or ()),
153 + )
154 + await websocket.accept(subprotocol=subprotocol)
155 + await asyncio.gather(
156 + self.browser_to_xpra(websocket, upstream, writer),
157 + self.xpra_to_browser(websocket, upstream, reader, writer),
158 + )
159 + except Exception:
160 + try:
161 + await websocket.close(code=1011)
162 + except Exception:
163 + pass
164 +
165 + async def open_websocket(
166 + self,
167 + endpoint: virtual_desktop.VirtualDesktopEndpoint,
168 + target: str,
169 + subprotocols: tuple[str, ...],
170 + ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter, WSConnection, str | None]:
171 + reader, writer = await asyncio.open_connection(endpoint.host, endpoint.port)
172 + upstream = WSConnection(ConnectionType.CLIENT)
173 + writer.write(
174 + upstream.send(
175 + WebSocketRequest(
176 + host=f"{endpoint.host}:{endpoint.port}",
177 + target=target,
178 + subprotocols=list(subprotocols),
179 + ),
180 + ),
181 + )
182 + await writer.drain()
183 +
184 + while True:
185 + data = await asyncio.wait_for(reader.read(65536), timeout=10)
186 + if not data:
187 + raise ConnectionError("Xpra WebSocket handshake closed early.")
188 + upstream.receive_data(data)
189 + for event in upstream.events():
190 + if isinstance(event, AcceptConnection):
191 + return reader, writer, upstream, event.subprotocol
192 + if isinstance(event, RejectConnection):
193 + raise ConnectionError(f"Xpra rejected WebSocket handshake with HTTP {event.status_code}.")
194 + if isinstance(event, CloseConnection):
195 + raise ConnectionError("Xpra closed WebSocket handshake.")
196 +
197 + async def browser_to_xpra(
198 + self,
199 + websocket: WebSocket,
200 + upstream: WSConnection,
201 + writer: asyncio.StreamWriter,
202 + ) -> None:
203 + try:
204 + while True:
205 + message = await websocket.receive()
206 + if message["type"] == "websocket.disconnect":
207 + writer.write(upstream.send(CloseConnection(code=1000)))
208 + await writer.drain()
209 + return
210 + if message.get("bytes") is not None:
211 + writer.write(upstream.send(BytesMessage(data=message["bytes"])))
212 + elif message.get("text") is not None:
213 + writer.write(upstream.send(TextMessage(data=str(message["text"]))))
214 + await writer.drain()
215 + finally:
216 + writer.close()
217 +
218 + async def xpra_to_browser(
219 + self,
220 + websocket: WebSocket,
221 + upstream: WSConnection,
222 + reader: asyncio.StreamReader,
223 + writer: asyncio.StreamWriter,
224 + ) -> None:
225 + try:
226 + while True:
227 + data = await reader.read(65536)
228 + if not data:
229 + return
230 + upstream.receive_data(data)
231 + for event in upstream.events():
232 + if isinstance(event, BytesMessage):
233 + await websocket.send_bytes(event.data)
234 + elif isinstance(event, TextMessage):
235 + await websocket.send_text(event.data)
236 + elif isinstance(event, Ping):
237 + writer.write(upstream.send(event.response()))
238 + await writer.drain()
239 + elif isinstance(event, CloseConnection):
240 + writer.write(upstream.send(event.response()))
241 + await writer.drain()
242 + return
243 + finally:
244 + try:
245 + await websocket.close()
246 + except Exception:
247 + pass
248 + writer.close()
249 +
250 + def session_request(self, path: str) -> tuple[str, str] | None:
251 + prefix = "/session/"
252 + if not path.startswith(prefix):
253 + return None
254 + rest = path[len(prefix):]
255 + token, separator, upstream_path = rest.partition("/")
256 + token = unquote(token)
257 + if not token:
258 + return None
259 + return token, f"/{upstream_path}" if separator else "/"
260 +
261 + def session_index_url(self, token: str) -> str:
262 + quoted_token = quote(str(token), safe="")
263 + base_path = f"{self.mount_path}/session/{quoted_token}/"
264 + return f"{base_path}index.html?path={quote(base_path, safe='')}"
265 +
266 + def fetch_http(
267 + self,
268 + endpoint: virtual_desktop.VirtualDesktopEndpoint,
269 + upstream_path: str,
270 + query: str,
271 + method: str,
272 + headers: dict[str, str],
273 + body: bytes,
274 + ) -> tuple[int, dict[str, str], bytes]:
275 + connection = http.client.HTTPConnection(endpoint.host, endpoint.port, timeout=60)
276 + try:
277 + connection.request(
278 + method,
279 + self.upstream_target(upstream_path, query.encode("latin-1")),
280 + body=body or None,
281 + headers={**headers, "Connection": "close"},
282 + )
283 + response = connection.getresponse()
284 + return response.status, dict(response.getheaders()), response.read()
285 + finally:
286 + connection.close()
287 +
288 + def upstream_target(self, upstream_path: str, query_string: bytes) -> str:
289 + query = query_string.decode("latin-1")
290 + target = upstream_path or "/"
291 + return f"{target}?{query}" if query else target
292 +
293 + def proxy_request_headers(self, scope: Scope) -> dict[str, str]:
294 + headers: dict[str, str] = {}
295 + for raw_name, raw_value in scope.get("headers", []):
296 + name = raw_name.decode("latin-1")
297 + lower = name.lower()
298 + if lower in HOP_BY_HOP_HEADERS or lower == "origin" or lower.startswith("sec-websocket"):
299 + continue
300 + headers[name] = raw_value.decode("latin-1")
301 + return headers
302 +
303 + def proxy_response_headers(self, headers: dict[str, str], token: str) -> dict[str, str]:
304 + response_headers: dict[str, str] = {}
305 + for name, value in dict(headers).items():
306 + lower = name.lower()
307 + if lower in HOP_BY_HOP_HEADERS:
308 + continue
309 + if lower == "location":
310 + value = self.rewrite_location(str(value), token)
311 + response_headers[name] = str(value)
312 + return response_headers
313 +
314 + def rewrite_location(self, location: str, token: str) -> str:
315 + quoted_token = quote(str(token), safe="")
316 + prefix = f"{self.mount_path}/session/{quoted_token}"
317 + parsed = urlsplit(location)
318 + if parsed.scheme in {"http", "https"} and parsed.hostname in {"127.0.0.1", "localhost"}:
319 + path = parsed.path or "/"
320 + query = f"?{parsed.query}" if parsed.query else ""
321 + return f"{prefix}{path}{query}"
322 + if location.startswith("/"):
323 + return f"{prefix}{location}"
324 + return location
325 +
326 + def relative_path(self, scope: Scope) -> str:
327 + raw_path = scope.get("raw_path")
328 + path = raw_path.decode("latin-1") if raw_path else str(scope.get("path") or "")
329 + if path.startswith(self.mount_path):
330 + path = path[len(self.mount_path):]
331 + return path or "/"
332 +
333 + def query(self, scope: Scope) -> dict[str, list[str]]:
334 + return parse_qs(scope.get("query_string", b"").decode("latin-1"), keep_blank_values=True)
335 +
336 + def is_authorized(self, scope: Scope) -> bool:
337 + credentials_hash = login.get_credentials_hash()
338 + if not credentials_hash:
339 + return True
340 + if not self.flask_app:
341 + return False
342 + serializer = SecureCookieSessionInterface().get_signing_serializer(self.flask_app)
343 + if not serializer:
344 + return False
345 + cookie_header = dict(scope.get("headers", [])).get(b"cookie", b"").decode("latin-1")
346 + if not cookie_header:
347 + return False
348 + cookies = SimpleCookie()
349 + cookies.load(cookie_header)
350 + session_cookie = cookies.get(self.flask_app.config.get("SESSION_COOKIE_NAME", "session"))
351 + if not session_cookie:
352 + return False
353 + try:
354 + session_data = serializer.loads(session_cookie.value)
355 + except Exception:
356 + return False
357 + return session_data.get("authentication") == credentials_hash
358 +
359 +
360 +def install_route_hooks() -> None:
361 + from helpers.ui_server import UiServerRuntime
362 +
363 + if getattr(UiServerRuntime, "_a0_virtual_desktop_route_hooks_installed", False):
364 + return
365 +
366 + original_build_asgi_app = UiServerRuntime.build_asgi_app
367 +
368 + def build_asgi_app(self, startup_monitor):
369 + from socketio import ASGIApp
370 + from starlette.applications import Starlette
371 + from starlette.routing import Mount
372 + from uvicorn.middleware.wsgi import WSGIMiddleware
373 +
374 + from helpers import fasta2a_server, mcp_server
375 +
376 + with startup_monitor.stage("wsgi.middleware.create"):
377 + wsgi_app = WSGIMiddleware(self.webapp)
378 +
379 + with startup_monitor.stage("mcp.proxy.init"):
380 + mcp_app = mcp_server.DynamicMcpProxy.get_instance()
381 +
382 + with startup_monitor.stage("a2a.proxy.init"):
383 + a2a_app = fasta2a_server.DynamicA2AProxy.get_instance()
384 +
385 + with startup_monitor.stage("starlette.app.create"):
386 + starlette_app = Starlette(
387 + routes=[
388 + Mount("/desktop", app=VirtualDesktopGateway(self.webapp, "/desktop")),
389 + Mount("/mcp", app=mcp_app),
390 + Mount("/a2a", app=a2a_app),
391 + Mount("/", app=wsgi_app),
392 + ],
393 + lifespan=startup_monitor.lifespan(),
394 + )
395 +
396 + with startup_monitor.stage("socketio.asgi.create"):
397 + return ASGIApp(self.socketio_server, other_asgi_app=starlette_app)
398 +
399 + UiServerRuntime.build_asgi_app = build_asgi_app
400 + UiServerRuntime._a0_virtual_desktop_route_hooks_installed = True
401 + UiServerRuntime._a0_virtual_desktop_original_build_asgi_app = original_build_asgi_app
402 +
403 +
404 +def is_installed() -> bool:
405 + from helpers.ui_server import UiServerRuntime
406 +
407 + return bool(getattr(UiServerRuntime, "_a0_virtual_desktop_route_hooks_installed", False))
plugins/_office/api/collabora_logs.py deleted
-13
@@ -1,13 +0,0 @@
1 -from __future__ import annotations
2 -
3 -from helpers.api import ApiHandler, Request
4 -from plugins._office.helpers import collabora_status
5 -
6 -
7 -class CollaboraLogs(ApiHandler):
8 - async def process(self, input: dict, request: Request) -> dict:
9 - return {
10 - "ok": True,
11 - "bootstrap": collabora_status.tail_file(collabora_status.BOOTSTRAP_LOG),
12 - "wrapper": collabora_status.tail_file(collabora_status.WRAPPER_LOG),
13 - }
plugins/_office/api/collabora_status.py deleted
-11
@@ -1,11 +0,0 @@
1 -from __future__ import annotations
2 -
3 -from helpers.api import ApiHandler, Request
4 -from plugins._office.helpers.collabora_status import collect_status, read_status
5 -
6 -
7 -class CollaboraStatus(ApiHandler):
8 - async def process(self, input: dict, request: Request) -> dict:
9 - if input.get("fresh"):
10 - return collect_status()
11 - return read_status()
plugins/_office/api/office_session.py
+198 -103
@@ -1,150 +1,245 @@
1 from __future__ import annotations
2
3 -import xml.etree.ElementTree as ET
4 -from urllib.parse import quote, urlparse
5 -
6 -import httpx
3 from helpers.api import ApiHandler, Request
8 -from plugins._office.helpers import collabora_runtime, collabora_status, wopi_store
9 -
10 -
11 -DISCOVERY_URLS = (
12 - "http://127.0.0.1:9980/office/hosting/discovery",
13 - "http://127.0.0.1:9980/hosting/discovery",
14 -)
4 +from plugins._office.helpers import document_store, libreoffice, libreoffice_desktop, libreofficekit_sessions
5
6
7 class OfficeSession(ApiHandler):
8 async def process(self, input: dict, request: Request) -> dict:
19 - action = str(input.get("action") or "open").lower()
9 + action = str(input.get("action") or "open").lower().strip()
10 + context_id = str(input.get("ctxid") or input.get("context_id") or "").strip()
11 +
12 if action == "status":
21 - return collabora_status.collect_status()
22 - if action == "retry":
23 - collabora_runtime.retry_bootstrap()
24 - return {"ok": True, **collabora_status.read_status()}
13 + return libreoffice.collect_status()
14 + if action == "home":
15 + return {"ok": True, "path": document_store.default_open_path(context_id)}
16 if action == "recent":
26 - return {"ok": True, "documents": wopi_store.get_recent_documents()}
17 + return {"ok": True, "documents": _public_docs(document_store.get_recent_documents())}
18 if action == "open_documents":
28 - return {"ok": True, "documents": wopi_store.get_open_documents(limit=24)}
19 + return {"ok": True, "documents": _public_docs(document_store.get_open_documents(limit=24))}
20 + if action == "desktop":
21 + return self._desktop()
22 if action == "sync_open_sessions":
23 session_ids = input.get("session_ids")
24 if not isinstance(session_ids, list):
25 session_ids = []
33 - closed = wopi_store.sync_open_sessions(session_ids)
34 - return {"ok": True, "closed": closed, "documents": wopi_store.get_open_documents(limit=24)}
26 + closed = document_store.sync_open_sessions(session_ids)
27 + return {"ok": True, "closed": closed, "documents": _public_docs(document_store.get_open_documents(limit=24))}
28 if action == "close":
36 - closed = wopi_store.close_session(
29 + closed = document_store.close_session(
30 session_id=str(input.get("session_id") or ""),
31 file_id=str(input.get("file_id") or ""),
32 )
40 - return {"ok": True, "closed": closed, "documents": wopi_store.get_open_documents(limit=24)}
33 + return {"ok": True, "closed": closed, "documents": _public_docs(document_store.get_open_documents(limit=24))}
34 if action == "create":
42 - doc = wopi_store.create_document(
43 - kind=str(input.get("kind") or "document"),
44 - title=str(input.get("title") or "Untitled"),
45 - fmt=str(input.get("format") or "docx"),
46 - content=str(input.get("content") or ""),
47 - path=str(input.get("path") or ""),
48 - )
35 + try:
36 + doc = document_store.create_document(
37 + kind=str(input.get("kind") or "document"),
38 + title=str(input.get("title") or "Untitled"),
39 + fmt=str(input.get("format") or "md"),
40 + content=str(input.get("content") or ""),
41 + path=str(input.get("path") or ""),
42 + context_id=context_id,
43 + )
44 + except ValueError as exc:
45 + return {"ok": False, "error": str(exc)}
46 + if doc["extension"] == "docx":
47 + validation = libreoffice.validate_docx(doc["path"])
48 + if not validation.get("ok"):
49 + return {"ok": False, "error": validation.get("error") or "DOCX validation failed."}
50 return await self._open_document(doc, input, request)
51 if action == "open":
52 file_id = str(input.get("file_id") or "").strip()
52 - doc = (
53 - wopi_store.get_document(file_id)
54 - if file_id
55 - else wopi_store.register_document(str(input.get("path") or ""))
56 - )
53 + try:
54 + doc = (
55 + document_store.get_document(file_id)
56 + if file_id
57 + else document_store.register_document(str(input.get("path") or ""), context_id=context_id)
58 + )
59 + except ValueError as exc:
60 + return {"ok": False, "error": str(exc)}
61 return await self._open_document(doc, input, request)
62 + if action == "save":
63 + return self._save(input)
64 + if action == "desktop_save":
65 + return self._desktop_save(input)
66 + if action == "desktop_sync":
67 + return self._desktop_sync(input)
68 + if action == "desktop_close":
69 + return self._desktop_close(input)
70 + if action == "key":
71 + return libreofficekit_sessions.get_manager().key(
72 + str(input.get("session_id") or ""),
73 + input.get("key") if isinstance(input.get("key"), dict) else {},
74 + )
75 + if action == "mouse":
76 + return libreofficekit_sessions.get_manager().mouse(
77 + str(input.get("session_id") or ""),
78 + input.get("mouse") if isinstance(input.get("mouse"), dict) else {},
79 + )
80 + if action == "command":
81 + return libreofficekit_sessions.get_manager().command(
82 + str(input.get("session_id") or ""),
83 + str(input.get("command") or ""),
84 + arguments=input.get("arguments"),
85 + notify=bool(input.get("notify", True)),
86 + )
87 + if action == "command_values":
88 + return libreofficekit_sessions.get_manager().command_values(
89 + str(input.get("session_id") or ""),
90 + str(input.get("command") or ""),
91 + )
92 + if action == "export":
93 + return self._export(input)
94 return {"ok": False, "error": f"Unsupported office session action: {action}"}
95
96 async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
97 mode = "edit" if str(input.get("mode") or "edit").lower() == "edit" else "view"
62 - permission = "write" if mode == "edit" else "read"
63 - origin = self._origin(request)
64 - session = wopi_store.create_session(
98 + store_session = document_store.create_session(
99 doc["file_id"],
100 user_id=str(input.get("user_id") or "agent-zero-user"),
67 - permission=permission,
68 - origin=origin,
101 + permission="write" if mode == "edit" else "read",
102 + origin=self._origin(request),
103 )
70 - discovery = await self._discover()
71 - if not discovery.get("ok"):
104 + if str(doc.get("extension") or "").lower() in libreoffice_desktop.OFFICIAL_EXTENSIONS:
105 + desktop = libreoffice_desktop.get_manager().open(doc)
106 + if not desktop.get("available"):
107 + document_store.close_session(session_id=store_session["session_id"])
108 + return {
109 + "ok": False,
110 + "error": desktop.get("error") or desktop.get("reason") or "Official LibreOffice desktop session is unavailable.",
111 + "desktop": desktop,
112 + "libreoffice": libreoffice.collect_status(),
113 + }
114 return {
73 - "ok": False,
74 - "error": discovery.get("error") or "Collabora discovery is unavailable",
115 + "ok": True,
116 + "session_id": desktop["session_id"],
117 + "desktop_session_id": desktop["session_id"],
118 "file_id": doc["file_id"],
119 "title": doc["basename"],
120 "extension": doc["extension"],
78 - "status": collabora_status.collect_status(),
121 + "path": doc["path"],
122 + "text": "",
123 + "tiles": [],
124 + "document": _public_doc(doc),
125 + "version": document_store.item_version(doc),
126 + "libreoffice": libreoffice.collect_status(),
127 + "native": {"available": False, "mode": "desktop"},
128 + "desktop": desktop,
129 + "store_session_id": store_session["session_id"],
130 + "preview": document_store.build_preview(doc),
131 + "mode": mode,
132 }
133 + editor = libreofficekit_sessions.get_manager().open(doc, sid="")
134 + return {
135 + **editor,
136 + "store_session_id": store_session["session_id"],
137 + "session_id": editor["session_id"],
138 + "preview": document_store.build_preview(doc),
139 + "mode": mode,
140 + }
141 +
142 + def _save(self, input: dict) -> dict:
143 + session_id = str(input.get("session_id") or "").strip()
144 + if not session_id:
145 + return {"ok": False, "error": "session_id is required."}
146 + return libreofficekit_sessions.get_manager().save(session_id, text=input.get("text"))
147
81 - action_url = self._select_action(discovery["xml"], doc["extension"], mode)
82 - if not action_url:
148 + def _desktop(self) -> dict:
149 + desktop = libreoffice_desktop.get_manager().ensure_system_desktop()
150 + if not desktop.get("available"):
151 return {
152 "ok": False,
85 - "error": f"Collabora does not advertise {mode} support for .{doc['extension']}",
86 - "file_id": doc["file_id"],
87 - "title": doc["basename"],
88 - "extension": doc["extension"],
153 + "error": desktop.get("error") or "Official LibreOffice desktop session is unavailable.",
154 + "desktop": desktop,
155 + "libreoffice": libreoffice.collect_status(),
156 }
90 -
91 - wopi_src = f"http://127.0.0.1:80/wopi/files/{doc['file_id']}"
92 - iframe_action = self._same_origin_action(action_url, wopi_src, session["session_id"])
157 + document = {
158 + "file_id": libreoffice_desktop.SYSTEM_FILE_ID,
159 + "path": desktop["path"],
160 + "basename": desktop["title"],
161 + "title": desktop["title"],
162 + "extension": "desktop",
163 + "size": 0,
164 + "version": 0,
165 + "preview": {},
166 + }
167 return {
168 "ok": True,
95 - "file_id": doc["file_id"],
96 - "session_id": session["session_id"],
97 - "iframe_action": iframe_action,
98 - "access_token": session["access_token"],
99 - "access_token_ttl": session["access_token_ttl"],
100 - "post_message_origin": origin,
101 - "title": doc["basename"],
102 - "extension": doc["extension"],
103 - "path": doc["path"],
104 - "version": wopi_store.item_version(doc),
105 - "preview": wopi_store.build_preview(doc),
169 + "session_id": desktop["session_id"],
170 + "desktop_session_id": desktop["session_id"],
171 + "file_id": libreoffice_desktop.SYSTEM_FILE_ID,
172 + "title": desktop["title"],
173 + "extension": "desktop",
174 + "path": desktop["path"],
175 + "text": "",
176 + "tiles": [],
177 + "document": document,
178 + "version": 0,
179 + "libreoffice": libreoffice.collect_status(),
180 + "native": {"available": False, "mode": "desktop"},
181 + "desktop": desktop,
182 + "store_session_id": "",
183 + "preview": {},
184 + "mode": "desktop",
185 }
186
187 + def _desktop_save(self, input: dict) -> dict:
188 + session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip()
189 + if not session_id:
190 + return {"ok": False, "error": "desktop_session_id is required."}
191 + return libreoffice_desktop.get_manager().save(
192 + session_id,
193 + file_id=str(input.get("file_id") or ""),
194 + )
195 +
196 + def _desktop_sync(self, input: dict) -> dict:
197 + return libreoffice_desktop.get_manager().sync(
198 + session_id=str(input.get("desktop_session_id") or input.get("session_id") or ""),
199 + file_id=str(input.get("file_id") or ""),
200 + )
201 +
202 + def _desktop_close(self, input: dict) -> dict:
203 + session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip()
204 + if not session_id:
205 + return {"ok": False, "error": "desktop_session_id is required."}
206 + return libreoffice_desktop.get_manager().close(
207 + session_id,
208 + save_first=bool(input.get("save_first", True)),
209 + )
210 +
211 + def _export(self, input: dict) -> dict:
212 + file_id = str(input.get("file_id") or "").strip()
213 + path = str(input.get("path") or "").strip()
214 + target_format = str(input.get("target_format") or input.get("format") or "pdf").lower().lstrip(".")
215 + doc = document_store.get_document(file_id) if file_id else document_store.register_document(path)
216 + result = libreoffice.convert_document(doc["path"], target_format)
217 + if not result.get("ok"):
218 + return result
219 + return {"ok": True, "path": document_store.display_path(result["path"]), "source": _public_doc(doc)}
220 +
221 def _origin(self, request: Request) -> str:
222 origin = request.headers.get("Origin") or request.host_url.rstrip("/")
223 return origin.rstrip("/")
224
112 - async def _discover(self) -> dict:
113 - for url in DISCOVERY_URLS:
114 - try:
115 - async with httpx.AsyncClient(timeout=8.0) as client:
116 - response = await client.get(url)
117 - if response.status_code == 200 and "wopi-discovery" in response.text.lower():
118 - return {"ok": True, "xml": response.text}
119 - except Exception:
120 - continue
121 - return {"ok": False, "error": "Collabora discovery is not reachable yet"}
122 -
123 - def _select_action(self, discovery_xml: str, extension: str, mode: str) -> str:
124 - root = ET.fromstring(discovery_xml)
125 - best = ""
126 - fallback = ""
127 - for action in root.findall(".//{*}action"):
128 - if action.attrib.get("ext", "").lower() != extension.lower():
129 - continue
130 - name = action.attrib.get("name", "").lower()
131 - urlsrc = action.attrib.get("urlsrc", "")
132 - if not urlsrc:
133 - continue
134 - if name == mode:
135 - best = urlsrc
136 - break
137 - if name == "view":
138 - fallback = urlsrc
139 - return best or fallback
140 -
141 - def _same_origin_action(self, urlsrc: str, wopi_src: str, session_id: str) -> str:
142 - parsed = urlparse(urlsrc)
143 - path = parsed.path or "/office/browser/cool.html"
144 - if not path.startswith("/office"):
145 - path = "/office" + path
146 - query = parsed.query
147 - base = path + (f"?{query}" if query else ("?" if urlsrc.endswith("?") else ""))
148 - separator = "" if base.endswith("?") or base.endswith("&") else ("&" if "?" in base else "?")
149 - base = f"{base}{separator}a0_session={quote(session_id, safe='')}"
150 - return f"{base}&WOPISrc={quote(wopi_src, safe='')}"
225 +
226 +def _public_docs(docs: list[dict]) -> list[dict]:
227 + return [_public_doc(doc) for doc in docs]
228 +
229 +
230 +def _public_doc(doc: dict) -> dict:
231 + result = {
232 + "file_id": doc["file_id"],
233 + "path": document_store.display_path(doc["path"]),
234 + "basename": doc["basename"],
235 + "title": doc["basename"],
236 + "extension": doc["extension"],
237 + "size": doc["size"],
238 + "version": document_store.item_version(doc),
239 + "last_modified": doc["last_modified"],
240 + "preview": doc.get("preview") or document_store.build_preview(doc),
241 + }
242 + for key in ("open_sessions", "last_opened_at", "session_expires_at"):
243 + if key in doc:
244 + result[key] = doc[key]
245 + return result
plugins/_office/api/ws_office.py new
+95
@@ -0,0 +1,95 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from helpers.ws import WsHandler
6 +from helpers.ws_manager import WsResult
7 +from plugins._office.helpers import document_store, libreofficekit_sessions
8 +
9 +
10 +class WsOffice(WsHandler):
11 + async def on_disconnect(self, sid: str) -> None:
12 + libreofficekit_sessions.get_manager().close_sid(sid)
13 +
14 + async def process(self, event: str, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult | None:
15 + if not event.startswith("office_"):
16 + return None
17 + try:
18 + if event == "office_open":
19 + return self._open(data, sid)
20 + if event == "office_input":
21 + return libreofficekit_sessions.get_manager().input(
22 + str(data.get("session_id") or ""),
23 + text=data.get("text") if "text" in data else None,
24 + patch=data.get("patch") if isinstance(data.get("patch"), dict) else None,
25 + )
26 + if event == "office_key":
27 + return libreofficekit_sessions.get_manager().key(
28 + str(data.get("session_id") or ""),
29 + data.get("key") if isinstance(data.get("key"), dict) else {},
30 + )
31 + if event == "office_mouse":
32 + return libreofficekit_sessions.get_manager().mouse(
33 + str(data.get("session_id") or ""),
34 + data.get("mouse") if isinstance(data.get("mouse"), dict) else {},
35 + )
36 + if event == "office_cursor":
37 + return libreofficekit_sessions.get_manager().cursor(
38 + str(data.get("session_id") or ""),
39 + data.get("cursor") if isinstance(data.get("cursor"), dict) else {},
40 + )
41 + if event == "office_selection":
42 + return libreofficekit_sessions.get_manager().selection(
43 + str(data.get("session_id") or ""),
44 + data.get("selection") if isinstance(data.get("selection"), dict) else {},
45 + )
46 + if event == "office_invalidated_tiles":
47 + session_id = str(data.get("session_id") or "")
48 + return {"session_id": session_id, "tiles": libreofficekit_sessions.get_manager().tiles(session_id)}
49 + if event == "office_command":
50 + return libreofficekit_sessions.get_manager().command(
51 + str(data.get("session_id") or ""),
52 + str(data.get("command") or ""),
53 + arguments=data.get("arguments"),
54 + notify=bool(data.get("notify", True)),
55 + )
56 + if event == "office_command_values":
57 + return libreofficekit_sessions.get_manager().command_values(
58 + str(data.get("session_id") or ""),
59 + str(data.get("command") or ""),
60 + )
61 + if event == "office_save":
62 + return libreofficekit_sessions.get_manager().save(
63 + str(data.get("session_id") or ""),
64 + text=data.get("text") if "text" in data else None,
65 + )
66 + if event == "office_close":
67 + return libreofficekit_sessions.get_manager().close(str(data.get("session_id") or ""))
68 + except FileNotFoundError as exc:
69 + return WsResult.error(code="OFFICE_SESSION_NOT_FOUND", message=str(exc), correlation_id=data.get("correlationId"))
70 + except Exception as exc:
71 + return WsResult.error(code="OFFICE_ERROR", message=str(exc), correlation_id=data.get("correlationId"))
72 +
73 + return WsResult.error(
74 + code="UNKNOWN_OFFICE_EVENT",
75 + message=f"Unknown office event: {event}",
76 + correlation_id=data.get("correlationId"),
77 + )
78 +
79 + def _open(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
80 + context_id = str(data.get("ctxid") or data.get("context_id") or "")
81 + file_id = str(data.get("file_id") or "").strip()
82 + path = str(data.get("path") or "").strip()
83 + if file_id:
84 + doc = document_store.get_document(file_id)
85 + elif path:
86 + doc = document_store.register_document(path, context_id=context_id)
87 + else:
88 + doc = document_store.create_document(
89 + kind=str(data.get("kind") or "document"),
90 + title=str(data.get("title") or "Untitled"),
91 + fmt=str(data.get("format") or "md"),
92 + content=str(data.get("content") or ""),
93 + context_id=context_id,
94 + )
95 + return libreofficekit_sessions.get_manager().open(doc, sid=sid)
plugins/_office/extensions/python/_functions/run_ui/init_a0/end/_20_collabora_bootstrap.py deleted
-9
@@ -1,9 +0,0 @@
1 -from __future__ import annotations
2 -
3 -from helpers.extension import Extension
4 -from plugins._office.helpers.collabora_runtime import start_bootstrap_worker
5 -
6 -
7 -class CollaboraBootstrap(Extension):
8 - def execute(self, **kwargs):
9 - start_bootstrap_worker(force=False)
plugins/_office/extensions/python/job_loop/_20_collabora_reconcile.py deleted
-9
@@ -1,9 +0,0 @@
1 -from __future__ import annotations
2 -
3 -from helpers.extension import Extension
4 -from plugins._office.helpers.collabora_runtime import reconcile
5 -
6 -
7 -class CollaboraReconcile(Extension):
8 - async def execute(self, **kwargs):
9 - reconcile()
plugins/_office/extensions/python/startup_migration/_20_office_routes.py
+13 -3
@@ -1,9 +1,19 @@
1 from __future__ import annotations
2
3 from helpers.extension import Extension
4 -from plugins._office.helpers.route_bootstrap import install_route_hooks
4 +from helpers.print_style import PrintStyle
5 +from plugins._office import hooks
6 +from plugins._office.helpers import libreoffice_desktop, libreoffice_desktop_routes
7
8
7 -class OfficeRoutesStartup(Extension):
9 +class OfficeStartupCleanup(Extension):
10 def execute(self, **kwargs):
9 - install_route_hooks()
11 + libreoffice_desktop_routes.install_route_hooks()
12 + result = hooks.cleanup_stale_runtime_state()
13 + if result.get("errors"):
14 + PrintStyle.warning("Office runtime preparation reported errors:", result["errors"])
15 + elif result.get("installed") or result.get("removed"):
16 + PrintStyle.info("Office runtime prepared:", result)
17 + desktop = libreoffice_desktop.get_manager().ensure_system_desktop()
18 + if not desktop.get("available"):
19 + PrintStyle.warning("Office desktop startup was deferred:", desktop.get("error") or desktop)
plugins/_office/extensions/python/tool_execute_after/_20_document_response_affordance.py
+9 -20
@@ -4,11 +4,10 @@ import json
4 from pathlib import Path
5 from typing import Any
6
7 -from helpers import files
7 from helpers.extension import Extension
8 from helpers.print_style import PrintStyle
9 from helpers.tool import Response
11 -from plugins._office.helpers import document_affordance, wopi_store
10 +from plugins._office.helpers import document_affordance, document_store
11
12
13 HANDOFF_CREATED_FLAG = "_office_document_handoff_created"
@@ -45,14 +44,15 @@ class DocumentResponseAffordance(Extension):
44 return
45
46 try:
48 - doc = wopi_store.create_document(
47 + doc = document_store.create_document(
48 kind=decision.kind,
49 title=decision.title,
50 fmt=decision.fmt,
51 content=decision.content,
52 + context_id=getattr(self.agent.context, "id", "") if self.agent.context else "",
53 )
54 except Exception as exc:
55 - PrintStyle().error(f"Office document affordance failed: {exc}")
55 + PrintStyle().error(f"Document affordance failed: {exc}")
56 return
57
58 payload = {
@@ -66,7 +66,7 @@ class DocumentResponseAffordance(Extension):
66 self.agent.hist_add_tool_result("document_artifact", content, **additional)
67 self.agent.loop_data.params_persistent[HANDOFF_CREATED_FLAG] = True
68
69 - display_path = display_workspace_path(doc["path"])
69 + display_path = document_store.display_path(doc["path"])
70 note = document_affordance.format_created_response(doc["basename"], display_path)
71 response.message = note
72 tool.args["text"] = note
@@ -90,11 +90,11 @@ class DocumentResponseAffordance(Extension):
90 def public_doc(doc: dict[str, Any]) -> dict[str, Any]:
91 return {
92 "file_id": doc["file_id"],
93 - "path": display_workspace_path(doc["path"]),
93 + "path": document_store.display_path(doc["path"]),
94 "basename": doc["basename"],
95 "extension": doc["extension"],
96 "size": doc["size"],
97 - "version": wopi_store.item_version(doc),
97 + "version": document_store.item_version(doc),
98 "last_modified": doc["last_modified"],
99 "exists": Path(doc["path"]).exists(),
100 }
@@ -107,17 +107,6 @@ def document_additional(doc: dict[str, Any]) -> dict[str, Any]:
107 "file_id": doc["file_id"],
108 "title": doc["basename"],
109 "format": doc["extension"],
110 - "path": display_workspace_path(doc["path"]),
111 - "version": wopi_store.item_version(doc),
110 + "path": document_store.display_path(doc["path"]),
111 + "version": document_store.item_version(doc),
112 }
113 -
114 -
115 -def display_workspace_path(path: str) -> str:
116 - base = Path(files.get_base_dir()).resolve(strict=False)
117 - resolved = Path(path).resolve(strict=False)
118 - if str(base).startswith("/a0"):
119 - return str(resolved)
120 - try:
121 - return "/a0/" + str(resolved.relative_to(base)).lstrip("/")
122 - except ValueError:
123 - return str(path)
plugins/_office/extensions/python/webui_ws_disconnect/_50_office.py new
+24
@@ -0,0 +1,24 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from helpers.extension import Extension
6 +from plugins._office.api.ws_office import WsOffice
7 +
8 +
9 +class OfficeWebuiWsDisconnect(Extension):
10 + async def execute(
11 + self,
12 + instance: Any = None,
13 + sid: str = "",
14 + **kwargs: Any,
15 + ) -> None:
16 + if instance is None:
17 + return
18 + handler = WsOffice(
19 + instance.socketio,
20 + instance.lock,
21 + manager=instance.manager,
22 + namespace=instance.namespace,
23 + )
24 + await handler.on_disconnect(sid)
plugins/_office/extensions/python/webui_ws_event/_50_office.py new
+47
@@ -0,0 +1,47 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from helpers.extension import Extension
6 +from helpers.ws_manager import WsResult
7 +from plugins._office.api.ws_office import WsOffice
8 +
9 +
10 +class OfficeWebuiWsEvents(Extension):
11 + async def execute(
12 + self,
13 + instance: Any = None,
14 + sid: str = "",
15 + event_type: str = "",
16 + data: dict[str, Any] | None = None,
17 + response_data: dict[str, Any] | None = None,
18 + **kwargs: Any,
19 + ) -> None:
20 + if not event_type.startswith("office_") or instance is None or response_data is None:
21 + return
22 +
23 + handler = WsOffice(
24 + instance.socketio,
25 + instance.lock,
26 + manager=instance.manager,
27 + namespace=instance.namespace,
28 + )
29 + result = await handler.process(event_type, data or {}, sid)
30 + if result is None:
31 + return
32 +
33 + if isinstance(result, WsResult):
34 + payload = result.as_result(
35 + handler_id=handler.identifier,
36 + fallback_correlation_id=(data or {}).get("correlationId"),
37 + )
38 + if payload.get("ok"):
39 + response_data.update(payload.get("data") or {})
40 + else:
41 + response_data["office_error"] = payload.get("error") or {
42 + "code": "OFFICE_ERROR",
43 + "error": "Office request failed",
44 + }
45 + return
46 +
47 + response_data.update(result)
plugins/_office/helpers/artifact_editor.py
+65 -17
@@ -10,7 +10,7 @@ from typing import Any
10 from xml.sax.saxutils import escape
11 import xml.etree.ElementTree as ET
12
13 -from plugins._office.helpers import wopi_store
13 +from plugins._office.helpers import document_store
14
15
16 W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
@@ -38,16 +38,16 @@ def read_artifact(doc: dict[str, Any], max_chars: int = 12000) -> dict[str, Any]
38 """Extract compact editable content from an Office artifact."""
39 path = Path(doc["path"])
40 ext = str(doc["extension"]).lower()
41 - if ext == "docx":
41 + if ext == "md":
42 + content = _read_markdown(path)
43 + elif ext == "docx":
44 content = _read_docx(path)
45 elif ext == "xlsx":
46 content = _read_xlsx(path)
47 elif ext == "pptx":
48 content = _read_pptx(path)
47 - elif ext in {"odt", "ods", "odp"}:
48 - content = _read_odf(path)
49 else:
50 - raise ValueError(f"Unsupported Office format: {ext}")
50 + raise ValueError(f"Unsupported document format: {ext}")
51
52 return _trim_payload(content, max_chars=max_chars)
53
@@ -71,21 +71,31 @@ def edit_artifact(
71 op = normalize_operation(operation, content=content, find=find, cells=cells, rows=rows, chart=chart, slides=slides)
72 before = path.read_bytes()
73
74 - if ext == "docx":
74 + invalidate_sessions = bool(kwargs.pop("invalidate_sessions", False))
75 + if ext == "md":
76 + updated, details = _edit_markdown(before, op, content=content, find=find, replace=replace, **kwargs)
77 + elif ext == "docx":
78 updated, details = _edit_docx(before, op, content=content, find=find, replace=replace, **kwargs)
79 elif ext == "xlsx":
80 updated, details = _edit_xlsx(path, op, content=content, find=find, replace=replace, sheet=sheet, cells=cells, rows=rows, chart=chart, **kwargs)
81 elif ext == "pptx":
82 updated, details = _edit_pptx(before, op, content=content, find=find, replace=replace, slides=slides, **kwargs)
83 else:
81 - raise ValueError(f"Direct edit is not available for .{ext}. Use Collabora in the Office canvas.")
84 + raise ValueError(f"Direct edit is not available for .{ext}.")
85
86 changed = updated != before
87 updated_doc = (
85 - wopi_store.replace_document_bytes(doc["file_id"], updated, actor="document_artifact:edit")
88 + document_store.replace_document_bytes(
89 + doc["file_id"],
90 + updated,
91 + actor="document_artifact:edit",
92 + invalidate_sessions=invalidate_sessions,
93 + )
94 if changed
95 else doc
96 )
97 + if changed:
98 + _refresh_open_editor_sessions(updated_doc["file_id"])
99 preview = read_artifact(updated_doc, max_chars=int(kwargs.get("preview_chars") or 4000))
100 payload = {
101 "ok": True,
@@ -98,6 +108,16 @@ def edit_artifact(
108 return updated_doc, payload
109
110
111 +def _refresh_open_editor_sessions(file_id: str) -> None:
112 + try:
113 + from plugins._office.helpers import libreofficekit_sessions
114 +
115 + libreofficekit_sessions.get_manager().refresh_document(file_id)
116 + except Exception:
117 + # Direct artifact edits should never fail just because no canvas is open.
118 + return
119 +
120 +
121 def normalize_operation(
122 operation: str,
123 *,
@@ -146,6 +166,19 @@ def normalize_operation(
166 raise ValueError("operation is required")
167
168
169 +def _read_markdown(path: Path) -> dict[str, Any]:
170 + text = path.read_text(encoding="utf-8", errors="replace")
171 + lines = [line for line in text.splitlines() if line.strip()]
172 + headings = [line.lstrip("#").strip() for line in lines if line.lstrip().startswith("#")]
173 + return {
174 + "kind": "document",
175 + "format": "markdown",
176 + "line_count": len(text.splitlines()),
177 + "headings": headings[:40],
178 + "text": text,
179 + }
180 +
181 +
182 def _read_docx(path: Path) -> dict[str, Any]:
183 with zipfile.ZipFile(path) as archive:
184 xml = archive.read("word/document.xml")
@@ -213,15 +246,30 @@ def _read_pptx(path: Path) -> dict[str, Any]:
246 }
247
248
216 -def _read_odf(path: Path) -> dict[str, Any]:
217 - with zipfile.ZipFile(path) as archive:
218 - xml = archive.read("content.xml")
219 - root = ET.fromstring(xml)
220 - text = "\n".join((node.text or "").strip() for node in root.iter() if (node.text or "").strip())
221 - return {
222 - "kind": "office_document",
223 - "text": text,
224 - }
249 +def _edit_markdown(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
250 + if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}:
251 + raise ValueError(f"Unsupported Markdown operation: {op}")
252 +
253 + text = before.decode("utf-8", errors="replace")
254 + if op == "set_text":
255 + updated = content
256 + details = {"lines_written": len(content.splitlines())}
257 + elif op == "append_text":
258 + separator = "" if not text or text.endswith("\n") else "\n"
259 + updated = f"{text}{separator}{content}"
260 + details = {"lines_appended": len(content.splitlines())}
261 + elif op == "prepend_text":
262 + separator = "" if not text or content.endswith("\n") else "\n"
263 + updated = f"{content}{separator}{text}"
264 + details = {"lines_prepended": len(content.splitlines())}
265 + else:
266 + if not find:
267 + raise ValueError("find is required for replace_text")
268 + replacement = "" if op == "delete_text" else replace
269 + count_limit = _int_or_none(kwargs.get("count"))
270 + updated, count = _replace_limited(text, find, replacement, count_limit)
271 + details = {"replacements": count}
272 + return updated.encode("utf-8"), details
273
274
275 def _edit_docx(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
plugins/_office/helpers/canvas_context.py
+5 -5
@@ -2,21 +2,21 @@ from __future__ import annotations
2
3 from typing import Any
4
5 -from plugins._office.helpers import wopi_store
5 +from plugins._office.helpers import document_store
6
7
8 def build_context(max_items: int = 6) -> str:
9 - documents = wopi_store.get_open_documents(limit=max_items)
9 + documents = document_store.get_open_documents(limit=max_items)
10 if not documents:
11 return ""
12
13 lines = [
14 - "These Office files have active canvas sessions. Content is omitted; load skill `office-artifacts` for edit workflow, then use document_artifact:read before content-sensitive edits.",
14 + "These document artifacts have active canvas sessions. Content is omitted; load skill `office-artifacts` for edit workflow, then use document_artifact:read before content-sensitive edits.",
15 ]
16 for doc in documents:
17 lines.append(format_document_line(doc))
18 lines.append(
19 - "Use document_artifact:edit with file_id or path for saved edits; tool results refresh the Office canvas."
19 + "Use document_artifact:edit with file_id or path for saved edits; tool results refresh the document canvas."
20 )
21 return "\n".join(lines)
22
@@ -25,7 +25,7 @@ def format_document_line(doc: dict[str, Any]) -> str:
25 return (
26 f"- {doc.get('basename', 'Untitled')} "
27 f"(.{doc.get('extension', '')}, file_id={doc.get('file_id', '')}, "
28 - f"path={doc.get('path', '')}, version={wopi_store.item_version(doc)}, "
28 + f"path={document_store.display_path(doc.get('path', ''))}, version={document_store.item_version(doc)}, "
29 f"size={doc.get('size', 0)} bytes, last_modified={doc.get('last_modified', '')}, "
30 f"open_sessions={doc.get('open_sessions', 1)})"
31 )
plugins/_office/helpers/collabora_runtime.py deleted
-348
@@ -1,348 +0,0 @@
1 -from __future__ import annotations
2 -
3 -import fcntl
4 -import os
5 -import shutil
6 -import subprocess
7 -import threading
8 -import time
9 -from pathlib import Path
10 -
11 -from plugins._office.helpers import collabora_status as status
12 -
13 -
14 -LOCK_FILE = status.RUNTIME_DIR / "bootstrap.lock"
15 -WRAPPER_FILE = status.RUNTIME_DIR / "run_coolwsd.sh"
16 -SUPERVISOR_CONF = Path("/etc/supervisor/conf.d/a0_office_collabora.conf")
17 -SUPERVISOR_INCLUDE_PATTERN = "/etc/supervisor/conf.d/a0_office_*.conf"
18 -SOURCES_FILE = Path("/etc/apt/sources.list.d/collaboraonline.sources")
19 -KEYRING_FILE = Path("/etc/apt/keyrings/collaboraonline-release-keyring.gpg")
20 -
21 -_worker_lock = threading.Lock()
22 -_worker: threading.Thread | None = None
23 -
24 -
25 -def start_bootstrap_worker(force: bool = False) -> bool:
26 - global _worker
27 - with _worker_lock:
28 - if _worker and _worker.is_alive():
29 - return False
30 - _worker = threading.Thread(target=bootstrap, kwargs={"force": force}, name="a0-office-collabora-bootstrap", daemon=True)
31 - _worker.start()
32 - return True
33 -
34 -
35 -def bootstrap(force: bool = False) -> None:
36 - status.ensure_dirs()
37 - with LOCK_FILE.open("w", encoding="utf-8") as lock:
38 - try:
39 - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
40 - except BlockingIOError:
41 - status.append_log("bootstrap already running")
42 - return
43 -
44 - try:
45 - _bootstrap_locked(force=force)
46 - except Exception as exc:
47 - status.append_log(f"bootstrap failed: {exc}")
48 - status.write_status("failed", healthy=False, installing=False, message=str(exc))
49 -
50 -
51 -def _bootstrap_locked(force: bool = False) -> None:
52 - status.write_status("installing", healthy=False, installing=True, message="Preparing Collabora Online")
53 - status.append_log("bootstrap start")
54 -
55 - _write_wrapper()
56 - _write_supervisor_conf()
57 - _reread_supervisor()
58 -
59 - if status.packages_installed() and not force:
60 - status.append_log("coolwsd and code-brand already installed")
61 - _restart_supervisor()
62 - _finish_status()
63 - return
64 -
65 - if not _can_install():
66 - status.write_status("degraded", healthy=False, installing=False, message="Container does not support automatic apt installation")
67 - _restart_supervisor()
68 - return
69 -
70 - _ensure_code_repo()
71 - _wait_for_apt_locks()
72 - _run(["apt-get", "update"], timeout=600)
73 - _run([
74 - "apt-get",
75 - "install",
76 - "-y",
77 - "--no-install-recommends",
78 - "coolwsd",
79 - "coolwsd-deprecated",
80 - "code-brand",
81 - ], timeout=1800, env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"})
82 -
83 - _restart_supervisor()
84 - _finish_status()
85 -
86 -
87 -def reconcile() -> None:
88 - wrapper_changed = _write_wrapper()
89 - supervisor_changed = _write_supervisor_conf()
90 - if supervisor_changed:
91 - _reread_supervisor()
92 - current = status.collect_status()
93 - if current.get("healthy"):
94 - if wrapper_changed or supervisor_changed:
95 - status.append_log("Collabora runtime configuration changed; restarting service")
96 - _restart_supervisor()
97 - time.sleep(1)
98 - current = status.collect_status()
99 - status.write_status("healthy", healthy=True, installed=True, installing=False, degraded=False, message="Collabora Online is healthy")
100 - return
101 - if current.get("installed"):
102 - _reread_supervisor()
103 - _restart_supervisor()
104 - status.write_status("degraded", healthy=False, installing=False, message="Collabora is installed but not healthy")
105 - return
106 - start_bootstrap_worker(force=False)
107 -
108 -
109 -def retry_bootstrap() -> None:
110 - start_bootstrap_worker(force=True)
111 -
112 -
113 -def _finish_status() -> None:
114 - for _ in range(12):
115 - current = status.collect_status()
116 - if current.get("healthy"):
117 - status.write_status("healthy", healthy=True, installed=True, installing=False, degraded=False, message="Collabora Online is healthy")
118 - return
119 - time.sleep(2)
120 - current = status.collect_status()
121 - state = "degraded" if current.get("installed") else "failed"
122 - status.write_status(
123 - state,
124 - healthy=False,
125 - installed=bool(current.get("installed")),
126 - installing=False,
127 - degraded=bool(current.get("installed")),
128 - message="Collabora did not become healthy yet",
129 - )
130 -
131 -
132 -def _can_install() -> bool:
133 - return os.geteuid() == 0 and shutil.which("apt-get") is not None and shutil.which("dpkg") is not None
134 -
135 -
136 -def _ensure_code_repo() -> None:
137 - KEYRING_FILE.parent.mkdir(parents=True, exist_ok=True)
138 - if not KEYRING_FILE.exists():
139 - _run([
140 - "wget",
141 - "-O",
142 - str(KEYRING_FILE),
143 - "https://collaboraoffice.com/downloads/gpg/collaboraonline-release-keyring.gpg",
144 - ], timeout=300)
145 - SOURCES_FILE.write_text(
146 - "\n".join([
147 - "Types: deb",
148 - "URIs: https://www.collaboraoffice.com/repos/CollaboraOnline/CODE-deb",
149 - "Suites: ./",
150 - f"Signed-By: {KEYRING_FILE}",
151 - "",
152 - ]),
153 - encoding="utf-8",
154 - )
155 -
156 -
157 -def _wait_for_apt_locks(timeout: int = 180) -> None:
158 - locks = [
159 - "/var/lib/dpkg/lock-frontend",
160 - "/var/lib/dpkg/lock",
161 - "/var/lib/apt/lists/lock",
162 - "/var/cache/apt/archives/lock",
163 - ]
164 - deadline = time.time() + timeout
165 - while time.time() < deadline:
166 - busy = False
167 - for lock_path in locks:
168 - try:
169 - fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
170 - try:
171 - fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
172 - fcntl.lockf(fd, fcntl.LOCK_UN)
173 - except OSError:
174 - busy = True
175 - finally:
176 - os.close(fd)
177 - except OSError:
178 - continue
179 - if not busy:
180 - return
181 - status.append_log("waiting for apt/dpkg locks")
182 - time.sleep(3)
183 - raise TimeoutError("Timed out waiting for apt/dpkg locks")
184 -
185 -
186 -def _write_wrapper() -> bool:
187 - status.ensure_dirs()
188 - changed = _write_text_if_changed(
189 - WRAPPER_FILE,
190 - """#!/usr/bin/env bash
191 -set -u
192 -LOG="/a0/tmp/_office/collabora/coolwsd-wrapper.log"
193 -LOG_DIR="$(dirname "$LOG")"
194 -mkdir -p "$LOG_DIR" /opt/cool/cache /opt/cool/child-roots
195 -while true; do
196 - if ! command -v coolwsd >/dev/null 2>&1; then
197 - echo "$(date -u +%FT%TZ) coolwsd missing; sleeping" >> "$LOG"
198 - sleep 20
199 - continue
200 - fi
201 - if ! id cool >/dev/null 2>&1; then
202 - echo "$(date -u +%FT%TZ) cool user missing; sleeping" >> "$LOG"
203 - sleep 20
204 - continue
205 - fi
206 - chown -R cool:cool "$LOG_DIR" /opt/cool/cache /opt/cool/child-roots 2>/dev/null || true
207 - args=(
208 - --o:sys_template_path=/opt/cool/systemplate
209 - --o:child_root_path=/opt/cool/child-roots
210 - --o:file_server_root_path=/usr/share/coolwsd
211 - --o:cache_files.path=/opt/cool/cache
212 - --o:ssl.enable=false
213 - --o:ssl.termination=false
214 - --o:net.listen=loopback
215 - --o:net.proto=IPv4
216 - --o:net.service_root=/office
217 - --o:home_mode.enable=true
218 - )
219 - if command -v runuser >/dev/null 2>&1; then
220 - runuser -u cool -- /usr/bin/coolwsd "${args[@]}" >> "$LOG" 2>&1 &
221 - else
222 - su -s /bin/bash cool -c 'exec /usr/bin/coolwsd "$@"' coolwsd "${args[@]}" >> "$LOG" 2>&1 &
223 - fi
224 - child=$!
225 - trap 'kill -TERM "$child" 2>/dev/null; wait "$child" 2>/dev/null; exit 0' TERM INT
226 - wait "$child"
227 - code=$?
228 - echo "$(date -u +%FT%TZ) coolwsd exited with ${code}; restarting after backoff" >> "$LOG"
229 - sleep 5
230 -done
231 -""",
232 - )
233 - WRAPPER_FILE.chmod(0o755)
234 - return changed
235 -
236 -
237 -def _write_supervisor_conf() -> bool:
238 - include_changed = _ensure_supervisor_include()
239 - if not os.access("/etc/supervisor/conf.d", os.W_OK):
240 - status.append_log("supervisor conf directory is not writable")
241 - return include_changed
242 - conf_changed = _write_text_if_changed(
243 - SUPERVISOR_CONF,
244 - f"""[program:{status.SUPERVISOR_PROGRAM}]
245 -command={WRAPPER_FILE}
246 -autostart=true
247 -autorestart=true
248 -startsecs=0
249 -startretries=999999
250 -stopsignal=TERM
251 -stdout_logfile=/a0/tmp/_office/collabora/supervisor.log
252 -stderr_logfile=/a0/tmp/_office/collabora/supervisor.err.log
253 -""",
254 - )
255 - return include_changed or conf_changed
256 -
257 -
258 -def _ensure_supervisor_include() -> bool:
259 - active_config = _active_supervisor_config()
260 - if not active_config or not active_config.exists() or not os.access(active_config, os.W_OK):
261 - return False
262 - try:
263 - text = active_config.read_text(encoding="utf-8")
264 - except OSError:
265 - return False
266 - if SUPERVISOR_INCLUDE_PATTERN in text:
267 - return False
268 - if "\n[include]\n" in f"\n{text}":
269 - updated = _append_to_include_files(text, SUPERVISOR_INCLUDE_PATTERN)
270 - else:
271 - updated = text.rstrip() + "\n\n[include]\nfiles = " + SUPERVISOR_INCLUDE_PATTERN + "\n"
272 - if updated != text:
273 - active_config.write_text(updated, encoding="utf-8")
274 - return True
275 - return False
276 -
277 -
278 -def _active_supervisor_config() -> Path | None:
279 - cmdline = Path("/proc/1/cmdline")
280 - try:
281 - parts = [part for part in cmdline.read_text(encoding="utf-8").split("\x00") if part]
282 - except OSError:
283 - return None
284 - for index, part in enumerate(parts):
285 - if part == "-c" and index + 1 < len(parts):
286 - return Path(parts[index + 1])
287 - if part.startswith("-c") and len(part) > 2:
288 - return Path(part[2:])
289 - return Path("/etc/supervisor/supervisord.conf")
290 -
291 -
292 -def _append_to_include_files(text: str, pattern: str) -> str:
293 - lines = text.splitlines()
294 - in_include = False
295 - for index, line in enumerate(lines):
296 - stripped = line.strip()
297 - if stripped.startswith("[") and stripped.endswith("]"):
298 - in_include = stripped.lower() == "[include]"
299 - continue
300 - if in_include and stripped.startswith("files"):
301 - separator = " " if line.rstrip().endswith("=") else " "
302 - lines[index] = line.rstrip() + separator + pattern
303 - return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
304 - return text.rstrip() + "\nfiles = " + pattern + "\n"
305 -
306 -
307 -def _write_text_if_changed(path: Path, text: str) -> bool:
308 - try:
309 - if path.exists() and path.read_text(encoding="utf-8") == text:
310 - return False
311 - except OSError:
312 - pass
313 - path.write_text(text, encoding="utf-8")
314 - return True
315 -
316 -
317 -def _reread_supervisor() -> None:
318 - if not shutil.which("supervisorctl"):
319 - return
320 - _run(["supervisorctl", "reread"], timeout=20, check=False)
321 - _run(["supervisorctl", "update", status.SUPERVISOR_PROGRAM], timeout=30, check=False)
322 -
323 -
324 -def _restart_supervisor() -> None:
325 - if not shutil.which("supervisorctl"):
326 - return
327 - _run(["supervisorctl", "restart", status.SUPERVISOR_PROGRAM], timeout=30, check=False)
328 -
329 -
330 -def _run(args: list[str], timeout: int, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
331 - status.append_log("$ " + " ".join(args))
332 - result = subprocess.run(
333 - args,
334 - text=True,
335 - stdout=subprocess.PIPE,
336 - stderr=subprocess.STDOUT,
337 - timeout=timeout,
338 - env=env,
339 - check=False,
340 - )
341 - if result.stdout:
342 - with status.BOOTSTRAP_LOG.open("a", encoding="utf-8") as handle:
343 - handle.write(result.stdout)
344 - if not result.stdout.endswith("\n"):
345 - handle.write("\n")
346 - if check and result.returncode != 0:
347 - raise RuntimeError(f"{' '.join(args)} failed with exit {result.returncode}")
348 - return result
plugins/_office/helpers/collabora_status.py deleted
-181
@@ -1,181 +0,0 @@
1 -from __future__ import annotations
2 -
3 -import json
4 -import os
5 -import shutil
6 -import subprocess
7 -import time
8 -from pathlib import Path
9 -from typing import Any
10 -from urllib.request import Request, urlopen
11 -
12 -from helpers import files
13 -
14 -
15 -PLUGIN_NAME = "_office"
16 -RUNTIME_DIR = Path(files.get_abs_path("tmp", PLUGIN_NAME, "collabora"))
17 -STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "collabora"))
18 -STATUS_FILE = RUNTIME_DIR / "status.json"
19 -BOOTSTRAP_LOG = RUNTIME_DIR / "bootstrap.log"
20 -WRAPPER_LOG = RUNTIME_DIR / "coolwsd-wrapper.log"
21 -SUPERVISOR_PROGRAM = "a0_office_collabora"
22 -
23 -
24 -def ensure_dirs() -> None:
25 - RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
26 - STATE_DIR.mkdir(parents=True, exist_ok=True)
27 - (STATE_DIR / "backups").mkdir(parents=True, exist_ok=True)
28 -
29 -
30 -def now_ts() -> float:
31 - return time.time()
32 -
33 -
34 -def read_status() -> dict[str, Any]:
35 - ensure_dirs()
36 - if not STATUS_FILE.exists():
37 - return default_status("idle")
38 - try:
39 - data = json.loads(STATUS_FILE.read_text(encoding="utf-8"))
40 - if isinstance(data, dict):
41 - return {**default_status("idle"), **data}
42 - except Exception:
43 - pass
44 - return default_status("idle")
45 -
46 -
47 -def write_status(state: str, **extra: Any) -> dict[str, Any]:
48 - ensure_dirs()
49 - payload = {
50 - **read_status(),
51 - "state": state,
52 - "updated_at": now_ts(),
53 - **extra,
54 - }
55 - STATUS_FILE.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
56 - return payload
57 -
58 -
59 -def default_status(state: str = "idle") -> dict[str, Any]:
60 - return {
61 - "plugin": PLUGIN_NAME,
62 - "state": state,
63 - "healthy": False,
64 - "installed": False,
65 - "installing": False,
66 - "degraded": False,
67 - "message": "",
68 - "updated_at": 0,
69 - "runtime_dir": str(RUNTIME_DIR),
70 - "state_dir": str(STATE_DIR),
71 - "status_file": str(STATUS_FILE),
72 - "bootstrap_log": str(BOOTSTRAP_LOG),
73 - "wrapper_log": str(WRAPPER_LOG),
74 - }
75 -
76 -
77 -def append_log(message: str) -> None:
78 - ensure_dirs()
79 - line = f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} {message}\n"
80 - with BOOTSTRAP_LOG.open("a", encoding="utf-8") as handle:
81 - handle.write(line)
82 -
83 -
84 -def tail_file(path: Path, max_bytes: int = 16000) -> str:
85 - try:
86 - with path.open("rb") as handle:
87 - handle.seek(0, os.SEEK_END)
88 - size = handle.tell()
89 - handle.seek(max(0, size - max_bytes))
90 - return handle.read().decode("utf-8", errors="replace")
91 - except FileNotFoundError:
92 - return ""
93 - except Exception as exc:
94 - return f"Could not read log: {exc}"
95 -
96 -
97 -def run_command(args: list[str], timeout: int = 10) -> subprocess.CompletedProcess[str]:
98 - return subprocess.run(
99 - args,
100 - text=True,
101 - stdout=subprocess.PIPE,
102 - stderr=subprocess.STDOUT,
103 - timeout=timeout,
104 - check=False,
105 - )
106 -
107 -
108 -def command_exists(name: str) -> bool:
109 - return shutil.which(name) is not None
110 -
111 -
112 -def package_installed(name: str) -> bool:
113 - if not command_exists("dpkg-query"):
114 - return False
115 - result = run_command(["dpkg-query", "-W", "-f=${Status}", name], timeout=8)
116 - return result.returncode == 0 and "install ok installed" in result.stdout
117 -
118 -
119 -def packages_installed() -> bool:
120 - return (
121 - command_exists("coolwsd")
122 - and command_exists("coolforkit-caps")
123 - and package_installed("coolwsd")
124 - and package_installed("coolwsd-deprecated")
125 - and package_installed("code-brand")
126 - )
127 -
128 -
129 -def supervisor_status() -> str:
130 - if not command_exists("supervisorctl"):
131 - return "supervisorctl unavailable"
132 - result = run_command(["supervisorctl", "status", SUPERVISOR_PROGRAM], timeout=8)
133 - return (result.stdout or "").strip() or f"exit {result.returncode}"
134 -
135 -
136 -def process_status() -> str:
137 - if not command_exists("pgrep"):
138 - return ""
139 - result = run_command(["pgrep", "-a", "coolwsd"], timeout=8)
140 - return (result.stdout or "").strip()
141 -
142 -
143 -def discovery_ok() -> bool:
144 - for url in (
145 - "http://127.0.0.1:9980/office/hosting/discovery",
146 - "http://127.0.0.1:9980/hosting/discovery",
147 - ):
148 - try:
149 - request = Request(url, headers={"User-Agent": "Agent-Zero-Office/1.0"})
150 - with urlopen(request, timeout=5) as response:
151 - body = response.read(256)
152 - if response.status == 200 and b"wopi-discovery" in body.lower():
153 - return True
154 - except Exception:
155 - continue
156 - return False
157 -
158 -
159 -def collect_status() -> dict[str, Any]:
160 - ensure_dirs()
161 - installed = packages_installed()
162 - supervisor = supervisor_status()
163 - process = process_status()
164 - http_ok = discovery_ok()
165 - healthy = installed and http_ok
166 - saved = read_status()
167 - installing = saved.get("state") == "installing"
168 - state = "healthy" if healthy else ("installing" if installing else ("degraded" if installed else saved.get("state") or "idle"))
169 - return {
170 - **saved,
171 - "state": state,
172 - "healthy": healthy,
173 - "installed": installed,
174 - "installing": installing and not healthy,
175 - "degraded": installed and not healthy,
176 - "coolwsd_path": shutil.which("coolwsd") or "",
177 - "supervisor": supervisor,
178 - "process": process,
179 - "discovery_ok": http_ok,
180 - "updated_at": now_ts(),
181 - }
plugins/_office/helpers/document_affordance.py
+11 -11
@@ -40,6 +40,7 @@ DOCUMENT_TERMS = {
40 "guide",
41 "letter",
42 "manual",
43 + "markdown",
44 "memo",
45 "policy",
46 "proposal",
@@ -86,9 +87,8 @@ DELIVERABLE_TERMS = {
87
88 EXPLICIT_FORMAT_TERMS = {
89 "docx",
89 - "odt",
90 - "ods",
91 - "odp",
90 + "md",
91 + "markdown",
92 "pptx",
93 "xlsx",
94 }
@@ -97,12 +97,10 @@ HANDOFF_TERMS = {
97 "artifact",
98 "artifacts",
99 "canvas",
100 + "document canvas",
101 "downloadable",
102 "editable",
102 - "in office",
103 - "office canvas",
103 "open it",
105 - "open in office",
104 "save it",
105 "save this",
106 }
@@ -205,7 +203,9 @@ def infer_kind_and_format(lowered_user: str) -> tuple[str, str]:
203 return "presentation", "pptx"
204 if has_any(lowered_user, SPREADSHEET_TERMS):
205 return "spreadsheet", "xlsx"
208 - return "document", "docx"
206 + if has_any(lowered_user, {"docx"}):
207 + return "document", "docx"
208 + return "document", "md"
209
210
211 def artifact_intent(lowered_user: str, response_text: str) -> str | None:
@@ -235,14 +235,14 @@ def has_explicit_handoff_signal(lowered_user: str) -> bool:
235 return True
236 if re.search(
237 r"\b(?:convert|format|save|turn)\b(?:\W+\w+){0,8}?\W+(?:as|to|into)\s+"
238 - r"(?:a|an|the)?\s*(?:doc|document|spreadsheet|workbook|presentation|deck|slides|docx|xlsx|pptx)\b",
238 + r"(?:a|an|the)?\s*(?:doc|document|markdown|spreadsheet|workbook|presentation|deck|slides|md|docx|xlsx|pptx)\b",
239 lowered_user,
240 ):
241 return True
242 return bool(re.search(
243 r"\b(?:write|draft|compose|create|generate|prepare|produce|make|build|author|format)\b"
244 r"(?:\s+(?:me|us|a|an|the|new|blank|editable|office|word|excel|powerpoint))*"
245 - r"\s+(?:doc|document|spreadsheet|workbook|presentation|deck|slides)\b",
245 + r"\s+(?:doc|document|markdown|spreadsheet|workbook|presentation|deck|slides)\b",
246 lowered_user,
247 ))
248
@@ -263,7 +263,7 @@ def looks_like_tool_or_status_response(text: str) -> bool:
263 stripped = text.strip()
264 if stripped.startswith("{") and '"tool_name"' in stripped[:300]:
265 return True
266 - if "/a0/usr/workdir/documents/" in stripped:
266 + if "/a0/usr/workdir/" in stripped or "/a0/usr/projects/" in stripped:
267 return True
268 return False
269
@@ -348,6 +348,6 @@ def clean_title(value: str) -> str:
348
349 def format_created_response(basename: str, path: str) -> str:
350 return (
351 - f"Created **{basename}** and opened it in the Office canvas.\n\n"
351 + f"Created **{basename}** and opened it in the document canvas.\n\n"
352 f"Path: `{path}`"
353 )
plugins/_office/helpers/document_store.py renamed
+203 -310
@@ -6,8 +6,6 @@ import io
6 import json
7 import os
8 import re
9 -import secrets
10 -import shutil
9 import sqlite3
10 import time
11 import uuid
@@ -22,12 +20,9 @@ from helpers import files
20
21
22 PLUGIN_NAME = "_office"
25 -SUPPORTED_EXTENSIONS = {"docx", "xlsx", "pptx", "odt", "ods", "odp"}
23 +SUPPORTED_EXTENSIONS = {"md", "docx", "xlsx", "pptx"}
24 DEFAULT_TTL_SECONDS = 8 * 60 * 60
27 -DEFAULT_LOCK_SECONDS = 30 * 60
25 ORPHAN_SESSION_GRACE_SECONDS = 30
29 -MAX_LOCK_SECONDS = 3600
30 -MIN_LOCK_SECONDS = 60
26 MAX_SAVE_BYTES = 512 * 1024 * 1024
27 PREVIEW_LINE_LIMIT = 5
28 PREVIEW_ROW_LIMIT = 5
@@ -38,11 +33,11 @@ W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
33 A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
34 X_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
35
41 -STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "collabora"))
36 +STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents"))
37 DB_PATH = STATE_DIR / "documents.sqlite3"
38 BACKUP_DIR = STATE_DIR / "backups"
44 -DOCUMENTS_DIR = Path(files.get_abs_path("usr", "workdir", "documents"))
39 WORKDIR = Path(files.get_abs_path("usr", "workdir"))
40 +DOCUMENTS_DIR = WORKDIR / "documents"
41
42
43 def now() -> float:
@@ -56,17 +51,12 @@ def now_iso() -> str:
51 def ensure_dirs() -> None:
52 STATE_DIR.mkdir(parents=True, exist_ok=True)
53 BACKUP_DIR.mkdir(parents=True, exist_ok=True)
59 - DOCUMENTS_DIR.mkdir(parents=True, exist_ok=True)
54
55
56 def sha256_bytes(data: bytes) -> str:
57 return hashlib.sha256(data).hexdigest()
58
59
66 -def token_hash(token: str) -> str:
67 - return hashlib.sha256(token.encode("utf-8")).hexdigest()
68 -
69 -
60 def safe_title(title: str, fallback: str = "Document") -> str:
61 cleaned = "".join(ch if ch.isalnum() or ch in " ._-" else "_" for ch in title).strip(" ._")
62 return cleaned or fallback
@@ -74,27 +64,111 @@ def safe_title(title: str, fallback: str = "Document") -> str:
64
65 def normalize_extension(value: str) -> str:
66 ext = value.lower().strip().lstrip(".")
67 + if not ext:
68 + ext = "md"
69 if ext not in SUPPORTED_EXTENSIONS:
78 - raise ValueError(f"Unsupported Office format: {ext}")
70 + if ext == "odt":
71 + raise ValueError("ODT editing is not supported in this migration. Use Markdown or DOCX.")
72 + raise ValueError(f"Unsupported document format: {ext}")
73 return ext
74
75
82 -def normalize_path(path: str | Path) -> Path:
76 +def document_home(context_id: str = "") -> Path:
77 + context_id = str(context_id or "").strip()
78 + if context_id:
79 + try:
80 + from agent import AgentContext
81 +
82 + context = AgentContext.get(context_id)
83 + project_helpers = _projects()
84 + project_name = project_helpers.get_context_project_name(context) if context else None
85 + if project_name:
86 + return Path(project_helpers.get_project_folder(project_name)).resolve(strict=False)
87 + except Exception:
88 + pass
89 +
90 + configured = str(_settings().get_settings().get("workdir_path") or "").strip()
91 + if configured:
92 + return _path_from_a0(configured).resolve(strict=False)
93 + return WORKDIR.resolve(strict=False)
94 +
95 +
96 +def document_binary_home(context_id: str = "") -> Path:
97 + if str(context_id or "").strip():
98 + return document_home(context_id) / "documents"
99 + return DOCUMENTS_DIR.resolve(strict=False)
100 +
101 +
102 +def default_open_path(context_id: str = "") -> str:
103 + return display_path(document_home(context_id))
104 +
105 +
106 +def display_path(path: str | Path) -> str:
107 + resolved = Path(path).resolve(strict=False)
108 + base = Path(files.get_base_dir()).resolve(strict=False)
109 + if str(base).startswith("/a0"):
110 + return str(resolved)
111 + try:
112 + return "/a0/" + str(resolved.relative_to(base)).lstrip("/")
113 + except ValueError:
114 + return str(path)
115 +
116 +
117 +def _path_from_a0(path: str | Path) -> Path:
118 raw = str(path)
119 if raw.startswith("/a0/") and not files.get_base_dir().startswith("/a0"):
120 raw = files.get_abs_path(raw.removeprefix("/a0/"))
86 - candidate = Path(raw if os.path.isabs(raw) else files.get_abs_path(raw))
87 - resolved = candidate.expanduser().resolve(strict=False)
88 - allowed_roots = [WORKDIR.resolve(strict=False)]
89 - if not any(os.path.commonpath([str(resolved), str(root)]) == str(root) for root in allowed_roots):
90 - raise PermissionError("Office documents must be inside /a0/usr/workdir")
121 + return Path(raw if os.path.isabs(raw) else files.get_abs_path(raw)).expanduser()
122 +
123 +
124 +def allowed_roots(context_id: str = "") -> list[Path]:
125 + project_helpers = _projects()
126 + roots = {
127 + WORKDIR.resolve(strict=False),
128 + DOCUMENTS_DIR.resolve(strict=False),
129 + Path(project_helpers.get_projects_parent_folder()).resolve(strict=False),
130 + document_home(context_id).resolve(strict=False),
131 + document_binary_home(context_id).resolve(strict=False),
132 + }
133 + configured = str(_settings().get_settings().get("workdir_path") or "").strip()
134 + if configured:
135 + roots.add(_path_from_a0(configured).resolve(strict=False))
136 + return sorted(roots, key=lambda item: str(item))
137 +
138 +
139 +def _projects() -> Any:
140 + from helpers import projects
141 +
142 + return projects
143 +
144 +
145 +def _settings() -> Any:
146 + from helpers import settings
147 +
148 + return settings
149 +
150 +
151 +def normalize_path(path: str | Path, context_id: str = "") -> Path:
152 + candidate = _path_from_a0(path)
153 + resolved = candidate.resolve(strict=False)
154 + roots = allowed_roots(context_id)
155 + if not any(_is_relative_to(resolved, root) for root in roots):
156 + raise PermissionError("Document artifacts must stay inside the active project or workdir.")
157 if candidate.exists():
158 real = candidate.resolve(strict=True)
93 - if not any(os.path.commonpath([str(real), str(root)]) == str(root) for root in allowed_roots):
94 - raise PermissionError("Office document symlink escapes the workdir")
159 + if not any(_is_relative_to(real, root) for root in roots):
160 + raise PermissionError("Document artifact symlink escapes the active project or workdir.")
161 return resolved
162
163
164 +def _is_relative_to(path: Path, root: Path) -> bool:
165 + try:
166 + os.path.commonpath([str(path), str(root)])
167 + except ValueError:
168 + return False
169 + return os.path.commonpath([str(path), str(root)]) == str(root)
170 +
171 +
172 @contextmanager
173 def connect() -> Any:
174 ensure_dirs()
@@ -135,23 +209,6 @@ def init_db(conn: sqlite3.Connection) -> None:
209 created_at REAL NOT NULL,
210 expires_at REAL NOT NULL
211 );
138 - CREATE TABLE IF NOT EXISTS tokens (
139 - token_hash TEXT PRIMARY KEY,
140 - file_id TEXT NOT NULL,
141 - session_id TEXT NOT NULL,
142 - user_id TEXT NOT NULL,
143 - permission TEXT NOT NULL,
144 - source_path TEXT NOT NULL,
145 - created_at REAL NOT NULL,
146 - expires_at REAL NOT NULL
147 - );
148 - CREATE TABLE IF NOT EXISTS locks (
149 - file_id TEXT PRIMARY KEY,
150 - lock_value TEXT NOT NULL,
151 - expires_at REAL NOT NULL,
152 - session_id TEXT NOT NULL,
153 - updated_at REAL NOT NULL
154 - );
212 CREATE TABLE IF NOT EXISTS versions (
213 id INTEGER PRIMARY KEY AUTOINCREMENT,
214 file_id TEXT NOT NULL,
@@ -172,8 +229,8 @@ def init_db(conn: sqlite3.Connection) -> None:
229 )
230
231
175 -def register_document(path: str | Path, owner_id: str = "a0") -> dict[str, Any]:
176 - resolved = normalize_path(path)
232 +def register_document(path: str | Path, owner_id: str = "a0", context_id: str = "") -> dict[str, Any]:
233 + resolved = normalize_path(path, context_id=context_id)
234 if not resolved.exists():
235 raise FileNotFoundError(str(resolved))
236 ext = normalize_extension(resolved.suffix.lstrip("."))
@@ -251,7 +308,32 @@ def get_open_documents(limit: int = 6) -> list[dict[str, Any]]:
308 """,
309 (now(), limit),
310 ).fetchall()
254 - return [dict(row) for row in rows]
311 + return [with_preview(dict(row)) for row in rows]
312 +
313 +
314 +def create_session(
315 + file_id: str,
316 + user_id: str = "agent-zero-user",
317 + permission: str = "write",
318 + origin: str = "",
319 + ttl_seconds: int = DEFAULT_TTL_SECONDS,
320 +) -> dict[str, Any]:
321 + permission = "write" if permission == "write" else "read"
322 + created = now()
323 + expires = created + ttl_seconds
324 + session_id = uuid.uuid4().hex
325 + with connect() as conn:
326 + conn.execute(
327 + "INSERT INTO sessions (session_id, file_id, user_id, permission, origin, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
328 + (session_id, file_id, user_id, permission, origin, created, expires),
329 + )
330 + return {
331 + "session_id": session_id,
332 + "file_id": file_id,
333 + "expires_at": expires,
334 + "permission": permission,
335 + "origin": origin,
336 + }
337
338
339 def close_session(session_id: str = "", file_id: str = "") -> int:
@@ -266,9 +348,7 @@ def close_session(session_id: str = "", file_id: str = "") -> int:
348 row = conn.execute("SELECT * FROM sessions WHERE session_id = ?", (session_id,)).fetchone()
349 if not row:
350 return 0
269 - conn.execute("DELETE FROM tokens WHERE session_id = ?", (session_id,))
351 conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
271 - conn.execute("DELETE FROM locks WHERE session_id = ?", (session_id,))
352 conn.execute(
353 "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
354 (row["file_id"], "close_session", json.dumps({"session_id": session_id}), now()),
@@ -276,9 +356,7 @@ def close_session(session_id: str = "", file_id: str = "") -> int:
356 return 1
357
358 rows = conn.execute("SELECT session_id FROM sessions WHERE file_id = ?", (file_id,)).fetchall()
279 - conn.execute("DELETE FROM tokens WHERE file_id = ?", (file_id,))
359 conn.execute("DELETE FROM sessions WHERE file_id = ?", (file_id,))
281 - conn.execute("DELETE FROM locks WHERE file_id = ?", (file_id,))
360 conn.execute(
361 "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
362 (file_id, "close_document_sessions", json.dumps({"closed": len(rows)}), now()),
@@ -305,54 +383,15 @@ def sync_open_sessions(active_session_ids: list[str] | tuple[str, ...] | set[str
383
384 session_ids = tuple(row["session_id"] for row in rows)
385 placeholders = ",".join("?" for _ in session_ids)
308 - conn.execute(f"DELETE FROM tokens WHERE session_id IN ({placeholders})", session_ids)
386 conn.execute(f"DELETE FROM sessions WHERE session_id IN ({placeholders})", session_ids)
310 - conn.execute(f"DELETE FROM locks WHERE session_id IN ({placeholders})", session_ids)
311 -
387 for row in rows:
388 conn.execute(
389 "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
315 - (
316 - row["file_id"],
317 - "close_orphan_session",
318 - json.dumps({"session_id": row["session_id"]}),
319 - now(),
320 - ),
390 + (row["file_id"], "close_orphan_session", json.dumps({"session_id": row["session_id"]}), now()),
391 )
392 return len(rows)
393
394
325 -def create_session(file_id: str, user_id: str, permission: str, origin: str, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> dict[str, Any]:
326 - permission = "write" if permission == "write" else "read"
327 - token = secrets.token_urlsafe(32)
328 - created = now()
329 - expires = created + ttl_seconds
330 - doc = get_document(file_id)
331 - session_id = uuid.uuid4().hex
332 - with connect() as conn:
333 - conn.execute(
334 - "INSERT INTO sessions (session_id, file_id, user_id, permission, origin, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
335 - (session_id, file_id, user_id, permission, origin, created, expires),
336 - )
337 - conn.execute(
338 - """
339 - INSERT INTO tokens
340 - (token_hash, file_id, session_id, user_id, permission, source_path, created_at, expires_at)
341 - VALUES (?, ?, ?, ?, ?, ?, ?, ?)
342 - """,
343 - (token_hash(token), file_id, session_id, user_id, permission, doc["path"], created, expires),
344 - )
345 - return {
346 - "session_id": session_id,
347 - "file_id": file_id,
348 - "access_token": token,
349 - "access_token_ttl": int(expires * 1000),
350 - "expires_at": expires,
351 - "permission": permission,
352 - "origin": origin,
353 - }
354 -
355 -
395 def with_preview(document: dict[str, Any]) -> dict[str, Any]:
396 return {**document, "preview": build_preview(document)}
397
@@ -370,6 +409,9 @@ def build_preview(document: dict[str, Any]) -> dict[str, Any]:
409 if not path.exists():
410 return preview
411 try:
412 + if ext == "md":
413 + lines = _preview_markdown(path)
414 + return {**preview, "available": bool(lines), "lines": lines}
415 if ext == "docx":
416 lines = _preview_docx(path)
417 return {**preview, "available": bool(lines), "lines": lines}
@@ -379,20 +421,17 @@ def build_preview(document: dict[str, Any]) -> dict[str, Any]:
421 if ext == "pptx":
422 slides = _preview_pptx(path)
423 return {**preview, "available": bool(slides), "slides": slides}
382 - if ext in {"odt", "ods", "odp"}:
383 - lines = _preview_odf(path)
384 - return {**preview, "available": bool(lines), "lines": lines}
424 except Exception:
425 return preview
426 return preview
427
428
429 def _preview_kind(ext: str) -> str:
391 - if ext in {"xlsx", "ods"}:
430 + if ext == "xlsx":
431 return "spreadsheet"
393 - if ext in {"pptx", "odp"}:
432 + if ext == "pptx":
433 return "presentation"
395 - if ext in {"docx", "odt"}:
434 + if ext in {"md", "docx"}:
435 return "document"
436 return "file"
437
@@ -405,7 +444,22 @@ def _clean_preview_text(value: Any) -> str:
444 return re.sub(r"\s+", " ", str(value or "")).strip()
445
446
447 +def _preview_markdown(path: Path) -> list[str]:
448 + lines = []
449 + for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
450 + text = _clean_preview_text(raw.lstrip("#>-*0123456789.[]() "))
451 + if text:
452 + lines.append(text)
453 + if len(lines) >= PREVIEW_LINE_LIMIT:
454 + break
455 + return lines
456 +
457 +
458 def _preview_docx(path: Path) -> list[str]:
459 + return _docx_paragraphs(path, limit=PREVIEW_LINE_LIMIT)
460 +
461 +
462 +def _docx_paragraphs(path: Path, limit: int | None = None) -> list[str]:
463 with zipfile.ZipFile(path) as archive:
464 root = ET.fromstring(archive.read("word/document.xml"))
465 lines = []
@@ -413,11 +467,25 @@ def _preview_docx(path: Path) -> list[str]:
467 text = _clean_preview_text("".join(node.text or "" for node in paragraph.iter(_qn(W_NS, "t"))))
468 if text:
469 lines.append(text)
416 - if len(lines) >= PREVIEW_LINE_LIMIT:
470 + if limit is not None and len(lines) >= limit:
471 break
472 return lines
473
474
475 +def read_text_for_editor(doc: dict[str, Any]) -> str:
476 + path = Path(doc["path"])
477 + ext = str(doc["extension"]).lower()
478 + if ext == "md":
479 + return path.read_text(encoding="utf-8", errors="replace")
480 + if ext == "docx":
481 + return "\n\n".join(_docx_paragraphs(path))
482 + raise ValueError(f"Text editing is not available for .{ext}.")
483 +
484 +
485 +def write_markdown(file_id: str, content: str) -> dict[str, Any]:
486 + return replace_document_bytes(file_id, str(content or "").encode("utf-8"), actor="office:markdown")
487 +
488 +
489 def _preview_xlsx(path: Path) -> list[list[str]]:
490 with zipfile.ZipFile(path) as archive:
491 shared_strings = _xlsx_shared_strings(archive)
@@ -487,19 +555,6 @@ def _preview_pptx(path: Path) -> list[dict[str, Any]]:
555 return slides
556
557
490 -def _preview_odf(path: Path) -> list[str]:
491 - with zipfile.ZipFile(path) as archive:
492 - root = ET.fromstring(archive.read("content.xml"))
493 - lines = []
494 - for node in root.iter():
495 - text = _clean_preview_text(node.text)
496 - if text:
497 - lines.append(text)
498 - if len(lines) >= PREVIEW_LINE_LIMIT:
499 - break
500 - return lines
501 -
502 -
558 def _natural_name_key(value: str) -> list[int | str]:
559 return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", value)]
560
@@ -508,10 +563,10 @@ def replace_document_bytes(
563 file_id: str,
564 data: bytes,
565 actor: str = "agent",
511 - invalidate_sessions: bool = True,
566 + invalidate_sessions: bool = False,
567 ) -> dict[str, Any]:
568 if len(data) > MAX_SAVE_BYTES:
514 - raise OverflowError("Office save exceeds maximum size")
569 + raise OverflowError("Document save exceeds maximum size")
570 with connect() as conn:
571 doc = get_document(file_id, conn=conn)
572 path = Path(doc["path"])
@@ -533,167 +588,18 @@ def replace_document_bytes(
588 (len(data), next_version, digest, now_iso(), changed_at, file_id),
589 )
590 if invalidate_sessions:
536 - conn.execute("DELETE FROM locks WHERE file_id = ?", (file_id,))
537 - conn.execute("DELETE FROM tokens WHERE file_id = ?", (file_id,))
591 conn.execute("DELETE FROM sessions WHERE file_id = ?", (file_id,))
592 conn.execute(
593 "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
541 - (
542 - file_id,
543 - "direct_edit",
544 - json.dumps({"actor": actor, "version": f"{next_version}-{digest[:12]}"}),
545 - changed_at,
546 - ),
594 + (file_id, "saved", json.dumps({"actor": actor, "version": f"{next_version}-{digest[:12]}"}), changed_at),
595 )
596 return get_document(file_id, conn=conn)
597
598
551 -def validate_token(raw_token: str, file_id: str, require_write: bool = False) -> dict[str, Any]:
552 - if not raw_token:
553 - raise PermissionError("Missing WOPI access token")
554 - with connect() as conn:
555 - row = conn.execute("SELECT * FROM tokens WHERE token_hash = ?", (token_hash(raw_token),)).fetchone()
556 - if not row or row["file_id"] != file_id:
557 - raise PermissionError("Invalid WOPI access token")
558 - if row["expires_at"] < now():
559 - raise PermissionError("Expired WOPI access token")
560 - if require_write and row["permission"] != "write":
561 - raise PermissionError("WOPI token is read-only")
562 - session = conn.execute("SELECT * FROM sessions WHERE session_id = ?", (row["session_id"],)).fetchone()
563 - return {"token": dict(row), "session": dict(session) if session else {}}
564 -
565 -
566 -def check_file_info(file_id: str, token_info: dict[str, Any]) -> dict[str, Any]:
567 - doc = get_document(file_id)
568 - session = token_info.get("session") or {}
569 - can_write = (token_info.get("token") or {}).get("permission") == "write"
570 - origin = session.get("origin") or "http://localhost:32080"
571 - info = {
572 - "BaseFileName": doc["basename"],
573 - "OwnerId": doc["owner_id"],
574 - "Size": int(doc["size"]),
575 - "Version": item_version(doc),
576 - "UserId": session.get("user_id") or "agent-zero-user",
577 - "UserFriendlyName": "Agent Zero",
578 - "UserCanWrite": bool(can_write),
579 - "ReadOnly": not bool(can_write),
580 - "SupportsLocks": True,
581 - "SupportsUpdate": True,
582 - "SupportsExtendedLockLength": True,
583 - "SupportsGetLock": True,
584 - "UserCanNotWriteRelative": True,
585 - "PostMessageOrigin": origin,
586 - "ClosePostMessage": True,
587 - "CloseUrl": origin.rstrip("/") + "/",
588 - "LastModifiedTime": doc["last_modified"],
589 - }
590 - return {key: value for key, value in info.items() if value is not None}
591 -
592 -
599 def item_version(doc: dict[str, Any]) -> str:
600 return f"{int(doc['version'])}-{str(doc['sha256'])[:12]}"
601
602
597 -def get_lock(file_id: str) -> str:
598 - with connect() as conn:
599 - _clear_expired_locks(conn)
600 - row = conn.execute("SELECT lock_value FROM locks WHERE file_id = ?", (file_id,)).fetchone()
601 - return row["lock_value"] if row else ""
602 -
603 -
604 -def lock(file_id: str, lock_value: str, session_id: str, timeout_seconds: int) -> tuple[bool, str]:
605 - timeout_seconds = clamp_lock_timeout(timeout_seconds)
606 - with connect() as conn:
607 - _clear_expired_locks(conn)
608 - row = conn.execute("SELECT * FROM locks WHERE file_id = ?", (file_id,)).fetchone()
609 - if row and row["lock_value"] != lock_value:
610 - return False, row["lock_value"]
611 - expires = now() + timeout_seconds
612 - conn.execute(
613 - """
614 - INSERT INTO locks (file_id, lock_value, expires_at, session_id, updated_at)
615 - VALUES (?, ?, ?, ?, ?)
616 - ON CONFLICT(file_id) DO UPDATE SET lock_value=excluded.lock_value, expires_at=excluded.expires_at, session_id=excluded.session_id, updated_at=excluded.updated_at
617 - """,
618 - (file_id, lock_value, expires, session_id, now()),
619 - )
620 - return True, lock_value
621 -
622 -
623 -def refresh_lock(file_id: str, lock_value: str, timeout_seconds: int) -> tuple[bool, str]:
624 - timeout_seconds = clamp_lock_timeout(timeout_seconds)
625 - with connect() as conn:
626 - _clear_expired_locks(conn)
627 - row = conn.execute("SELECT * FROM locks WHERE file_id = ?", (file_id,)).fetchone()
628 - if not row or row["lock_value"] != lock_value:
629 - return False, row["lock_value"] if row else ""
630 - conn.execute(
631 - "UPDATE locks SET expires_at = ?, updated_at = ? WHERE file_id = ?",
632 - (now() + timeout_seconds, now(), file_id),
633 - )
634 - return True, lock_value
635 -
636 -
637 -def unlock(file_id: str, lock_value: str) -> tuple[bool, str]:
638 - with connect() as conn:
639 - _clear_expired_locks(conn)
640 - row = conn.execute("SELECT * FROM locks WHERE file_id = ?", (file_id,)).fetchone()
641 - if not row:
642 - return True, ""
643 - if row["lock_value"] != lock_value:
644 - return False, row["lock_value"]
645 - conn.execute("DELETE FROM locks WHERE file_id = ?", (file_id,))
646 - return True, ""
647 -
648 -
649 -def unlock_and_relock(file_id: str, old_lock: str, new_lock: str, session_id: str, timeout_seconds: int) -> tuple[bool, str]:
650 - with connect() as conn:
651 - _clear_expired_locks(conn)
652 - row = conn.execute("SELECT * FROM locks WHERE file_id = ?", (file_id,)).fetchone()
653 - if row and row["lock_value"] != old_lock:
654 - return False, row["lock_value"]
655 - expires = now() + clamp_lock_timeout(timeout_seconds)
656 - conn.execute(
657 - """
658 - INSERT INTO locks (file_id, lock_value, expires_at, session_id, updated_at)
659 - VALUES (?, ?, ?, ?, ?)
660 - ON CONFLICT(file_id) DO UPDATE SET lock_value=excluded.lock_value, expires_at=excluded.expires_at, session_id=excluded.session_id, updated_at=excluded.updated_at
661 - """,
662 - (file_id, new_lock, expires, session_id, now()),
663 - )
664 - return True, new_lock
665 -
666 -
667 -def put_file(file_id: str, data: bytes, lock_value: str) -> str:
668 - if len(data) > MAX_SAVE_BYTES:
669 - raise OverflowError("Office save exceeds maximum size")
670 - with connect() as conn:
671 - _clear_expired_locks(conn)
672 - doc = get_document(file_id, conn=conn)
673 - current_lock = conn.execute("SELECT lock_value FROM locks WHERE file_id = ?", (file_id,)).fetchone()
674 - current = current_lock["lock_value"] if current_lock else ""
675 - path = Path(doc["path"])
676 - if current and current != lock_value:
677 - raise LockMismatch(current)
678 - if not current and int(doc["size"]) > 0:
679 - raise LockMismatch("")
680 -
681 - previous = path.read_bytes() if path.exists() else b""
682 - _record_version(conn, file_id, path, item_version(doc), previous)
683 - _write_atomic(path, data)
684 - digest = sha256_bytes(data)
685 - next_version = int(doc["version"]) + 1
686 - conn.execute(
687 - """
688 - UPDATE documents
689 - SET size=?, version=?, sha256=?, last_modified=?, updated_at=?
690 - WHERE file_id=?
691 - """,
692 - (len(data), next_version, digest, now_iso(), now(), file_id),
693 - )
694 - return f"{next_version}-{digest[:12]}"
695 -
696 -
603 def _write_atomic(path: Path, data: bytes) -> None:
604 path.parent.mkdir(parents=True, exist_ok=True)
605 tmp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
@@ -708,29 +614,8 @@ def _write_atomic(path: Path, data: bytes) -> None:
614 tmp_path.unlink(missing_ok=True)
615
616
711 -class LockMismatch(Exception):
712 - def __init__(self, current_lock: str) -> None:
713 - super().__init__("WOPI lock mismatch")
714 - self.current_lock = current_lock
715 -
716 -
717 -def clamp_lock_timeout(value: int | str | None) -> int:
718 - try:
719 - seconds = int(value or DEFAULT_LOCK_SECONDS)
720 - except (TypeError, ValueError):
721 - seconds = DEFAULT_LOCK_SECONDS
722 - return max(MIN_LOCK_SECONDS, min(MAX_LOCK_SECONDS, seconds))
723 -
724 -
725 -def _clear_expired_locks(conn: sqlite3.Connection) -> None:
726 - conn.execute("DELETE FROM locks WHERE expires_at < ?", (now(),))
727 -
728 -
617 def _clear_expired_sessions(conn: sqlite3.Connection) -> None:
730 - current = now()
731 - conn.execute("DELETE FROM tokens WHERE expires_at < ?", (current,))
732 - conn.execute("DELETE FROM sessions WHERE expires_at < ?", (current,))
733 - conn.execute("DELETE FROM locks WHERE expires_at < ?", (current,))
618 + conn.execute("DELETE FROM sessions WHERE expires_at < ?", (now(),))
619
620
621 def _record_version(conn: sqlite3.Connection, file_id: str, path: Path, version: str, data: bytes) -> None:
@@ -763,7 +648,7 @@ def restore_version(file_id: str, version_id: int) -> dict[str, Any]:
648 data = Path(row["path"]).read_bytes()
649 path = Path(doc["path"])
650 _record_version(conn, file_id, path, item_version(doc), path.read_bytes() if path.exists() else b"")
766 - path.write_bytes(data)
651 + _write_atomic(path, data)
652 digest = sha256_bytes(data)
653 next_version = int(doc["version"]) + 1
654 conn.execute(
@@ -773,53 +658,69 @@ def restore_version(file_id: str, version_id: int) -> dict[str, Any]:
658 return get_document(file_id, conn=conn)
659
660
776 -def create_document(kind: str, title: str, fmt: str, content: str = "", path: str = "") -> dict[str, Any]:
777 - ext = normalize_extension(fmt)
778 - target = normalize_path(path) if path else _unique_document_path(title, ext)
661 +def create_document(
662 + kind: str,
663 + title: str,
664 + fmt: str = "md",
665 + content: str = "",
666 + path: str = "",
667 + context_id: str = "",
668 +) -> dict[str, Any]:
669 + ext = normalize_extension(fmt or "md")
670 + target = normalize_path(path, context_id=context_id) if path else _unique_document_path(title, ext, context_id=context_id)
671 target.parent.mkdir(parents=True, exist_ok=True)
672 if target.exists():
673 raise FileExistsError(str(target))
674 data = template_bytes(kind, ext, title, content)
783 - target.write_bytes(data)
784 - return register_document(target)
675 + _write_atomic(target, data)
676 + return register_document(target, context_id=context_id)
677
678
787 -def _unique_document_path(title: str, ext: str) -> Path:
679 +def _unique_document_path(title: str, ext: str, context_id: str = "") -> Path:
680 base = safe_title(title, "Document")
789 - candidate = DOCUMENTS_DIR / f"{base}.{ext}"
681 + root = document_home(context_id) if ext == "md" else document_binary_home(context_id)
682 + candidate = root / f"{base}.{ext}"
683 index = 2
684 while candidate.exists():
792 - candidate = DOCUMENTS_DIR / f"{base} {index}.{ext}"
685 + candidate = root / f"{base} {index}.{ext}"
686 index += 1
687 return candidate.resolve(strict=False)
688
689
690 def template_bytes(kind: str, ext: str, title: str, content: str) -> bytes:
691 + ext = normalize_extension(ext or "md")
692 + if ext == "md":
693 + return _markdown(title, content).encode("utf-8")
694 if ext == "docx":
695 return _docx(title, content)
696 if ext == "xlsx":
697 return _xlsx(title, content)
698 if ext == "pptx":
699 return _pptx(title, content)
804 - if ext in {"odt", "ods", "odp"}:
805 - return _odf(ext, title, content)
700 raise ValueError(ext)
701
702
809 -def _zip_bytes(files_map: dict[str, str | bytes], stored: set[str] | None = None) -> bytes:
703 +def _markdown(title: str, content: str) -> str:
704 + text = str(content or "").strip()
705 + if text:
706 + return text if text.startswith("#") else f"# {title}\n\n{text}\n"
707 + return f"# {title}\n"
708 +
709 +
710 +def _zip_bytes(files_map: dict[str, str | bytes]) -> bytes:
711 buffer = io.BytesIO()
712 with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
713 for name, value in files_map.items():
714 data = value.encode("utf-8") if isinstance(value, str) else value
814 - info = zipfile.ZipInfo(name)
815 - info.compress_type = zipfile.ZIP_STORED if stored and name in stored else zipfile.ZIP_DEFLATED
816 - archive.writestr(info, data)
715 + archive.writestr(name, data)
716 return buffer.getvalue()
717
718
719 def _docx(title: str, content: str) -> bytes:
720 lines = [title] + [line for line in content.splitlines() if line.strip()]
822 - body = "".join(f"<w:p><w:r><w:t>{escape(line)}</w:t></w:r></w:p>" for line in lines)
721 + if len(lines) == 1:
722 + lines.append("")
723 + body = "".join(_docx_paragraph(line) for line in lines)
724 return _zip_bytes({
725 "[Content_Types].xml": """<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>""",
726 "_rels/.rels": """<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>""",
@@ -827,6 +728,12 @@ def _docx(title: str, content: str) -> bytes:
728 })
729
730
731 +def _docx_paragraph(line: str) -> str:
732 + if not str(line).strip():
733 + return '<w:p><w:r><w:t xml:space="preserve">&#160;</w:t></w:r></w:p>'
734 + return f"<w:p><w:r><w:t>{escape(line)}</w:t></w:r></w:p>"
735 +
736 +
737 def _xlsx(title: str, content: str) -> bytes:
738 rows = _xlsx_rows(title, content)
739 sheet_rows = "".join(
@@ -930,17 +837,3 @@ def _pptx(title: str, content: str) -> bytes:
837 "ppt/presentation.xml": """<?xml version="1.0" encoding="UTF-8"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst><p:sldSz cx="9144000" cy="5143500"/></p:presentation>""",
838 "ppt/slides/slide1.xml": f"""<?xml version="1.0" encoding="UTF-8"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/><p:sp><p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>{escape(title)}</a:t></a:r></a:p><a:p><a:r><a:t>{escape(subtitle)}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>""",
839 })
933 -
934 -
935 -def _odf(ext: str, title: str, content: str) -> bytes:
936 - mime = {
937 - "odt": "application/vnd.oasis.opendocument.text",
938 - "ods": "application/vnd.oasis.opendocument.spreadsheet",
939 - "odp": "application/vnd.oasis.opendocument.presentation",
940 - }[ext]
941 - body = f"<text:p>{escape(title)}</text:p><text:p>{escape(content)}</text:p>"
942 - return _zip_bytes({
943 - "mimetype": mime,
944 - "META-INF/manifest.xml": f"""<?xml version="1.0" encoding="UTF-8"?><manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><manifest:file-entry manifest:media-type="{mime}" manifest:full-path="/"/><manifest:file-entry manifest:media-type="text/xml" manifest:full-path="content.xml"/></manifest:manifest>""",
945 - "content.xml": f"""<?xml version="1.0" encoding="UTF-8"?><office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" office:version="1.2"><office:body><office:text>{body}</office:text></office:body></office:document-content>""",
946 - }, stored={"mimetype"})
plugins/_office/helpers/libreoffice.py new
+163
@@ -0,0 +1,163 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import shutil
5 +import subprocess
6 +import sys
7 +import tempfile
8 +import zipfile
9 +from functools import lru_cache
10 +from pathlib import Path
11 +from typing import Any
12 +
13 +
14 +SOFFICE_BINARIES = ("soffice", "libreoffice")
15 +CONVERT_TIMEOUT_SECONDS = 45
16 +
17 +
18 +def find_soffice() -> str:
19 + for name in SOFFICE_BINARIES:
20 + path = shutil.which(name)
21 + if path:
22 + return path
23 + return ""
24 +
25 +
26 +def collect_status() -> dict[str, Any]:
27 + soffice = find_soffice()
28 + status = {
29 + "ok": True,
30 + "state": "healthy" if soffice else "missing",
31 + "healthy": bool(soffice),
32 + "soffice": soffice,
33 + "libreofficekit": _libreofficekit_available(),
34 + "message": "LibreOffice is available." if soffice else "LibreOffice is not installed in this runtime.",
35 + }
36 + try:
37 + from plugins._office.helpers import libreoffice_desktop
38 +
39 + status["desktop"] = libreoffice_desktop.collect_desktop_status()
40 + except Exception as exc:
41 + status["desktop"] = {"ok": False, "healthy": False, "error": str(exc)}
42 + return status
43 +
44 +
45 +@lru_cache(maxsize=1)
46 +def _libreofficekit_available() -> bool:
47 + system_dist_packages = Path("/usr/lib/python3/dist-packages")
48 + if system_dist_packages.exists() and str(system_dist_packages) not in sys.path:
49 + sys.path.append(str(system_dist_packages))
50 + try:
51 + import gi # type: ignore
52 +
53 + gi.require_version("LOKDocView", "0.1")
54 + return True
55 + except Exception:
56 + return _lokdocview_typelib_available()
57 +
58 +
59 +def _lokdocview_typelib_available() -> bool:
60 + candidates = [
61 + Path("/usr/lib/x86_64-linux-gnu/girepository-1.0/LOKDocView-0.1.typelib"),
62 + Path("/usr/lib/aarch64-linux-gnu/girepository-1.0/LOKDocView-0.1.typelib"),
63 + Path("/usr/share/gir-1.0/LOKDocView-0.1.gir"),
64 + ]
65 + return any(path.exists() for path in candidates)
66 +
67 +
68 +def validate_docx(path: str | Path) -> dict[str, Any]:
69 + source = Path(path)
70 + if not source.exists():
71 + return {"ok": False, "error": f"File not found: {source}"}
72 + try:
73 + with zipfile.ZipFile(source) as archive:
74 + archive.getinfo("[Content_Types].xml")
75 + archive.getinfo("word/document.xml")
76 + except Exception as exc:
77 + return {"ok": False, "error": f"DOCX package validation failed: {exc}"}
78 +
79 + soffice = find_soffice()
80 + if not soffice:
81 + return {"ok": True, "warning": "LibreOffice binary was not available; package validation only."}
82 +
83 + with tempfile.TemporaryDirectory(prefix="a0-office-validate-") as temp_dir:
84 + result = _run_soffice(
85 + soffice,
86 + [
87 + "--headless",
88 + "--safe-mode",
89 + "--convert-to",
90 + "pdf",
91 + "--outdir",
92 + temp_dir,
93 + str(source),
94 + ],
95 + timeout=CONVERT_TIMEOUT_SECONDS,
96 + )
97 + if result.returncode != 0:
98 + return {"ok": False, "error": _format_process_error(result)}
99 + return {"ok": True}
100 +
101 +
102 +def convert_document(path: str | Path, target_format: str, output_dir: str | Path | None = None) -> dict[str, Any]:
103 + source = Path(path)
104 + if not source.exists():
105 + return {"ok": False, "error": f"File not found: {source}"}
106 + soffice = find_soffice()
107 + if not soffice:
108 + return {"ok": False, "error": "LibreOffice is not installed in this runtime."}
109 +
110 + target_format = str(target_format or "").lower().strip().lstrip(".")
111 + if not target_format:
112 + return {"ok": False, "error": "target_format is required."}
113 +
114 + destination_dir = Path(output_dir) if output_dir else source.parent
115 + destination_dir.mkdir(parents=True, exist_ok=True)
116 + before = {item.name for item in destination_dir.iterdir()} if destination_dir.exists() else set()
117 + result = _run_soffice(
118 + soffice,
119 + [
120 + "--headless",
121 + "--safe-mode",
122 + "--convert-to",
123 + target_format,
124 + "--outdir",
125 + str(destination_dir),
126 + str(source),
127 + ],
128 + timeout=CONVERT_TIMEOUT_SECONDS,
129 + )
130 + if result.returncode != 0:
131 + return {"ok": False, "error": _format_process_error(result)}
132 +
133 + expected = destination_dir / f"{source.stem}.{target_format}"
134 + if expected.exists():
135 + return {"ok": True, "path": str(expected)}
136 +
137 + created = [item for item in destination_dir.iterdir() if item.name not in before]
138 + if created:
139 + return {"ok": True, "path": str(created[0])}
140 + return {"ok": False, "error": "LibreOffice completed without producing an output file."}
141 +
142 +
143 +def _run_soffice(soffice: str, args: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
144 + env = {
145 + **os.environ,
146 + "HOME": os.environ.get("HOME") or "/tmp",
147 + "SAL_USE_VCLPLUGIN": os.environ.get("SAL_USE_VCLPLUGIN") or "gen",
148 + }
149 + return subprocess.run(
150 + [soffice, *args],
151 + check=False,
152 + text=True,
153 + capture_output=True,
154 + timeout=timeout,
155 + env=env,
156 + )
157 +
158 +
159 +def _format_process_error(result: subprocess.CompletedProcess[str]) -> str:
160 + details = (result.stderr or result.stdout or "").strip()
161 + if details:
162 + return f"LibreOffice exited with {result.returncode}: {details}"
163 + return f"LibreOffice exited with {result.returncode}."
plugins/_office/helpers/libreoffice_desktop.py new
+1308
@@ -0,0 +1,1308 @@
1 +from __future__ import annotations
2 +
3 +import atexit
4 +import json
5 +import os
6 +import shutil
7 +import socket
8 +import subprocess
9 +import threading
10 +import time
11 +import uuid
12 +import xml.etree.ElementTree as ET
13 +from dataclasses import dataclass, field
14 +from pathlib import Path
15 +from typing import Any
16 +
17 +from helpers import files, virtual_desktop
18 +from plugins._office.helpers import document_store, libreoffice
19 +
20 +
21 +OFFICIAL_EXTENSIONS = {"docx", "xlsx", "pptx"}
22 +SYSTEM_SESSION_ID = "agent-zero-desktop"
23 +SYSTEM_FILE_ID = "system-desktop"
24 +SYSTEM_TITLE = "Desktop"
25 +STATE_DIR = Path(files.get_abs_path("tmp", "_office", "desktop"))
26 +SESSION_DIR = STATE_DIR / "sessions"
27 +PROFILE_DIR = STATE_DIR / "profiles"
28 +DISPLAY_BASE = 120
29 +XPRA_PORT_BASE = 14500
30 +MAX_SESSIONS = 12
31 +DEFAULT_SCREEN_WIDTH = virtual_desktop.DEFAULT_WIDTH
32 +DEFAULT_SCREEN_HEIGHT = virtual_desktop.DEFAULT_HEIGHT
33 +MAX_SCREEN_WIDTH = virtual_desktop.MAX_WIDTH
34 +MAX_SCREEN_HEIGHT = virtual_desktop.MAX_HEIGHT
35 +BLOCKING_DIALOG_TITLES = ("Remote Files", "File Services")
36 +DISPLAY_START_TIMEOUT_SECONDS = 30.0
37 +PORT_START_TIMEOUT_SECONDS = 30.0
38 +STARTUP_GRACE_SECONDS = 45
39 +HIDDEN_XPRA_DESKTOP_ENTRIES = (
40 + "xpra.desktop",
41 + "xpra-gui.desktop",
42 + "xpra-launcher.desktop",
43 + "xpra-shadow.desktop",
44 + "xpra-start.desktop",
45 +)
46 +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 +
54 +
55 +@dataclass
56 +class DesktopSession:
57 + session_id: str
58 + file_id: str
59 + extension: str
60 + path: str
61 + title: str
62 + display: int
63 + xpra_port: int
64 + token: str
65 + url: str
66 + profile_dir: Path
67 + width: int = DEFAULT_SCREEN_WIDTH
68 + height: int = DEFAULT_SCREEN_HEIGHT
69 + processes: dict[str, subprocess.Popen[Any]] = field(default_factory=dict)
70 + started_at: float = field(default_factory=time.time)
71 +
72 + def alive(self) -> bool:
73 + return _running(self.processes.get("xpra"))
74 +
75 + def public(self, doc: dict[str, Any] | None = None) -> dict[str, Any]:
76 + title = str(doc.get("basename") or "") if doc else self.title
77 + path = str(doc.get("path") or "") if doc else self.path
78 + extension = str(doc.get("extension") or "") if doc else self.extension
79 + file_id = str(doc.get("file_id") or "") if doc else self.file_id
80 + return {
81 + "available": True,
82 + "session_id": self.session_id,
83 + "file_id": file_id,
84 + "extension": extension,
85 + "title": title,
86 + "path": document_store.display_path(path),
87 + "url": self.url,
88 + "token": self.token,
89 + "display": f":{self.display}",
90 + "desktop_path": virtual_desktop.SESSION_PATH,
91 + "width": self.width,
92 + "height": self.height,
93 + "started_at": self.started_at,
94 + }
95 +
96 +
97 +class LibreOfficeDesktopManager:
98 + def __init__(self) -> None:
99 + self._lock = threading.RLock()
100 + self._sessions: dict[str, DesktopSession] = {}
101 +
102 + def ensure_system_desktop(self) -> dict[str, Any]:
103 + try:
104 + with self._lock:
105 + self._reap_dead_locked()
106 + session = self._ensure_system_desktop_locked()
107 + return session.public()
108 + except Exception as exc:
109 + status = collect_desktop_status()
110 + return {
111 + "available": False,
112 + "error": str(exc),
113 + "status": status,
114 + }
115 +
116 + def open(self, doc: dict[str, Any]) -> dict[str, Any]:
117 + ext = str(doc.get("extension") or "").lower()
118 + if ext not in OFFICIAL_EXTENSIONS:
119 + return {"available": False, "reason": f".{ext} does not use the LibreOffice desktop surface."}
120 +
121 + with self._lock:
122 + self._reap_dead_locked()
123 + try:
124 + session = self._ensure_system_desktop_locked()
125 + except Exception as exc:
126 + status = collect_desktop_status()
127 + return {
128 + "available": False,
129 + "error": str(exc),
130 + "status": status,
131 + }
132 + self._open_document_locked(session, doc)
133 + session.file_id = str(doc["file_id"])
134 + session.extension = ext
135 + session.path = str(doc["path"])
136 + session.title = str(doc["basename"])
137 + self._write_manifest(session)
138 + return session.public(doc)
139 +
140 + def save(self, session_id: str, file_id: str = "") -> dict[str, Any]:
141 + session = self.require(session_id)
142 + doc = self._document_for_save(session, file_id)
143 + xdotool = shutil.which("xdotool")
144 + if not xdotool:
145 + updated = document_store.register_document(doc["path"]) if doc else None
146 + return {
147 + "ok": False,
148 + "error": "xdotool is not installed; use LibreOffice's Save control inside the canvas.",
149 + "document": _public_doc(updated) if updated else None,
150 + }
151 +
152 + result = subprocess.run(
153 + [xdotool, "key", "--clearmodifiers", "ctrl+s"],
154 + check=False,
155 + capture_output=True,
156 + text=True,
157 + timeout=8,
158 + env=self._display_env(session),
159 + )
160 + time.sleep(0.8)
161 + updated = document_store.register_document(doc["path"]) if doc else None
162 + if result.returncode != 0:
163 + detail = (result.stderr or result.stdout or "").strip()
164 + return {
165 + "ok": False,
166 + "error": detail or "LibreOffice desktop save shortcut failed.",
167 + "document": _public_doc(updated) if updated else None,
168 + }
169 + return {
170 + "ok": True,
171 + "session_id": session.session_id,
172 + "document": _public_doc(updated) if updated else None,
173 + }
174 +
175 + def sync(self, session_id: str = "", file_id: str = "") -> dict[str, Any]:
176 + session = self.get(session_id) if session_id else self._find_by_file_id(file_id)
177 + if not session:
178 + return {"ok": False, "error": "LibreOffice desktop session not found."}
179 + doc = self._document_for_save(session, file_id)
180 + if not doc:
181 + return {"ok": True, "session_id": session.session_id, "desktop": session.public()}
182 + updated = document_store.register_document(doc["path"])
183 + return {"ok": True, "session_id": session.session_id, "document": _public_doc(updated)}
184 +
185 + def close(self, session_id: str, save_first: bool = True) -> dict[str, Any]:
186 + with self._lock:
187 + normalized = str(session_id or "").strip()
188 + session = self._sessions.get(normalized)
189 + if not session:
190 + return {"ok": True, "closed": 0}
191 + if session.session_id == SYSTEM_SESSION_ID:
192 + save_result = None
193 + if save_first:
194 + try:
195 + save_result = self.save(session.session_id)
196 + except Exception as exc:
197 + save_result = {"ok": False, "error": str(exc)}
198 + return {
199 + "ok": True,
200 + "closed": 0,
201 + "session_id": session.session_id,
202 + "persistent": True,
203 + "save": save_result,
204 + }
205 +
206 + save_result = None
207 + if save_first:
208 + try:
209 + save_result = self.save(session.session_id)
210 + except Exception as exc:
211 + save_result = {"ok": False, "error": str(exc)}
212 + with self._lock:
213 + self._sessions.pop(session.session_id, None)
214 + virtual_desktop.unregister_session(session.token)
215 + self._terminate_session(session)
216 + self._remove_manifest(session.session_id)
217 + return {"ok": True, "closed": 1, "session_id": session.session_id, "save": save_result}
218 +
219 + def close_file(self, file_id: str) -> int:
220 + return 0
221 +
222 + def resize(self, session_id: str, width: int, height: int) -> dict[str, Any]:
223 + session = self.get(session_id)
224 + if not session:
225 + return {"ok": False, "error": "LibreOffice desktop session not found."}
226 + result = virtual_desktop.resize_display(
227 + display=session.display,
228 + width=width,
229 + height=height,
230 + max_width=MAX_SCREEN_WIDTH,
231 + max_height=MAX_SCREEN_HEIGHT,
232 + window_class="libreoffice",
233 + keys=("Escape",),
234 + xauthority=self._xauthority(session),
235 + home=str(session.profile_dir),
236 + )
237 + if result.get("ok"):
238 + session.width = int(result["width"])
239 + session.height = int(result["height"])
240 + self._dismiss_blocking_dialogs(session)
241 + return result
242 +
243 + def proxy_for_token(self, token: str) -> tuple[str, int] | None:
244 + normalized = str(token or "").strip()
245 + with self._lock:
246 + session = self._sessions.get(normalized)
247 + if not session:
248 + session = next((item for item in self._sessions.values() if item.token == normalized), None)
249 + if not session or not session.alive():
250 + return None
251 + return ("127.0.0.1", session.xpra_port)
252 +
253 + def resize_for_token(self, token: str, width: int, height: int) -> dict[str, Any]:
254 + normalized = str(token or "").strip()
255 + with self._lock:
256 + session = self._sessions.get(normalized)
257 + if not session:
258 + session = next((item for item in self._sessions.values() if item.token == normalized), None)
259 + if not session:
260 + return {"ok": False, "error": "LibreOffice desktop session not found."}
261 + return self.resize(session.session_id, width, height)
262 +
263 + def get(self, session_id: str) -> DesktopSession | None:
264 + with self._lock:
265 + session = self._sessions.get(str(session_id or "").strip())
266 + return session if session and session.alive() else None
267 +
268 + def require(self, session_id: str) -> DesktopSession:
269 + session = self.get(session_id)
270 + if not session:
271 + raise FileNotFoundError(f"LibreOffice desktop session not found: {session_id}")
272 + return session
273 +
274 + def shutdown(self) -> None:
275 + with self._lock:
276 + sessions = list(self._sessions.values())
277 + self._sessions.clear()
278 + for session in sessions:
279 + virtual_desktop.unregister_session(session.token)
280 + self._terminate_session(session)
281 + self._remove_manifest(session.session_id)
282 +
283 + def _document_for_save(self, session: DesktopSession, file_id: str = "") -> dict[str, Any] | None:
284 + normalized = str(file_id or "").strip()
285 + if normalized == SYSTEM_FILE_ID:
286 + return None
287 + if normalized and normalized != SYSTEM_FILE_ID:
288 + return document_store.get_document(normalized)
289 + if session.file_id and session.file_id != SYSTEM_FILE_ID:
290 + try:
291 + return document_store.get_document(session.file_id)
292 + except Exception:
293 + path = Path(session.path)
294 + if path.is_file():
295 + return document_store.register_document(path)
296 + return None
297 +
298 + def _register_virtual_desktop(self, session: DesktopSession) -> None:
299 + virtual_desktop.register_session(
300 + token=session.token,
301 + host="127.0.0.1",
302 + port=session.xpra_port,
303 + owner="libreoffice",
304 + title=session.title,
305 + resize=lambda width, height, session_id=session.session_id: self.resize(session_id, width, height),
306 + )
307 +
308 + def _ensure_system_desktop_locked(self) -> DesktopSession:
309 + existing = self._sessions.get(SYSTEM_SESSION_ID)
310 + if existing and existing.alive():
311 + return existing
312 +
313 + status = collect_desktop_status()
314 + if not status["healthy"]:
315 + raise RuntimeError(status["message"])
316 +
317 + display, xpra_port = self._allocate_endpoint_locked()
318 + profile_dir = PROFILE_DIR / SYSTEM_SESSION_ID
319 + session = DesktopSession(
320 + session_id=SYSTEM_SESSION_ID,
321 + file_id=SYSTEM_FILE_ID,
322 + extension="desktop",
323 + path=str(document_store.document_binary_home()),
324 + title=SYSTEM_TITLE,
325 + display=display,
326 + xpra_port=xpra_port,
327 + token=SYSTEM_SESSION_ID,
328 + url=_xpra_url(SYSTEM_SESSION_ID),
329 + profile_dir=profile_dir,
330 + )
331 + try:
332 + self._prepare_profile(session)
333 + self._prepare_desktop_launchers(session)
334 + self._spawn_desktop_locked(session)
335 + except Exception:
336 + self._terminate_session(session)
337 + raise
338 + self._sessions[session.session_id] = session
339 + self._register_virtual_desktop(session)
340 + self._write_manifest(session)
341 + return session
342 +
343 + def _spawn_desktop_locked(self, session: DesktopSession) -> None:
344 + STATE_DIR.mkdir(parents=True, exist_ok=True)
345 + SESSION_DIR.mkdir(parents=True, exist_ok=True)
346 + session.profile_dir.mkdir(parents=True, exist_ok=True)
347 +
348 + xpra = _require_binary("xpra")
349 + xvfb = _require_binary("Xvfb")
350 + _require_binary("xfce4-session")
351 + _require_binary("dbus-launch")
352 + xfce_launcher = self._prepare_xfce_launcher(session)
353 +
354 + session.processes["xvfb"] = subprocess.Popen(
355 + _xvfb_command(xvfb, session),
356 + stdin=subprocess.DEVNULL,
357 + stdout=subprocess.DEVNULL,
358 + stderr=subprocess.DEVNULL,
359 + env=self._session_env(session),
360 + )
361 + self._wait_for_display(session)
362 + self._set_display_size(session, session.width, session.height)
363 + self._prepare_root_window(session)
364 + session.processes["xfce"] = subprocess.Popen(
365 + [str(xfce_launcher)],
366 + stdin=subprocess.DEVNULL,
367 + stdout=subprocess.DEVNULL,
368 + stderr=subprocess.DEVNULL,
369 + env=self._display_env(session),
370 + )
371 + self._wait_for_xfce(session)
372 + session.processes["xpra"] = subprocess.Popen(
373 + _xpra_shadow_command(xpra, session),
374 + stdin=subprocess.DEVNULL,
375 + stdout=subprocess.DEVNULL,
376 + stderr=subprocess.DEVNULL,
377 + env=self._display_env(session),
378 + )
379 + _wait_for_port(
380 + "127.0.0.1",
381 + session.xpra_port,
382 + timeout=PORT_START_TIMEOUT_SECONDS,
383 + process=session.processes.get("xpra"),
384 + )
385 + self._refresh_xfce_desktop(session)
386 +
387 + def _restart_xpra_shadow(self, session: DesktopSession) -> None:
388 + xpra = _require_binary("xpra")
389 + process = session.processes.get("xpra")
390 + if process:
391 + _terminate_process(process)
392 + session.processes["xpra"] = subprocess.Popen(
393 + _xpra_shadow_command(xpra, session),
394 + stdin=subprocess.DEVNULL,
395 + stdout=subprocess.DEVNULL,
396 + stderr=subprocess.DEVNULL,
397 + env=self._display_env(session),
398 + )
399 + _wait_for_port(
400 + "127.0.0.1",
401 + session.xpra_port,
402 + timeout=PORT_START_TIMEOUT_SECONDS,
403 + process=session.processes.get("xpra"),
404 + )
405 +
406 + def _open_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> None:
407 + soffice = libreoffice.find_soffice()
408 + if not soffice:
409 + raise RuntimeError("LibreOffice is not installed in this runtime.")
410 + path = str(doc["path"])
411 + self._remove_stale_lock_file(session, path=path)
412 + process_key = f"soffice-{doc['file_id']}"
413 + session.processes[process_key] = subprocess.Popen(
414 + [
415 + soffice,
416 + "--norestore",
417 + "--nofirststartwizard",
418 + "--nolockcheck",
419 + f"-env:UserInstallation=file://{session.profile_dir}",
420 + path,
421 + ],
422 + cwd=str(Path(path).parent),
423 + stdin=subprocess.DEVNULL,
424 + stdout=subprocess.DEVNULL,
425 + stderr=subprocess.DEVNULL,
426 + env=self._display_env(session),
427 + )
428 + self._fit_office_window(session, process=session.processes[process_key])
429 +
430 + def _prepare_profile(self, session: DesktopSession) -> None:
431 + user_dir = session.profile_dir / "user"
432 + user_dir.mkdir(parents=True, exist_ok=True)
433 + 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 + )
448 +
449 + def _prepare_desktop_launchers(self, session: DesktopSession) -> None:
450 + soffice = libreoffice.find_soffice()
451 + if not soffice:
452 + raise RuntimeError("LibreOffice is not installed in this runtime.")
453 + documents_home = document_store.document_binary_home()
454 + documents_home.mkdir(parents=True, exist_ok=True)
455 +
456 + desktop_dir = session.profile_dir / "Desktop"
457 + desktop_dir.mkdir(parents=True, exist_ok=True)
458 + _remove_path_if_owned(desktop_dir / "Browser.desktop")
459 + _remove_path_if_owned(desktop_dir / "Files.desktop")
460 + config_dir = session.profile_dir / ".config"
461 + config_dir.mkdir(parents=True, exist_ok=True)
462 + _remove_path_if_owned(config_dir / "xfce4" / "panel")
463 + data_dir = session.profile_dir / ".local" / "share"
464 + data_dir.mkdir(parents=True, exist_ok=True)
465 + applications_dir = data_dir / "applications"
466 + applications_dir.mkdir(parents=True, exist_ok=True)
467 + cache_dir = session.profile_dir / ".cache"
468 + cache_dir.mkdir(parents=True, exist_ok=True)
469 + (config_dir / "user-dirs.dirs").write_text(
470 + "\n".join(
471 + [
472 + '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()}"',
480 + "",
481 + ],
482 + ),
483 + encoding="utf-8",
484 + )
485 + xfce_conf_dir = config_dir / "xfce4" / "xfconf" / "xfce-perchannel-xml"
486 + xfce_conf_dir.mkdir(parents=True, exist_ok=True)
487 + (xfce_conf_dir / "xfce4-desktop.xml").write_text(
488 + """<?xml version="1.1" encoding="UTF-8"?>
489 +
490 +<channel name="xfce4-desktop" version="1.0">
491 + <property name="last-settings-migration-version" type="uint" value="1"/>
492 + <property name="desktop-icons" type="empty">
493 + <property name="style" type="int" value="2"/>
494 + <property name="file-icons" type="empty">
495 + <property name="show-home" type="bool" value="false"/>
496 + <property name="show-filesystem" type="bool" value="false"/>
497 + <property name="show-removable" type="bool" value="false"/>
498 + <property name="show-trash" type="bool" value="false"/>
499 + </property>
500 + </property>
501 +</channel>
502 +""",
503 + encoding="utf-8",
504 + )
505 + helpers_rc = config_dir / "xfce4" / "helpers.rc"
506 + helpers_rc.parent.mkdir(parents=True, exist_ok=True)
507 + helpers_rc.write_text(
508 + "\n".join(
509 + [
510 + "TerminalEmulator=xfce4-terminal",
511 + "FileManager=thunar",
512 + "",
513 + ],
514 + ),
515 + encoding="utf-8",
516 + )
517 + self._hide_xpra_desktop_entries(applications_dir)
518 +
519 + base_args = (
520 + soffice,
521 + "--norestore",
522 + "--nofirststartwizard",
523 + "--nolockcheck",
524 + f"-env:UserInstallation=file://{session.profile_dir}",
525 + )
526 + office_launchers = (
527 + ("LibreOffice Writer", "libreoffice-writer", "--writer", "Office;WordProcessor;"),
528 + ("LibreOffice Calc", "libreoffice-calc", "--calc", "Office;Spreadsheet;"),
529 + ("LibreOffice Impress", "libreoffice-impress", "--impress", "Office;Presentation;"),
530 + )
531 + for name, icon, mode, categories in office_launchers:
532 + _write_desktop_launcher(
533 + desktop_dir / f"{name}.desktop",
534 + name=name,
535 + exec_line=_desktop_exec(*base_args, mode),
536 + icon=icon,
537 + categories=categories,
538 + try_exec=soffice,
539 + )
540 +
541 + terminal = shutil.which("xfce4-terminal") or "xfce4-terminal"
542 + settings = shutil.which("xfce4-settings-manager") or "xfce4-settings-manager"
543 + workdir = document_store.document_home()
544 + desktop_apps = (
545 + {
546 + "filename": "Terminal.desktop",
547 + "name": "Terminal",
548 + "exec": _desktop_exec(terminal, f"--working-directory={workdir}"),
549 + "try_exec": terminal,
550 + "icon": _desktop_icon(
551 + "/usr/share/icons/hicolor/128x128/apps/org.xfce.terminal.png",
552 + "/usr/share/icons/hicolor/scalable/apps/org.xfce.terminal.svg",
553 + "org.xfce.terminal",
554 + "utilities-terminal",
555 + ),
556 + "categories": "System;TerminalEmulator;",
557 + },
558 + {
559 + "filename": "Settings.desktop",
560 + "name": "Settings",
561 + "exec": _desktop_exec(settings),
562 + "try_exec": settings,
563 + "icon": _desktop_icon(
564 + "/usr/share/icons/hicolor/128x128/apps/org.xfce.settings.manager.png",
565 + "/usr/share/icons/hicolor/scalable/apps/org.xfce.settings.manager.svg",
566 + "org.xfce.settings.manager",
567 + "preferences-system",
568 + ),
569 + "categories": "Settings;DesktopSettings;",
570 + },
571 + )
572 + for app in desktop_apps:
573 + _write_desktop_launcher(
574 + desktop_dir / str(app["filename"]),
575 + name=str(app["name"]),
576 + exec_line=str(app["exec"]),
577 + icon=str(app["icon"]),
578 + categories=str(app["categories"]),
579 + try_exec=str(app["try_exec"]),
580 + )
581 + for label, target_parts in DESKTOP_FOLDER_LINKS:
582 + _ensure_desktop_folder_link(desktop_dir, label, Path(files.get_abs_path(*target_parts)))
583 +
584 + self._prepare_xfce_panel_config(session)
585 + self._prepare_xfce_profile_autostart(session)
586 +
587 + def _hide_xpra_desktop_entries(self, applications_dir: Path) -> None:
588 + for filename in HIDDEN_XPRA_DESKTOP_ENTRIES:
589 + (applications_dir / filename).write_text(
590 + "\n".join(
591 + [
592 + "[Desktop Entry]",
593 + "Type=Application",
594 + "Name=Xpra",
595 + "NoDisplay=true",
596 + "Hidden=true",
597 + "",
598 + ],
599 + ),
600 + encoding="utf-8",
601 + )
602 +
603 + def _prepare_xfce_panel_config(self, session: DesktopSession) -> None:
604 + panel_xml = (
605 + session.profile_dir
606 + / ".config"
607 + / "xfce4"
608 + / "xfconf"
609 + / "xfce-perchannel-xml"
610 + / "xfce4-panel.xml"
611 + )
612 + panel_xml.parent.mkdir(parents=True, exist_ok=True)
613 +
614 + root = ET.Element("channel", {"name": "xfce4-panel", "version": "1.0"})
615 + ET.SubElement(root, "property", {"name": "configver", "type": "int", "value": "2"})
616 +
617 + panels = ET.SubElement(root, "property", {"name": "panels", "type": "array"})
618 + ET.SubElement(panels, "value", {"type": "int", "value": "1"})
619 + panel = ET.SubElement(panels, "property", {"name": "panel-1", "type": "empty"})
620 + for name, prop_type, value in (
621 + ("position", "string", "p=6;x=0;y=0"),
622 + ("length", "uint", "100"),
623 + ("position-locked", "bool", "true"),
624 + ("size", "uint", "24"),
625 + ("mode", "uint", "0"),
626 + ("autohide-behavior", "uint", "0"),
627 + ("disable-struts", "bool", "false"),
628 + ("nrows", "uint", "1"),
629 + ):
630 + ET.SubElement(panel, "property", {"name": name, "type": prop_type, "value": value})
631 + plugin_ids = ET.SubElement(panel, "property", {"name": "plugin-ids", "type": "array"})
632 + for plugin_id in ("1", "2", "3", "4", "5", "6", "7", "8", "9"):
633 + ET.SubElement(plugin_ids, "value", {"type": "int", "value": plugin_id})
634 +
635 + plugins = ET.SubElement(root, "property", {"name": "plugins", "type": "empty"})
636 + ET.SubElement(plugins, "property", {"name": "plugin-1", "type": "string", "value": "applicationsmenu"})
637 + ET.SubElement(plugins, "property", {"name": "plugin-2", "type": "string", "value": "tasklist"})
638 + tasklist = _xfce_property(plugins, "plugin-2", "string", "tasklist")
639 + ET.SubElement(tasklist, "property", {"name": "flat-buttons", "type": "bool", "value": "true"})
640 + ET.SubElement(tasklist, "property", {"name": "show-handle", "type": "bool", "value": "false"})
641 + ET.SubElement(tasklist, "property", {"name": "show-labels", "type": "bool", "value": "true"})
642 + separator = ET.SubElement(plugins, "property", {"name": "plugin-3", "type": "string", "value": "separator"})
643 + ET.SubElement(separator, "property", {"name": "expand", "type": "bool", "value": "true"})
644 + ET.SubElement(separator, "property", {"name": "style", "type": "uint", "value": "0"})
645 + ET.SubElement(plugins, "property", {"name": "plugin-4", "type": "string", "value": "pager"})
646 + ET.SubElement(plugins, "property", {"name": "plugin-5", "type": "string", "value": "systray"})
647 + ET.SubElement(plugins, "property", {"name": "plugin-6", "type": "string", "value": "separator"})
648 + ET.SubElement(plugins, "property", {"name": "plugin-7", "type": "string", "value": "clock"})
649 + ET.SubElement(plugins, "property", {"name": "plugin-8", "type": "string", "value": "separator"})
650 + ET.SubElement(plugins, "property", {"name": "plugin-9", "type": "string", "value": "actions"})
651 +
652 + tree = ET.ElementTree(root)
653 + try:
654 + ET.indent(tree, space=" ")
655 + except AttributeError:
656 + pass
657 + tree.write(panel_xml, encoding="utf-8", xml_declaration=True)
658 +
659 + def _prepare_xfce_profile_autostart(self, session: DesktopSession) -> None:
660 + script = session.profile_dir / "prepare-xfce-profile.sh"
661 + script.write_text(
662 + """#!/bin/sh
663 +set -eu
664 +export HOME="${HOME:-%s}"
665 +export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
666 +export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
667 +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
668 +export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"
669 +mkdir -p "$HOME/Desktop" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
670 +if command -v xfconf-query >/dev/null 2>&1; then
671 + xfconf-query -c xfce4-desktop -p /desktop-icons/style -n -t int -s 2 >/dev/null 2>&1 || true
672 + xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-home -n -t bool -s false >/dev/null 2>&1 || true
673 + xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-filesystem -n -t bool -s false >/dev/null 2>&1 || true
674 + xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-removable -n -t bool -s false >/dev/null 2>&1 || true
675 + xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-trash -n -t bool -s false >/dev/null 2>&1 || true
676 + xfconf-query -c xfce4-panel -p /panels -n -a -t int -s 1 >/dev/null 2>&1 || true
677 + xfconf-query -c xfce4-panel -p /panels/panel-2 -r -R >/dev/null 2>&1 || true
678 + xfconf-query -c xfce4-panel -p /panels/panel-1/plugin-ids -r -R >/dev/null 2>&1 || true
679 + 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
680 + for plugin_id in $(seq 10 30); do
681 + xfconf-query -c xfce4-panel -p "/plugins/plugin-${plugin_id}" -r -R >/dev/null 2>&1 || true
682 + done
683 +fi
684 +rm -rf "$XDG_CONFIG_HOME"/xfce4/panel/launcher-* 2>/dev/null || true
685 +for launcher in "$HOME"/Desktop/*.desktop; do
686 + [ -f "$launcher" ] || continue
687 + chmod +x "$launcher" 2>/dev/null || true
688 + if command -v gio >/dev/null 2>&1; then
689 + checksum="$(sha256sum "$launcher" 2>/dev/null | cut -d " " -f 1)"
690 + gio set "$launcher" metadata::trusted true >/dev/null 2>&1 || true
691 + if [ -n "$checksum" ]; then
692 + gio set -t string "$launcher" metadata::xfce-exe-checksum "$checksum" >/dev/null 2>&1 || true
693 + fi
694 + fi
695 +done
696 +if command -v xfdesktop >/dev/null 2>&1; then
697 + timeout 4 xfdesktop --reload >/dev/null 2>&1 || true
698 +fi
699 +""" % str(session.profile_dir),
700 + encoding="utf-8",
701 + )
702 + try:
703 + script.chmod(0o700)
704 + except OSError:
705 + pass
706 +
707 + autostart_dir = session.profile_dir / ".config" / "autostart"
708 + autostart_dir.mkdir(parents=True, exist_ok=True)
709 + autostart = autostart_dir / "agent-zero-office-desktop.desktop"
710 + autostart.write_text(
711 + "\n".join(
712 + [
713 + "[Desktop Entry]",
714 + "Type=Application",
715 + "Name=Agent Zero desktop profile",
716 + f"Exec={script}",
717 + "Terminal=false",
718 + "OnlyShowIn=XFCE;",
719 + "X-GNOME-Autostart-enabled=true",
720 + "",
721 + ],
722 + ),
723 + encoding="utf-8",
724 + )
725 +
726 + def _prepare_xfce_launcher(self, session: DesktopSession) -> Path:
727 + launcher = session.profile_dir / "start-xfce.sh"
728 + launcher.write_text(
729 + "\n".join(
730 + [
731 + "#!/bin/sh",
732 + 'export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"',
733 + 'export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"',
734 + 'export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"',
735 + 'export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"',
736 + (
737 + "exec dbus-launch --exit-with-session sh -c "
738 + f"'\"{session.profile_dir / 'prepare-xfce-profile.sh'}\" >/dev/null 2>&1 || true; exec xfce4-session'"
739 + ),
740 + "",
741 + ],
742 + ),
743 + encoding="utf-8",
744 + )
745 + try:
746 + launcher.chmod(0o700)
747 + except OSError:
748 + pass
749 + return launcher
750 +
751 + def _prepare_root_window(self, session: DesktopSession) -> None:
752 + xsetroot = shutil.which("xsetroot")
753 + if not xsetroot:
754 + return
755 + subprocess.run(
756 + [xsetroot, "-solid", "#20242a"],
757 + check=False,
758 + stdout=subprocess.DEVNULL,
759 + stderr=subprocess.DEVNULL,
760 + timeout=2,
761 + env=self._display_env(session),
762 + )
763 +
764 + def _fit_office_window(
765 + self,
766 + session: DesktopSession,
767 + *,
768 + process: subprocess.Popen[Any] | None = None,
769 + ) -> None:
770 + virtual_desktop.fit_window_until(
771 + display=session.display,
772 + width=session.width,
773 + height=session.height,
774 + window_class="libreoffice",
775 + keys=("Escape",),
776 + settle_seconds=4,
777 + timeout_seconds=10,
778 + process=process,
779 + xauthority=self._xauthority(session),
780 + home=str(session.profile_dir),
781 + )
782 + self._dismiss_blocking_dialogs(session)
783 +
784 + def _set_display_size(self, session: DesktopSession, width: int, height: int) -> dict[str, Any]:
785 + result = virtual_desktop.resize_display(
786 + display=session.display,
787 + width=width,
788 + height=height,
789 + max_width=MAX_SCREEN_WIDTH,
790 + max_height=MAX_SCREEN_HEIGHT,
791 + window_class="",
792 + keys=(),
793 + xauthority=self._xauthority(session),
794 + home=str(session.profile_dir),
795 + )
796 + if result.get("ok"):
797 + session.width = int(result["width"])
798 + session.height = int(result["height"])
799 + return result
800 +
801 + def _dismiss_blocking_dialogs(self, session: DesktopSession) -> None:
802 + virtual_desktop.close_windows(
803 + display=session.display,
804 + names=BLOCKING_DIALOG_TITLES,
805 + xauthority=self._xauthority(session),
806 + home=str(session.profile_dir),
807 + )
808 +
809 + def _refresh_xfce_desktop(self, session: DesktopSession) -> None:
810 + xfdesktop = shutil.which("xfdesktop")
811 + if not xfdesktop:
812 + return
813 + env = self._xfce_process_env(session, "xfdesktop")
814 + try:
815 + subprocess.run(
816 + [xfdesktop, "--reload"],
817 + check=False,
818 + stdout=subprocess.DEVNULL,
819 + stderr=subprocess.DEVNULL,
820 + timeout=4,
821 + env=env,
822 + )
823 + except (OSError, subprocess.TimeoutExpired):
824 + return
825 +
826 + def _xfce_process_env(self, session: DesktopSession, command_name: str) -> dict[str, str]:
827 + env = self._display_env(session)
828 + proc = Path("/proc")
829 + for candidate in proc.iterdir():
830 + if not candidate.name.isdigit():
831 + continue
832 + try:
833 + if (candidate / "comm").read_text(encoding="utf-8").strip() != command_name:
834 + continue
835 + process_env = self._read_process_env(candidate)
836 + except OSError:
837 + continue
838 + if process_env.get("HOME") != str(session.profile_dir):
839 + continue
840 + if process_env.get("DISPLAY") != f":{session.display}":
841 + continue
842 + for key, value in process_env.items():
843 + if (
844 + key in {"DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "HOME", "XAUTHORITY"}
845 + or key.startswith("XDG_")
846 + ):
847 + env[key] = value
848 + break
849 + return env
850 +
851 + def _read_process_env(self, proc_dir: Path) -> dict[str, str]:
852 + raw = (proc_dir / "environ").read_bytes()
853 + env: dict[str, str] = {}
854 + for item in raw.split(b"\0"):
855 + if not item or b"=" not in item:
856 + continue
857 + key, value = item.split(b"=", 1)
858 + env[key.decode("utf-8", errors="ignore")] = value.decode("utf-8", errors="ignore")
859 + return env
860 +
861 + def _session_env(self, session: DesktopSession) -> dict[str, str]:
862 + env = {
863 + **os.environ,
864 + "HOME": str(session.profile_dir),
865 + "LANG": os.environ.get("LANG") or "C.UTF-8",
866 + }
867 + env.setdefault("XDG_RUNTIME_DIR", str(STATE_DIR / "xdg-runtime"))
868 + runtime_dir = Path(env["XDG_RUNTIME_DIR"])
869 + runtime_dir.mkdir(parents=True, exist_ok=True)
870 + try:
871 + runtime_dir.chmod(0o700)
872 + except OSError:
873 + pass
874 + return env
875 +
876 + def _display_env(self, session: DesktopSession) -> dict[str, str]:
877 + env = {
878 + **self._session_env(session),
879 + "DISPLAY": f":{session.display}",
880 + "SAL_USE_VCLPLUGIN": os.environ.get("SAL_USE_VCLPLUGIN") or "gtk3",
881 + }
882 + xauthority = self._xauthority(session)
883 + if xauthority:
884 + env["XAUTHORITY"] = xauthority
885 + return env
886 +
887 + def _xauthority(self, session: DesktopSession) -> str:
888 + path = session.profile_dir / ".Xauthority"
889 + return str(path) if path.exists() else ""
890 +
891 + def _allocate_endpoint_locked(self) -> tuple[int, int]:
892 + used_displays = {session.display for session in self._sessions.values()}
893 + used_ports = {session.xpra_port for session in self._sessions.values()}
894 + for offset in range(MAX_SESSIONS):
895 + display = DISPLAY_BASE + offset
896 + port = XPRA_PORT_BASE + offset
897 + if display in used_displays or port in used_ports:
898 + continue
899 + if _port_is_free(port):
900 + return display, port
901 + raise RuntimeError("No LibreOffice desktop slots are available.")
902 +
903 + def _find_by_file_id_locked(self, file_id: str) -> DesktopSession | None:
904 + for session in self._sessions.values():
905 + if session.file_id == file_id and session.alive():
906 + return session
907 + return None
908 +
909 + def _find_by_file_id(self, file_id: str) -> DesktopSession | None:
910 + with self._lock:
911 + return self._find_by_file_id_locked(str(file_id or "").strip())
912 +
913 + def _reap_dead_locked(self) -> None:
914 + for session_id, session in list(self._sessions.items()):
915 + if not session.alive():
916 + self._terminate_session(session)
917 + virtual_desktop.unregister_session(session.token)
918 + self._sessions.pop(session_id, None)
919 + self._remove_manifest(session_id)
920 +
921 + def _wait_for_display(self, session: DesktopSession) -> None:
922 + marker = Path(f"/tmp/.X11-unix/X{session.display}")
923 + deadline = time.time() + DISPLAY_START_TIMEOUT_SECONDS
924 + while time.time() < deadline:
925 + process = session.processes.get("xvfb") or session.processes.get("xpra")
926 + if process and process.poll() is not None:
927 + raise RuntimeError("The LibreOffice X display exited before it was ready.")
928 + if marker.exists():
929 + return
930 + time.sleep(0.1)
931 + raise TimeoutError("Timed out waiting for the LibreOffice X display.")
932 +
933 + def _wait_for_xfce(self, session: DesktopSession) -> None:
934 + deadline = time.time() + STARTUP_GRACE_SECONDS
935 + while time.time() < deadline:
936 + process = session.processes.get("xfce")
937 + if process and process.poll() is not None:
938 + return
939 + if virtual_desktop.has_window(
940 + display=session.display,
941 + name="xfce4-panel",
942 + xauthority=self._xauthority(session),
943 + home=str(session.profile_dir),
944 + ):
945 + return
946 + time.sleep(0.25)
947 +
948 + def _write_manifest(self, session: DesktopSession) -> None:
949 + SESSION_DIR.mkdir(parents=True, exist_ok=True)
950 + payload = {
951 + "session_id": session.session_id,
952 + "file_id": session.file_id,
953 + "path": session.path,
954 + "display": session.display,
955 + "xpra_port": session.xpra_port,
956 + "owner_pid": os.getpid(),
957 + "pids": {name: process.pid for name, process in session.processes.items()},
958 + }
959 + (SESSION_DIR / f"{session.session_id}.json").write_text(json.dumps(payload), encoding="utf-8")
960 +
961 + def _remove_manifest(self, session_id: str) -> None:
962 + (SESSION_DIR / f"{session_id}.json").unlink(missing_ok=True)
963 +
964 + def _terminate_session(self, session: DesktopSession) -> None:
965 + process_names = [name for name in session.processes if name.startswith("soffice")]
966 + process_names.extend(["xfce", "xpra", "xvfb"])
967 + for name in process_names:
968 + process = session.processes.get(name)
969 + if not process:
970 + continue
971 + _terminate_process(process)
972 + self._remove_stale_lock_file(session)
973 +
974 + def _remove_stale_lock_file(self, session: DesktopSession, *, path: str | Path | None = None) -> None:
975 + path = Path(path or session.path)
976 + if not path.name:
977 + return
978 + lock_file = path.with_name(f".~lock.{path.name}#")
979 + try:
980 + lock_file.unlink(missing_ok=True)
981 + except OSError:
982 + pass
983 +
984 +
985 +def collect_desktop_status() -> dict[str, Any]:
986 + desktop = virtual_desktop.collect_status()
987 + binaries = {
988 + **desktop["binaries"],
989 + "soffice": libreoffice.find_soffice(),
990 + "thunar": shutil.which("thunar") or "",
991 + "xfce4-terminal": shutil.which("xfce4-terminal") or "",
992 + "xfce4-settings-manager": shutil.which("xfce4-settings-manager") or "",
993 + "gio": shutil.which("gio") or "",
994 + "pulseaudio": shutil.which("pulseaudio") or "",
995 + "pactl": shutil.which("pactl") or "",
996 + }
997 + missing = [
998 + name
999 + for name in (
1000 + "soffice",
1001 + "thunar",
1002 + "xfce4-terminal",
1003 + "xfce4-settings-manager",
1004 + "gio",
1005 + "pulseaudio",
1006 + "pactl",
1007 + )
1008 + if not binaries[name]
1009 + ]
1010 + missing.extend(
1011 + name
1012 + for name in ("xpra", "Xvfb", "xfce4-session", "dbus-launch", "xrandr", "xdotool")
1013 + if not binaries.get(name)
1014 + )
1015 + if not desktop.get("xpra_html_root"):
1016 + missing.append("xpra-html5")
1017 + if desktop.get("binaries", {}).get("xpra") and desktop.get("packages", {}).get("xpra-x11") is False:
1018 + missing.append("xpra-x11")
1019 + healthy = not missing
1020 + return {
1021 + "ok": True,
1022 + "healthy": healthy,
1023 + "state": "healthy" if healthy else "missing",
1024 + "binaries": binaries,
1025 + "xpra_html_root": str(desktop.get("xpra_html_root") or ""),
1026 + "message": (
1027 + "Official LibreOffice desktop sessions are available."
1028 + if healthy
1029 + else f"Official LibreOffice desktop sessions need: {', '.join(missing)}."
1030 + ),
1031 + }
1032 +
1033 +
1034 +def cleanup_stale_runtime_state() -> dict[str, Any]:
1035 + killed: list[int] = []
1036 + errors: list[str] = []
1037 + if SESSION_DIR.exists():
1038 + for manifest in SESSION_DIR.glob("*.json"):
1039 + try:
1040 + payload = json.loads(manifest.read_text(encoding="utf-8"))
1041 + owner_pid = _coerce_pid(payload.get("owner_pid"))
1042 + if owner_pid and _pid_is_running(owner_pid):
1043 + continue
1044 + for pid in dict(payload.get("pids") or {}).values():
1045 + pid_int = _coerce_pid(pid)
1046 + if not pid_int:
1047 + continue
1048 + if _kill_pid(pid_int):
1049 + killed.append(pid_int)
1050 + manifest.unlink(missing_ok=True)
1051 + except Exception as exc:
1052 + errors.append(str(exc))
1053 + return {"ok": not errors, "killed": killed, "errors": errors}
1054 +
1055 +
1056 +def get_manager() -> LibreOfficeDesktopManager:
1057 + global _manager
1058 + try:
1059 + return _manager
1060 + except NameError:
1061 + _manager = LibreOfficeDesktopManager()
1062 + atexit.register(_manager.shutdown)
1063 + return _manager
1064 +
1065 +
1066 +def _xpra_url(token: str) -> str:
1067 + return virtual_desktop.session_url(token, title="Desktop")
1068 +
1069 +
1070 +def _xvfb_command(xvfb: str, session: DesktopSession) -> list[str]:
1071 + return [
1072 + xvfb,
1073 + f":{session.display}",
1074 + "-screen",
1075 + "0",
1076 + f"{MAX_SCREEN_WIDTH}x{MAX_SCREEN_HEIGHT}x24",
1077 + "+extension",
1078 + "GLX",
1079 + "+extension",
1080 + "RANDR",
1081 + "+extension",
1082 + "RENDER",
1083 + "+extension",
1084 + "Composite",
1085 + "-extension",
1086 + "DOUBLE-BUFFER",
1087 + "-nolisten",
1088 + "tcp",
1089 + "-noreset",
1090 + "-ac",
1091 + ]
1092 +
1093 +
1094 +def _xpra_shadow_command(xpra: str, session: DesktopSession) -> list[str]:
1095 + return [
1096 + xpra,
1097 + "shadow",
1098 + f":{session.display}",
1099 + "--daemon=no",
1100 + "--mdns=no",
1101 + "--html=on",
1102 + "--tray=no",
1103 + "--system-tray=no",
1104 + "--notifications=no",
1105 + "--file-transfer=yes",
1106 + "--open-files=no",
1107 + "--open-url=no",
1108 + "--printing=yes",
1109 + "--audio=yes",
1110 + "--pulseaudio=auto",
1111 + "--speaker=on",
1112 + "--microphone=off",
1113 + f"--bind-tcp=127.0.0.1:{session.xpra_port}",
1114 + "--resize-display=yes",
1115 + f"--log-dir={session.profile_dir}",
1116 + "--log-file=xpra.log",
1117 + ]
1118 +
1119 +
1120 +def _desktop_exec(*args: str | Path) -> str:
1121 + return " ".join(_desktop_exec_arg(str(arg)) for arg in args if str(arg))
1122 +
1123 +
1124 +def _desktop_icon(*candidates: str) -> str:
1125 + for candidate in candidates:
1126 + if candidate.startswith("/") and Path(candidate).exists():
1127 + return candidate
1128 + return next(
1129 + (candidate for candidate in candidates if not candidate.startswith("/")),
1130 + candidates[-1],
1131 + )
1132 +
1133 +
1134 +def _ensure_desktop_folder_link(desktop_dir: Path, label: str, target: Path) -> None:
1135 + target.mkdir(parents=True, exist_ok=True)
1136 + link = desktop_dir / label
1137 + try:
1138 + if link.is_symlink() or link.is_file():
1139 + link.unlink()
1140 + if not link.exists():
1141 + link.symlink_to(target, target_is_directory=True)
1142 + except OSError:
1143 + return
1144 +
1145 +
1146 +def _remove_path_if_owned(path: Path) -> None:
1147 + try:
1148 + if path.is_symlink() or path.is_file():
1149 + path.unlink()
1150 + elif path.is_dir():
1151 + shutil.rmtree(path)
1152 + except OSError:
1153 + return
1154 +
1155 +
1156 +def _desktop_exec_arg(value: str) -> str:
1157 + if not any(char.isspace() or char in '"\\' for char in value):
1158 + return value
1159 + escaped = value.replace("\\", "\\\\").replace('"', '\\"')
1160 + return f'"{escaped}"'
1161 +
1162 +
1163 +def _write_desktop_launcher(
1164 + path: Path,
1165 + *,
1166 + name: str,
1167 + exec_line: str,
1168 + icon: str,
1169 + categories: str,
1170 + try_exec: str = "",
1171 +) -> None:
1172 + path.parent.mkdir(parents=True, exist_ok=True)
1173 + lines = [
1174 + "[Desktop Entry]",
1175 + "Version=1.0",
1176 + "Type=Application",
1177 + f"Name={name}",
1178 + f"Exec={exec_line}",
1179 + ]
1180 + if try_exec:
1181 + lines.append(f"TryExec={try_exec}")
1182 + lines.extend(
1183 + [
1184 + f"Icon={icon}",
1185 + "Terminal=false",
1186 + f"Categories={categories}",
1187 + "StartupNotify=true",
1188 + "X-XFCE-Trusted=true",
1189 + "",
1190 + ],
1191 + )
1192 + path.write_text("\n".join(lines), encoding="utf-8")
1193 + try:
1194 + path.chmod(0o755)
1195 + except OSError:
1196 + pass
1197 +
1198 +
1199 +def _xfce_property(parent: ET.Element, name: str, property_type: str, value: str | None = None) -> ET.Element:
1200 + for child in parent.findall("property"):
1201 + if child.get("name") == name:
1202 + child.set("type", property_type)
1203 + if value is None:
1204 + child.attrib.pop("value", None)
1205 + else:
1206 + child.set("value", value)
1207 + return child
1208 + attributes = {"name": name, "type": property_type}
1209 + if value is not None:
1210 + attributes["value"] = value
1211 + return ET.SubElement(parent, "property", attributes)
1212 +
1213 +
1214 +def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
1215 + return {
1216 + "file_id": doc["file_id"],
1217 + "path": document_store.display_path(doc["path"]),
1218 + "basename": doc["basename"],
1219 + "extension": doc["extension"],
1220 + "size": doc["size"],
1221 + "version": document_store.item_version(doc),
1222 + "last_modified": doc["last_modified"],
1223 + }
1224 +
1225 +
1226 +def _require_binary(name: str) -> str:
1227 + found = shutil.which(name)
1228 + if not found:
1229 + raise RuntimeError(f"{name} is required for official LibreOffice desktop sessions.")
1230 + return found
1231 +
1232 +
1233 +def _running(process: subprocess.Popen[Any] | None) -> bool:
1234 + return bool(process and process.poll() is None)
1235 +
1236 +
1237 +def _wait_for_port(
1238 + host: str,
1239 + port: int,
1240 + timeout: float = 15.0,
1241 + process: subprocess.Popen[Any] | None = None,
1242 +) -> None:
1243 + deadline = time.time() + timeout
1244 + while time.time() < deadline:
1245 + if process and process.poll() is not None:
1246 + raise RuntimeError(f"Xpra exited before port {port} was ready.")
1247 + try:
1248 + with socket.create_connection((host, port), timeout=0.2):
1249 + return
1250 + except OSError:
1251 + time.sleep(0.1)
1252 + raise TimeoutError(f"Timed out waiting for Xpra port {port}.")
1253 +
1254 +
1255 +def _port_is_free(port: int) -> bool:
1256 + try:
1257 + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
1258 + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1259 + probe.bind(("127.0.0.1", port))
1260 + return True
1261 + except OSError:
1262 + return False
1263 +
1264 +
1265 +def _terminate_process(process: subprocess.Popen[Any]) -> None:
1266 + if process.poll() is not None:
1267 + return
1268 + try:
1269 + process.terminate()
1270 + process.wait(timeout=2)
1271 + return
1272 + except Exception:
1273 + pass
1274 + try:
1275 + process.kill()
1276 + process.wait(timeout=2)
1277 + except Exception:
1278 + pass
1279 +
1280 +
1281 +def _kill_pid(pid: int) -> bool:
1282 + if pid <= 0:
1283 + return False
1284 + try:
1285 + os.kill(pid, 15)
1286 + return True
1287 + except ProcessLookupError:
1288 + return False
1289 + except PermissionError:
1290 + return False
1291 +
1292 +
1293 +def _coerce_pid(value: Any) -> int:
1294 + try:
1295 + pid = int(value)
1296 + except (TypeError, ValueError):
1297 + return 0
1298 + return pid if pid > 0 else 0
1299 +
1300 +
1301 +def _pid_is_running(pid: int) -> bool:
1302 + try:
1303 + os.kill(pid, 0)
1304 + return True
1305 + except ProcessLookupError:
1306 + return False
1307 + except PermissionError:
1308 + return True
plugins/_office/helpers/libreoffice_desktop_routes.py new
+14
@@ -0,0 +1,14 @@
1 +from __future__ import annotations
2 +
3 +from helpers.virtual_desktop_routes import (
4 + VirtualDesktopGateway as LibreOfficeDesktopGateway,
5 + install_route_hooks,
6 + is_installed,
7 +)
8 +
9 +
10 +__all__ = [
11 + "LibreOfficeDesktopGateway",
12 + "install_route_hooks",
13 + "is_installed",
14 +]
plugins/_office/helpers/libreofficekit_native.py new
+423
@@ -0,0 +1,423 @@
1 +from __future__ import annotations
2 +
3 +import ctypes
4 +import atexit
5 +import base64
6 +import math
7 +import json
8 +import os
9 +import shutil
10 +import struct
11 +import tempfile
12 +import zlib
13 +from pathlib import Path
14 +from typing import Any
15 +
16 +
17 +PROGRAM_DIR = Path(os.environ.get("A0_LIBREOFFICE_PROGRAM_DIR") or "/usr/lib/libreoffice/program")
18 +MERGED_LIBRARY = PROGRAM_DIR / "libmergedlo.so"
19 +DEFAULT_TILE_WIDTH_PX = 920
20 +MAX_TILE_HEIGHT_PX = 1800
21 +MAX_TILES = 12
22 +
23 +
24 +class LibreOfficeKitNativeError(RuntimeError):
25 + pass
26 +
27 +
28 +class _Office(ctypes.Structure):
29 + pass
30 +
31 +
32 +class _OfficeClass(ctypes.Structure):
33 + pass
34 +
35 +
36 +class _Document(ctypes.Structure):
37 + pass
38 +
39 +
40 +class _DocumentClass(ctypes.Structure):
41 + pass
42 +
43 +
44 +_OfficePtr = ctypes.POINTER(_Office)
45 +_DocumentPtr = ctypes.POINTER(_Document)
46 +
47 +_DestroyOffice = ctypes.CFUNCTYPE(None, _OfficePtr)
48 +_DocumentLoad = ctypes.CFUNCTYPE(_DocumentPtr, _OfficePtr, ctypes.c_char_p)
49 +_GetError = ctypes.CFUNCTYPE(ctypes.c_char_p, _OfficePtr)
50 +_DocumentLoadWithOptions = ctypes.CFUNCTYPE(_DocumentPtr, _OfficePtr, ctypes.c_char_p, ctypes.c_char_p)
51 +_FreeError = ctypes.CFUNCTYPE(None, ctypes.c_char_p)
52 +
53 +_DestroyDocument = ctypes.CFUNCTYPE(None, _DocumentPtr)
54 +_SaveAs = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p)
55 +_GetDocumentType = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr)
56 +_GetParts = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr)
57 +_GetPartPageRectangles = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr)
58 +_GetPart = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr)
59 +_SetPart = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int)
60 +_GetPartName = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr, ctypes.c_int)
61 +_SetPartMode = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int)
62 +_PaintTile = ctypes.CFUNCTYPE(
63 + None,
64 + _DocumentPtr,
65 + ctypes.POINTER(ctypes.c_ubyte),
66 + ctypes.c_int,
67 + ctypes.c_int,
68 + ctypes.c_int,
69 + ctypes.c_int,
70 + ctypes.c_int,
71 + ctypes.c_int,
72 +)
73 +_GetTileMode = ctypes.CFUNCTYPE(ctypes.c_int, _DocumentPtr)
74 +_GetDocumentSize = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.POINTER(ctypes.c_long), ctypes.POINTER(ctypes.c_long))
75 +_InitializeForRendering = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_char_p)
76 +_RegisterDocumentCallback = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_void_p, ctypes.c_void_p)
77 +_PostKeyEvent = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int)
78 +_PostMouseEvent = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int)
79 +_PostUnoCommand = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_bool)
80 +_SetTextSelection = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int)
81 +_GetTextSelection = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p))
82 +_Paste = ctypes.CFUNCTYPE(ctypes.c_bool, _DocumentPtr, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t)
83 +_SetGraphicSelection = ctypes.CFUNCTYPE(None, _DocumentPtr, ctypes.c_int, ctypes.c_int, ctypes.c_int)
84 +_ResetSelection = ctypes.CFUNCTYPE(None, _DocumentPtr)
85 +_GetCommandValues = ctypes.CFUNCTYPE(ctypes.c_char_p, _DocumentPtr, ctypes.c_char_p)
86 +
87 +
88 +_Office._fields_ = [("pClass", ctypes.POINTER(_OfficeClass))]
89 +_OfficeClass._fields_ = [
90 + ("nSize", ctypes.c_size_t),
91 + ("destroy", _DestroyOffice),
92 + ("documentLoad", _DocumentLoad),
93 + ("getError", _GetError),
94 + ("documentLoadWithOptions", _DocumentLoadWithOptions),
95 + ("freeError", _FreeError),
96 +]
97 +
98 +_Document._fields_ = [("pClass", ctypes.POINTER(_DocumentClass))]
99 +_DocumentClass._fields_ = [
100 + ("nSize", ctypes.c_size_t),
101 + ("destroy", _DestroyDocument),
102 + ("saveAs", _SaveAs),
103 + ("getDocumentType", _GetDocumentType),
104 + ("getParts", _GetParts),
105 + ("getPartPageRectangles", _GetPartPageRectangles),
106 + ("getPart", _GetPart),
107 + ("setPart", _SetPart),
108 + ("getPartName", _GetPartName),
109 + ("setPartMode", _SetPartMode),
110 + ("paintTile", _PaintTile),
111 + ("getTileMode", _GetTileMode),
112 + ("getDocumentSize", _GetDocumentSize),
113 + ("initializeForRendering", _InitializeForRendering),
114 + ("registerCallback", _RegisterDocumentCallback),
115 + ("postKeyEvent", _PostKeyEvent),
116 + ("postMouseEvent", _PostMouseEvent),
117 + ("postUnoCommand", _PostUnoCommand),
118 + ("setTextSelection", _SetTextSelection),
119 + ("getTextSelection", _GetTextSelection),
120 + ("paste", _Paste),
121 + ("setGraphicSelection", _SetGraphicSelection),
122 + ("resetSelection", _ResetSelection),
123 + ("getCommandValues", _GetCommandValues),
124 +]
125 +
126 +
127 +def available() -> bool:
128 + return PROGRAM_DIR.exists() and MERGED_LIBRARY.exists() and os.environ.get("A0_OFFICE_DISABLE_NATIVE_LOK") != "1"
129 +
130 +
131 +def open_document(path: str | Path) -> Any:
132 + from plugins._office.helpers import libreofficekit_worker
133 +
134 + return libreofficekit_worker.open_document(path)
135 +
136 +
137 +def open_document_in_process(path: str | Path) -> "NativeLokDocument":
138 + return get_office().open_document(path)
139 +
140 +
141 +def get_office() -> "NativeLokOffice":
142 + global _office
143 + try:
144 + return _office
145 + except NameError:
146 + _office = NativeLokOffice()
147 + atexit.register(_close_global_office)
148 + return _office
149 +
150 +
151 +def _close_global_office() -> None:
152 + office = globals().get("_office")
153 + if office:
154 + try:
155 + office.close()
156 + except Exception:
157 + pass
158 +
159 +
160 +class NativeLokOffice:
161 + def __init__(self) -> None:
162 + if not available():
163 + raise LibreOfficeKitNativeError("LibreOfficeKit native library is not available.")
164 +
165 + os.environ.setdefault("HOME", "/tmp")
166 + os.environ.setdefault("SAL_USE_VCLPLUGIN", "gen")
167 + self._profile_dir = Path(tempfile.mkdtemp(prefix="a0-lok-profile-"))
168 + self._library = ctypes.CDLL(str(MERGED_LIBRARY))
169 + self._library.libreofficekit_hook_2.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
170 + self._library.libreofficekit_hook_2.restype = _OfficePtr
171 + profile_url = f"file://{self._profile_dir}".encode("utf-8")
172 + self._office = self._library.libreofficekit_hook_2(str(PROGRAM_DIR).encode("utf-8"), profile_url)
173 + if not self._office:
174 + raise LibreOfficeKitNativeError("LibreOfficeKit hook returned no office instance.")
175 +
176 + def open_document(self, path: str | Path) -> "NativeLokDocument":
177 + source = Path(path)
178 + if not source.exists():
179 + raise FileNotFoundError(str(source))
180 + loaded = self._office.contents.pClass.contents.documentLoad(self._office, str(source).encode("utf-8"))
181 + if not loaded:
182 + raise LibreOfficeKitNativeError(self.error() or f"LibreOfficeKit could not load {source}.")
183 + document = NativeLokDocument(loaded, source)
184 + document.initialize_for_rendering()
185 + return document
186 +
187 + def error(self) -> str:
188 + get_error = self._office.contents.pClass.contents.getError
189 + if not get_error:
190 + return ""
191 + value = get_error(self._office)
192 + return _decode_c_string(value)
193 +
194 + def close(self) -> None:
195 + office = getattr(self, "_office", None)
196 + if office:
197 + office.contents.pClass.contents.destroy(office)
198 + self._office = None
199 +
200 +
201 +class NativeLokDocument:
202 + def __init__(self, document: _DocumentPtr, path: Path) -> None:
203 + self._document = document
204 + self.path = path
205 +
206 + @property
207 + def _class(self) -> _DocumentClass:
208 + return self._document.contents.pClass.contents
209 +
210 + def initialize_for_rendering(self) -> None:
211 + self._class.initializeForRendering(self._document, None)
212 +
213 + def metadata(self) -> dict[str, Any]:
214 + width = ctypes.c_long()
215 + height = ctypes.c_long()
216 + self._class.getDocumentSize(self._document, ctypes.byref(width), ctypes.byref(height))
217 + page_rectangles = self.page_rectangles(width.value, height.value)
218 + return {
219 + "available": True,
220 + "doctype": self._class.getDocumentType(self._document),
221 + "parts": self._class.getParts(self._document),
222 + "part": self._class.getPart(self._document),
223 + "tile_mode": self._class.getTileMode(self._document),
224 + "width_twips": int(width.value),
225 + "height_twips": int(height.value),
226 + "page_rectangles": page_rectangles,
227 + }
228 +
229 + def page_rectangles(self, width: int = 0, height: int = 0) -> list[dict[str, int]]:
230 + raw = self._class.getPartPageRectangles(self._document)
231 + rectangles = _parse_rectangles(_decode_c_string(raw))
232 + if rectangles:
233 + return rectangles
234 + if not width or not height:
235 + width_ref = ctypes.c_long()
236 + height_ref = ctypes.c_long()
237 + self._class.getDocumentSize(self._document, ctypes.byref(width_ref), ctypes.byref(height_ref))
238 + width = int(width_ref.value)
239 + height = int(height_ref.value)
240 + return [{"x": 0, "y": 0, "width": int(width), "height": int(height)}]
241 +
242 + def render_tiles(self, pixel_width: int = DEFAULT_TILE_WIDTH_PX, max_tiles: int = MAX_TILES) -> list[dict[str, Any]]:
243 + tile_mode = int(self._class.getTileMode(self._document))
244 + tiles: list[dict[str, Any]] = []
245 + for index, rectangle in enumerate(self.page_rectangles()[:max_tiles]):
246 + width_twips = max(1, int(rectangle["width"]))
247 + height_twips = max(1, int(rectangle["height"]))
248 + width_px = max(320, min(int(pixel_width), 1400))
249 + height_px = max(320, min(MAX_TILE_HEIGHT_PX, math.ceil(width_px * (height_twips / width_twips))))
250 + buffer = (ctypes.c_ubyte * (width_px * height_px * 4))()
251 + self._class.paintTile(
252 + self._document,
253 + buffer,
254 + width_px,
255 + height_px,
256 + int(rectangle["x"]),
257 + int(rectangle["y"]),
258 + width_twips,
259 + height_twips,
260 + )
261 + png = _png_from_lok_buffer(buffer, width_px, height_px, tile_mode)
262 + tiles.append({
263 + "index": index,
264 + "kind": "lok-tile",
265 + "width": width_px,
266 + "height": height_px,
267 + "twips": rectangle,
268 + "image": f"data:image/png;base64,{base64.b64encode(png).decode('ascii')}",
269 + })
270 + return tiles
271 +
272 + def post_uno_command(self, command: str, arguments: dict[str, Any] | str | None = None, notify: bool = True) -> dict[str, Any]:
273 + normalized = normalize_uno_command(command)
274 + payload = _encode_arguments(arguments)
275 + self._class.postUnoCommand(
276 + self._document,
277 + normalized.encode("utf-8"),
278 + payload,
279 + bool(notify),
280 + )
281 + return {"ok": True, "native": True, "command": normalized}
282 +
283 + def post_key_event(self, kind: str, char_code: int = 0, key_code: int = 0) -> dict[str, Any]:
284 + event_type = 1 if str(kind or "").lower() in {"up", "keyup"} else 0
285 + self._class.postKeyEvent(self._document, event_type, int(char_code or 0), int(key_code or 0))
286 + return {"ok": True, "native": True, "event": "key", "type": event_type}
287 +
288 + def type_text(self, text: str) -> dict[str, Any]:
289 + inserted = 0
290 + for character in str(text or ""):
291 + code = ord(character)
292 + self._class.postKeyEvent(self._document, 0, code, code)
293 + self._class.postKeyEvent(self._document, 1, code, code)
294 + inserted += 1
295 + return {"ok": True, "native": True, "event": "text", "inserted": inserted}
296 +
297 + def post_mouse_event(
298 + self,
299 + kind: str,
300 + x: int,
301 + y: int,
302 + count: int = 1,
303 + buttons: int = 1,
304 + modifier: int = 0,
305 + ) -> dict[str, Any]:
306 + mapping = {"down": 0, "mousedown": 0, "up": 1, "mouseup": 1, "move": 2, "mousemove": 2}
307 + event_type = mapping.get(str(kind or "").lower(), 0)
308 + self._class.postMouseEvent(
309 + self._document,
310 + event_type,
311 + int(x),
312 + int(y),
313 + int(count or 1),
314 + int(buttons or 1),
315 + int(modifier or 0),
316 + )
317 + return {"ok": True, "native": True, "event": "mouse", "type": event_type}
318 +
319 + def command_values(self, command: str) -> dict[str, Any]:
320 + normalized = normalize_uno_command(command)
321 + raw = self._class.getCommandValues(self._document, normalized.encode("utf-8"))
322 + text = _decode_c_string(raw)
323 + try:
324 + parsed = json.loads(text) if text else {}
325 + except json.JSONDecodeError:
326 + parsed = {"raw": text}
327 + return {"ok": True, "native": True, "command": normalized, "values": parsed}
328 +
329 + def save_as(self, path: str | Path | None = None, fmt: str | None = None) -> bool:
330 + target = Path(path) if path else self.path
331 + result = self._class.saveAs(
332 + self._document,
333 + str(target).encode("utf-8"),
334 + fmt.encode("utf-8") if fmt else None,
335 + None,
336 + )
337 + return result != 0
338 +
339 + def save_to_bytes(self, suffix: str = ".docx", fmt: str | None = "docx") -> bytes:
340 + temp_dir = Path(tempfile.mkdtemp(prefix="a0-lok-save-"))
341 + try:
342 + target = temp_dir / f"document{suffix}"
343 + if not self.save_as(target, fmt):
344 + raise LibreOfficeKitNativeError("LibreOfficeKit saveAs failed.")
345 + return target.read_bytes()
346 + finally:
347 + shutil.rmtree(temp_dir, ignore_errors=True)
348 +
349 + def close(self) -> None:
350 + document = getattr(self, "_document", None)
351 + if document:
352 + self._class.destroy(document)
353 + self._document = None
354 +
355 +
356 +def normalize_uno_command(command: str) -> str:
357 + value = str(command or "").strip()
358 + if not value:
359 + raise ValueError("UNO command is required.")
360 + return value if value.startswith(".uno:") else f".uno:{value}"
361 +
362 +
363 +def _encode_arguments(arguments: dict[str, Any] | str | None) -> bytes | None:
364 + if arguments is None or arguments == "":
365 + return None
366 + if isinstance(arguments, str):
367 + return arguments.encode("utf-8")
368 + return json.dumps(arguments, separators=(",", ":")).encode("utf-8")
369 +
370 +
371 +def _decode_c_string(value: bytes | int | None) -> str:
372 + if not value:
373 + return ""
374 + if isinstance(value, bytes):
375 + return value.decode("utf-8", errors="replace")
376 + return ctypes.string_at(value).decode("utf-8", errors="replace")
377 +
378 +
379 +def _parse_rectangles(payload: str) -> list[dict[str, int]]:
380 + rectangles: list[dict[str, int]] = []
381 + for item in str(payload or "").split(";"):
382 + numbers = [part.strip() for part in item.split(",")]
383 + if len(numbers) < 4:
384 + continue
385 + try:
386 + x, y, width, height = [int(float(value)) for value in numbers[:4]]
387 + except ValueError:
388 + continue
389 + if width > 0 and height > 0:
390 + rectangles.append({"x": x, "y": y, "width": width, "height": height})
391 + return rectangles
392 +
393 +
394 +def _png_from_lok_buffer(buffer: Any, width: int, height: int, tile_mode: int) -> bytes:
395 + raw = bytes(buffer)
396 + rows = []
397 + stride = width * 4
398 + for y in range(height):
399 + source = raw[y * stride:(y + 1) * stride]
400 + if tile_mode == 1:
401 + row = bytearray(stride)
402 + for index in range(0, stride, 4):
403 + blue = source[index]
404 + green = source[index + 1]
405 + red = source[index + 2]
406 + alpha = source[index + 3]
407 + row[index:index + 4] = bytes((red, green, blue, alpha))
408 + source = bytes(row)
409 + rows.append(b"\x00" + source)
410 + return _png_rgba(width, height, b"".join(rows))
411 +
412 +
413 +def _png_rgba(width: int, height: int, scanlines: bytes) -> bytes:
414 + def chunk(kind: bytes, payload: bytes) -> bytes:
415 + return (
416 + struct.pack(">I", len(payload))
417 + + kind
418 + + payload
419 + + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)
420 + )
421 +
422 + header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
423 + return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(scanlines, 6)) + chunk(b"IEND", b"")
plugins/_office/helpers/libreofficekit_sessions.py new
+348
@@ -0,0 +1,348 @@
1 +from __future__ import annotations
2 +
3 +import time
4 +import uuid
5 +from dataclasses import dataclass, field
6 +from pathlib import Path
7 +from typing import Any
8 +
9 +from plugins._office.helpers import document_store, libreoffice, libreofficekit_native
10 +
11 +
12 +@dataclass
13 +class EditorSession:
14 + session_id: str
15 + file_id: str
16 + sid: str
17 + extension: str
18 + path: str
19 + title: str
20 + text: str = ""
21 + native_document: Any | None = None
22 + native_metadata: dict[str, Any] = field(default_factory=dict)
23 + native_error: str = ""
24 + cursor: dict[str, Any] = field(default_factory=dict)
25 + selection: dict[str, Any] = field(default_factory=dict)
26 + opened_at: float = field(default_factory=time.time)
27 + updated_at: float = field(default_factory=time.time)
28 +
29 +
30 +class LibreOfficeKitSessionManager:
31 + """Small session facade for the right canvas.
32 +
33 + The public contract is shaped around LibreOfficeKit-style events: open,
34 + input, cursor/selection, invalidated tiles, save, and close. When the native
35 + Python LOK bridge is available the rendering path can be swapped underneath
36 + this manager without changing the browser or tool APIs.
37 + """
38 +
39 + def __init__(self) -> None:
40 + self._sessions: dict[str, EditorSession] = {}
41 +
42 + def open(self, doc: dict[str, Any], sid: str = "") -> dict[str, Any]:
43 + ext = str(doc["extension"]).lower()
44 + session_id = uuid.uuid4().hex
45 + text = ""
46 + if ext in {"md", "docx"}:
47 + text = document_store.read_text_for_editor(doc)
48 + native_document = None
49 + native_metadata: dict[str, Any] = {}
50 + native_error = ""
51 + if ext == "docx":
52 + try:
53 + native_document = libreofficekit_native.open_document(doc["path"])
54 + native_metadata = native_document.metadata()
55 + except Exception as exc:
56 + native_error = str(exc)
57 +
58 + session = EditorSession(
59 + session_id=session_id,
60 + file_id=doc["file_id"],
61 + sid=sid,
62 + extension=ext,
63 + path=doc["path"],
64 + title=doc["basename"],
65 + text=text,
66 + native_document=native_document,
67 + native_metadata=native_metadata,
68 + native_error=native_error,
69 + )
70 + self._sessions[session_id] = session
71 + return self._payload(session, doc)
72 +
73 + def input(self, session_id: str, text: str | None = None, patch: dict[str, Any] | None = None) -> dict[str, Any]:
74 + session = self._require(session_id)
75 + if text is not None:
76 + session.text = str(text)
77 + elif patch:
78 + session.text = _apply_text_patch(session.text, patch)
79 + session.updated_at = time.time()
80 + return {"ok": True, "session_id": session_id, "invalidated_tiles": self.tiles(session_id)}
81 +
82 + def key(self, session_id: str, key: dict[str, Any]) -> dict[str, Any]:
83 + session = self._require(session_id)
84 + native_document = session.native_document
85 + if not native_document:
86 + return {"ok": False, "native": False, "error": session.native_error or "Native key input is not available."}
87 +
88 + text = str(key.get("text") or "")
89 + if text:
90 + result = native_document.type_text(text)
91 + else:
92 + result = native_document.post_key_event(
93 + str(key.get("type") or "down"),
94 + char_code=int(key.get("char_code") or 0),
95 + key_code=int(key.get("key_code") or 0),
96 + )
97 + session.native_metadata = native_document.metadata()
98 + session.updated_at = time.time()
99 + return {**result, "metadata": session.native_metadata, "tiles": self.tiles(session_id)}
100 +
101 + def mouse(self, session_id: str, mouse: dict[str, Any]) -> dict[str, Any]:
102 + session = self._require(session_id)
103 + native_document = session.native_document
104 + if not native_document:
105 + return {"ok": False, "native": False, "error": session.native_error or "Native mouse input is not available."}
106 +
107 + result = native_document.post_mouse_event(
108 + str(mouse.get("type") or "down"),
109 + int(mouse.get("x") or 0),
110 + int(mouse.get("y") or 0),
111 + count=int(mouse.get("count") or 1),
112 + buttons=int(mouse.get("buttons") or 1),
113 + modifier=int(mouse.get("modifier") or 0),
114 + )
115 + session.native_metadata = native_document.metadata()
116 + session.updated_at = time.time()
117 + return {**result, "metadata": session.native_metadata, "tiles": self.tiles(session_id)}
118 +
119 + def cursor(self, session_id: str, cursor: dict[str, Any]) -> dict[str, Any]:
120 + session = self._require(session_id)
121 + session.cursor = dict(cursor or {})
122 + session.updated_at = time.time()
123 + return {"ok": True, "session_id": session_id, "cursor": session.cursor}
124 +
125 + def selection(self, session_id: str, selection: dict[str, Any]) -> dict[str, Any]:
126 + session = self._require(session_id)
127 + session.selection = dict(selection or {})
128 + session.updated_at = time.time()
129 + return {"ok": True, "session_id": session_id, "selection": session.selection}
130 +
131 + def tiles(self, session_id: str) -> list[dict[str, Any]]:
132 + session = self._require(session_id)
133 + if session.extension == "docx" and session.native_document:
134 + try:
135 + return session.native_document.render_tiles()
136 + except Exception as exc:
137 + session.native_error = str(exc)
138 + if session.extension == "docx":
139 + return _docx_text_tiles(session.text)
140 + if session.extension == "md":
141 + return _markdown_text_tiles(session.text)
142 + doc = document_store.get_document(session.file_id)
143 + preview = document_store.build_preview(doc)
144 + return [{"index": 0, "kind": preview.get("kind") or "file", "preview": preview}]
145 +
146 + def save(self, session_id: str, text: str | None = None) -> dict[str, Any]:
147 + session = self._require(session_id)
148 + if text is not None:
149 + session.text = str(text)
150 +
151 + doc = document_store.get_document(session.file_id)
152 + if session.extension == "md":
153 + updated = document_store.write_markdown(session.file_id, session.text)
154 + session.updated_at = time.time()
155 + return {"ok": True, "document": _public_doc(updated), "tiles": self.tiles(session_id), "native": self._native_payload(session)}
156 +
157 + if session.extension == "docx":
158 + from plugins._office.helpers import artifact_editor
159 +
160 + if session.native_document and text is None:
161 + updated = document_store.replace_document_bytes(
162 + session.file_id,
163 + session.native_document.save_to_bytes(".docx", "docx"),
164 + actor="libreofficekit:save",
165 + invalidate_sessions=False,
166 + )
167 + else:
168 + updated, _payload = artifact_editor.edit_artifact(
169 + doc,
170 + operation="set_text",
171 + content=session.text,
172 + invalidate_sessions=False,
173 + )
174 + validation = libreoffice.validate_docx(updated["path"])
175 + if not validation.get("ok"):
176 + return {"ok": False, "error": validation.get("error") or "DOCX save verification failed."}
177 + self._reopen_native_document(session, updated["path"])
178 + session.updated_at = time.time()
179 + return {
180 + "ok": True,
181 + "document": _public_doc(updated),
182 + "tiles": self.tiles(session_id),
183 + "validation": validation,
184 + "native": self._native_payload(session),
185 + }
186 +
187 + return {"ok": False, "error": f"Canvas editing is not available for .{session.extension}."}
188 +
189 + def command(self, session_id: str, command: str, arguments: Any = None, notify: bool = True) -> dict[str, Any]:
190 + session = self._require(session_id)
191 + native_document = session.native_document
192 + if not native_document:
193 + return {
194 + "ok": False,
195 + "native": False,
196 + "error": session.native_error or f"Native LibreOfficeKit commands are not available for .{session.extension}.",
197 + }
198 + result = native_document.post_uno_command(command, arguments=arguments, notify=notify)
199 + session.native_metadata = native_document.metadata()
200 + session.updated_at = time.time()
201 + return {**result, "metadata": session.native_metadata, "tiles": self.tiles(session_id)}
202 +
203 + def command_values(self, session_id: str, command: str) -> dict[str, Any]:
204 + session = self._require(session_id)
205 + native_document = session.native_document
206 + if not native_document:
207 + return {
208 + "ok": False,
209 + "native": False,
210 + "error": session.native_error or f"Native LibreOfficeKit command values are not available for .{session.extension}.",
211 + }
212 + return native_document.command_values(command)
213 +
214 + def refresh_document(self, file_id: str) -> dict[str, Any]:
215 + normalized = str(file_id or "").strip()
216 + if not normalized:
217 + return {"ok": True, "refreshed": 0, "sessions": []}
218 + try:
219 + doc = document_store.get_document(normalized)
220 + except Exception:
221 + return {"ok": False, "refreshed": 0, "sessions": []}
222 +
223 + refreshed: list[str] = []
224 + for session in self._sessions.values():
225 + if session.file_id != normalized:
226 + continue
227 + if session.extension in {"md", "docx"}:
228 + session.text = document_store.read_text_for_editor(doc)
229 + if session.extension == "docx":
230 + self._reopen_native_document(session, doc["path"])
231 + session.updated_at = time.time()
232 + refreshed.append(session.session_id)
233 + return {"ok": True, "refreshed": len(refreshed), "sessions": refreshed}
234 +
235 + def close(self, session_id: str) -> dict[str, Any]:
236 + session = self._sessions.pop(str(session_id or ""), None)
237 + if not session:
238 + return {"ok": True, "closed": 0}
239 + self._close_native_document(session)
240 + return {"ok": True, "closed": 1, "session_id": session_id}
241 +
242 + def close_sid(self, sid: str) -> int:
243 + doomed = [session_id for session_id, session in self._sessions.items() if session.sid == sid]
244 + for session_id in doomed:
245 + session = self._sessions.pop(session_id, None)
246 + if session:
247 + self._close_native_document(session)
248 + return len(doomed)
249 +
250 + def _payload(self, session: EditorSession, doc: dict[str, Any]) -> dict[str, Any]:
251 + return {
252 + "ok": True,
253 + "session_id": session.session_id,
254 + "file_id": session.file_id,
255 + "title": session.title,
256 + "extension": session.extension,
257 + "path": session.path,
258 + "text": session.text,
259 + "tiles": self.tiles(session.session_id),
260 + "document": _public_doc(doc),
261 + "version": document_store.item_version(doc),
262 + "libreoffice": libreoffice.collect_status(),
263 + "native": self._native_payload(session),
264 + }
265 +
266 + def _require(self, session_id: str) -> EditorSession:
267 + normalized = str(session_id or "").strip()
268 + session = self._sessions.get(normalized)
269 + if not session:
270 + raise FileNotFoundError(f"Editor session not found: {normalized}")
271 + return session
272 +
273 + def _native_payload(self, session: EditorSession) -> dict[str, Any]:
274 + if session.native_document:
275 + return {"available": True, **session.native_metadata}
276 + return {"available": False, "error": session.native_error}
277 +
278 + def _reopen_native_document(self, session: EditorSession, path: str) -> None:
279 + self._close_native_document(session)
280 + try:
281 + session.native_document = libreofficekit_native.open_document(path)
282 + session.native_metadata = session.native_document.metadata()
283 + session.native_error = ""
284 + except Exception as exc:
285 + session.native_document = None
286 + session.native_metadata = {}
287 + session.native_error = str(exc)
288 +
289 + def _close_native_document(self, session: EditorSession) -> None:
290 + native_document = session.native_document
291 + if native_document:
292 + try:
293 + native_document.close()
294 + except Exception:
295 + pass
296 + session.native_document = None
297 +
298 +
299 +def get_manager() -> LibreOfficeKitSessionManager:
300 + global _manager
301 + try:
302 + return _manager
303 + except NameError:
304 + _manager = LibreOfficeKitSessionManager()
305 + return _manager
306 +
307 +
308 +def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
309 + return {
310 + "file_id": doc["file_id"],
311 + "path": document_store.display_path(doc["path"]),
312 + "basename": doc["basename"],
313 + "extension": doc["extension"],
314 + "size": doc["size"],
315 + "version": document_store.item_version(doc),
316 + "last_modified": doc["last_modified"],
317 + "exists": Path(doc["path"]).exists(),
318 + }
319 +
320 +
321 +def _apply_text_patch(text: str, patch: dict[str, Any]) -> str:
322 + if "content" in patch:
323 + return str(patch.get("content") or "")
324 + start = int(patch.get("start") or 0)
325 + end = int(patch.get("end") if patch.get("end") is not None else start)
326 + replacement = str(patch.get("text") or "")
327 + start = max(0, min(len(text), start))
328 + end = max(start, min(len(text), end))
329 + return text[:start] + replacement + text[end:]
330 +
331 +
332 +def _markdown_text_tiles(text: str) -> list[dict[str, Any]]:
333 + lines = [line for line in str(text or "").splitlines() if line.strip()]
334 + return [{"index": 0, "kind": "markdown", "lines": lines[:36]}]
335 +
336 +
337 +def _docx_text_tiles(text: str) -> list[dict[str, Any]]:
338 + paragraphs = [line.strip() for line in str(text or "").splitlines() if line.strip()]
339 + if not paragraphs:
340 + paragraphs = [""]
341 + pages = []
342 + for index in range(0, len(paragraphs), 18):
343 + pages.append({
344 + "index": len(pages),
345 + "kind": "docx",
346 + "lines": paragraphs[index:index + 18],
347 + })
348 + return pages
plugins/_office/helpers/libreofficekit_worker.py new
+214
@@ -0,0 +1,214 @@
1 +from __future__ import annotations
2 +
3 +import base64
4 +import json
5 +import os
6 +import select
7 +import subprocess
8 +import sys
9 +import threading
10 +import time
11 +from pathlib import Path
12 +from typing import Any
13 +
14 +
15 +REQUEST_TIMEOUT_SECONDS = 18
16 +
17 +
18 +def open_document(path: str | Path) -> "WorkerLokDocument":
19 + return WorkerLokDocument(path)
20 +
21 +
22 +class WorkerLokDocument:
23 + def __init__(self, path: str | Path) -> None:
24 + self.path = Path(path)
25 + self._counter = 0
26 + self._lock = threading.RLock()
27 + self._process = subprocess.Popen(
28 + [sys.executable, "-m", "plugins._office.helpers.libreofficekit_worker", "--worker"],
29 + cwd=str(Path(__file__).resolve().parents[3]),
30 + stdin=subprocess.PIPE,
31 + stdout=subprocess.PIPE,
32 + stderr=subprocess.PIPE,
33 + text=True,
34 + bufsize=1,
35 + env={**os.environ, "PYTHONUNBUFFERED": "1", "SAL_USE_VCLPLUGIN": os.environ.get("SAL_USE_VCLPLUGIN", "gen")},
36 + )
37 + opened = self._request("open", {"path": str(self.path)}, timeout=REQUEST_TIMEOUT_SECONDS)
38 + if not opened.get("ok"):
39 + raise RuntimeError(opened.get("error") or "LibreOfficeKit worker could not open document.")
40 + self._metadata = opened.get("metadata") or {}
41 +
42 + def metadata(self) -> dict[str, Any]:
43 + response = self._request("metadata")
44 + if response.get("metadata"):
45 + self._metadata = response["metadata"]
46 + return dict(self._metadata)
47 +
48 + def render_tiles(self, pixel_width: int = 920, max_tiles: int = 12) -> list[dict[str, Any]]:
49 + response = self._request("tiles", {"pixel_width": pixel_width, "max_tiles": max_tiles}, timeout=REQUEST_TIMEOUT_SECONDS)
50 + return response.get("tiles") or []
51 +
52 + def post_uno_command(self, command: str, arguments: dict[str, Any] | str | None = None, notify: bool = True) -> dict[str, Any]:
53 + return self._request("command", {"command": command, "arguments": arguments, "notify": notify})
54 +
55 + def command_values(self, command: str) -> dict[str, Any]:
56 + return self._request("command_values", {"command": command})
57 +
58 + def post_mouse_event(
59 + self,
60 + kind: str,
61 + x: int,
62 + y: int,
63 + count: int = 1,
64 + buttons: int = 1,
65 + modifier: int = 0,
66 + ) -> dict[str, Any]:
67 + return self._request("mouse", {
68 + "type": kind,
69 + "x": x,
70 + "y": y,
71 + "count": count,
72 + "buttons": buttons,
73 + "modifier": modifier,
74 + })
75 +
76 + def post_key_event(self, kind: str, char_code: int = 0, key_code: int = 0) -> dict[str, Any]:
77 + return self._request("key", {
78 + "type": kind,
79 + "char_code": char_code,
80 + "key_code": key_code,
81 + })
82 +
83 + def type_text(self, text: str) -> dict[str, Any]:
84 + return self._request("text", {"text": text})
85 +
86 + def save_to_bytes(self, suffix: str = ".docx", fmt: str | None = "docx") -> bytes:
87 + response = self._request("save", {"suffix": suffix, "format": fmt}, timeout=REQUEST_TIMEOUT_SECONDS)
88 + data = response.get("bytes") or ""
89 + return base64.b64decode(data.encode("ascii"))
90 +
91 + def close(self) -> None:
92 + process = self._process
93 + if process.poll() is not None:
94 + return
95 + try:
96 + self._request("close", timeout=3)
97 + process.wait(timeout=3)
98 + except Exception:
99 + process.kill()
100 + process.wait(timeout=3)
101 +
102 + def _request(self, action: str, payload: dict[str, Any] | None = None, timeout: float = REQUEST_TIMEOUT_SECONDS) -> dict[str, Any]:
103 + with self._lock:
104 + return self._request_unlocked(action, payload=payload, timeout=timeout)
105 +
106 + def _request_unlocked(self, action: str, payload: dict[str, Any] | None = None, timeout: float = REQUEST_TIMEOUT_SECONDS) -> dict[str, Any]:
107 + process = self._process
108 + if process.poll() is not None:
109 + stderr = process.stderr.read() if process.stderr else ""
110 + raise RuntimeError(f"LibreOfficeKit worker exited with {process.returncode}: {stderr.strip()}")
111 + self._counter += 1
112 + message = {"id": self._counter, "action": action, **(payload or {})}
113 + assert process.stdin is not None
114 + process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n")
115 + process.stdin.flush()
116 + assert process.stdout is not None
117 + deadline = time.time() + timeout
118 + while time.time() < deadline:
119 + ready, _, _ = select.select([process.stdout], [], [], max(0.05, min(0.5, deadline - time.time())))
120 + if not ready:
121 + continue
122 + line = process.stdout.readline()
123 + if not line:
124 + break
125 + try:
126 + response = json.loads(line)
127 + except json.JSONDecodeError:
128 + continue
129 + if response.get("id") == self._counter:
130 + if response.get("ok") is False:
131 + raise RuntimeError(response.get("error") or f"LibreOfficeKit worker {action} failed.")
132 + return response
133 + process.kill()
134 + raise TimeoutError(f"LibreOfficeKit worker timed out during {action}.")
135 +
136 +
137 +def _worker_loop() -> None:
138 + from plugins._office.helpers import libreofficekit_native
139 +
140 + document = None
141 + for line in sys.stdin:
142 + try:
143 + request = json.loads(line)
144 + action = request.get("action")
145 + if action == "open":
146 + document = libreofficekit_native.open_document_in_process(request["path"])
147 + _respond(request, {"ok": True, "metadata": document.metadata()})
148 + elif not document:
149 + _respond(request, {"ok": False, "error": "Document is not open."})
150 + elif action == "metadata":
151 + _respond(request, {"ok": True, "metadata": document.metadata()})
152 + elif action == "tiles":
153 + _respond(request, {
154 + "ok": True,
155 + "tiles": document.render_tiles(
156 + pixel_width=int(request.get("pixel_width") or 920),
157 + max_tiles=int(request.get("max_tiles") or 12),
158 + ),
159 + })
160 + elif action == "command":
161 + result = document.post_uno_command(
162 + str(request.get("command") or ""),
163 + arguments=request.get("arguments"),
164 + notify=bool(request.get("notify", True)),
165 + )
166 + _respond(request, {"ok": True, **result, "metadata": document.metadata()})
167 + elif action == "command_values":
168 + _respond(request, document.command_values(str(request.get("command") or "")))
169 + elif action == "mouse":
170 + result = document.post_mouse_event(
171 + str(request.get("type") or "down"),
172 + int(request.get("x") or 0),
173 + int(request.get("y") or 0),
174 + count=int(request.get("count") or 1),
175 + buttons=int(request.get("buttons") or 1),
176 + modifier=int(request.get("modifier") or 0),
177 + )
178 + _respond(request, {"ok": True, **result, "metadata": document.metadata(), "tiles": document.render_tiles()})
179 + elif action == "key":
180 + result = document.post_key_event(
181 + str(request.get("type") or "down"),
182 + char_code=int(request.get("char_code") or 0),
183 + key_code=int(request.get("key_code") or 0),
184 + )
185 + _respond(request, {"ok": True, **result, "metadata": document.metadata(), "tiles": document.render_tiles()})
186 + elif action == "text":
187 + result = document.type_text(str(request.get("text") or ""))
188 + _respond(request, {"ok": True, **result, "metadata": document.metadata(), "tiles": document.render_tiles()})
189 + elif action == "save":
190 + data = document.save_to_bytes(
191 + suffix=str(request.get("suffix") or ".docx"),
192 + fmt=request.get("format") or "docx",
193 + )
194 + _respond(request, {"ok": True, "bytes": base64.b64encode(data).decode("ascii"), "metadata": document.metadata()})
195 + elif action == "close":
196 + if document:
197 + document.close()
198 + _respond(request, {"ok": True, "closed": True})
199 + sys.stdout.flush()
200 + os._exit(0)
201 + else:
202 + _respond(request, {"ok": False, "error": f"Unknown worker action: {action}"})
203 + except Exception as exc:
204 + _respond(json.loads(line) if line.strip().startswith("{") else {}, {"ok": False, "error": str(exc)})
205 + os._exit(0)
206 +
207 +
208 +def _respond(request: dict[str, Any], response: dict[str, Any]) -> None:
209 + sys.stdout.write(json.dumps({"id": request.get("id"), **response}, separators=(",", ":")) + "\n")
210 + sys.stdout.flush()
211 +
212 +
213 +if __name__ == "__main__" and "--worker" in sys.argv:
214 + _worker_loop()
plugins/_office/helpers/office_proxy.py deleted
-261
@@ -1,261 +0,0 @@
1 -from __future__ import annotations
2 -
3 -import asyncio
4 -from http.cookies import SimpleCookie
5 -from urllib.parse import parse_qs, unquote
6 -
7 -import httpx
8 -from flask.sessions import SecureCookieSessionInterface
9 -from starlette.responses import PlainTextResponse, Response
10 -from starlette.types import Receive, Scope, Send
11 -from starlette.websockets import WebSocket
12 -
13 -from helpers import login
14 -from plugins._office.helpers import wopi_store
15 -
16 -
17 -UPSTREAM_HTTP = "http://127.0.0.1:9980"
18 -UPSTREAM_WS = "ws://127.0.0.1:9980"
19 -HTTP_PROXY_ATTEMPTS = 4
20 -HTTP_PROXY_RETRY_DELAYS = (0.2, 0.5, 1.0)
21 -TRANSIENT_HTTP_ERRORS = (
22 - httpx.ConnectError,
23 - httpx.ConnectTimeout,
24 - httpx.ReadError,
25 - httpx.ReadTimeout,
26 - httpx.RemoteProtocolError,
27 - httpx.WriteError,
28 - httpx.WriteTimeout,
29 -)
30 -
31 -
32 -class OfficeProxy:
33 - def __init__(self, flask_app=None) -> None:
34 - self.flask_app = flask_app
35 -
36 - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
37 - if scope["type"] == "websocket":
38 - await self.websocket(scope, receive, send)
39 - return
40 - if scope["type"] == "http":
41 - await self.http(scope, receive, send)
42 - return
43 - await PlainTextResponse("Unsupported scope", status_code=500)(scope, receive, send)
44 -
45 - def upstream_path(self, scope: Scope) -> str:
46 - raw_path = scope.get("raw_path")
47 - if raw_path:
48 - path = raw_path.decode("latin-1")
49 - else:
50 - path = scope.get("path", "")
51 - if not path.startswith("/office"):
52 - path = "/office" + (path if path.startswith("/") else "/" + path)
53 - query = scope.get("query_string", b"").decode("latin-1")
54 - return path + (f"?{query}" if query else "")
55 -
56 - async def http(self, scope: Scope, receive: Receive, send: Send) -> None:
57 - if not self.is_authorized(scope):
58 - await PlainTextResponse("Authentication required", status_code=401)(scope, receive, send)
59 - return
60 -
61 - body = b""
62 - more = True
63 - while more:
64 - message = await receive()
65 - if message["type"] != "http.request":
66 - break
67 - body += message.get("body", b"")
68 - more = bool(message.get("more_body"))
69 -
70 - method = scope.get("method", "GET")
71 - headers = self.forward_headers(scope)
72 - url = UPSTREAM_HTTP + self.upstream_path(scope)
73 - try:
74 - upstream, attempts = await self.request_upstream_http(method, url, body, headers)
75 - disable_cache = self.should_disable_cache(scope, upstream.status_code)
76 - omitted_headers = {"content-encoding", "content-length", "transfer-encoding", "connection"}
77 - if disable_cache:
78 - omitted_headers.update({"cache-control", "pragma", "expires"})
79 - response_headers = {
80 - key: value
81 - for key, value in upstream.headers.items()
82 - if key.lower() not in omitted_headers
83 - }
84 - response_headers["X-A0-Office-Proxy-Attempts"] = str(attempts)
85 - if disable_cache:
86 - response_headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"
87 - response_headers["Pragma"] = "no-cache"
88 - response_headers["Expires"] = "0"
89 - await Response(upstream.content, status_code=upstream.status_code, headers=response_headers)(scope, receive, send)
90 - except Exception as exc:
91 - await PlainTextResponse(
92 - f"Collabora is unavailable after {HTTP_PROXY_ATTEMPTS} attempts: {exc}",
93 - status_code=503,
94 - headers={
95 - "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
96 - "Pragma": "no-cache",
97 - "Expires": "0",
98 - },
99 - )(scope, receive, send)
100 -
101 - async def request_upstream_http(
102 - self,
103 - method: str,
104 - url: str,
105 - body: bytes,
106 - headers: dict[str, str],
107 - ) -> tuple[httpx.Response, int]:
108 - for attempt in range(1, HTTP_PROXY_ATTEMPTS + 1):
109 - try:
110 - async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, read=36000.0), follow_redirects=False) as client:
111 - response = await client.request(method, url, content=body, headers=headers)
112 - return response, attempt
113 - except TRANSIENT_HTTP_ERRORS:
114 - if attempt >= HTTP_PROXY_ATTEMPTS:
115 - raise
116 - delay_index = min(attempt - 1, len(HTTP_PROXY_RETRY_DELAYS) - 1)
117 - await asyncio.sleep(HTTP_PROXY_RETRY_DELAYS[delay_index])
118 - raise RuntimeError("Collabora proxy retry loop exited unexpectedly")
119 -
120 - def should_disable_cache(self, scope: Scope, status_code: int) -> bool:
121 - path = self.upstream_path(scope).split("?", 1)[0]
122 - return status_code >= 500 or path.endswith("/cool.html")
123 -
124 - async def websocket(self, scope: Scope, receive: Receive, send: Send) -> None:
125 - import websockets
126 -
127 - websocket = WebSocket(scope, receive=receive, send=send)
128 - if not self.is_authorized(scope):
129 - await websocket.close(code=1008)
130 - return
131 -
132 - await websocket.accept()
133 - url = self.upstream_websocket_url(scope)
134 - headers = self.websocket_headers(scope)
135 - origin = self.header_value(scope, b"origin", "")
136 - try:
137 - async with websockets.connect(
138 - url,
139 - host="127.0.0.1",
140 - port=9980,
141 - origin=origin or None,
142 - additional_headers=headers,
143 - open_timeout=10,
144 - ping_interval=None,
145 - ) as upstream:
146 - async def browser_to_upstream():
147 - while True:
148 - msg = await websocket.receive()
149 - if msg["type"] == "websocket.disconnect":
150 - await upstream.close()
151 - return
152 - if "bytes" in msg and msg["bytes"] is not None:
153 - await upstream.send(msg["bytes"])
154 - elif "text" in msg and msg["text"] is not None:
155 - await upstream.send(msg["text"])
156 -
157 - async def upstream_to_browser():
158 - async for msg in upstream:
159 - if isinstance(msg, bytes):
160 - await websocket.send_bytes(msg)
161 - else:
162 - await websocket.send_text(msg)
163 -
164 - await asyncio.gather(browser_to_upstream(), upstream_to_browser())
165 - except Exception:
166 - await websocket.close(code=1011)
167 -
168 - def forward_headers(self, scope: Scope) -> dict[str, str]:
169 - headers: dict[str, str] = {}
170 - for key_b, value_b in scope.get("headers", []):
171 - key = key_b.decode("latin-1")
172 - value = value_b.decode("latin-1")
173 - if key.lower() in {"host", "content-length", "connection"}:
174 - continue
175 - headers[key] = value
176 - host = dict(scope.get("headers", [])).get(b"host", b"localhost:32080").decode("latin-1")
177 - headers["Host"] = host
178 - headers["X-Forwarded-Proto"] = scope.get("scheme", "http")
179 - return headers
180 -
181 - def upstream_websocket_url(self, scope: Scope) -> str:
182 - host = self.header_value(scope, b"host", "localhost:32080")
183 - return f"ws://{host}{self.upstream_path(scope)}"
184 -
185 - def websocket_headers(self, scope: Scope) -> list[tuple[str, str]]:
186 - hop_by_hop = {
187 - b"host",
188 - b"connection",
189 - b"upgrade",
190 - b"origin",
191 - b"sec-websocket-key",
192 - b"sec-websocket-version",
193 - b"sec-websocket-extensions",
194 - }
195 - headers = [
196 - (key.decode("latin-1"), value.decode("latin-1"))
197 - for key, value in scope.get("headers", [])
198 - if key.lower() not in hop_by_hop
199 - ]
200 - return headers
201 -
202 - def header_value(self, scope: Scope, name: bytes, default: str = "") -> str:
203 - value = dict(scope.get("headers", [])).get(name, default.encode("latin-1"))
204 - return value.decode("latin-1") if isinstance(value, bytes) else str(value)
205 -
206 - def is_authorized(self, scope: Scope) -> bool:
207 - if self._has_valid_wopi_token(scope):
208 - return True
209 - credentials_hash = login.get_credentials_hash()
210 - if not credentials_hash:
211 - return True
212 - if not self.flask_app:
213 - return False
214 - serializer = SecureCookieSessionInterface().get_signing_serializer(self.flask_app)
215 - if not serializer:
216 - return False
217 - cookie_header = dict(scope.get("headers", [])).get(b"cookie", b"").decode("latin-1")
218 - if not cookie_header:
219 - return False
220 - cookies = SimpleCookie()
221 - cookies.load(cookie_header)
222 - session_cookie = cookies.get(self.flask_app.config.get("SESSION_COOKIE_NAME", "session"))
223 - if not session_cookie:
224 - return False
225 - try:
226 - session_data = serializer.loads(session_cookie.value)
227 - except Exception:
228 - return False
229 - return session_data.get("authentication") == credentials_hash
230 -
231 - def _has_valid_wopi_token(self, scope: Scope) -> bool:
232 - if not self._is_collabora_editor_channel(scope):
233 - return False
234 - path = self.upstream_path(scope)
235 - decoded = unquote(path)
236 - marker = "/wopi/files/"
237 - marker_index = decoded.find(marker)
238 - if marker_index == -1:
239 - return False
240 - file_part = decoded[marker_index + len(marker):]
241 - file_id, separator, query_text = file_part.partition("?")
242 - if not separator or not file_id:
243 - return False
244 - file_id = file_id.strip("/")
245 - if "/" in file_id:
246 - file_id = file_id.split("/", 1)[0]
247 - token = (parse_qs(query_text, keep_blank_values=True).get("access_token") or [""])[0]
248 - if not token:
249 - return False
250 - try:
251 - wopi_store.validate_token(token, file_id, require_write=False)
252 - except Exception:
253 - return False
254 - return True
255 -
256 - def _is_collabora_editor_channel(self, scope: Scope) -> bool:
257 - path = scope.get("path", "")
258 - raw_path = scope.get("raw_path")
259 - if raw_path:
260 - path = raw_path.decode("latin-1", errors="ignore")
261 - return path.startswith("/office/cool/")
plugins/_office/helpers/route_bootstrap.py deleted
-61
@@ -1,61 +0,0 @@
1 -from __future__ import annotations
2 -
3 -
4 -def install_route_hooks() -> None:
5 - from helpers.ui_server import UiServerRuntime
6 -
7 - if getattr(UiServerRuntime, "_a0_office_route_hooks_installed", False):
8 - return
9 -
10 - original_register_http_routes = UiServerRuntime.register_http_routes
11 - original_build_asgi_app = UiServerRuntime.build_asgi_app
12 -
13 - def register_http_routes(self):
14 - result = original_register_http_routes(self)
15 - from plugins._office.helpers.wopi_routes import register_wopi_routes
16 -
17 - register_wopi_routes(self.webapp)
18 - return result
19 -
20 - def build_asgi_app(self, startup_monitor):
21 - from socketio import ASGIApp
22 - from starlette.applications import Starlette
23 - from starlette.routing import Mount
24 - from uvicorn.middleware.wsgi import WSGIMiddleware
25 -
26 - from helpers import fasta2a_server, mcp_server
27 - from plugins._office.helpers.office_proxy import OfficeProxy
28 -
29 - with startup_monitor.stage("wsgi.middleware.create"):
30 - wsgi_app = WSGIMiddleware(self.webapp)
31 -
32 - with startup_monitor.stage("mcp.proxy.init"):
33 - mcp_app = mcp_server.DynamicMcpProxy.get_instance()
34 -
35 - with startup_monitor.stage("a2a.proxy.init"):
36 - a2a_app = fasta2a_server.DynamicA2AProxy.get_instance()
37 -
38 - with startup_monitor.stage("starlette.app.create"):
39 - starlette_app = Starlette(
40 - routes=[
41 - Mount("/office", app=OfficeProxy(self.webapp)),
42 - Mount("/mcp", app=mcp_app),
43 - Mount("/a2a", app=a2a_app),
44 - Mount("/", app=wsgi_app),
45 - ],
46 - lifespan=startup_monitor.lifespan(),
47 - )
48 -
49 - with startup_monitor.stage("socketio.asgi.create"):
50 - return ASGIApp(self.socketio_server, other_asgi_app=starlette_app)
51 -
52 - UiServerRuntime.register_http_routes = register_http_routes
53 - UiServerRuntime.build_asgi_app = build_asgi_app
54 - UiServerRuntime._a0_office_route_hooks_installed = True
55 - UiServerRuntime._a0_office_original_build_asgi_app = original_build_asgi_app
56 -
57 -
58 -def is_installed() -> bool:
59 - from helpers.ui_server import UiServerRuntime
60 -
61 - return bool(getattr(UiServerRuntime, "_a0_office_route_hooks_installed", False))
plugins/_office/helpers/wopi_routes.py deleted
-133
@@ -1,133 +0,0 @@
1 -from __future__ import annotations
2 -
3 -import json
4 -import mimetypes
5 -from pathlib import Path
6 -from typing import Any
7 -
8 -from flask import Flask, Response, request, send_file
9 -
10 -from plugins._office.helpers import wopi_store
11 -
12 -
13 -def register_wopi_routes(app: Flask) -> None:
14 - if getattr(app, "_a0_office_wopi_routes_registered", False):
15 - return
16 - app._a0_office_wopi_routes_registered = True
17 -
18 - app.add_url_rule("/wopi/files/<file_id>", "office_wopi_file", wopi_file, methods=["GET", "POST"])
19 - app.add_url_rule("/wopi/files/<file_id>/contents", "office_wopi_contents", wopi_contents, methods=["GET", "POST"])
20 -
21 -
22 -def token_from_request() -> str:
23 - auth = request.headers.get("Authorization", "")
24 - if auth.lower().startswith("bearer "):
25 - return auth.split(" ", 1)[1].strip()
26 - return request.args.get("access_token", "") or request.form.get("access_token", "")
27 -
28 -
29 -def validate(file_id: str, require_write: bool = False) -> dict[str, Any] | Response:
30 - try:
31 - return wopi_store.validate_token(token_from_request(), file_id, require_write=require_write)
32 - except PermissionError as exc:
33 - return Response(str(exc), status=401)
34 - except Exception as exc:
35 - return Response(str(exc), status=404)
36 -
37 -
38 -def json_response(data: dict[str, Any], status: int = 200, headers: dict[str, str] | None = None) -> Response:
39 - return Response(
40 - json.dumps(data, separators=(",", ":"), ensure_ascii=False),
41 - status=status,
42 - mimetype="application/json",
43 - headers=headers or {},
44 - )
45 -
46 -
47 -def conflict(current_lock: str, reason: str = "Lock mismatch") -> Response:
48 - return Response(
49 - "",
50 - status=409,
51 - headers={
52 - "X-WOPI-Lock": current_lock or "",
53 - "X-WOPI-LockFailureReason": reason,
54 - },
55 - )
56 -
57 -
58 -def wopi_file(file_id: str):
59 - if request.method == "GET":
60 - token_info = validate(file_id)
61 - if isinstance(token_info, Response):
62 - return token_info
63 - try:
64 - return json_response(wopi_store.check_file_info(file_id, token_info))
65 - except FileNotFoundError:
66 - return Response("File not found", status=404)
67 - except Exception as exc:
68 - return Response(str(exc), status=500)
69 -
70 - override = request.headers.get("X-WOPI-Override", "").upper().replace("-", "_")
71 - require_write = override in {"LOCK", "REFRESH_LOCK", "UNLOCK"}
72 - token_info = validate(file_id, require_write=require_write)
73 - if isinstance(token_info, Response):
74 - return token_info
75 -
76 - lock_value = request.headers.get("X-WOPI-Lock", "")
77 - old_lock = request.headers.get("X-WOPI-OldLock", "")
78 - timeout = request.headers.get("X-WOPI-LockExpirationTimeout")
79 - session_id = (token_info.get("token") or {}).get("session_id", "")
80 -
81 - try:
82 - if override == "GET_LOCK":
83 - return Response("", status=200, headers={"X-WOPI-Lock": wopi_store.get_lock(file_id)})
84 - if override == "LOCK" and old_lock:
85 - ok, current = wopi_store.unlock_and_relock(file_id, old_lock, lock_value, session_id, timeout)
86 - return Response("", status=200) if ok else conflict(current)
87 - if override == "LOCK":
88 - ok, current = wopi_store.lock(file_id, lock_value, session_id, timeout)
89 - return Response("", status=200) if ok else conflict(current)
90 - if override == "REFRESH_LOCK":
91 - ok, current = wopi_store.refresh_lock(file_id, lock_value, timeout)
92 - return Response("", status=200) if ok else conflict(current)
93 - if override == "UNLOCK":
94 - ok, current = wopi_store.unlock(file_id, lock_value)
95 - return Response("", status=200) if ok else conflict(current)
96 - return Response("Unsupported WOPI override", status=501)
97 - except Exception as exc:
98 - return Response(str(exc), status=500)
99 -
100 -
101 -def wopi_contents(file_id: str):
102 - if request.method == "GET":
103 - token_info = validate(file_id)
104 - if isinstance(token_info, Response):
105 - return token_info
106 - try:
107 - doc = wopi_store.get_document(file_id)
108 - path = Path(doc["path"])
109 - mimetype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
110 - return send_file(path, mimetype=mimetype, as_attachment=False, download_name=doc["basename"])
111 - except FileNotFoundError:
112 - return Response("File not found", status=404)
113 - except Exception as exc:
114 - return Response(str(exc), status=500)
115 -
116 - override = request.headers.get("X-WOPI-Override", "").upper().replace("-", "_")
117 - if override != "PUT":
118 - return Response("Unsupported WOPI override", status=501)
119 - token_info = validate(file_id, require_write=True)
120 - if isinstance(token_info, Response):
121 - return token_info
122 -
123 - try:
124 - version = wopi_store.put_file(file_id, request.get_data() or b"", request.headers.get("X-WOPI-Lock", ""))
125 - return Response("", status=200, headers={"X-WOPI-ItemVersion": version})
126 - except wopi_store.LockMismatch as exc:
127 - return conflict(exc.current_lock)
128 - except OverflowError as exc:
129 - return Response(str(exc), status=413)
130 - except FileNotFoundError:
131 - return Response("File not found", status=404)
132 - except Exception as exc:
133 - return Response(str(exc), status=500)
plugins/_office/hooks.py new
+363
@@ -0,0 +1,363 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import shutil
5 +import subprocess
6 +import urllib.request
7 +from pathlib import Path
8 +from typing import Any
9 +
10 +
11 +PROJECT_ROOT = Path(__file__).resolve().parents[2]
12 +APT_SOURCE_FILE = Path("/etc/apt/sources.list.d/collaboraonline.sources")
13 +APT_KEYRING_FILE = Path("/etc/apt/keyrings/collaboraonline-release-keyring.gpg")
14 +XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources")
15 +XPRA_KEYRING_FILE = Path("/usr/share/keyrings/xpra.asc")
16 +XPRA_KEY_URL = "https://xpra.org/xpra.asc"
17 +SUPERVISOR_FILE = Path("/etc/supervisor/conf.d/a0_office_collabora.conf")
18 +RUNTIME_DIRS = [
19 + Path("/a0/tmp/_office/collabora"),
20 + Path("/a0/usr/plugins/_office/collabora"),
21 + PROJECT_ROOT / "tmp" / "_office" / "collabora",
22 + PROJECT_ROOT / "usr" / "plugins" / "_office" / "collabora",
23 +]
24 +PACKAGES = (
25 + "coolwsd",
26 + "coolwsd-deprecated",
27 + "code-brand",
28 + "collaboraoffice",
29 + "collaboraofficebasis-calc",
30 + "collaboraofficebasis-draw",
31 + "collaboraofficebasis-en-us",
32 + "collaboraofficebasis-extension-pdf-import",
33 + "collaboraofficebasis-graphicfilter",
34 + "collaboraofficebasis-images",
35 + "collaboraofficebasis-impress",
36 + "collaboraofficebasis-math",
37 + "collaboraofficebasis-ooolinguistic",
38 + "collaboraofficebasis-writer",
39 +)
40 +RUNTIME_PACKAGES = (
41 + "libreoffice-core",
42 + "libreoffice-writer",
43 + "libreoffice-calc",
44 + "libreoffice-impress",
45 + "libreoffice-gtk3",
46 + "libreofficekit-data",
47 + "libreofficekit-dev",
48 + "gir1.2-lokdocview-0.1",
49 + "python3-gi",
50 + "python3-uno",
51 + "xpra",
52 + "xpra-x11",
53 + "xpra-html5",
54 + "xfce4-session",
55 + "xfwm4",
56 + "xfce4-panel",
57 + "xfdesktop4",
58 + "xfce4-settings",
59 + "thunar",
60 + "gvfs",
61 + "libglib2.0-bin",
62 + "xfce4-terminal",
63 + "pulseaudio",
64 + "pulseaudio-utils",
65 + "x11-xserver-utils",
66 + "xdotool",
67 + "xauth",
68 + "dbus-x11",
69 + "fonts-dejavu",
70 + "fonts-liberation",
71 + "fonts-crosextra-caladea",
72 + "fonts-crosextra-carlito",
73 + "fonts-noto-core",
74 + "fonts-noto-cjk",
75 + "fonts-noto-color-emoji",
76 +)
77 +RETIRED_RUNTIME_PACKAGES = (
78 + "firefox-esr",
79 +)
80 +CLEANUP_MARKER = PROJECT_ROOT / "usr" / "plugins" / "_office" / "stale-cleanup-v2.done"
81 +
82 +
83 +def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
84 + """Prepare the LibreOffice runtime and remove retired office state.
85 +
86 + The hook is intentionally idempotent: existing dependencies, missing stale
87 + files, packages, and processes count as already clean. It is safe to call
88 + during startup and self-update.
89 + """
90 +
91 + removed: list[str] = []
92 + installed: list[str] = []
93 + errors: list[str] = []
94 +
95 + stale_paths = [
96 + path
97 + for path in [APT_SOURCE_FILE, APT_KEYRING_FILE, SUPERVISOR_FILE, *RUNTIME_DIRS]
98 + if path.exists() or path.is_symlink()
99 + ]
100 + stale_packages = _installed_packages(PACKAGES)
101 + cleanup_needed = force or not CLEANUP_MARKER.exists() or bool(stale_paths or stale_packages)
102 +
103 + if cleanup_needed:
104 + _kill_old_processes(errors)
105 +
106 + for path in [APT_SOURCE_FILE, APT_KEYRING_FILE, SUPERVISOR_FILE, *RUNTIME_DIRS]:
107 + try:
108 + if _remove_path(path):
109 + removed.append(str(path))
110 + except Exception as exc:
111 + errors.append(f"{path}: {exc}")
112 +
113 + _purge_packages(removed, errors, installed_packages=stale_packages)
114 +
115 + try:
116 + CLEANUP_MARKER.parent.mkdir(parents=True, exist_ok=True)
117 + CLEANUP_MARKER.write_text("ok\n", encoding="utf-8")
118 + except Exception as exc:
119 + errors.append(f"{CLEANUP_MARKER}: {exc}")
120 +
121 + retired_packages = [
122 + package
123 + for package in _installed_packages(RETIRED_RUNTIME_PACKAGES)
124 + if package not in stale_packages
125 + ]
126 + if retired_packages:
127 + _purge_packages(removed, errors, installed_packages=retired_packages)
128 +
129 + _ensure_runtime_dependencies(installed, errors)
130 + _cleanup_desktop_sessions(errors)
131 +
132 + return {
133 + "ok": not errors,
134 + "skipped": not cleanup_needed,
135 + "removed": removed,
136 + "installed": installed,
137 + "errors": errors,
138 + }
139 +
140 +
141 +def _remove_path(path: Path) -> bool:
142 + if path.is_symlink() or path.is_file():
143 + path.unlink(missing_ok=True)
144 + return True
145 + if path.exists():
146 + shutil.rmtree(path)
147 + return True
148 + return False
149 +
150 +
151 +def _kill_old_processes(errors: list[str]) -> None:
152 + if not shutil.which("pkill"):
153 + return
154 + result = subprocess.run(
155 + ["pkill", "-f", "coolwsd"],
156 + check=False,
157 + text=True,
158 + capture_output=True,
159 + timeout=8,
160 + )
161 + if result.returncode not in {0, 1}:
162 + errors.append((result.stderr or result.stdout or "pkill coolwsd failed").strip())
163 +
164 +
165 +def _installed_packages(packages: tuple[str, ...]) -> list[str]:
166 + if not shutil.which("dpkg-query"):
167 + return []
168 + return [package for package in packages if _package_installed(package)]
169 +
170 +
171 +def _purge_packages(
172 + removed: list[str],
173 + errors: list[str],
174 + *,
175 + installed_packages: list[str] | None = None,
176 +) -> None:
177 + if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
178 + return
179 + installed = installed_packages if installed_packages is not None else _installed_packages(PACKAGES)
180 + if not installed:
181 + return
182 + result = subprocess.run(
183 + ["apt-get", "purge", "-y", *installed],
184 + check=False,
185 + text=True,
186 + capture_output=True,
187 + timeout=180,
188 + env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
189 + )
190 + if result.returncode == 0:
191 + removed.extend(installed)
192 + return
193 + errors.append((result.stderr or result.stdout or "apt-get purge failed").strip())
194 +
195 +
196 +def _package_installed(package: str) -> bool:
197 + result = subprocess.run(
198 + ["dpkg-query", "-W", "-f=${Status}", package],
199 + check=False,
200 + text=True,
201 + capture_output=True,
202 + timeout=8,
203 + )
204 + return result.returncode == 0 and "install ok installed" in result.stdout
205 +
206 +
207 +def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> None:
208 + if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
209 + return
210 + missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
211 + if not missing:
212 + return
213 +
214 + if not _apt_update(errors):
215 + return
216 +
217 + if "xpra" in missing and not _package_candidate_available("xpra"):
218 + previous_error_count = len(errors)
219 + _ensure_xpra_repository(installed, errors)
220 + if len(errors) > previous_error_count or not _apt_update(errors):
221 + return
222 + missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
223 + if not missing:
224 + return
225 +
226 + result = subprocess.run(
227 + ["apt-get", "install", "-y", "--no-install-recommends", *missing],
228 + check=False,
229 + text=True,
230 + capture_output=True,
231 + timeout=900,
232 + env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
233 + )
234 + if result.returncode == 0:
235 + installed.extend(missing)
236 + return
237 + errors.append((result.stderr or result.stdout or "apt-get install failed").strip())
238 +
239 +
240 +def _apt_update(errors: list[str]) -> bool:
241 + result = subprocess.run(
242 + ["apt-get", "update"],
243 + check=False,
244 + text=True,
245 + capture_output=True,
246 + timeout=300,
247 + env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
248 + )
249 + if result.returncode == 0:
250 + return True
251 + errors.append((result.stderr or result.stdout or "apt-get update failed").strip())
252 + return False
253 +
254 +
255 +def _package_candidate_available(package: str) -> bool:
256 + if not shutil.which("apt-cache"):
257 + return True
258 + result = subprocess.run(
259 + ["apt-cache", "policy", package],
260 + check=False,
261 + text=True,
262 + capture_output=True,
263 + timeout=15,
264 + )
265 + if result.returncode != 0:
266 + return True
267 + return "Candidate: (none)" not in result.stdout
268 +
269 +
270 +def _ensure_xpra_repository(installed: list[str], errors: list[str]) -> None:
271 + if not _package_installed("ca-certificates"):
272 + result = subprocess.run(
273 + ["apt-get", "install", "-y", "--no-install-recommends", "ca-certificates"],
274 + check=False,
275 + text=True,
276 + capture_output=True,
277 + timeout=180,
278 + env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
279 + )
280 + if result.returncode != 0:
281 + errors.append((result.stderr or result.stdout or "apt-get install ca-certificates failed").strip())
282 + return
283 + installed.append("ca-certificates")
284 +
285 + try:
286 + key = _download(XPRA_KEY_URL)
287 + XPRA_KEYRING_FILE.parent.mkdir(parents=True, exist_ok=True)
288 + if not XPRA_KEYRING_FILE.exists() or XPRA_KEYRING_FILE.read_bytes() != key:
289 + XPRA_KEYRING_FILE.write_bytes(key)
290 +
291 + XPRA_SOURCE_FILE.parent.mkdir(parents=True, exist_ok=True)
292 + source = _xpra_repository_source()
293 + if not XPRA_SOURCE_FILE.exists() or XPRA_SOURCE_FILE.read_text(encoding="utf-8") != source:
294 + XPRA_SOURCE_FILE.write_text(source, encoding="utf-8")
295 + except Exception as exc:
296 + errors.append(f"Xpra repository setup failed: {exc}")
297 +
298 +
299 +def _download(url: str) -> bytes:
300 + with urllib.request.urlopen(url, timeout=45) as response:
301 + return response.read()
302 +
303 +
304 +def _xpra_repository_source() -> str:
305 + os_release = _read_os_release()
306 + os_id = os_release.get("ID", "")
307 + codename = os_release.get("VERSION_CODENAME", "")
308 + arch = _dpkg_architecture()
309 +
310 + if os_id == "kali":
311 + uri = "https://xpra.org/beta"
312 + suite = "sid"
313 + elif codename in {"sid", "forky"}:
314 + uri = "https://xpra.org/beta"
315 + suite = codename
316 + else:
317 + uri = "https://xpra.org"
318 + suite = codename or "trixie"
319 +
320 + return (
321 + f"Types: deb\n"
322 + f"URIs: {uri}\n"
323 + f"Suites: {suite}\n"
324 + f"Components: main\n"
325 + f"Signed-By: {XPRA_KEYRING_FILE}\n"
326 + f"Architectures: {arch}\n"
327 + )
328 +
329 +
330 +def _read_os_release() -> dict[str, str]:
331 + path = Path("/etc/os-release")
332 + if not path.exists():
333 + return {}
334 + values: dict[str, str] = {}
335 + for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
336 + if not line or line.startswith("#") or "=" not in line:
337 + continue
338 + key, value = line.split("=", 1)
339 + values[key] = value.strip().strip('"')
340 + return values
341 +
342 +
343 +def _dpkg_architecture() -> str:
344 + result = subprocess.run(
345 + ["dpkg", "--print-architecture"],
346 + check=False,
347 + text=True,
348 + capture_output=True,
349 + timeout=8,
350 + )
351 + if result.returncode == 0 and result.stdout.strip():
352 + return result.stdout.strip()
353 + return "amd64"
354 +
355 +
356 +def _cleanup_desktop_sessions(errors: list[str]) -> None:
357 + try:
358 + from plugins._office.helpers import libreoffice_desktop
359 +
360 + result = libreoffice_desktop.cleanup_stale_runtime_state()
361 + errors.extend(str(item) for item in result.get("errors") or [])
362 + except Exception as exc:
363 + errors.append(f"LibreOffice desktop cleanup failed: {exc}")
plugins/_office/plugin.yaml
+2 -2
@@ -1,6 +1,6 @@
1 name: _office
2 -title: Office
3 -description: Universal Canvas office documents with Collabora Online, WOPI, and document artifacts.
2 +title: LibreOffice
3 +description: Markdown-first writing and LibreOffice-backed document artifacts in the right canvas.
4 version: "0.1"
5 settings_sections:
6 - developer
plugins/_office/prompts/agent.extras.office_canvas.md
+1 -1
@@ -1,2 +1,2 @@
1 -[OFFICE CANVAS]
1 +[DOCUMENT CANVAS]
2 {{office_canvas}}
plugins/_office/prompts/agent.system.tool.document_artifact.md
+5 -3
@@ -1,9 +1,11 @@
1 ### document_artifact
2 -create/open/read/edit reusable Office artifacts in the Agent Zero canvas
3 -formats: docx xlsx pptx odt ods odp
2 +create/open/read/edit reusable document artifacts in the Agent Zero canvas
3 +formats: md docx xlsx pptx
4 +default format: md
5 methods: create open read edit inspect export version_history restore_version status
6 common args: kind title format content path file_id
7 XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts
8 chart types: line bar column pie area scatter stock ohlc candlestick
9 XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells
9 -for nontrivial Office artifact work, load skill `office-artifacts` first
10 +ODT/ODS/ODP editing is intentionally unsupported in this migration
11 +for nontrivial document artifact work, load skill `office-artifacts` first
plugins/_office/skills/office-artifacts/SKILL.md
+14 -11
@@ -1,12 +1,14 @@
1 ---
2 name: office-artifacts
3 -description: Use when creating, opening, reading, or editing editable Office canvas artifacts such as DOCX documents, XLSX spreadsheets, and PPTX presentations with the document_artifact tool.
4 -version: "1.1.0"
3 +description: Use when creating, opening, reading, or editing editable document canvas artifacts such as Markdown documents, DOCX documents, XLSX spreadsheets, and PPTX presentations with the document_artifact tool.
4 +version: "1.2.0"
5 author: "Agent Zero Core Team"
6 -tags: ["office", "docx", "xlsx", "pptx", "canvas", "documents", "spreadsheets", "presentations"]
6 +tags: ["documents", "markdown", "md", "docx", "xlsx", "pptx", "canvas", "spreadsheets", "presentations"]
7 triggers:
8 - - "office canvas"
8 + - "document canvas"
9 + - "markdown document"
10 - "editable document"
11 + - "md"
12 - "docx"
13 - "xlsx"
14 - "pptx"
@@ -16,9 +18,9 @@ allowed_tools:
18 - document_artifact
19 ---
20
19 -# Office Artifacts
21 +# Document Artifacts
22
21 -Use `document_artifact` for substantial Office deliverables that should remain editable in the canvas. Do not paste long document, spreadsheet, or deck bodies only into chat when the user asked for an editable file.
23 +Use `document_artifact` for substantial deliverables that should remain editable in the canvas. Markdown is the default document format. Use DOCX only when the user explicitly asks for it or needs a Word-compatible binary file.
24
25 ## Workflow
26
@@ -27,7 +29,7 @@ Use `document_artifact` for substantial Office deliverables that should remain e
29 3. Apply saved changes with `document_artifact:edit`.
30 4. Use `version_history` or `restore_version` when the user asks to audit or roll back.
31
30 -Canvas context may list opened Office files with `file_id`, path, version, size, and timestamp. It intentionally omits full file contents; use `read` when the content matters.
32 +Canvas context may list opened files with `file_id`, path, version, size, and timestamp. It intentionally omits full file contents; use `read` when the content matters.
33
34 ## Minimal Calls
35
@@ -38,7 +40,7 @@ Create:
40 "tool_args": {
41 "kind": "document",
42 "title": "Project Brief",
41 - "format": "docx",
43 + "format": "md",
44 "content": "Draft text here."
45 }
46 }
@@ -56,7 +58,7 @@ Read:
58 }
59 ```
60
59 -Edit text in a DOCX or PPTX:
61 +Edit text in a Markdown, DOCX, or PPTX file:
62 ```json
63 {
64 "tool_name": "document_artifact:edit",
@@ -107,7 +109,7 @@ Create an embedded spreadsheet chart:
109
110 ## Edit Operations
111
110 -- DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`.
112 +- MD and DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`.
113 - XLSX: `set_cells`, `append_rows`, `set_rows`, `create_chart`, `replace_text`, `delete_text`.
114 - PPTX: `set_slides`, `append_slide`, `replace_text`, `delete_text`.
115
@@ -124,6 +126,7 @@ Arguments:
126
127 - Prefer `file_id` from canvas context or prior tool output; use `path` when that is all you have.
128 - Use `read` before editing unless the current saved content is already known.
129 +- Do not create ODT, ODS, or ODP in this pass; return a clear unsupported response if asked.
130 - Use native `create_chart` for embedded spreadsheet charts. Reach for Python/code execution only when the requested chart behavior is not supported by the tool.
128 -- Use `edit` for precise saved changes; use the visual Office canvas for human/manual layout polish.
131 +- Use `edit` for precise saved changes; use the visual document canvas for human/manual layout polish.
132 - Direct edits update version history and refresh the canvas on edit/open results.
plugins/_office/tools/document_artifact.py
+39 -13
@@ -5,7 +5,7 @@ from pathlib import Path
5 from typing import Any
6
7 from helpers.tool import Response, Tool
8 -from plugins._office.helpers import artifact_editor, collabora_status, wopi_store
8 +from plugins._office.helpers import artifact_editor, document_store, libreoffice
9
10
11 class DocumentArtifact(Tool):
@@ -14,7 +14,7 @@ class DocumentArtifact(Tool):
14 action: str = "",
15 kind: str = "document",
16 title: str = "Untitled",
17 - format: str = "docx",
17 + format: str = "md",
18 content: str = "",
19 path: str = "",
20 file_id: str = "",
@@ -33,7 +33,21 @@ class DocumentArtifact(Tool):
33 action = str(action or self.method or "status").strip().lower().replace("-", "_")
34 try:
35 if action == "create":
36 - doc = wopi_store.create_document(kind=kind, title=title, fmt=format, content=content, path=path)
36 + doc = document_store.create_document(
37 + kind=kind,
38 + title=title,
39 + fmt=format,
40 + content=content,
41 + path=path,
42 + context_id=self._context_id(),
43 + )
44 + if doc["extension"] == "docx":
45 + validation = libreoffice.validate_docx(doc["path"])
46 + if not validation.get("ok"):
47 + return Response(
48 + message=f"document_artifact create failed: {validation.get('error')}",
49 + break_loop=False,
50 + )
51 return self._document_response("Created document artifact.", doc, action=action)
52 if action == "open":
53 doc = self._document_from_input(file_id=file_id, path=path)
@@ -69,26 +83,35 @@ class DocumentArtifact(Tool):
83 return self._json_response({"ok": True, "action": action, "document": self._public_doc(doc)}, doc=doc, action=action)
84 if action == "version_history":
85 doc = self._document_from_input(file_id=file_id, path=path)
72 - versions = wopi_store.version_history(doc["file_id"])
86 + versions = document_store.version_history(doc["file_id"])
87 return self._json_response({"ok": True, "action": action, "versions": versions}, doc=doc, action=action)
88 if action == "restore_version":
89 if version_id is None or str(version_id).strip() == "":
90 return Response(message="version_id is required for restore_version.", break_loop=False)
91 doc = self._document_from_input(file_id=file_id, path=path)
78 - restored = wopi_store.restore_version(doc["file_id"], int(version_id))
92 + restored = document_store.restore_version(doc["file_id"], int(version_id))
93 return self._document_response("Restored document artifact version.", restored, action=action)
94 if action == "export":
95 doc = self._document_from_input(file_id=file_id, path=path)
96 target_format = str(kwargs.get("target_format") or kwargs.get("export_format") or "").lower().lstrip(".")
97 if target_format and target_format != doc["extension"]:
98 + result = libreoffice.convert_document(doc["path"], target_format)
99 + if result.get("ok"):
100 + payload = {
101 + "ok": True,
102 + "action": action,
103 + "path": document_store.display_path(result["path"]),
104 + "document": self._public_doc(doc),
105 + }
106 + return self._json_response(payload, doc=doc, action=action)
107 return Response(
85 - message=f"Export to .{target_format} is not available yet. The source file remains unchanged at {doc['path']}.",
108 + message=f"document_artifact export failed: {result.get('error')}",
109 break_loop=False,
110 additional=self._additional(doc, action=action),
111 )
112 return self._document_response("Document artifact export path is ready.", doc, action=action)
113 if action == "status":
91 - return self._json_response({"ok": True, "action": action, "status": collabora_status.collect_status()}, action=action)
114 + return self._json_response({"ok": True, "action": action, "status": libreoffice.collect_status()}, action=action)
115 return Response(message=f"Unknown document_artifact action: {action}", break_loop=False)
116 except Exception as exc:
117 return Response(message=f"document_artifact {action} failed: {exc}", break_loop=False)
@@ -104,11 +127,14 @@ class DocumentArtifact(Tool):
127
128 def _document_from_input(self, file_id: str = "", path: str = "") -> dict[str, Any]:
129 if file_id:
107 - return wopi_store.get_document(file_id)
130 + return document_store.get_document(file_id)
131 if path:
109 - return wopi_store.register_document(path)
132 + return document_store.register_document(path, context_id=self._context_id())
133 raise ValueError("file_id or path is required")
134
135 + def _context_id(self) -> str:
136 + return self.agent.context.id if self.agent and self.agent.context else ""
137 +
138 def _document_response(self, message: str, doc: dict[str, Any], action: str = "") -> Response:
139 payload = {"ok": True, "action": action, "message": message, "document": self._public_doc(doc)}
140 return Response(
@@ -134,18 +160,18 @@ class DocumentArtifact(Tool):
160 "file_id": doc["file_id"],
161 "title": doc["basename"],
162 "format": doc["extension"],
137 - "path": doc["path"],
138 - "version": wopi_store.item_version(doc),
163 + "path": document_store.display_path(doc["path"]),
164 + "version": document_store.item_version(doc),
165 }
166
167 def _public_doc(self, doc: dict[str, Any]) -> dict[str, Any]:
168 return {
169 "file_id": doc["file_id"],
144 - "path": doc["path"],
170 + "path": document_store.display_path(doc["path"]),
171 "basename": doc["basename"],
172 "extension": doc["extension"],
173 "size": doc["size"],
148 - "version": wopi_store.item_version(doc),
174 + "version": document_store.item_version(doc),
175 "last_modified": doc["last_modified"],
176 "exists": Path(doc["path"]).exists(),
177 }
tests/test_office_document_affordance.py
+2 -1
@@ -14,7 +14,7 @@ from plugins._office.helpers import document_affordance
14 def substantial_text(prefix: str = "Here is the material.") -> str:
15 paragraph = (
16 "This section gives concrete context, constraints, tradeoffs, and next steps "
17 - "so the artifact has enough substance to be useful in a real collaboration. "
17 + "so the artifact has enough substance to be useful in a real shared workflow. "
18 )
19 return f"{prefix}\n\n" + paragraph * 8
20
@@ -65,6 +65,7 @@ def test_convert_into_document_creates_document_artifact():
65
66 assert decision is not None
67 assert decision.kind == "document"
68 + assert decision.fmt == "md"
69 assert decision.reason == "explicit_handoff"
70
71
tests/test_office_document_store.py new
+666
@@ -0,0 +1,666 @@
1 +from __future__ import annotations
2 +
3 +import importlib.util
4 +import json
5 +import os
6 +import sys
7 +import types
8 +import zipfile
9 +from pathlib import Path
10 +
11 +import pytest
12 +
13 +
14 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
15 +if str(PROJECT_ROOT) not in sys.path:
16 + sys.path.insert(0, str(PROJECT_ROOT))
17 +
18 +from plugins._office import hooks
19 +from plugins._office.helpers import (
20 + artifact_editor,
21 + canvas_context,
22 + document_store,
23 + libreoffice,
24 + libreoffice_desktop,
25 + libreofficekit_native,
26 + libreofficekit_sessions,
27 + libreofficekit_worker,
28 +)
29 +
30 +
31 +@pytest.fixture
32 +def office_state(tmp_path, monkeypatch):
33 + state = tmp_path / "state"
34 + backups = state / "backups"
35 + workdir = tmp_path / "workdir"
36 + documents = workdir / "documents"
37 + projects_parent = tmp_path / "projects"
38 +
39 + monkeypatch.setattr(document_store, "STATE_DIR", state)
40 + monkeypatch.setattr(document_store, "DB_PATH", state / "documents.sqlite3")
41 + monkeypatch.setattr(document_store, "BACKUP_DIR", backups)
42 + monkeypatch.setattr(document_store, "WORKDIR", workdir)
43 + monkeypatch.setattr(document_store, "DOCUMENTS_DIR", documents)
44 + settings_helpers = types.SimpleNamespace(get_settings=lambda: {"workdir_path": str(workdir)})
45 + project_helpers = types.SimpleNamespace(
46 + get_context_project_name=lambda context: None,
47 + get_project_folder=lambda name: str(projects_parent / name),
48 + get_projects_parent_folder=lambda: str(projects_parent),
49 + )
50 + monkeypatch.setattr(document_store, "_settings", lambda: settings_helpers)
51 + monkeypatch.setattr(document_store, "_projects", lambda: project_helpers)
52 +
53 + workdir.mkdir(parents=True, exist_ok=True)
54 + documents.mkdir(parents=True, exist_ok=True)
55 + projects_parent.mkdir(parents=True, exist_ok=True)
56 + document_store.ensure_dirs()
57 + return types.SimpleNamespace(
58 + state=state,
59 + backups=backups,
60 + workdir=workdir,
61 + documents=documents,
62 + projects_parent=projects_parent,
63 + project_helpers=project_helpers,
64 + )
65 +
66 +
67 +def test_document_artifact_create_defaults_to_markdown(office_state):
68 + doc = document_store.create_document("document", "Research Note", content="A precise note.")
69 +
70 + assert doc["extension"] == "md"
71 + assert Path(doc["path"]).parent == office_state.workdir
72 + assert Path(doc["path"]).read_text(encoding="utf-8").startswith("# Research Note")
73 +
74 +
75 +def test_explicit_docx_creates_valid_word_package(office_state):
76 + doc = document_store.create_document("document", "Board Memo", "docx", "A careful memo.")
77 +
78 + assert doc["extension"] == "docx"
79 + assert Path(doc["path"]).parent == office_state.documents
80 + assert libreoffice.validate_docx(doc["path"])["ok"] is True
81 + with zipfile.ZipFile(doc["path"]) as archive:
82 + assert "word/document.xml" in archive.namelist()
83 +
84 +
85 +def test_blank_docx_includes_editable_body_paragraph(office_state):
86 + doc = document_store.create_document("document", "Blank Memo", "docx", "")
87 + with zipfile.ZipFile(doc["path"]) as archive:
88 + xml = archive.read("word/document.xml").decode("utf-8")
89 + root = document_store.ET.fromstring(xml)
90 +
91 + assert len(list(root.iter(document_store._qn(document_store.W_NS, "p")))) >= 2
92 + assert 'xml:space="preserve">&#160;</w:t>' in xml
93 +
94 +
95 +def test_xlsx_and_pptx_creation_and_direct_edits_still_work(office_state):
96 + sheet = document_store.create_document(
97 + "spreadsheet",
98 + "Budget",
99 + "xlsx",
100 + "Name,Amount\nPlatform,1000",
101 + )
102 + updated_sheet, sheet_payload = artifact_editor.edit_artifact(
103 + sheet,
104 + operation="set_cells",
105 + cells={"Sheet1!B2": 12500, "Sheet1!A3": "Research", "Sheet1!B3": 4700},
106 + )
107 + sheet_read = artifact_editor.read_artifact(updated_sheet)
108 + rows = sheet_read["sheets"][0]["preview_rows"]
109 +
110 + assert sheet_payload["changed"] is True
111 + assert rows[1][1] == 12500
112 + assert rows[2][0] == "Research"
113 +
114 + deck = document_store.create_document("presentation", "Roadmap", "pptx", "Initial")
115 + updated_deck, deck_payload = artifact_editor.edit_artifact(
116 + deck,
117 + operation="set_slides",
118 + slides=[
119 + {"title": "Now", "bullets": ["Stabilize"]},
120 + {"title": "Next", "bullets": ["Polish"]},
121 + ],
122 + )
123 + deck_read = artifact_editor.read_artifact(updated_deck)
124 +
125 + assert deck_payload["changed"] is True
126 + assert deck_read["slide_count"] == 2
127 + assert deck_read["slides"][1]["title"] == "Next"
128 +
129 +
130 +def test_odt_is_not_advertised_and_returns_clear_unsupported_response(office_state):
131 + prompt = (PROJECT_ROOT / "plugins" / "_office" / "prompts" / "agent.system.tool.document_artifact.md").read_text(
132 + encoding="utf-8",
133 + )
134 +
135 + assert "formats: md docx xlsx pptx" in prompt
136 + with pytest.raises(ValueError, match="ODT editing is not supported"):
137 + document_store.create_document("document", "Skip ODT", "odt", "")
138 +
139 +
140 +def test_project_scoped_creation_uses_active_project_root(office_state, monkeypatch):
141 + project_root = office_state.projects_parent / "apollo"
142 + project_root.mkdir(parents=True, exist_ok=True)
143 + context = object()
144 + agent_module = types.SimpleNamespace(
145 + AgentContext=types.SimpleNamespace(get=staticmethod(lambda context_id: context))
146 + )
147 +
148 + monkeypatch.setitem(sys.modules, "agent", agent_module)
149 + monkeypatch.setattr(office_state.project_helpers, "get_context_project_name", lambda active_context: "apollo")
150 + monkeypatch.setattr(office_state.project_helpers, "get_project_folder", lambda name: str(project_root))
151 +
152 + markdown = document_store.create_document("document", "Project Note", "md", "Scoped.", context_id="ctx-project")
153 + docx = document_store.create_document("document", "Project Memo", "docx", "Scoped.", context_id="ctx-project")
154 +
155 + assert Path(markdown["path"]).parent == project_root
156 + assert Path(docx["path"]).parent == project_root / "documents"
157 +
158 +
159 +def test_non_project_creation_uses_configured_workdir(office_state):
160 + markdown = document_store.create_document("document", "Workdir Note", content="Plain.")
161 + spreadsheet = document_store.create_document("spreadsheet", "Workdir Sheet", "xlsx", "Name,Value")
162 +
163 + assert markdown["extension"] == "md"
164 + assert Path(markdown["path"]).parent == office_state.workdir
165 + assert Path(spreadsheet["path"]).parent == office_state.documents
166 +
167 +
168 +def test_sessions_recent_preview_and_canvas_context_are_neutral(office_state):
169 + doc = document_store.create_document("document", "Canvas Context", "md", "Private body text.")
170 + session = document_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
171 +
172 + open_docs = document_store.get_open_documents()
173 + recent = document_store.get_recent_documents()
174 + context = canvas_context.build_context()
175 +
176 + assert open_docs[0]["file_id"] == doc["file_id"]
177 + assert recent[0]["preview"]["lines"]
178 + assert "document artifacts" in context
179 + assert "Private body text" not in context
180 + assert document_store.close_session(session_id=session["session_id"]) == 1
181 + assert document_store.get_open_documents() == []
182 +
183 +
184 +def test_markdown_save_tracks_version_history(office_state):
185 + doc = document_store.create_document("document", "Versioned", "md", "First")
186 + updated = document_store.write_markdown(doc["file_id"], "# Versioned\n\nSecond\n")
187 + history = document_store.version_history(doc["file_id"])
188 +
189 + assert updated["version"] == 2
190 + assert history
191 + assert Path(updated["path"]).read_text(encoding="utf-8").endswith("Second\n")
192 +
193 +
194 +def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeypatch):
195 + manager = libreofficekit_sessions.LibreOfficeKitSessionManager()
196 + monkeypatch.setattr(libreofficekit_sessions, "_manager", manager, raising=False)
197 + doc = document_store.create_document("document", "Receiver", "md", "First")
198 + session = manager.open(doc)
199 +
200 + artifact_editor.edit_artifact(doc, operation="set_text", content="# Receiver\n\nSecond")
201 +
202 + assert manager._sessions[session["session_id"]].text == "# Receiver\n\nSecond"
203 +
204 +
205 +def test_docx_session_dispatches_native_uno_commands(office_state, monkeypatch):
206 + calls = []
207 +
208 + class FakeNativeDocument:
209 + def metadata(self):
210 + return {"available": True, "doctype": 0, "parts": 1, "width_twips": 100, "height_twips": 200}
211 +
212 + def post_uno_command(self, command, arguments=None, notify=True):
213 + calls.append((command, arguments, notify))
214 + return {"ok": True, "native": True, "command": command}
215 +
216 + def command_values(self, command):
217 + return {"ok": True, "native": True, "command": command, "values": {"commandName": command}}
218 +
219 + def close(self):
220 + calls.append(("close", None, None))
221 +
222 + monkeypatch.setattr(libreofficekit_native, "open_document", lambda path: FakeNativeDocument())
223 +
224 + manager = libreofficekit_sessions.LibreOfficeKitSessionManager()
225 + doc = document_store.create_document("document", "Native", "docx", "Native text")
226 + session = manager.open(doc)
227 + result = manager.command(session["session_id"], ".uno:Bold", notify=True)
228 + values = manager.command_values(session["session_id"], ".uno:StyleApply")
229 + manager.close(session["session_id"])
230 +
231 + assert session["native"]["available"] is True
232 + assert result["ok"] is True
233 + assert result["native"] is True
234 + assert values["values"]["commandName"] == ".uno:StyleApply"
235 + assert calls[0] == (".uno:Bold", None, True)
236 + assert calls[-1] == ("close", None, None)
237 +
238 +
239 +def test_lok_worker_serializes_concurrent_rpc_calls():
240 + import concurrent.futures
241 + import threading
242 + import time
243 +
244 + document = object.__new__(libreofficekit_worker.WorkerLokDocument)
245 + document._lock = threading.RLock()
246 + active = 0
247 + max_active = 0
248 +
249 + def fake_request_unlocked(action, payload=None, timeout=18):
250 + nonlocal active, max_active
251 + active += 1
252 + max_active = max(max_active, active)
253 + time.sleep(0.01)
254 + active -= 1
255 + return {"ok": True, "action": action, "payload": payload}
256 +
257 + document._request_unlocked = fake_request_unlocked
258 + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
259 + results = list(pool.map(lambda index: document._request("key", {"index": index}), range(12)))
260 +
261 + assert all(result["ok"] is True for result in results)
262 + assert max_active == 1
263 +
264 +
265 +def test_official_libreoffice_desktop_status_and_url_contract(tmp_path, monkeypatch):
266 + xpra_html = tmp_path / "xpra" / "www"
267 + xpra_html.mkdir(parents=True)
268 + (xpra_html / "index.html").write_text("xpra", encoding="utf-8")
269 +
270 + monkeypatch.setattr(libreoffice_desktop.libreoffice, "find_soffice", lambda: "/usr/bin/soffice")
271 + monkeypatch.setattr(
272 + libreoffice_desktop.shutil,
273 + "which",
274 + lambda name: f"/usr/bin/{name}"
275 + if name
276 + in {
277 + "xpra",
278 + "Xvfb",
279 + "xfce4-session",
280 + "dbus-launch",
281 + "xrandr",
282 + "xdotool",
283 + "thunar",
284 + "xfce4-terminal",
285 + "xfce4-settings-manager",
286 + "gio",
287 + "pulseaudio",
288 + "pactl",
289 + }
290 + else "",
291 + )
292 + monkeypatch.setattr(libreoffice_desktop.virtual_desktop, "XPRA_HTML_ROOT_CANDIDATES", (xpra_html,))
293 + monkeypatch.setattr(libreoffice_desktop.virtual_desktop, "_package_installed", lambda package: True)
294 +
295 + status = libreoffice_desktop.collect_desktop_status()
296 + url = libreoffice_desktop._xpra_url("abc123")
297 +
298 + assert status["healthy"] is True
299 + assert status["xpra_html_root"] == str(xpra_html)
300 + assert url.startswith("/desktop/session/abc123/index.html?")
301 + assert "path=%2Fdesktop%2Fsession%2Fabc123%2F" in url
302 + assert "xpramenu=false" in url
303 + assert "floating_menu=false" in url
304 + assert "file_transfer=true" in url
305 + assert "sound=true" in url
306 + assert "printing=true" in url
307 +
308 +
309 +def test_official_libreoffice_desktop_manager_opens_binary_session(office_state, tmp_path, monkeypatch):
310 + class FakeProcess:
311 + pid = 4242
312 +
313 + def poll(self):
314 + return None
315 +
316 + def terminate(self):
317 + return None
318 +
319 + def wait(self, timeout=None):
320 + return 0
321 +
322 + def kill(self):
323 + return None
324 +
325 + monkeypatch.setattr(libreoffice_desktop, "STATE_DIR", tmp_path / "desktop")
326 + monkeypatch.setattr(libreoffice_desktop, "SESSION_DIR", tmp_path / "desktop" / "sessions")
327 + monkeypatch.setattr(libreoffice_desktop, "PROFILE_DIR", tmp_path / "desktop" / "profiles")
328 + monkeypatch.setattr(libreoffice_desktop, "collect_desktop_status", lambda: {"healthy": True, "message": "ok"})
329 + monkeypatch.setattr(libreoffice_desktop.libreoffice, "find_soffice", lambda: "/usr/bin/soffice")
330 + monkeypatch.setattr(libreoffice_desktop, "_port_is_free", lambda port: True)
331 + monkeypatch.setattr(libreoffice_desktop.virtual_desktop, "has_window", lambda **kwargs: True)
332 + real_get_abs_path = libreoffice_desktop.files.get_abs_path
333 +
334 + def fake_get_abs_path(*parts):
335 + if parts and parts[0] == "usr":
336 + return str(tmp_path.joinpath(*parts))
337 + return real_get_abs_path(*parts)
338 +
339 + monkeypatch.setattr(libreoffice_desktop.files, "get_abs_path", fake_get_abs_path)
340 +
341 + def fake_spawn(self, session):
342 + session.profile_dir.mkdir(parents=True, exist_ok=True)
343 + session.processes["xpra"] = FakeProcess()
344 +
345 + def fake_open_document(self, session, doc):
346 + session.processes[f"soffice-{doc['file_id']}"] = FakeProcess()
347 +
348 + monkeypatch.setattr(libreoffice_desktop.LibreOfficeDesktopManager, "_spawn_desktop_locked", fake_spawn)
349 + monkeypatch.setattr(libreoffice_desktop.LibreOfficeDesktopManager, "_open_document_locked", fake_open_document)
350 +
351 + doc = document_store.create_document("spreadsheet", "Official Sheet", "xlsx", "Name,Value\nA,1")
352 + manager = libreoffice_desktop.LibreOfficeDesktopManager()
353 + payload = manager.open(doc)
354 +
355 + assert payload["available"] is True
356 + assert payload["extension"] == "xlsx"
357 + assert payload["url"].startswith("/desktop/session/")
358 + registry = tmp_path / "desktop" / "profiles" / payload["session_id"] / "user" / "registrymodifications.xcu"
359 + assert "ooSetupInstCompleted" in registry.read_text(encoding="utf-8")
360 + assert "FirstRun" in registry.read_text(encoding="utf-8")
361 + writer_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "LibreOffice Writer.desktop"
362 + assert "--writer" in writer_launcher.read_text(encoding="utf-8")
363 + assert "X-XFCE-Trusted=true" in writer_launcher.read_text(encoding="utf-8")
364 + terminal_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Terminal.desktop"
365 + files_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Files.desktop"
366 + settings_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Settings.desktop"
367 + terminal_text = terminal_launcher.read_text(encoding="utf-8")
368 + settings_text = settings_launcher.read_text(encoding="utf-8")
369 + assert "xfce4-terminal" in terminal_text
370 + assert "org.xfce.terminal" in terminal_text
371 + assert not files_launcher.exists()
372 + assert not (tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Browser.desktop").exists()
373 + assert "xfce4-settings-manager" in settings_text
374 + assert "org.xfce.settings.manager" in settings_text
375 + for link_name, target in {
376 + "Workdir": "usr/workdir",
377 + "Projects": "usr/projects",
378 + "Skills": "usr/skills",
379 + "Agents": "usr/agents",
380 + "Downloads": "usr/downloads",
381 + }.items():
382 + link = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / link_name
383 + assert link.is_symlink()
384 + assert str(link.resolve()).endswith(target)
385 + xpra_override = (
386 + tmp_path
387 + / "desktop"
388 + / "profiles"
389 + / payload["session_id"]
390 + / ".local"
391 + / "share"
392 + / "applications"
393 + / "xpra-gui.desktop"
394 + )
395 + assert "Hidden=true" in xpra_override.read_text(encoding="utf-8")
396 + desktop_profile = (
397 + tmp_path
398 + / "desktop"
399 + / "profiles"
400 + / payload["session_id"]
401 + / ".config"
402 + / "xfce4"
403 + / "xfconf"
404 + / "xfce-perchannel-xml"
405 + / "xfce4-desktop.xml"
406 + )
407 + assert "desktop-icons" in desktop_profile.read_text(encoding="utf-8")
408 + panel_profile = (
409 + tmp_path
410 + / "desktop"
411 + / "profiles"
412 + / payload["session_id"]
413 + / ".config"
414 + / "xfce4"
415 + / "xfconf"
416 + / "xfce-perchannel-xml"
417 + / "xfce4-panel.xml"
418 + ).read_text(encoding="utf-8")
419 + assert "panel-1" in panel_profile
420 + assert "panel-2" not in panel_profile
421 + assert "launcher" not in panel_profile
422 + desktop_helper = (
423 + PROJECT_ROOT / "plugins" / "_office" / "helpers" / "libreoffice_desktop.py"
424 + ).read_text(encoding="utf-8")
425 + assert "_refresh_xfce_desktop" in desktop_helper
426 + assert "DBUS_SESSION_BUS_ADDRESS" in desktop_helper
427 + autostart = (
428 + tmp_path
429 + / "desktop"
430 + / "profiles"
431 + / payload["session_id"]
432 + / ".config"
433 + / "autostart"
434 + / "agent-zero-office-desktop.desktop"
435 + )
436 + assert "prepare-xfce-profile.sh" in autostart.read_text(encoding="utf-8")
437 + profile_script = (
438 + tmp_path
439 + / "desktop"
440 + / "profiles"
441 + / payload["session_id"]
442 + / "prepare-xfce-profile.sh"
443 + ).read_text(encoding="utf-8")
444 + assert '"$HOME"/Desktop/*.desktop' in profile_script
445 + assert "agent-zero-settings.desktop" not in profile_script
446 + assert "metadata::xfce-exe-checksum" in profile_script
447 + assert manager.proxy_for_token(payload["token"]) == ("127.0.0.1", libreoffice_desktop.XPRA_PORT_BASE)
448 + assert manager.close(payload["session_id"], save_first=False)["closed"] == 0
449 + assert manager.close(payload["session_id"], save_first=False)["persistent"] is True
450 +
451 +
452 +def test_libreoffice_desktop_cleanup_preserves_live_owner_manifest(tmp_path, monkeypatch):
453 + session_dir = tmp_path / "sessions"
454 + session_dir.mkdir()
455 + manifest = session_dir / "live.json"
456 + manifest.write_text(
457 + json.dumps({"owner_pid": os.getpid(), "pids": {"xpra": 987654}}),
458 + encoding="utf-8",
459 + )
460 + monkeypatch.setattr(libreoffice_desktop, "SESSION_DIR", session_dir)
461 + monkeypatch.setattr(
462 + libreoffice_desktop,
463 + "_kill_pid",
464 + lambda _pid: pytest.fail("cleanup should not kill a desktop owned by a live UI process"),
465 + )
466 +
467 + result = libreoffice_desktop.cleanup_stale_runtime_state()
468 +
469 + assert result["killed"] == []
470 + assert manifest.exists()
471 +
472 +
473 +def test_libreoffice_desktop_removes_stale_lock_file(tmp_path):
474 + doc_path = tmp_path / "Deck.pptx"
475 + doc_path.write_text("pptx", encoding="utf-8")
476 + lock_path = tmp_path / ".~lock.Deck.pptx#"
477 + lock_path.write_text("stale", encoding="utf-8")
478 + session = libreoffice_desktop.DesktopSession(
479 + session_id="session",
480 + file_id="file",
481 + extension="pptx",
482 + path=str(doc_path),
483 + title=doc_path.name,
484 + display=libreoffice_desktop.DISPLAY_BASE,
485 + xpra_port=libreoffice_desktop.XPRA_PORT_BASE,
486 + token="token",
487 + url="/desktop/session/token/index.html",
488 + profile_dir=tmp_path / "profile",
489 + )
490 +
491 + libreoffice_desktop.LibreOfficeDesktopManager()._remove_stale_lock_file(session)
492 +
493 + assert not lock_path.exists()
494 +
495 +
496 +def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeypatch):
497 + source = tmp_path / "sources.list.d" / "retired.sources"
498 + keyring = tmp_path / "keyrings" / "retired.gpg"
499 + supervisor = tmp_path / "supervisor" / "retired.conf"
500 + runtime_dir = tmp_path / "runtime"
501 + marker = tmp_path / "state" / "cleanup.done"
502 +
503 + for path in (source, keyring, supervisor):
504 + path.parent.mkdir(parents=True, exist_ok=True)
505 + path.write_text("old\n", encoding="utf-8")
506 + (runtime_dir / "nested").mkdir(parents=True, exist_ok=True)
507 + (runtime_dir / "nested" / "state.txt").write_text("old\n", encoding="utf-8")
508 +
509 + monkeypatch.setattr(hooks, "APT_SOURCE_FILE", source)
510 + monkeypatch.setattr(hooks, "APT_KEYRING_FILE", keyring)
511 + monkeypatch.setattr(hooks, "SUPERVISOR_FILE", supervisor)
512 + monkeypatch.setattr(hooks, "RUNTIME_DIRS", [runtime_dir])
513 + monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
514 + monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
515 + monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
516 +
517 + def fake_ensure(installed, errors):
518 + assert not source.exists()
519 + installed.append("xpra")
520 +
521 + def fake_purge(removed, errors, **kwargs):
522 + return None
523 +
524 + monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", fake_ensure)
525 + monkeypatch.setattr(hooks, "_purge_packages", fake_purge)
526 +
527 + first = hooks.cleanup_stale_runtime_state(force=True)
528 + second = hooks.cleanup_stale_runtime_state(force=True)
529 + skipped = hooks.cleanup_stale_runtime_state()
530 +
531 + assert first["ok"] is True
532 + assert first["installed"] == ["xpra"]
533 + assert second["ok"] is True
534 + assert skipped["skipped"] is True
535 + assert not source.exists()
536 + assert not keyring.exists()
537 + assert not supervisor.exists()
538 + assert not runtime_dir.exists()
539 + assert marker.exists()
540 +
541 +
542 +def test_cleanup_hook_reruns_when_stale_packages_exist_after_old_marker(tmp_path, monkeypatch):
543 + marker = tmp_path / "state" / "cleanup.done"
544 + marker.parent.mkdir(parents=True)
545 + marker.write_text("old\n", encoding="utf-8")
546 +
547 + monkeypatch.setattr(hooks, "APT_SOURCE_FILE", tmp_path / "missing.sources")
548 + monkeypatch.setattr(hooks, "APT_KEYRING_FILE", tmp_path / "missing.gpg")
549 + monkeypatch.setattr(hooks, "SUPERVISOR_FILE", tmp_path / "missing.conf")
550 + monkeypatch.setattr(hooks, "RUNTIME_DIRS", [])
551 + monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
552 + monkeypatch.setattr(hooks, "_installed_packages", lambda packages: ["coolwsd"])
553 + monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
554 + monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
555 +
556 + def fake_purge(removed, errors, **kwargs):
557 + removed.extend(kwargs["installed_packages"])
558 +
559 + monkeypatch.setattr(hooks, "_purge_packages", fake_purge)
560 +
561 + result = hooks.cleanup_stale_runtime_state()
562 +
563 + assert result["skipped"] is False
564 + assert result["removed"] == ["coolwsd"]
565 +
566 +
567 +def test_cleanup_hook_installs_missing_libreoffice_desktop_dependencies(monkeypatch):
568 + calls = []
569 + installed_state = {"xpra": False}
570 +
571 + monkeypatch.setattr(hooks.os, "geteuid", lambda: 0)
572 + monkeypatch.setattr(hooks.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "")
573 + monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("xpra",))
574 + monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False))
575 +
576 + def fake_run(command, **kwargs):
577 + calls.append(command)
578 + if command[:2] == ["apt-get", "install"]:
579 + installed_state["xpra"] = True
580 + return types.SimpleNamespace(returncode=0, stdout="", stderr="")
581 +
582 + monkeypatch.setattr(hooks.subprocess, "run", fake_run)
583 + installed = []
584 + errors = []
585 +
586 + hooks._ensure_runtime_dependencies(installed, errors)
587 +
588 + assert installed == ["xpra"]
589 + assert errors == []
590 + assert calls[0] == ["apt-get", "update"]
591 + assert calls[1][:4] == ["apt-get", "install", "-y", "--no-install-recommends"]
592 +
593 +
594 +def test_cleanup_hook_enables_official_xpra_repo_when_kali_lacks_candidate(tmp_path, monkeypatch):
595 + calls = []
596 + installed_state = {"xpra": False, "ca-certificates": True}
597 + keyring = tmp_path / "keyrings" / "xpra.asc"
598 + source = tmp_path / "sources.list.d" / "xpra.sources"
599 +
600 + monkeypatch.setattr(hooks.os, "geteuid", lambda: 0)
601 + monkeypatch.setattr(
602 + hooks.shutil,
603 + "which",
604 + lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "",
605 + )
606 + monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("xpra",))
607 + monkeypatch.setattr(hooks, "XPRA_KEYRING_FILE", keyring)
608 + monkeypatch.setattr(hooks, "XPRA_SOURCE_FILE", source)
609 + monkeypatch.setattr(hooks, "_download", lambda url: b"xpra-key")
610 + monkeypatch.setattr(hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"})
611 + monkeypatch.setattr(hooks, "_dpkg_architecture", lambda: "amd64")
612 + monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False))
613 +
614 + def fake_run(command, **kwargs):
615 + calls.append(command)
616 + if command[:2] == ["apt-cache", "policy"]:
617 + return types.SimpleNamespace(returncode=0, stdout="Candidate: (none)\n", stderr="")
618 + if command[:2] == ["apt-get", "install"]:
619 + installed_state["xpra"] = True
620 + return types.SimpleNamespace(returncode=0, stdout="", stderr="")
621 +
622 + monkeypatch.setattr(hooks.subprocess, "run", fake_run)
623 + installed = []
624 + errors = []
625 +
626 + hooks._ensure_runtime_dependencies(installed, errors)
627 +
628 + assert errors == []
629 + assert installed == ["xpra"]
630 + assert keyring.read_bytes() == b"xpra-key"
631 + assert "URIs: https://xpra.org/beta" in source.read_text(encoding="utf-8")
632 + assert "Suites: sid" in source.read_text(encoding="utf-8")
633 + assert calls.count(["apt-get", "update"]) == 2
634 + assert calls[-1][:4] == ["apt-get", "install", "-y", "--no-install-recommends"]
635 +
636 +
637 +def test_self_update_launch_invokes_office_cleanup(monkeypatch, tmp_path):
638 + manager = load_self_update_manager()
639 + calls = []
640 +
641 + class Logger:
642 + def log(self, message=""):
643 + return None
644 +
645 + class Process:
646 + pass
647 +
648 + monkeypatch.setattr(manager, "run_office_cleanup_hook", lambda repo_dir, logger: calls.append(repo_dir))
649 + monkeypatch.setattr(manager, "run_command", lambda *args, **kwargs: None)
650 + monkeypatch.setattr(manager.subprocess, "Popen", lambda *args, **kwargs: Process())
651 +
652 + repo = tmp_path / "repo"
653 + repo.mkdir()
654 + process = manager.launch_ui_process(repo, Logger())
655 +
656 + assert isinstance(process, Process)
657 + assert calls == [repo]
658 +
659 +
660 +def load_self_update_manager():
661 + manager_path = PROJECT_ROOT / "docker" / "run" / "fs" / "exe" / "self_update_manager.py"
662 + spec = importlib.util.spec_from_file_location("test_self_update_manager_office", manager_path)
663 + assert spec is not None and spec.loader is not None
664 + module = importlib.util.module_from_spec(spec)
665 + spec.loader.exec_module(module)
666 + return module
tests/test_office_wopi_store.py deleted
-388
@@ -1,388 +0,0 @@
1 -from __future__ import annotations
2 -
3 -import sys
4 -from pathlib import Path
5 -
6 -import pytest
7 -from flask import Flask
8 -
9 -PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 -if str(PROJECT_ROOT) not in sys.path:
11 - sys.path.insert(0, str(PROJECT_ROOT))
12 -
13 -from plugins._office.helpers import artifact_editor, canvas_context, wopi_routes, wopi_store
14 -
15 -
16 -@pytest.fixture()
17 -def office_state(tmp_path, monkeypatch):
18 - workdir = tmp_path / "workdir"
19 - state = tmp_path / "state"
20 - documents = workdir / "documents"
21 - monkeypatch.setattr(wopi_store, "STATE_DIR", state)
22 - monkeypatch.setattr(wopi_store, "DB_PATH", state / "documents.sqlite3")
23 - monkeypatch.setattr(wopi_store, "BACKUP_DIR", state / "backups")
24 - monkeypatch.setattr(wopi_store, "DOCUMENTS_DIR", documents)
25 - monkeypatch.setattr(wopi_store, "WORKDIR", workdir)
26 - wopi_store.ensure_dirs()
27 - return {"workdir": workdir, "state": state, "documents": documents}
28 -
29 -
30 -def test_check_file_info_has_no_nulls_and_token_is_scoped(office_state):
31 - doc = wopi_store.create_document("document", "Scope Test", "docx", "hello")
32 - session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
33 -
34 - token_info = wopi_store.validate_token(session["access_token"], doc["file_id"], require_write=True)
35 - info = wopi_store.check_file_info(doc["file_id"], token_info)
36 -
37 - assert all(value is not None for value in info.values())
38 - assert info["UserCanWrite"] is True
39 - assert info["ReadOnly"] is False
40 - assert info["SupportsLocks"] is True
41 -
42 - other = wopi_store.create_document("document", "Other", "docx", "")
43 - with pytest.raises(PermissionError):
44 - wopi_store.validate_token(session["access_token"], other["file_id"])
45 -
46 -
47 -def test_path_traversal_and_symlink_escape_are_rejected(office_state, tmp_path):
48 - outside = tmp_path / "outside.docx"
49 - outside.write_bytes(wopi_store.template_bytes("document", "docx", "Outside", ""))
50 -
51 - with pytest.raises(PermissionError):
52 - wopi_store.register_document(outside)
53 -
54 - link = office_state["workdir"] / "escape.docx"
55 - link.symlink_to(outside)
56 - with pytest.raises(PermissionError):
57 - wopi_store.register_document(link)
58 -
59 -
60 -def test_lock_conflicts_refresh_unlock_and_relock(office_state):
61 - doc = wopi_store.create_document("document", "Lock Test", "docx", "")
62 - session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
63 -
64 - ok, current = wopi_store.lock(doc["file_id"], "lock-a", session["session_id"], 120)
65 - assert ok is True
66 - assert current == "lock-a"
67 -
68 - ok, current = wopi_store.lock(doc["file_id"], "lock-b", session["session_id"], 120)
69 - assert ok is False
70 - assert current == "lock-a"
71 -
72 - ok, current = wopi_store.refresh_lock(doc["file_id"], "lock-a", 120)
73 - assert ok is True
74 - assert current == "lock-a"
75 -
76 - ok, current = wopi_store.unlock_and_relock(doc["file_id"], "lock-a", "lock-c", session["session_id"], 120)
77 - assert ok is True
78 - assert current == "lock-c"
79 -
80 - ok, current = wopi_store.unlock(doc["file_id"], "lock-b")
81 - assert ok is False
82 - assert current == "lock-c"
83 -
84 - ok, current = wopi_store.unlock(doc["file_id"], "lock-c")
85 - assert ok is True
86 - assert current == ""
87 -
88 -
89 -def test_close_session_revokes_token_lock_and_open_document_metadata(office_state):
90 - doc = wopi_store.create_document("document", "Close Test", "docx", "")
91 - session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
92 - ok, current = wopi_store.lock(doc["file_id"], "close-lock", session["session_id"], 120)
93 - assert ok is True
94 - assert current == "close-lock"
95 -
96 - open_docs = wopi_store.get_open_documents()
97 - assert len(open_docs) == 1
98 - assert open_docs[0]["file_id"] == doc["file_id"]
99 - assert open_docs[0]["open_sessions"] == 1
100 -
101 - assert wopi_store.close_session(session_id=session["session_id"]) == 1
102 - assert wopi_store.get_open_documents() == []
103 - assert wopi_store.get_lock(doc["file_id"]) == ""
104 - with pytest.raises(PermissionError):
105 - wopi_store.validate_token(session["access_token"], doc["file_id"])
106 - assert wopi_store.close_session(session_id=session["session_id"]) == 0
107 -
108 -
109 -def test_sync_open_sessions_closes_sessions_without_visible_tabs(office_state):
110 - first = wopi_store.create_document("document", "Visible", "docx", "shown")
111 - second = wopi_store.create_document("document", "Orphan", "docx", "hidden")
112 - visible = wopi_store.create_session(first["file_id"], "user-a", "write", "http://localhost:32080")
113 - orphan = wopi_store.create_session(second["file_id"], "user-a", "write", "http://localhost:32080")
114 - ok, _ = wopi_store.lock(second["file_id"], "orphan-lock", orphan["session_id"], 120)
115 - assert ok is True
116 - with wopi_store.connect() as conn:
117 - conn.execute(
118 - "UPDATE sessions SET created_at = ? WHERE session_id = ?",
119 - (wopi_store.now() - wopi_store.ORPHAN_SESSION_GRACE_SECONDS - 1, orphan["session_id"]),
120 - )
121 -
122 - assert wopi_store.sync_open_sessions([visible["session_id"]]) == 1
123 -
124 - open_docs = wopi_store.get_open_documents()
125 - assert len(open_docs) == 1
126 - assert open_docs[0]["file_id"] == first["file_id"]
127 - assert wopi_store.get_lock(second["file_id"]) == ""
128 - with pytest.raises(PermissionError):
129 - wopi_store.validate_token(orphan["access_token"], second["file_id"])
130 -
131 -
132 -def test_sync_open_sessions_preserves_new_sessions_during_mount_race(office_state):
133 - doc = wopi_store.create_document("document", "Fresh", "docx", "new")
134 - session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
135 -
136 - assert wopi_store.sync_open_sessions([]) == 0
137 -
138 - token_info = wopi_store.validate_token(session["access_token"], doc["file_id"], require_write=True)
139 - assert token_info["session"]["session_id"] == session["session_id"]
140 -
141 -
142 -def test_recent_documents_include_lightweight_previews(office_state):
143 - doc = wopi_store.create_document("document", "Preview Memo", "docx", "A calm dashboard.")
144 - sheet = wopi_store.create_document("spreadsheet", "Preview Sheet", "xlsx", "Name,Value\nOffice,1")
145 - deck = wopi_store.create_document("presentation", "Preview Deck", "pptx", "First slide")
146 -
147 - previews = {
148 - item["file_id"]: item["preview"]
149 - for item in wopi_store.get_recent_documents(limit=3)
150 - }
151 -
152 - assert previews[doc["file_id"]]["lines"][0] == "Preview Memo"
153 - assert previews[sheet["file_id"]]["rows"][0] == ["Name", "Value"]
154 - assert previews[deck["file_id"]]["slides"][0]["title"] == "Preview Deck"
155 -
156 -
157 -def test_put_file_requires_lock_and_updates_version_history(office_state):
158 - doc = wopi_store.create_document("document", "Save Test", "docx", "before")
159 - session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
160 -
161 - with pytest.raises(wopi_store.LockMismatch):
162 - wopi_store.put_file(doc["file_id"], b"after", "")
163 -
164 - ok, _ = wopi_store.lock(doc["file_id"], "save-lock", session["session_id"], 120)
165 - assert ok is True
166 - next_version = wopi_store.put_file(doc["file_id"], b"after", "save-lock")
167 - saved = wopi_store.get_document(doc["file_id"])
168 -
169 - assert next_version == wopi_store.item_version(saved)
170 - assert saved["size"] == len(b"after")
171 - assert (office_state["documents"] / "Save Test.docx").read_bytes() == b"after"
172 - assert wopi_store.version_history(doc["file_id"])
173 -
174 -
175 -def test_wopi_routes_return_conflict_lock_header(office_state):
176 - app = Flask(__name__)
177 - wopi_routes.register_wopi_routes(app)
178 - doc = wopi_store.create_document("document", "Route Test", "docx", "")
179 - session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
180 -
181 - with app.test_client() as client:
182 - first = client.post(
183 - f"/wopi/files/{doc['file_id']}?access_token={session['access_token']}",
184 - headers={"X-WOPI-Override": "LOCK", "X-WOPI-Lock": "route-lock"},
185 - )
186 - assert first.status_code == 200
187 -
188 - conflict = client.post(
189 - f"/wopi/files/{doc['file_id']}?access_token={session['access_token']}",
190 - headers={"X-WOPI-Override": "LOCK", "X-WOPI-Lock": "other-lock"},
191 - )
192 - assert conflict.status_code == 409
193 - assert conflict.headers["X-WOPI-Lock"] == "route-lock"
194 -
195 -
196 -def test_office_proxy_accepts_encoded_wopi_socket_token_without_session_cookie(office_state):
197 - pytest.importorskip("starlette")
198 - from plugins._office.helpers import office_proxy
199 -
200 - doc = wopi_store.create_document("document", "Socket Token", "docx", "")
201 - session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://127.0.0.1:32080")
202 - encoded_wopi = (
203 - f"http%3A%2F%2F127.0.0.1%3A80%2Fwopi%2Ffiles%2F{doc['file_id']}"
204 - f"%3Faccess_token%3D{session['access_token']}"
205 - f"%26access_token_ttl%3D{session['access_token_ttl']}"
206 - )
207 - scope = {
208 - "type": "websocket",
209 - "path": f"/office/cool/{encoded_wopi}/ws",
210 - "raw_path": f"/office/cool/{encoded_wopi}/ws".encode("latin-1"),
211 - "query_string": b"",
212 - "headers": [],
213 - }
214 -
215 - proxy = office_proxy.OfficeProxy()
216 -
217 - assert proxy._has_valid_wopi_token(scope) is True
218 -
219 - headers = proxy.websocket_headers({
220 - "headers": [
221 - (b"host", b"127.0.0.1:32080"),
222 - (b"origin", b"http://127.0.0.1:32080"),
223 - (b"user-agent", b"qa"),
224 - (b"sec-websocket-key", b"ignored"),
225 - ],
226 - })
227 - assert proxy.upstream_websocket_url(scope).startswith("ws://127.0.0.1:32080/office/cool/")
228 - assert all(key.lower() not in {"host", "origin", "sec-websocket-key"} for key, _ in headers)
229 - assert ("user-agent", "qa") in headers
230 -
231 -
232 -def test_document_artifact_docx_edit_replaces_text_and_tracks_version(office_state):
233 - doc = wopi_store.create_document("document", "Edit Text", "docx", "The old phrase stays here.")
234 -
235 - updated, payload = artifact_editor.edit_artifact(
236 - doc,
237 - operation="replace_text",
238 - find="old phrase",
239 - replace="new phrase",
240 - )
241 - content = artifact_editor.read_artifact(updated)
242 -
243 - assert payload["changed"] is True
244 - assert payload["replacements"] == 1
245 - assert "new phrase" in content["text"]
246 - assert "old phrase" not in content["text"]
247 - assert int(updated["version"]) == 2
248 - assert wopi_store.version_history(doc["file_id"])
249 -
250 -
251 -def test_document_artifact_xlsx_edit_sets_cells_and_appends_rows(office_state):
252 - doc = wopi_store.create_document("spreadsheet", "Budget", "xlsx", "Name,Amount")
253 -
254 - updated, payload = artifact_editor.edit_artifact(
255 - doc,
256 - operation="set_cells",
257 - cells={"A2": "Tools", "B2": 12500},
258 - )
259 - updated, payload = artifact_editor.edit_artifact(
260 - updated,
261 - operation="append_rows",
262 - rows=[["Research", 9800]],
263 - )
264 - content = artifact_editor.read_artifact(updated)
265 - rows = content["sheets"][0]["preview_rows"]
266 -
267 - assert payload["changed"] is True
268 - assert ["Tools", 12500] in rows
269 - assert ["Research", 9800] in rows
270 -
271 -
272 -def test_document_artifact_xlsx_create_parses_csv_content_for_charting(office_state):
273 - doc = wopi_store.create_document(
274 - "spreadsheet",
275 - "Revenue Demo",
276 - "xlsx",
277 - "\n".join([
278 - "Month,Revenue,Costs",
279 - "Jan,120,80",
280 - "Feb,135,92",
281 - "Mar,150,96",
282 - ]),
283 - )
284 - content = artifact_editor.read_artifact(doc)
285 - rows = content["sheets"][0]["preview_rows"]
286 -
287 - assert rows[0] == ["Month", "Revenue", "Costs"]
288 - assert rows[1] == ["Jan", 120, 80]
289 -
290 - updated, payload = artifact_editor.edit_artifact(
291 - doc,
292 - operation="create_chart",
293 - chart={"type": "line", "position": "E1"},
294 - )
295 -
296 - assert payload["changed"] is True
297 - assert payload["charts"][0]["type"] == "line"
298 - assert payload["charts"][0]["position"] == "E1"
299 - assert artifact_editor.read_artifact(updated)["sheets"][0]["chart_count"] == 1
300 -
301 -
302 -def test_document_artifact_xlsx_stock_chart_rejects_non_numeric_ohlc_data(office_state):
303 - doc = wopi_store.create_document(
304 - "spreadsheet",
305 - "Broken Trading Demo",
306 - "xlsx",
307 - "\n".join([
308 - "Date,Open,High,Low,Close",
309 - "2026-04-24,open,high,low,close",
310 - "2026-04-25,still,not,real,numbers",
311 - ]),
312 - )
313 -
314 - with pytest.raises(ValueError, match="no numeric data"):
315 - artifact_editor.edit_artifact(doc, operation="create_chart", chart={"type": "candlestick"})
316 -
317 -
318 -def test_document_artifact_xlsx_edit_creates_stock_chart(office_state):
319 - doc = wopi_store.create_document("spreadsheet", "Trading Demo", "xlsx", "")
320 - rows = [
321 - ["Date", "Open", "High", "Low", "Close", "Volume"],
322 - ["2026-04-24", 100, 105, 99, 104, 1000],
323 - ["2026-04-25", 104, 106, 102, 103, 1200],
324 - ["2026-04-28", 103, 108, 101, 107, 1800],
325 - ]
326 - updated, _ = artifact_editor.edit_artifact(doc, operation="set_rows", rows=rows)
327 -
328 - updated, payload = artifact_editor.edit_artifact(
329 - updated,
330 - operation="create_chart",
331 - chart={
332 - "type": "candlestick",
333 - "title": "DEMO Stock Price (OHLC)",
334 - "position": "A8",
335 - "width": 16,
336 - "height": 8,
337 - },
338 - )
339 - content = artifact_editor.read_artifact(updated)
340 - sheet = content["sheets"][0]
341 -
342 - assert payload["changed"] is True
343 - assert payload["charts_created"] == 1
344 - assert payload["charts"][0]["type"] == "stock"
345 - assert payload["charts"][0]["series_count"] == 4
346 - assert sheet["chart_count"] == 1
347 - assert sheet["charts"][0]["type"] == "stock"
348 - assert sheet["charts"][0]["title"] == "DEMO Stock Price (OHLC)"
349 -
350 -
351 -def test_document_artifact_pptx_edit_sets_slides(office_state):
352 - doc = wopi_store.create_document("presentation", "Roadmap", "pptx", "Initial")
353 -
354 - updated, payload = artifact_editor.edit_artifact(
355 - doc,
356 - operation="set_slides",
357 - slides=[
358 - {"title": "Vision", "bullets": ["Elegant", "Useful"]},
359 - {"title": "Plan", "bullets": ["Build", "Verify"]},
360 - ],
361 - )
362 - content = artifact_editor.read_artifact(updated)
363 -
364 - assert payload["changed"] is True
365 - assert content["slide_count"] == 2
366 - assert [slide["title"] for slide in content["slides"]] == ["Vision", "Plan"]
367 -
368 -
369 -def test_office_canvas_context_lists_active_metadata_without_file_contents(office_state):
370 - doc = wopi_store.create_document("document", "Canvas Context", "docx", "private body text")
371 - wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
372 -
373 - context = canvas_context.build_context()
374 -
375 - assert "Canvas Context.docx" in context
376 - assert doc["file_id"] in context
377 - assert "private body text" not in context
378 -
379 -
380 -def test_office_artifacts_skill_metadata_is_valid():
381 - skill_path = PROJECT_ROOT / "plugins" / "_office" / "skills" / "office-artifacts" / "SKILL.md"
382 - text = skill_path.read_text(encoding="utf-8")
383 -
384 - assert text.startswith("---\n")
385 - assert "\nname: office-artifacts\n" in text
386 - assert "description:" in text
387 - assert "allowed_tools:" in text
388 - assert "document_artifact" in text