fix(office): retire legacy Collabora during self-update
Stop and remove the old a0_office_collabora supervisor program during Docker self-update preparation, before stale Collabora runtime paths are deleted. Add a narrow repo-side retirement hook so older bootstrap managers can clean the checked-out runtime without reinstalling desktop packages during health checks. Harden XFCE desktop startup with explicit XDG config/data directories so upgraded Desktop sessions avoid the failsafe-session path after the Collabora-to-LibreOffice transition.
Alessandro committed
May 9, 2026 at 15:09 UTC
02838f7698112fa841cd574227f0a92c444506fa
3 files changed
+134
-20
plugins/_desktop/helpers/desktop_session.py
+4
@@ -1201,6 +1201,8 @@ export HOME="${HOME:-%s}"
1201
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
1202
export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
1203
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
1204
+export XDG_CONFIG_DIRS="/etc/xdg${XDG_CONFIG_DIRS:+:$XDG_CONFIG_DIRS}"
1205
+export XDG_DATA_DIRS="/usr/local/share:/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
1206
export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"
1207
mkdir -p "$HOME/Desktop" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
1208
if command -v xfconf-query >/dev/null 2>&1; then
@@ -1261,6 +1263,8 @@ fi
1263
'export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"',
1264
'export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"',
1265
'export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"',
1266
+ 'export XDG_CONFIG_DIRS="/etc/xdg${XDG_CONFIG_DIRS:+:$XDG_CONFIG_DIRS}"',
1267
+ 'export XDG_DATA_DIRS="/usr/local/share:/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"',
1268
'export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"',
1269
(
1270
"exec dbus-launch --exit-with-session sh -c "
plugins/_office/hooks.py
+110
-20
@@ -1,6 +1,7 @@
1
from __future__ import annotations
2
3
import os
4
+import re
5
import shutil
6
import subprocess
7
from pathlib import Path
@@ -99,22 +100,12 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
100
cleanup_needed = force or not CLEANUP_MARKER.exists() or bool(retired_web_paths or retired_web_packages)
101
102
if cleanup_needed:
102
- _kill_old_processes(errors)
103
-
104
- for path in [
105
- RETIRED_WEB_APT_SOURCE_FILE,
106
- RETIRED_WEB_APT_KEYRING_FILE,
107
- RETIRED_WEB_SUPERVISOR_FILE,
108
- *RETIRED_WEB_RUNTIME_DIRS,
109
- ]:
110
- try:
111
- if _remove_path(path):
112
- removed.append(str(path))
113
- except Exception as exc:
114
- errors.append(f"{path}: {exc}")
115
-
116
- _retire_supervisor_program(errors)
117
- _purge_packages(removed, errors, installed_packages=retired_web_packages)
103
+ _cleanup_retired_web_runtime(
104
+ removed,
105
+ errors,
106
+ retired_web_packages=retired_web_packages,
107
+ purge_packages=True,
108
+ )
109
110
try:
111
CLEANUP_MARKER.parent.mkdir(parents=True, exist_ok=True)
@@ -144,6 +135,45 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
135
}
136
137
138
+def retire_collabora_web_runtime(force: bool = False) -> dict[str, Any]:
139
+ """Retire the legacy Collabora web runtime without preparing LibreOffice.
140
+
141
+ This is intentionally narrower than cleanup_stale_runtime_state(). Older
142
+ Docker self-update managers run the checked-out repo's prepare.py before the
143
+ updated UI starts, so prepare.py can call this fast hook to remove the stale
144
+ supervisor program left by v1.10 without blocking health checks on desktop
145
+ package installation.
146
+ """
147
+
148
+ removed: list[str] = []
149
+ errors: list[str] = []
150
+ retired_web_paths = [
151
+ path
152
+ for path in [
153
+ RETIRED_WEB_APT_SOURCE_FILE,
154
+ RETIRED_WEB_APT_KEYRING_FILE,
155
+ RETIRED_WEB_SUPERVISOR_FILE,
156
+ *RETIRED_WEB_RUNTIME_DIRS,
157
+ ]
158
+ if path.exists() or path.is_symlink()
159
+ ]
160
+
161
+ if force or retired_web_paths:
162
+ _cleanup_retired_web_runtime(
163
+ removed,
164
+ errors,
165
+ retired_web_packages=[],
166
+ purge_packages=False,
167
+ )
168
+
169
+ return {
170
+ "ok": not errors,
171
+ "skipped": not force and not retired_web_paths,
172
+ "removed": removed,
173
+ "errors": errors,
174
+ }
175
+
176
+
177
def _migrate_legacy_document_state(
178
migrated: list[str],
179
warnings: list[str],
@@ -218,11 +248,41 @@ def _remove_path(path: Path) -> bool:
248
path.unlink(missing_ok=True)
249
return True
250
if path.exists():
221
- shutil.rmtree(path)
251
+ try:
252
+ shutil.rmtree(path)
253
+ except FileNotFoundError:
254
+ pass
255
return True
256
return False
257
258
259
+def _cleanup_retired_web_runtime(
260
+ removed: list[str],
261
+ errors: list[str],
262
+ *,
263
+ retired_web_packages: list[str],
264
+ purge_packages: bool,
265
+) -> None:
266
+ _stop_supervisor_program(errors)
267
+ _kill_old_processes(errors)
268
+
269
+ for path in [
270
+ RETIRED_WEB_APT_SOURCE_FILE,
271
+ RETIRED_WEB_APT_KEYRING_FILE,
272
+ RETIRED_WEB_SUPERVISOR_FILE,
273
+ *RETIRED_WEB_RUNTIME_DIRS,
274
+ ]:
275
+ try:
276
+ if _remove_path(path):
277
+ removed.append(str(path))
278
+ except Exception as exc:
279
+ errors.append(f"{path}: {exc}")
280
+
281
+ _retire_supervisor_program(errors)
282
+ if purge_packages:
283
+ _purge_packages(removed, errors, installed_packages=retired_web_packages)
284
+
285
+
286
def _kill_old_processes(errors: list[str]) -> None:
287
if not shutil.which("pkill"):
288
return
@@ -237,22 +297,48 @@ def _kill_old_processes(errors: list[str]) -> None:
297
errors.append((result.stderr or result.stdout or "pkill coolwsd failed").strip())
298
299
240
-def _retire_supervisor_program(errors: list[str]) -> None:
300
+def _stop_supervisor_program(errors: list[str]) -> None:
301
if not shutil.which("supervisorctl"):
302
return
303
status = _supervisorctl("status", RETIRED_WEB_SUPERVISOR_PROGRAM)
304
status_output = _supervisor_output(status)
305
if status.returncode != 0:
246
- if _supervisor_absent(status_output):
306
+ if _supervisor_absent(status_output) or _supervisor_stopped(status_output):
307
return
308
errors.append(status_output or f"supervisorctl status {RETIRED_WEB_SUPERVISOR_PROGRAM} failed")
309
return
310
311
+ if _supervisor_stopped(status_output):
312
+ return
313
+
314
stopped = _supervisorctl("stop", RETIRED_WEB_SUPERVISOR_PROGRAM)
315
stopped_output = _supervisor_output(stopped)
253
- if stopped.returncode != 0 and not _supervisor_absent(stopped_output):
316
+ if stopped.returncode != 0 and not (
317
+ _supervisor_absent(stopped_output) or _supervisor_stopped(stopped_output)
318
+ ):
319
errors.append(stopped_output or f"supervisorctl stop {RETIRED_WEB_SUPERVISOR_PROGRAM} failed")
320
+
321
+
322
+def _retire_supervisor_program(errors: list[str]) -> None:
323
+ if not shutil.which("supervisorctl"):
324
return
325
+ status = _supervisorctl("status", RETIRED_WEB_SUPERVISOR_PROGRAM)
326
+ status_output = _supervisor_output(status)
327
+ if status.returncode != 0:
328
+ if _supervisor_absent(status_output):
329
+ return
330
+ if not _supervisor_stopped(status_output):
331
+ errors.append(status_output or f"supervisorctl status {RETIRED_WEB_SUPERVISOR_PROGRAM} failed")
332
+ return
333
+
334
+ if not _supervisor_stopped(status_output):
335
+ stopped = _supervisorctl("stop", RETIRED_WEB_SUPERVISOR_PROGRAM)
336
+ stopped_output = _supervisor_output(stopped)
337
+ if stopped.returncode != 0 and not (
338
+ _supervisor_absent(stopped_output) or _supervisor_stopped(stopped_output)
339
+ ):
340
+ errors.append(stopped_output or f"supervisorctl stop {RETIRED_WEB_SUPERVISOR_PROGRAM} failed")
341
+ return
342
343
removed = _supervisorctl("remove", RETIRED_WEB_SUPERVISOR_PROGRAM)
344
removed_output = _supervisor_output(removed)
@@ -293,6 +379,10 @@ def _supervisor_absent(output: str) -> bool:
379
)
380
381
382
+def _supervisor_stopped(output: str) -> bool:
383
+ return bool(re.search(rf"(^|\s){re.escape(RETIRED_WEB_SUPERVISOR_PROGRAM)}\s+STOPPED\b", output))
384
+
385
+
386
def _installed_packages(packages: tuple[str, ...]) -> list[str]:
387
if not shutil.which("dpkg-query"):
388
return []
prepare.py
+20
@@ -1,13 +1,33 @@
1
from helpers import dotenv, runtime, settings
2
import string
3
import random
4
+import sys
5
from helpers.print_style import PrintStyle
6
7
8
+def _retire_legacy_collabora_runtime() -> None:
9
+ if not any(arg.lower() == "--dockerized=true" for arg in sys.argv):
10
+ return
11
+
12
+ try:
13
+ from plugins._office import hooks as office_hooks
14
+
15
+ result = office_hooks.retire_collabora_web_runtime(force=True)
16
+ except Exception as exc:
17
+ PrintStyle.warning("Legacy Collabora runtime cleanup failed:", exc)
18
+ return
19
+
20
+ if result.get("errors"):
21
+ PrintStyle.warning("Legacy Collabora runtime cleanup reported errors:", result["errors"])
22
+ elif result.get("removed"):
23
+ PrintStyle.info("Legacy Collabora runtime retired:", result)
24
+
25
+
26
PrintStyle.standard("Preparing environment...")
27
28
try:
29
30
+ _retire_legacy_collabora_runtime()
31
runtime.initialize()
32
33
# generate random root password if not set (for SSH)