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";