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)