Keep Office canvas documents live after edits

Reload LibreOffice-backed Writer, Spreadsheet, and Presentation documents after artifact edits by closing and reopening the visible document window instead of relying on a canvas repaint. Cold-start Office canvas sessions from tool results, guard null document headers during initial render, and avoid terminating rehydrated desktop processes from temporary manager instances.

Alessandro committed May 5, 2026 at 19:01 UTC 9950aa4f59dee8c349a3686501a0e662812b51f3
6 files changed +466 -15
plugins/_office/api/office_session.py
+1 -1
@@ -76,7 +76,7 @@ class OfficeSession(ApiHandler):
76 origin=self._origin(request),
77 )
78 if str(doc.get("extension") or "").lower() in libreoffice_desktop.OFFICIAL_EXTENSIONS:
79 - desktop = libreoffice_desktop.get_manager().open(doc)
79 + desktop = libreoffice_desktop.get_manager().open(doc, refresh=input.get("refresh") is True)
80 if not desktop.get("available"):
81 document_store.close_session(session_id=store_session["session_id"])
82 return {
plugins/_office/extensions/webui/right_canvas_register_surfaces/register-office.js
+2 -1
@@ -2,7 +2,7 @@ import { store as officeStore } from "/plugins/_office/webui/office-store.js";
2
3 void officeStore;
4
5 -function waitForElement(selector, timeoutMs = 3000) {
5 +function waitForElement(selector, timeoutMs = 10000) {
6 const found = document.querySelector(selector);
7 if (found) return Promise.resolve(found);
8 return new Promise((resolve) => {
@@ -42,6 +42,7 @@ export default async function registerOfficeSurface(canvas) {
42 },
43 async open(payload = {}) {
44 const panel = await waitForElement('[data-surface-id="office"] .office-panel');
45 + if (!panel) throw new Error("Office canvas panel did not mount.");
46 const office = globalThis.Alpine?.store?.("office");
47 await office?.onMount?.(panel, { mode: "canvas" });
48 await office?.onOpen?.(payload);
plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
+54 -2
@@ -1,9 +1,9 @@
1 const SYNC_WINDOW_MS = 10 * 60 * 1000;
2 +const DESKTOP_OFFICE_FORMATS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
3 const syncedDocumentResults = new Set();
4
5 export default async function syncDocumentResultsIntoOpenCanvas(context) {
6 if (!context?.results?.length || context.historyEmpty) return;
6 - if (!isOfficeCanvasAlreadyOpen()) return;
7
8 for (const { args } of context.results) {
9 const payload = getDocumentPayload(args);
@@ -25,13 +25,19 @@ export default async function syncDocumentResultsIntoOpenCanvas(context) {
25 if (syncedDocumentResults.has(key)) continue;
26 syncedDocumentResults.add(key);
27
28 + if (!isOfficeCanvasOrModalOpen() && shouldColdOpenOfficeCanvas(payload, document)) {
29 + await openOfficeCanvasFromResult({ path, file_id: fileId });
30 + continue;
31 + }
32 +
33 globalThis.setTimeout(async () => {
29 - if (!isOfficeCanvasAlreadyOpen()) return;
34 + if (!isOfficeCanvasOrModalOpen()) return;
35 const office = globalThis.Alpine?.store?.("office");
36 if (!office || isDirtySameDocument(office, { path, file_id: fileId })) return;
37 await office.openSession?.({
38 path,
39 file_id: fileId,
40 + refresh: true,
41 source: "tool-result-sync",
42 });
43 }, 0);
@@ -56,7 +62,9 @@ function pickPayloadFields(args = {}) {
62 "_tool_name",
63 "tool_name",
64 "action",
65 + "canvas_surface",
66 "file_id",
67 + "format",
68 "path",
69 "version",
70 "last_modified",
@@ -76,11 +84,55 @@ function shouldSyncOpenOfficeCanvas(args = {}, payload = {}) {
84 return ["create", "open", "edit", "restore_version"].includes(action);
85 }
86
87 +function shouldColdOpenOfficeCanvas(payload = {}, document = {}) {
88 + const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
89 + if (!["create", "open"].includes(action)) return false;
90 + return DESKTOP_OFFICE_FORMATS.has(documentFormat(payload, document));
91 +}
92 +
93 +async function openOfficeCanvasFromResult(document = {}) {
94 + const canvas = globalThis.Alpine?.store?.("rightCanvas")
95 + || (await import("/components/canvas/right-canvas-store.js")).store;
96 + await canvas?.open?.("office", {
97 + path: document.path || "",
98 + file_id: document.file_id || "",
99 + refresh: true,
100 + source: "tool-result-sync",
101 + });
102 +}
103 +
104 +function documentFormat(payload = {}, document = {}) {
105 + return String(
106 + payload.format
107 + || payload.extension
108 + || document.extension
109 + || extensionOf(payload.path || document.path || ""),
110 + ).trim().toLowerCase().replace(/^\./, "");
111 +}
112 +
113 +function extensionOf(path = "") {
114 + const name = String(path || "").split("?")[0].split("#")[0].split("/").filter(Boolean).pop() || "";
115 + const index = name.lastIndexOf(".");
116 + return index >= 0 ? name.slice(index + 1) : "";
117 +}
118 +
119 function isOfficeCanvasAlreadyOpen() {
120 const canvas = globalThis.Alpine?.store?.("rightCanvas");
121 return Boolean(canvas?.isOpen && canvas?.activeSurfaceId === "office");
122 }
123
124 +function isOfficeCanvasOrModalOpen() {
125 + return Boolean(isOfficeCanvasAlreadyOpen() || isOfficeModalAlreadyOpen());
126 +}
127 +
128 +function isOfficeModalAlreadyOpen() {
129 + return Boolean(
130 + globalThis.isModalOpen?.("/plugins/_office/webui/main.html")
131 + || globalThis.isModalOpen?.("plugins/_office/webui/main.html")
132 + || globalThis.document?.querySelector?.(".office-modal .office-panel, .modal .office-panel"),
133 + );
134 +}
135 +
136 function isDirtySameDocument(office, document = {}) {
137 if (!office?.dirty || !office?.session) return false;
138 const path = String(document.path || "");
plugins/_office/helpers/artifact_editor.py
+7 -1
@@ -139,7 +139,13 @@ def _refresh_open_editor_sessions(file_id: str) -> None:
139 markdown_sessions.get_manager().refresh_document(file_id)
140 except Exception:
141 # Direct artifact edits should never fail just because no canvas is open.
142 - return
142 + pass
143 + try:
144 + from plugins._office.helpers import libreoffice_desktop
145 +
146 + libreoffice_desktop.get_manager().refresh_document(file_id)
147 + except Exception:
148 + pass
149
150
151 def normalize_operation(
plugins/_office/helpers/libreoffice_desktop.py
+397 -10
@@ -5,6 +5,7 @@ import fcntl
5 import hashlib
6 import json
7 import os
8 +import re
9 import shutil
10 import socket
11 import subprocess
@@ -88,10 +89,12 @@ class DesktopSession:
89 width: int = DEFAULT_SCREEN_WIDTH
90 height: int = DEFAULT_SCREEN_HEIGHT
91 processes: dict[str, subprocess.Popen[Any]] = field(default_factory=dict)
92 + process_ids: dict[str, int] = field(default_factory=dict)
93 + owns_processes: bool = True
94 started_at: float = field(default_factory=time.time)
95
96 def alive(self) -> bool:
94 - return _running(self.processes.get("xpra"))
97 + return _running(self.processes.get("xpra")) or _pid_is_running(self.process_ids.get("xpra", 0))
98
99 def public(self, doc: dict[str, Any] | None = None) -> dict[str, Any]:
100 title = str(doc.get("basename") or "") if doc else self.title
@@ -134,7 +137,7 @@ class LibreOfficeDesktopManager:
137 "status": status,
138 }
139
137 - def open(self, doc: dict[str, Any]) -> dict[str, Any]:
140 + def open(self, doc: dict[str, Any], *, refresh: bool = False) -> dict[str, Any]:
141 ext = str(doc.get("extension") or "").lower()
142 if ext not in OFFICIAL_EXTENSIONS:
143 return {"available": False, "reason": f".{ext} does not use the LibreOffice desktop surface."}
@@ -150,13 +153,59 @@ class LibreOfficeDesktopManager:
153 "error": str(exc),
154 "status": status,
155 }
153 - self._open_document_locked(session, doc)
156 + refreshed = False
157 + try:
158 + if refresh and session.file_id == str(doc.get("file_id") or ""):
159 + refreshed = self._reload_document_locked(session, doc)
160 + else:
161 + self._open_document_locked(session, doc)
162 + except Exception as exc:
163 + return {
164 + "available": False,
165 + "error": str(exc),
166 + "status": collect_desktop_status(),
167 + }
168 session.file_id = str(doc["file_id"])
169 session.extension = ext
170 session.path = str(doc["path"])
171 session.title = str(doc["basename"])
172 self._write_manifest(session)
159 - return session.public(doc)
173 + public = session.public(doc)
174 + public["refreshed"] = refreshed
175 + return public
176 +
177 + def refresh_document(self, file_id: str) -> dict[str, Any]:
178 + normalized = str(file_id or "").strip()
179 + if not normalized:
180 + return {"ok": True, "refreshed": False}
181 + try:
182 + doc = document_store.get_document(normalized)
183 + except Exception:
184 + return {"ok": False, "refreshed": False, "error": "Document not found."}
185 +
186 + ext = str(doc.get("extension") or "").lower()
187 + if ext not in OFFICIAL_EXTENSIONS:
188 + return {"ok": True, "refreshed": False}
189 +
190 + with self._lock:
191 + self._reap_dead_locked()
192 + session = self._find_by_file_id_locked(normalized)
193 + if not session:
194 + existing = self._load_system_desktop_from_manifest_locked()
195 + if existing:
196 + self._sessions[existing.session_id] = existing
197 + self._register_virtual_desktop(existing)
198 + if existing.file_id == normalized:
199 + session = existing
200 + if not session:
201 + return {"ok": True, "refreshed": False}
202 + refreshed = self._reload_document_locked(session, doc)
203 + session.file_id = str(doc["file_id"])
204 + session.extension = ext
205 + session.path = str(doc["path"])
206 + session.title = str(doc["basename"])
207 + self._write_manifest(session)
208 + return {"ok": True, "refreshed": refreshed, "desktop": session.public(doc)}
209
210 def save(self, session_id: str, file_id: str = "") -> dict[str, Any]:
211 session = self.require(session_id)
@@ -268,7 +317,7 @@ class LibreOfficeDesktopManager:
317 if self._sessions.get(SYSTEM_SESSION_ID) is session:
318 self._sessions.pop(SYSTEM_SESSION_ID, None)
319 virtual_desktop.unregister_session(session.token)
271 - self._terminate_session(session)
320 + self._terminate_session(session, include_rehydrated=True)
321 self._remove_manifest(session.session_id)
322 _clear_shutdown_request(session)
323 return {
@@ -322,7 +371,7 @@ class LibreOfficeDesktopManager:
371 with self._lock:
372 self._sessions.pop(session.session_id, None)
373 virtual_desktop.unregister_session(session.token)
325 - self._terminate_session(session)
374 + self._terminate_session(session, include_rehydrated=True)
375 self._remove_manifest(session.session_id)
376 return {"ok": True, "closed": 1, "session_id": session.session_id, "save": save_result}
377
@@ -389,8 +438,9 @@ class LibreOfficeDesktopManager:
438 self._sessions.clear()
439 for session in sessions:
440 virtual_desktop.unregister_session(session.token)
392 - self._terminate_session(session)
393 - self._remove_manifest(session.session_id)
441 + if session.owns_processes:
442 + self._terminate_session(session)
443 + self._remove_manifest(session.session_id)
444
445 def _document_for_save(self, session: DesktopSession, file_id: str = "") -> dict[str, Any] | None:
446 normalized = str(file_id or "").strip()
@@ -424,6 +474,14 @@ class LibreOfficeDesktopManager:
474 self._refresh_xfce_desktop(existing)
475 return existing
476
477 + existing = self._load_system_desktop_from_manifest_locked()
478 + if existing:
479 + self._sessions[existing.session_id] = existing
480 + self._register_virtual_desktop(existing)
481 + self._prepare_desktop_url_bridge(existing)
482 + self._refresh_xfce_desktop(existing)
483 + return existing
484 +
485 status = collect_desktop_status()
486 if not status["healthy"]:
487 raise RuntimeError(status["message"])
@@ -454,6 +512,54 @@ class LibreOfficeDesktopManager:
512 self._write_manifest(session)
513 return session
514
515 + def _load_system_desktop_from_manifest_locked(self) -> DesktopSession | None:
516 + manifest = SESSION_DIR / f"{SYSTEM_SESSION_ID}.json"
517 + if not manifest.exists():
518 + return None
519 + try:
520 + payload = json.loads(manifest.read_text(encoding="utf-8"))
521 + display = int(payload.get("display") or 0)
522 + xpra_port = int(payload.get("xpra_port") or 0)
523 + process_ids = {
524 + str(name): pid
525 + for name, value in dict(payload.get("pids") or {}).items()
526 + if (pid := _coerce_pid(value))
527 + }
528 + if not display or not xpra_port:
529 + return None
530 + if not _pid_is_running(process_ids.get("xpra", 0)):
531 + return None
532 + if not _port_is_accepting("127.0.0.1", xpra_port):
533 + return None
534 +
535 + path = str(payload.get("path") or document_store.document_binary_home())
536 + file_id = str(payload.get("file_id") or SYSTEM_FILE_ID)
537 + extension = str(payload.get("extension") or "").lower()
538 + if not extension:
539 + extension = "desktop" if file_id == SYSTEM_FILE_ID else Path(path).suffix.lower().lstrip(".")
540 + title = str(payload.get("title") or "")
541 + if not title:
542 + title = SYSTEM_TITLE if file_id == SYSTEM_FILE_ID else Path(path).name or SYSTEM_TITLE
543 + return DesktopSession(
544 + session_id=SYSTEM_SESSION_ID,
545 + file_id=file_id,
546 + extension=extension,
547 + path=path,
548 + title=title,
549 + display=display,
550 + xpra_port=xpra_port,
551 + token=SYSTEM_SESSION_ID,
552 + url=_xpra_url(SYSTEM_SESSION_ID),
553 + profile_dir=Path(payload.get("profile_dir") or PROFILE_DIR / SYSTEM_SESSION_ID),
554 + width=int(payload.get("width") or DEFAULT_SCREEN_WIDTH),
555 + height=int(payload.get("height") or DEFAULT_SCREEN_HEIGHT),
556 + process_ids=process_ids,
557 + owns_processes=False,
558 + started_at=float(payload.get("started_at") or time.time()),
559 + )
560 + except Exception:
561 + return None
562 +
563 def _spawn_desktop_locked(self, session: DesktopSession) -> None:
564 STATE_DIR.mkdir(parents=True, exist_ok=True)
565 SESSION_DIR.mkdir(parents=True, exist_ok=True)
@@ -540,6 +646,261 @@ class LibreOfficeDesktopManager:
646 env=self._display_env(session),
647 )
648 self._fit_office_window(session, process=session.processes[process_key])
649 + window_id = self._wait_for_office_window_locked(
650 + session,
651 + title=str(doc.get("basename") or ""),
652 + process=session.processes[process_key],
653 + )
654 + if not window_id:
655 + raise RuntimeError(
656 + f"LibreOffice did not show {doc.get('basename') or 'the document'} "
657 + f"on desktop :{session.display}.",
658 + )
659 + self._fit_office_window_id_locked(
660 + session,
661 + window_id,
662 + env=self._display_env(session),
663 + keys=("Escape",),
664 + )
665 +
666 + def _reload_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
667 + if self._close_document_window_locked(session, doc):
668 + self._open_document_locked(session, doc)
669 + return True
670 + if self._send_reload_shortcut_locked(session, doc):
671 + return True
672 + self._open_document_locked(session, doc)
673 + return False
674 +
675 + def _close_document_window_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
676 + xdotool = shutil.which("xdotool")
677 + if not xdotool:
678 + return False
679 + title = str(doc.get("basename") or Path(str(doc.get("path") or "")).name or "").strip()
680 + if not title:
681 + return False
682 + window_id = self._office_window_id_locked(session, title=title, fallback=False)
683 + if not window_id:
684 + return False
685 + env = self._display_env(session)
686 + self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
687 + for command in (
688 + [xdotool, "key", "--clearmodifiers", "alt+F4"],
689 + [xdotool, "windowclose", window_id],
690 + ):
691 + try:
692 + subprocess.run(
693 + command,
694 + check=False,
695 + stdout=subprocess.DEVNULL,
696 + stderr=subprocess.DEVNULL,
697 + timeout=3,
698 + env=env,
699 + )
700 + except (OSError, subprocess.TimeoutExpired):
701 + continue
702 + if self._wait_for_window_closed_locked(session, window_id):
703 + return True
704 + self._dismiss_blocking_dialogs(session)
705 + return self._wait_for_window_closed_locked(session, window_id, timeout_seconds=1.5)
706 +
707 + def _send_reload_shortcut_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
708 + xdotool = shutil.which("xdotool")
709 + if not xdotool:
710 + return False
711 + window_id = self._office_window_id_locked(
712 + session,
713 + title=str(doc.get("basename") or ""),
714 + )
715 + if not window_id:
716 + return False
717 + env = self._display_env(session)
718 + self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
719 + try:
720 + result = subprocess.run(
721 + [xdotool, "key", "--clearmodifiers", "ctrl+shift+r"],
722 + check=False,
723 + capture_output=True,
724 + text=True,
725 + timeout=4,
726 + env=env,
727 + )
728 + except (OSError, subprocess.TimeoutExpired):
729 + return False
730 + time.sleep(0.8)
731 + self._dismiss_blocking_dialogs(session)
732 + self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
733 + return result.returncode == 0
734 +
735 + def _office_window_id_locked(
736 + self,
737 + session: DesktopSession,
738 + *,
739 + title: str = "",
740 + fallback: bool = True,
741 + ) -> str:
742 + xdotool = shutil.which("xdotool")
743 + if not xdotool:
744 + return ""
745 + env = self._display_env(session)
746 + title = str(title or "").strip()
747 + searches: list[list[str]] = []
748 + if title:
749 + escaped_title = re.escape(title)
750 + searches.append([
751 + xdotool,
752 + "search",
753 + "--onlyvisible",
754 + "--name",
755 + escaped_title,
756 + ])
757 + for window_class in (
758 + "libreoffice",
759 + "libreoffice-writer",
760 + "libreoffice-calc",
761 + "libreoffice-impress",
762 + ):
763 + searches.append([
764 + xdotool,
765 + "search",
766 + "--onlyvisible",
767 + "--class",
768 + window_class,
769 + "--name",
770 + escaped_title,
771 + ])
772 + if fallback:
773 + for window_class in (
774 + "libreoffice",
775 + "libreoffice-writer",
776 + "libreoffice-calc",
777 + "libreoffice-impress",
778 + ):
779 + searches.append([xdotool, "search", "--onlyvisible", "--class", window_class])
780 + searches.append([xdotool, "search", "--onlyvisible", "--name", "LibreOffice"])
781 + for command in searches:
782 + try:
783 + result = subprocess.run(
784 + command,
785 + check=False,
786 + capture_output=True,
787 + text=True,
788 + timeout=2,
789 + env=env,
790 + )
791 + except (OSError, subprocess.TimeoutExpired):
792 + continue
793 + window_ids = [
794 + line.strip()
795 + for line in result.stdout.splitlines()
796 + if line.strip()
797 + ]
798 + if window_ids:
799 + return window_ids[-1]
800 + return ""
801 +
802 + def _wait_for_office_window_locked(
803 + self,
804 + session: DesktopSession,
805 + *,
806 + title: str = "",
807 + process: subprocess.Popen[Any] | None = None,
808 + timeout_seconds: float = 20.0,
809 + ) -> str:
810 + deadline = time.time() + timeout_seconds
811 + last_fallback = ""
812 + title = str(title or "").strip()
813 + while time.time() < deadline:
814 + window_id = self._office_window_id_locked(session, title=title, fallback=False)
815 + if window_id:
816 + return window_id
817 + last_fallback = self._office_window_id_locked(session, fallback=True) or last_fallback
818 + if last_fallback and not title:
819 + return last_fallback
820 + if process and process.poll() is not None and last_fallback:
821 + return last_fallback
822 + time.sleep(0.25)
823 + return self._office_window_id_locked(session, title=title, fallback=True) or last_fallback
824 +
825 + def _wait_for_window_closed_locked(
826 + self,
827 + session: DesktopSession,
828 + window_id: str,
829 + *,
830 + timeout_seconds: float = 6.0,
831 + ) -> bool:
832 + deadline = time.time() + timeout_seconds
833 + while time.time() < deadline:
834 + if not self._window_exists_locked(session, window_id):
835 + return True
836 + time.sleep(0.2)
837 + return not self._window_exists_locked(session, window_id)
838 +
839 + def _window_exists_locked(self, session: DesktopSession, window_id: str) -> bool:
840 + xdotool = shutil.which("xdotool")
841 + if not xdotool or not window_id:
842 + return False
843 + try:
844 + result = subprocess.run(
845 + [xdotool, "getwindowname", str(window_id)],
846 + check=False,
847 + stdout=subprocess.DEVNULL,
848 + stderr=subprocess.DEVNULL,
849 + timeout=2,
850 + env=self._display_env(session),
851 + )
852 + except (OSError, subprocess.TimeoutExpired):
853 + return False
854 + return result.returncode == 0
855 +
856 + def _fit_office_window_id_locked(
857 + self,
858 + session: DesktopSession,
859 + window_id: str,
860 + *,
861 + env: dict[str, str],
862 + keys: tuple[str, ...] = (),
863 + ) -> None:
864 + xdotool = shutil.which("xdotool")
865 + if not xdotool or not window_id:
866 + return
867 + for command in (
868 + [xdotool, "windowactivate", window_id],
869 + [
870 + xdotool,
871 + "windowmove",
872 + window_id,
873 + "0",
874 + "0",
875 + "windowsize",
876 + window_id,
877 + str(session.width),
878 + str(session.height),
879 + ],
880 + ):
881 + try:
882 + subprocess.run(
883 + command,
884 + check=False,
885 + stdout=subprocess.DEVNULL,
886 + stderr=subprocess.DEVNULL,
887 + timeout=2,
888 + env=env,
889 + )
890 + except (OSError, subprocess.TimeoutExpired):
891 + continue
892 + for key in keys:
893 + try:
894 + subprocess.run(
895 + [xdotool, "key", "--clearmodifiers", key],
896 + check=False,
897 + stdout=subprocess.DEVNULL,
898 + stderr=subprocess.DEVNULL,
899 + timeout=2,
900 + env=env,
901 + )
902 + except (OSError, subprocess.TimeoutExpired):
903 + continue
904
905 def _prepare_profile(self, session: DesktopSession) -> None:
906 user_dir = session.profile_dir / "user"
@@ -1137,29 +1498,45 @@ fi
1498
1499 def _write_manifest(self, session: DesktopSession) -> None:
1500 SESSION_DIR.mkdir(parents=True, exist_ok=True)
1501 + pids = dict(session.process_ids)
1502 + pids.update({name: process.pid for name, process in session.processes.items()})
1503 payload = {
1504 "session_id": session.session_id,
1505 "file_id": session.file_id,
1506 + "extension": session.extension,
1507 "path": session.path,
1508 + "title": session.title,
1509 "display": session.display,
1510 "xpra_port": session.xpra_port,
1511 "profile_dir": str(session.profile_dir),
1512 + "width": session.width,
1513 + "height": session.height,
1514 + "started_at": session.started_at,
1515 "owner_pid": os.getpid(),
1148 - "pids": {name: process.pid for name, process in session.processes.items()},
1516 + "pids": pids,
1517 }
1518 (SESSION_DIR / f"{session.session_id}.json").write_text(json.dumps(payload), encoding="utf-8")
1519
1520 def _remove_manifest(self, session_id: str) -> None:
1521 (SESSION_DIR / f"{session_id}.json").unlink(missing_ok=True)
1522
1155 - def _terminate_session(self, session: DesktopSession) -> None:
1523 + def _terminate_session(self, session: DesktopSession, *, include_rehydrated: bool = False) -> None:
1524 process_names = [name for name in session.processes if name.startswith("soffice")]
1525 process_names.extend(["xfce", "xpra", "xvfb"])
1526 + terminated_pids: set[int] = set()
1527 for name in process_names:
1528 process = session.processes.get(name)
1529 if not process:
1530 continue
1531 + if process.pid:
1532 + terminated_pids.add(process.pid)
1533 _terminate_process(process)
1534 + if session.owns_processes or include_rehydrated:
1535 + for name, pid in session.process_ids.items():
1536 + if pid in terminated_pids:
1537 + continue
1538 + if name.startswith("soffice") or name in {"xfce", "xpra", "xvfb"}:
1539 + _kill_pid(pid)
1540 self._remove_stale_lock_file(session)
1541
1542 def _remove_stale_lock_file(self, session: DesktopSession, *, path: str | Path | None = None) -> None:
@@ -1924,6 +2301,14 @@ def _port_is_free(port: int) -> bool:
2301 return False
2302
2303
2304 +def _port_is_accepting(host: str, port: int) -> bool:
2305 + try:
2306 + with socket.create_connection((host, port), timeout=0.2):
2307 + return True
2308 + except OSError:
2309 + return False
2310 +
2311 +
2312 def _terminate_process(process: subprocess.Popen[Any]) -> None:
2313 if process.poll() is not None:
2314 return
@@ -1961,6 +2346,8 @@ def _coerce_pid(value: Any) -> int:
2346
2347
2348 def _pid_is_running(pid: int) -> bool:
2349 + if pid <= 0:
2350 + return False
2351 try:
2352 os.kill(pid, 0)
2353 return True
plugins/_office/webui/office-store.js
+5
@@ -268,6 +268,8 @@ const model = {
268 await this.openSession({
269 path: payload.path || "",
270 file_id: payload.file_id || "",
271 + refresh: payload.refresh === true,
272 + source: payload.source || "",
273 });
274 } else if (this._desktopIntentionalShutdown) {
275 this.session = null;
@@ -2261,15 +2263,18 @@ const model = {
2263 },
2264
2265 tabTitle(tab = {}) {
2266 + tab = tab || {};
2267 return tab.title || tab.document?.basename || basename(tab.path);
2268 },
2269
2270 tabLabel(tab = {}) {
2271 + tab = tab || {};
2272 const title = this.tabTitle(tab);
2273 return tab.dirty ? `${title} unsaved` : title;
2274 },
2275
2276 tabIcon(tab = {}) {
2277 + tab = tab || {};
2278 const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
2279 if (this.isDesktopSession(tab)) return "desktop_windows";
2280 if (ext === "md") return "article";