| 1 | from __future__ import annotations |
| 2 | |
| 3 | import os |
| 4 | import re |
| 5 | import shutil |
| 6 | import subprocess |
| 7 | from pathlib import Path |
| 8 | from typing import Any |
| 9 | |
| 10 | from helpers import files, state_migration, system_packages |
| 11 | |
| 12 | |
| 13 | PROJECT_ROOT = Path(__file__).resolve().parents[2] |
| 14 | PLUGIN_NAME = "_office" |
| 15 | STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME)) |
| 16 | RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME)) |
| 17 | DOCUMENT_STATE_DIR = STATE_DIR / "documents" |
| 18 | LEGACY_DOCUMENT_STATE_DIRS = [ |
| 19 | RETIRED_STATE_DIR / "documents", |
| 20 | Path(files.get_abs_path("usr", "state", "_office", "documents")), |
| 21 | Path(files.get_abs_path("usr", "state", "office", "documents")), |
| 22 | ] |
| 23 | RETIRED_WEB_APT_SOURCE_FILE = Path("/etc/apt/sources.list.d/collaboraonline.sources") |
| 24 | RETIRED_WEB_APT_KEYRING_FILE = Path("/etc/apt/keyrings/collaboraonline-release-keyring.gpg") |
| 25 | RETIRED_WEB_SUPERVISOR_FILE = Path("/etc/supervisor/conf.d/a0_office_collabora.conf") |
| 26 | RETIRED_WEB_SUPERVISOR_PROGRAM = "a0_office_collabora" |
| 27 | RETIRED_WEB_RUNTIME_DIRS = [ |
| 28 | Path("/opt/cool"), |
| 29 | Path("/opt/collaboraoffice"), |
| 30 | Path("/a0/tmp/_office/collabora"), |
| 31 | Path("/a0/usr/plugins/_office/collabora"), |
| 32 | PROJECT_ROOT / "tmp" / "_office" / "collabora", |
| 33 | PROJECT_ROOT / "usr" / "plugins" / "_office" / "collabora", |
| 34 | ] |
| 35 | RETIRED_WEB_PACKAGES = ( |
| 36 | "coolwsd", |
| 37 | "coolwsd-deprecated", |
| 38 | "code-brand", |
| 39 | "collaboraoffice", |
| 40 | "collaboraoffice-ure", |
| 41 | "collaboraofficebasis-calc", |
| 42 | "collaboraofficebasis-core", |
| 43 | "collaboraofficebasis-draw", |
| 44 | "collaboraofficebasis-en-us", |
| 45 | "collaboraofficebasis-extension-pdf-import", |
| 46 | "collaboraofficebasis-graphicfilter", |
| 47 | "collaboraofficebasis-images", |
| 48 | "collaboraofficebasis-impress", |
| 49 | "collaboraofficebasis-math", |
| 50 | "collaboraofficebasis-ooolinguistic", |
| 51 | "collaboraofficebasis-ooofonts", |
| 52 | "collaboraofficebasis-writer", |
| 53 | ) |
| 54 | RUNTIME_PACKAGES = ( |
| 55 | "libreoffice-core", |
| 56 | "libreoffice-writer", |
| 57 | "libreoffice-calc", |
| 58 | "libreoffice-impress", |
| 59 | "libreoffice-gtk3", |
| 60 | "python3-uno", |
| 61 | "fonts-dejavu", |
| 62 | "fonts-liberation", |
| 63 | "fonts-crosextra-caladea", |
| 64 | "fonts-crosextra-carlito", |
| 65 | "fonts-noto-core", |
| 66 | "fonts-noto-cjk", |
| 67 | "fonts-noto-color-emoji", |
| 68 | ) |
| 69 | RETIRED_RUNTIME_PACKAGES = ( |
| 70 | "firefox-esr", |
| 71 | ) |
| 72 | CLEANUP_MARKER = STATE_DIR / "stale-cleanup-v3.done" |
| 73 | |
| 74 | |
| 75 | def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]: |
| 76 | """Prepare the LibreOffice runtime and remove retired office state. |
| 77 | |
| 78 | The hook is intentionally idempotent: existing dependencies, missing stale |
| 79 | files, packages, and processes count as already clean. It is safe to call |
| 80 | during startup and self-update. |
| 81 | """ |
| 82 | |
| 83 | removed: list[str] = [] |
| 84 | installed: list[str] = [] |
| 85 | migrated: list[str] = [] |
| 86 | warnings: list[str] = [] |
| 87 | errors: list[str] = [] |
| 88 | |
| 89 | _migrate_retired_plugin_state(migrated, warnings, errors) |
| 90 | _migrate_legacy_document_state(migrated, warnings, errors) |
| 91 | |
| 92 | retired_web_paths = [ |
| 93 | path |
| 94 | for path in [ |
| 95 | RETIRED_WEB_APT_SOURCE_FILE, |
| 96 | RETIRED_WEB_APT_KEYRING_FILE, |
| 97 | RETIRED_WEB_SUPERVISOR_FILE, |
| 98 | *RETIRED_WEB_RUNTIME_DIRS, |
| 99 | ] |
| 100 | if path.exists() or path.is_symlink() |
| 101 | ] |
| 102 | retired_web_packages = _installed_retired_web_packages() |
| 103 | cleanup_needed = force or not CLEANUP_MARKER.exists() or bool(retired_web_paths or retired_web_packages) |
| 104 | |
| 105 | if cleanup_needed: |
| 106 | _cleanup_retired_web_runtime( |
| 107 | removed, |
| 108 | errors, |
| 109 | retired_web_packages=retired_web_packages, |
| 110 | purge_packages=True, |
| 111 | ) |
| 112 | |
| 113 | try: |
| 114 | CLEANUP_MARKER.parent.mkdir(parents=True, exist_ok=True) |
| 115 | CLEANUP_MARKER.write_text("ok\n", encoding="utf-8") |
| 116 | except Exception as exc: |
| 117 | errors.append(f"{CLEANUP_MARKER}: {exc}") |
| 118 | |
| 119 | retired_packages = [ |
| 120 | package |
| 121 | for package in _installed_packages(RETIRED_RUNTIME_PACKAGES) |
| 122 | if package not in retired_web_packages |
| 123 | ] |
| 124 | if retired_packages: |
| 125 | _purge_packages(removed, errors, installed_packages=retired_packages) |
| 126 | |
| 127 | _retire_supervisor_program(errors) |
| 128 | _ensure_runtime_dependencies(installed, errors) |
| 129 | return { |
| 130 | "ok": not errors, |
| 131 | "skipped": not cleanup_needed, |
| 132 | "removed": removed, |
| 133 | "installed": installed, |
| 134 | "migrated": migrated, |
| 135 | "warnings": warnings, |
| 136 | "errors": errors, |
| 137 | } |
| 138 | |
| 139 | |
| 140 | def timezone_changed(timezone: str, previous_timezone: str | None = None) -> dict[str, Any]: |
| 141 | try: |
| 142 | from plugins._desktop.helpers import desktop_session |
| 143 | |
| 144 | return desktop_session.get_manager().sync_timezone(timezone) |
| 145 | except Exception as exc: |
| 146 | return { |
| 147 | "ok": False, |
| 148 | "error": str(exc), |
| 149 | "timezone": timezone, |
| 150 | "previous_timezone": previous_timezone, |
| 151 | } |
| 152 | |
| 153 | |
| 154 | def retire_collabora_web_runtime(force: bool = False) -> dict[str, Any]: |
| 155 | """Retire the legacy Collabora web runtime without preparing LibreOffice. |
| 156 | |
| 157 | This is intentionally narrower than cleanup_stale_runtime_state(). Older |
| 158 | Docker self-update managers run the checked-out repo's prepare.py before the |
| 159 | updated UI starts, so prepare.py can call this fast hook to remove the stale |
| 160 | supervisor program left by v1.10 without blocking health checks on desktop |
| 161 | package installation. |
| 162 | """ |
| 163 | |
| 164 | removed: list[str] = [] |
| 165 | errors: list[str] = [] |
| 166 | retired_web_paths = [ |
| 167 | path |
| 168 | for path in [ |
| 169 | RETIRED_WEB_APT_SOURCE_FILE, |
| 170 | RETIRED_WEB_APT_KEYRING_FILE, |
| 171 | RETIRED_WEB_SUPERVISOR_FILE, |
| 172 | *RETIRED_WEB_RUNTIME_DIRS, |
| 173 | ] |
| 174 | if path.exists() or path.is_symlink() |
| 175 | ] |
| 176 | |
| 177 | if force or retired_web_paths: |
| 178 | _cleanup_retired_web_runtime( |
| 179 | removed, |
| 180 | errors, |
| 181 | retired_web_packages=[], |
| 182 | purge_packages=False, |
| 183 | ) |
| 184 | |
| 185 | return { |
| 186 | "ok": not errors, |
| 187 | "skipped": not force and not retired_web_paths, |
| 188 | "removed": removed, |
| 189 | "errors": errors, |
| 190 | } |
| 191 | |
| 192 | |
| 193 | def _migrate_legacy_document_state( |
| 194 | migrated: list[str], |
| 195 | warnings: list[str], |
| 196 | errors: list[str], |
| 197 | ) -> None: |
| 198 | legacy_dirs = [ |
| 199 | path |
| 200 | for path in LEGACY_DOCUMENT_STATE_DIRS |
| 201 | if path != DOCUMENT_STATE_DIR and path.exists() |
| 202 | ] |
| 203 | if not legacy_dirs: |
| 204 | return |
| 205 | |
| 206 | if DOCUMENT_STATE_DIR.exists(): |
| 207 | warnings.extend( |
| 208 | f"Legacy Office document state left in place because {DOCUMENT_STATE_DIR} already exists: {path}" |
| 209 | for path in legacy_dirs |
| 210 | ) |
| 211 | return |
| 212 | |
| 213 | source = legacy_dirs[0] |
| 214 | try: |
| 215 | DOCUMENT_STATE_DIR.parent.mkdir(parents=True, exist_ok=True) |
| 216 | shutil.copytree(source, DOCUMENT_STATE_DIR, symlinks=True) |
| 217 | migrated.append(f"{source} -> {DOCUMENT_STATE_DIR}") |
| 218 | except Exception as exc: |
| 219 | errors.append(f"Office document state migration failed from {source}: {exc}") |
| 220 | return |
| 221 | |
| 222 | warnings.extend( |
| 223 | f"Additional legacy Office document state left in place after migrating {source}: {path}" |
| 224 | for path in legacy_dirs[1:] |
| 225 | ) |
| 226 | |
| 227 | |
| 228 | def _migrate_retired_plugin_state( |
| 229 | migrated: list[str], |
| 230 | warnings: list[str], |
| 231 | errors: list[str], |
| 232 | ) -> None: |
| 233 | state_migration.migrate_retired_state_tree( |
| 234 | source=RETIRED_STATE_DIR, |
| 235 | destination=STATE_DIR, |
| 236 | owner="Office", |
| 237 | migrated=migrated, |
| 238 | warnings=warnings, |
| 239 | errors=errors, |
| 240 | ) |
| 241 | |
| 242 | |
| 243 | def _remove_path(path: Path) -> bool: |
| 244 | if path.is_symlink() or path.is_file(): |
| 245 | path.unlink(missing_ok=True) |
| 246 | return True |
| 247 | if path.exists(): |
| 248 | try: |
| 249 | shutil.rmtree(path) |
| 250 | except FileNotFoundError: |
| 251 | pass |
| 252 | return True |
| 253 | return False |
| 254 | |
| 255 | |
| 256 | def _cleanup_retired_web_runtime( |
| 257 | removed: list[str], |
| 258 | errors: list[str], |
| 259 | *, |
| 260 | retired_web_packages: list[str], |
| 261 | purge_packages: bool, |
| 262 | ) -> None: |
| 263 | _stop_supervisor_program(errors) |
| 264 | _kill_old_processes(errors) |
| 265 | |
| 266 | for path in [ |
| 267 | RETIRED_WEB_APT_SOURCE_FILE, |
| 268 | RETIRED_WEB_APT_KEYRING_FILE, |
| 269 | RETIRED_WEB_SUPERVISOR_FILE, |
| 270 | *RETIRED_WEB_RUNTIME_DIRS, |
| 271 | ]: |
| 272 | try: |
| 273 | if _remove_path(path): |
| 274 | removed.append(str(path)) |
| 275 | except Exception as exc: |
| 276 | errors.append(f"{path}: {exc}") |
| 277 | |
| 278 | _retire_supervisor_program(errors) |
| 279 | if purge_packages: |
| 280 | _purge_packages(removed, errors, installed_packages=retired_web_packages) |
| 281 | |
| 282 | |
| 283 | def _kill_old_processes(errors: list[str]) -> None: |
| 284 | if not shutil.which("pkill"): |
| 285 | return |
| 286 | result = subprocess.run( |
| 287 | ["pkill", "-f", "coolwsd"], |
| 288 | check=False, |
| 289 | text=True, |
| 290 | capture_output=True, |
| 291 | timeout=8, |
| 292 | ) |
| 293 | if result.returncode not in {0, 1}: |
| 294 | errors.append((result.stderr or result.stdout or "pkill coolwsd failed").strip()) |
| 295 | |
| 296 | |
| 297 | def _stop_supervisor_program(errors: list[str]) -> None: |
| 298 | if not shutil.which("supervisorctl"): |
| 299 | return |
| 300 | status = _supervisorctl("status", RETIRED_WEB_SUPERVISOR_PROGRAM) |
| 301 | status_output = _supervisor_output(status) |
| 302 | if status.returncode != 0: |
| 303 | if _supervisor_absent(status_output) or _supervisor_stopped(status_output): |
| 304 | return |
| 305 | errors.append(status_output or f"supervisorctl status {RETIRED_WEB_SUPERVISOR_PROGRAM} failed") |
| 306 | return |
| 307 | |
| 308 | if _supervisor_stopped(status_output): |
| 309 | return |
| 310 | |
| 311 | stopped = _supervisorctl("stop", RETIRED_WEB_SUPERVISOR_PROGRAM) |
| 312 | stopped_output = _supervisor_output(stopped) |
| 313 | if stopped.returncode != 0 and not ( |
| 314 | _supervisor_absent(stopped_output) or _supervisor_stopped(stopped_output) |
| 315 | ): |
| 316 | errors.append(stopped_output or f"supervisorctl stop {RETIRED_WEB_SUPERVISOR_PROGRAM} failed") |
| 317 | |
| 318 | |
| 319 | def _retire_supervisor_program(errors: list[str]) -> None: |
| 320 | if not shutil.which("supervisorctl"): |
| 321 | return |
| 322 | status = _supervisorctl("status", RETIRED_WEB_SUPERVISOR_PROGRAM) |
| 323 | status_output = _supervisor_output(status) |
| 324 | if status.returncode != 0: |
| 325 | if _supervisor_absent(status_output): |
| 326 | return |
| 327 | if not _supervisor_stopped(status_output): |
| 328 | errors.append(status_output or f"supervisorctl status {RETIRED_WEB_SUPERVISOR_PROGRAM} failed") |
| 329 | return |
| 330 | |
| 331 | if not _supervisor_stopped(status_output): |
| 332 | stopped = _supervisorctl("stop", RETIRED_WEB_SUPERVISOR_PROGRAM) |
| 333 | stopped_output = _supervisor_output(stopped) |
| 334 | if stopped.returncode != 0 and not ( |
| 335 | _supervisor_absent(stopped_output) or _supervisor_stopped(stopped_output) |
| 336 | ): |
| 337 | errors.append(stopped_output or f"supervisorctl stop {RETIRED_WEB_SUPERVISOR_PROGRAM} failed") |
| 338 | return |
| 339 | |
| 340 | removed = _supervisorctl("remove", RETIRED_WEB_SUPERVISOR_PROGRAM) |
| 341 | removed_output = _supervisor_output(removed) |
| 342 | if removed.returncode != 0 and not _supervisor_absent(removed_output): |
| 343 | errors.append(removed_output or f"supervisorctl remove {RETIRED_WEB_SUPERVISOR_PROGRAM} failed") |
| 344 | return |
| 345 | |
| 346 | for command in (("reread",), ("update",)): |
| 347 | result = _supervisorctl(*command) |
| 348 | output = _supervisor_output(result) |
| 349 | if result.returncode != 0 and not _supervisor_absent(output): |
| 350 | errors.append(output or f"supervisorctl {' '.join(command)} failed") |
| 351 | |
| 352 | |
| 353 | def _supervisorctl(*args: str) -> subprocess.CompletedProcess[str]: |
| 354 | return subprocess.run( |
| 355 | ["supervisorctl", *args], |
| 356 | check=False, |
| 357 | text=True, |
| 358 | capture_output=True, |
| 359 | timeout=15, |
| 360 | ) |
| 361 | |
| 362 | |
| 363 | def _supervisor_output(result: subprocess.CompletedProcess[str]) -> str: |
| 364 | return (result.stderr or result.stdout or "").strip() |
| 365 | |
| 366 | |
| 367 | def _supervisor_absent(output: str) -> bool: |
| 368 | normalized = output.lower() |
| 369 | return ( |
| 370 | "no such process" in normalized |
| 371 | or "no such group" in normalized |
| 372 | or "not running" in normalized |
| 373 | or "unix:///var/run/supervisor.sock" in normalized |
| 374 | or "connection refused" in normalized |
| 375 | or "no such file" in normalized |
| 376 | ) |
| 377 | |
| 378 | |
| 379 | def _supervisor_stopped(output: str) -> bool: |
| 380 | return bool(re.search(rf"(^|\s){re.escape(RETIRED_WEB_SUPERVISOR_PROGRAM)}\s+STOPPED\b", output)) |
| 381 | |
| 382 | |
| 383 | def _installed_packages(packages: tuple[str, ...]) -> list[str]: |
| 384 | if not shutil.which("dpkg-query"): |
| 385 | return [] |
| 386 | return [package for package in packages if _package_installed(package)] |
| 387 | |
| 388 | |
| 389 | def _installed_retired_web_packages() -> list[str]: |
| 390 | packages = [ |
| 391 | *_installed_packages(RETIRED_WEB_PACKAGES), |
| 392 | *_installed_collabora_packages(), |
| 393 | ] |
| 394 | return list(dict.fromkeys(packages)) |
| 395 | |
| 396 | |
| 397 | def _installed_collabora_packages() -> list[str]: |
| 398 | if not shutil.which("dpkg-query"): |
| 399 | return [] |
| 400 | |
| 401 | result = subprocess.run( |
| 402 | ["dpkg-query", "-W", "-f=${binary:Package}\t${Status}\n", "collaboraoffice*"], |
| 403 | check=False, |
| 404 | text=True, |
| 405 | capture_output=True, |
| 406 | timeout=15, |
| 407 | ) |
| 408 | |
| 409 | packages: list[str] = [] |
| 410 | for line in result.stdout.splitlines(): |
| 411 | package, _, status = line.partition("\t") |
| 412 | if package.startswith("collaboraoffice") and "install ok installed" in status: |
| 413 | packages.append(package) |
| 414 | return packages |
| 415 | |
| 416 | |
| 417 | def _purge_packages( |
| 418 | removed: list[str], |
| 419 | errors: list[str], |
| 420 | *, |
| 421 | installed_packages: list[str] | None = None, |
| 422 | ) -> None: |
| 423 | if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"): |
| 424 | return |
| 425 | installed = installed_packages if installed_packages is not None else _installed_retired_web_packages() |
| 426 | if not installed: |
| 427 | return |
| 428 | result = _run_apt_command(["apt-get", "purge", "-y", *installed], timeout=180) |
| 429 | if result.returncode == 0: |
| 430 | removed.extend(installed) |
| 431 | return |
| 432 | errors.append((result.stderr or result.stdout or "apt-get purge failed").strip()) |
| 433 | |
| 434 | |
| 435 | def _package_installed(package: str) -> bool: |
| 436 | result = subprocess.run( |
| 437 | ["dpkg-query", "-W", "-f=${Status}", package], |
| 438 | check=False, |
| 439 | text=True, |
| 440 | capture_output=True, |
| 441 | timeout=8, |
| 442 | ) |
| 443 | return result.returncode == 0 and "install ok installed" in result.stdout |
| 444 | |
| 445 | |
| 446 | def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> None: |
| 447 | if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"): |
| 448 | return |
| 449 | missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)] |
| 450 | if not missing: |
| 451 | return |
| 452 | |
| 453 | if not _apt_update(errors): |
| 454 | return |
| 455 | |
| 456 | _install_runtime_packages(missing, installed, errors) |
| 457 | |
| 458 | |
| 459 | def _install_runtime_packages( |
| 460 | packages: list[str], |
| 461 | installed: list[str], |
| 462 | errors: list[str], |
| 463 | ) -> bool: |
| 464 | result = _run_apt_command(["apt-get", "install", "-y", "--no-install-recommends", *packages], timeout=900) |
| 465 | if result.returncode == 0: |
| 466 | installed.extend(packages) |
| 467 | return True |
| 468 | output = (result.stderr or result.stdout or "apt-get install failed").strip() |
| 469 | errors.append(output) |
| 470 | return False |
| 471 | |
| 472 | |
| 473 | def _apt_update(errors: list[str]) -> bool: |
| 474 | result = _run_apt_command(["apt-get", "update"], timeout=300) |
| 475 | if result.returncode == 0: |
| 476 | return True |
| 477 | errors.append((result.stderr or result.stdout or "apt-get update failed").strip()) |
| 478 | return False |
| 479 | |
| 480 | |
| 481 | def _run_apt_command(command: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]: |
| 482 | return system_packages.run_apt_with_retries( |
| 483 | lambda: subprocess.run( |
| 484 | command, |
| 485 | check=False, |
| 486 | text=True, |
| 487 | capture_output=True, |
| 488 | timeout=timeout, |
| 489 | env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"}, |
| 490 | ) |
| 491 | ) |