| 1 | from __future__ import annotations |
| 2 | |
| 3 | import os |
| 4 | import shutil |
| 5 | import subprocess |
| 6 | import tempfile |
| 7 | import threading |
| 8 | import time |
| 9 | import urllib.request |
| 10 | from pathlib import Path |
| 11 | from typing import Any |
| 12 | |
| 13 | from helpers import files, state_migration, system_packages |
| 14 | |
| 15 | LIBREOFFICE_RUNTIME_PACKAGES = ( |
| 16 | "libreoffice-core", |
| 17 | "libreoffice-writer", |
| 18 | "libreoffice-calc", |
| 19 | "libreoffice-impress", |
| 20 | "libreoffice-gtk3", |
| 21 | "python3-uno", |
| 22 | ) |
| 23 | XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources") |
| 24 | XPRA_KEYRING_FILE = Path("/usr/share/keyrings/xpra.asc") |
| 25 | XPRA_KEY_URL = "https://xpra.org/xpra.asc" |
| 26 | XPRA_VERSION = "6.5.2-r0-1" |
| 27 | GTK_RUNTIME_PACKAGE = "gir1.2-gtk-3.0" |
| 28 | KALI_ROLLING_SOURCE = "deb http://http.kali.org/kali kali-rolling main contrib non-free non-free-firmware\n" |
| 29 | XPRA_VERSIONED_RUNTIME_PACKAGES = frozenset( |
| 30 | { |
| 31 | "xpra-common", |
| 32 | "xpra-server", |
| 33 | "xpra-client", |
| 34 | "xpra-client-gtk3", |
| 35 | "xpra-x11", |
| 36 | } |
| 37 | ) |
| 38 | RUNTIME_PACKAGES = ( |
| 39 | *LIBREOFFICE_RUNTIME_PACKAGES, |
| 40 | GTK_RUNTIME_PACKAGE, |
| 41 | "xpra-common", |
| 42 | "xpra-server", |
| 43 | "xpra-client", |
| 44 | "xpra-client-gtk3", |
| 45 | "xpra-x11", |
| 46 | "xpra-html5", |
| 47 | "xfce4-session", |
| 48 | "xfwm4", |
| 49 | "xfce4-panel", |
| 50 | "xfdesktop4", |
| 51 | "xfce4-settings", |
| 52 | "thunar", |
| 53 | "gvfs", |
| 54 | "libglib2.0-bin", |
| 55 | "xfce4-terminal", |
| 56 | "x11-xserver-utils", |
| 57 | "x11-utils", |
| 58 | "x11-apps", |
| 59 | "xdotool", |
| 60 | "xclip", |
| 61 | "xauth", |
| 62 | "dbus-x11", |
| 63 | "python3-pil", |
| 64 | "fonts-dejavu", |
| 65 | "fonts-liberation", |
| 66 | "fonts-crosextra-caladea", |
| 67 | "fonts-crosextra-carlito", |
| 68 | "fonts-noto-core", |
| 69 | "fonts-noto-cjk", |
| 70 | "fonts-noto-color-emoji", |
| 71 | ) |
| 72 | RETIRED_RUNTIME_PACKAGES = ( |
| 73 | "firefox-esr", |
| 74 | ) |
| 75 | PLUGIN_NAME = "_desktop" |
| 76 | STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME)) |
| 77 | RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME)) |
| 78 | _preparation_lock = threading.RLock() |
| 79 | _preparation_state: dict[str, Any] = { |
| 80 | "preparing": False, |
| 81 | "active_count": 0, |
| 82 | "started_at": 0.0, |
| 83 | "completed_at": 0.0, |
| 84 | "result": None, |
| 85 | "error": "", |
| 86 | } |
| 87 | |
| 88 | |
| 89 | def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]: |
| 90 | """Prepare the Linux Desktop runtime and reap stale Desktop sessions.""" |
| 91 | |
| 92 | _begin_runtime_preparation() |
| 93 | result: dict[str, Any] | None = None |
| 94 | error = "" |
| 95 | try: |
| 96 | installed: list[str] = [] |
| 97 | removed: list[str] = [] |
| 98 | migrated: list[str] = [] |
| 99 | warnings: list[str] = [] |
| 100 | errors: list[str] = [] |
| 101 | |
| 102 | _migrate_retired_plugin_state(migrated, warnings, errors) |
| 103 | _remove_persisted_screenshots(warnings, errors) |
| 104 | |
| 105 | retired_packages = _installed_packages(RETIRED_RUNTIME_PACKAGES) |
| 106 | if retired_packages: |
| 107 | _purge_packages(removed, errors, installed_packages=retired_packages) |
| 108 | |
| 109 | _ensure_runtime_dependencies(installed, errors) |
| 110 | _cleanup_desktop_sessions(errors) |
| 111 | |
| 112 | result = { |
| 113 | "ok": not errors, |
| 114 | "skipped": False, |
| 115 | "removed": removed, |
| 116 | "installed": installed, |
| 117 | "migrated": migrated, |
| 118 | "warnings": warnings, |
| 119 | "errors": errors, |
| 120 | } |
| 121 | return result |
| 122 | except Exception as exc: |
| 123 | error = str(exc) |
| 124 | raise |
| 125 | finally: |
| 126 | _finish_runtime_preparation(result=result, error=error) |
| 127 | |
| 128 | |
| 129 | def runtime_preparation_status() -> dict[str, Any]: |
| 130 | with _preparation_lock: |
| 131 | return { |
| 132 | "preparing": bool(_preparation_state["preparing"]), |
| 133 | "active_count": int(_preparation_state["active_count"]), |
| 134 | "started_at": float(_preparation_state["started_at"]), |
| 135 | "completed_at": float(_preparation_state["completed_at"]), |
| 136 | "result": _preparation_state["result"], |
| 137 | "error": str(_preparation_state["error"]), |
| 138 | } |
| 139 | |
| 140 | |
| 141 | def _migrate_retired_plugin_state( |
| 142 | migrated: list[str], |
| 143 | warnings: list[str], |
| 144 | errors: list[str], |
| 145 | ) -> None: |
| 146 | state_migration.migrate_retired_state_tree( |
| 147 | source=RETIRED_STATE_DIR, |
| 148 | destination=STATE_DIR, |
| 149 | owner="Desktop", |
| 150 | migrated=migrated, |
| 151 | warnings=warnings, |
| 152 | errors=errors, |
| 153 | ) |
| 154 | |
| 155 | |
| 156 | def _remove_persisted_screenshots( |
| 157 | warnings: list[str], |
| 158 | errors: list[str], |
| 159 | ) -> None: |
| 160 | screenshots_dir = STATE_DIR / "screenshots" |
| 161 | if not screenshots_dir.exists(): |
| 162 | return |
| 163 | try: |
| 164 | shutil.rmtree(screenshots_dir) |
| 165 | warnings.append(f"Removed retired persistent Desktop screenshots: {screenshots_dir}") |
| 166 | except Exception as exc: |
| 167 | errors.append(f"Failed to remove retired persistent Desktop screenshots at {screenshots_dir}: {exc}") |
| 168 | |
| 169 | |
| 170 | def _begin_runtime_preparation() -> None: |
| 171 | with _preparation_lock: |
| 172 | if not _preparation_state["active_count"]: |
| 173 | _preparation_state["preparing"] = True |
| 174 | _preparation_state["started_at"] = time.time() |
| 175 | _preparation_state["completed_at"] = 0.0 |
| 176 | _preparation_state["result"] = None |
| 177 | _preparation_state["error"] = "" |
| 178 | _preparation_state["active_count"] = int(_preparation_state["active_count"]) + 1 |
| 179 | |
| 180 | |
| 181 | def _finish_runtime_preparation( |
| 182 | *, |
| 183 | result: dict[str, Any] | None = None, |
| 184 | error: str = "", |
| 185 | ) -> None: |
| 186 | with _preparation_lock: |
| 187 | active_count = max(0, int(_preparation_state["active_count"]) - 1) |
| 188 | _preparation_state["active_count"] = active_count |
| 189 | if result is not None: |
| 190 | _preparation_state["result"] = result |
| 191 | if error: |
| 192 | _preparation_state["error"] = error |
| 193 | if active_count: |
| 194 | return |
| 195 | _preparation_state["preparing"] = False |
| 196 | _preparation_state["completed_at"] = time.time() |
| 197 | |
| 198 | |
| 199 | def _installed_packages(packages: tuple[str, ...]) -> list[str]: |
| 200 | if not shutil.which("dpkg-query"): |
| 201 | return [] |
| 202 | return [package for package in packages if _package_installed(package)] |
| 203 | |
| 204 | |
| 205 | def _package_installed(package: str) -> bool: |
| 206 | result = subprocess.run( |
| 207 | ["dpkg-query", "-W", "-f=${Status}", package], |
| 208 | check=False, |
| 209 | text=True, |
| 210 | capture_output=True, |
| 211 | timeout=8, |
| 212 | ) |
| 213 | return result.returncode == 0 and "install ok installed" in result.stdout |
| 214 | |
| 215 | |
| 216 | def _package_version(package: str) -> str: |
| 217 | result = subprocess.run( |
| 218 | ["dpkg-query", "-W", "-f=${Version}", package], |
| 219 | check=False, |
| 220 | text=True, |
| 221 | capture_output=True, |
| 222 | timeout=8, |
| 223 | ) |
| 224 | return result.stdout.strip() if result.returncode == 0 else "" |
| 225 | |
| 226 | |
| 227 | def _purge_packages( |
| 228 | removed: list[str], |
| 229 | errors: list[str], |
| 230 | *, |
| 231 | installed_packages: list[str] | None = None, |
| 232 | ) -> None: |
| 233 | if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"): |
| 234 | return |
| 235 | installed = installed_packages if installed_packages is not None else [] |
| 236 | if not installed: |
| 237 | return |
| 238 | result = _run_apt_command(["apt-get", "purge", "-y", *installed], timeout=180) |
| 239 | if result.returncode == 0: |
| 240 | removed.extend(installed) |
| 241 | return |
| 242 | errors.append((result.stderr or result.stdout or "apt-get purge failed").strip()) |
| 243 | |
| 244 | |
| 245 | def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> None: |
| 246 | if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"): |
| 247 | return |
| 248 | if not _ensure_kali_gtk_runtime(installed, errors): |
| 249 | return |
| 250 | missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)] |
| 251 | if not missing: |
| 252 | return |
| 253 | |
| 254 | if not _apt_update(errors): |
| 255 | return |
| 256 | |
| 257 | required_xpra_missing = [package for package in missing if package.startswith("xpra")] |
| 258 | if required_xpra_missing and not _package_candidates_available(required_xpra_missing): |
| 259 | previous_error_count = len(errors) |
| 260 | _ensure_xpra_repository(installed, errors) |
| 261 | if len(errors) > previous_error_count or not _apt_update(errors): |
| 262 | return |
| 263 | missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)] |
| 264 | if not missing: |
| 265 | return |
| 266 | |
| 267 | if missing: |
| 268 | _install_runtime_packages(missing, installed, errors) |
| 269 | |
| 270 | |
| 271 | def _ensure_kali_gtk_runtime(installed: list[str], errors: list[str]) -> bool: |
| 272 | if ( |
| 273 | GTK_RUNTIME_PACKAGE not in RUNTIME_PACKAGES |
| 274 | or _package_installed(GTK_RUNTIME_PACKAGE) |
| 275 | or _read_os_release().get("ID") != "kali" |
| 276 | ): |
| 277 | return True |
| 278 | |
| 279 | with tempfile.TemporaryDirectory(prefix="a0-desktop-apt-") as directory: |
| 280 | source = Path(directory) / "kali-rolling.list" |
| 281 | source.write_text(KALI_ROLLING_SOURCE, encoding="utf-8") |
| 282 | options = [ |
| 283 | "-o", |
| 284 | f"Dir::Etc::sourcelist={source}", |
| 285 | "-o", |
| 286 | "Dir::Etc::sourceparts=-", |
| 287 | ] |
| 288 | result = _run_apt_command(["apt-get", *options, "update"], timeout=300) |
| 289 | if result.returncode == 0: |
| 290 | result = _run_apt_command( |
| 291 | [ |
| 292 | "apt-get", |
| 293 | *options, |
| 294 | "install", |
| 295 | "-y", |
| 296 | "--no-install-recommends", |
| 297 | GTK_RUNTIME_PACKAGE, |
| 298 | ], |
| 299 | timeout=900, |
| 300 | ) |
| 301 | if result.returncode == 0: |
| 302 | installed.append(GTK_RUNTIME_PACKAGE) |
| 303 | return True |
| 304 | errors.append((result.stderr or result.stdout or "GTK runtime install failed").strip()) |
| 305 | return False |
| 306 | |
| 307 | |
| 308 | def _install_runtime_packages( |
| 309 | packages: list[str], |
| 310 | installed: list[str], |
| 311 | errors: list[str], |
| 312 | ) -> bool: |
| 313 | xpra_version = "" |
| 314 | if any(package in XPRA_VERSIONED_RUNTIME_PACKAGES for package in packages): |
| 315 | xpra_version = _package_version("xpra-common") or XPRA_VERSION |
| 316 | package_specs = [ |
| 317 | f"{package}={xpra_version}" if package in XPRA_VERSIONED_RUNTIME_PACKAGES else package |
| 318 | for package in packages |
| 319 | ] |
| 320 | result = _run_apt_command( |
| 321 | ["apt-get", "install", "-y", "--no-install-recommends", *package_specs], |
| 322 | timeout=900, |
| 323 | ) |
| 324 | if result.returncode == 0: |
| 325 | installed.extend(packages) |
| 326 | return True |
| 327 | output = (result.stderr or result.stdout or "apt-get install failed").strip() |
| 328 | errors.append(output) |
| 329 | return False |
| 330 | |
| 331 | |
| 332 | def _apt_update(errors: list[str]) -> bool: |
| 333 | result = _run_apt_command(["apt-get", "update"], timeout=300) |
| 334 | if result.returncode == 0: |
| 335 | return True |
| 336 | errors.append((result.stderr or result.stdout or "apt-get update failed").strip()) |
| 337 | return False |
| 338 | |
| 339 | |
| 340 | def _package_candidate_available(package: str) -> bool: |
| 341 | if not shutil.which("apt-cache"): |
| 342 | return True |
| 343 | result = subprocess.run( |
| 344 | ["apt-cache", "policy", package], |
| 345 | check=False, |
| 346 | text=True, |
| 347 | capture_output=True, |
| 348 | timeout=15, |
| 349 | ) |
| 350 | if result.returncode != 0: |
| 351 | return True |
| 352 | if not result.stdout.strip(): |
| 353 | return False |
| 354 | return "Candidate: (none)" not in result.stdout |
| 355 | |
| 356 | |
| 357 | def _package_candidates_available(packages: list[str]) -> bool: |
| 358 | return all(_package_candidate_available(package) for package in packages) |
| 359 | |
| 360 | |
| 361 | def _ensure_xpra_repository(installed: list[str], errors: list[str]) -> None: |
| 362 | if not _package_installed("ca-certificates"): |
| 363 | result = _run_apt_command( |
| 364 | ["apt-get", "install", "-y", "--no-install-recommends", "ca-certificates"], |
| 365 | timeout=180, |
| 366 | ) |
| 367 | if result.returncode != 0: |
| 368 | errors.append((result.stderr or result.stdout or "apt-get install ca-certificates failed").strip()) |
| 369 | return |
| 370 | installed.append("ca-certificates") |
| 371 | |
| 372 | try: |
| 373 | key = _download(XPRA_KEY_URL) |
| 374 | XPRA_KEYRING_FILE.parent.mkdir(parents=True, exist_ok=True) |
| 375 | if not XPRA_KEYRING_FILE.exists() or XPRA_KEYRING_FILE.read_bytes() != key: |
| 376 | XPRA_KEYRING_FILE.write_bytes(key) |
| 377 | |
| 378 | XPRA_SOURCE_FILE.parent.mkdir(parents=True, exist_ok=True) |
| 379 | source = _xpra_repository_source() |
| 380 | if not XPRA_SOURCE_FILE.exists() or XPRA_SOURCE_FILE.read_text(encoding="utf-8") != source: |
| 381 | XPRA_SOURCE_FILE.write_text(source, encoding="utf-8") |
| 382 | except Exception as exc: |
| 383 | errors.append(f"Xpra repository setup failed: {exc}") |
| 384 | |
| 385 | |
| 386 | def _download(url: str) -> bytes: |
| 387 | with urllib.request.urlopen(url, timeout=45) as response: |
| 388 | return response.read() |
| 389 | |
| 390 | |
| 391 | def _run_apt_command(command: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]: |
| 392 | return system_packages.run_apt_with_retries( |
| 393 | lambda: subprocess.run( |
| 394 | command, |
| 395 | check=False, |
| 396 | text=True, |
| 397 | capture_output=True, |
| 398 | timeout=timeout, |
| 399 | env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"}, |
| 400 | ) |
| 401 | ) |
| 402 | |
| 403 | |
| 404 | def _xpra_repository_source() -> str: |
| 405 | os_release = _read_os_release() |
| 406 | os_id = os_release.get("ID", "") |
| 407 | codename = os_release.get("VERSION_CODENAME", "") |
| 408 | arch = _dpkg_architecture() |
| 409 | |
| 410 | uri = "https://xpra.org" |
| 411 | suite = "trixie" if os_id == "kali" or codename in {"sid", "forky"} else codename or "trixie" |
| 412 | |
| 413 | return ( |
| 414 | f"Types: deb\n" |
| 415 | f"URIs: {uri}\n" |
| 416 | f"Suites: {suite}\n" |
| 417 | f"Components: main\n" |
| 418 | f"Signed-By: {XPRA_KEYRING_FILE}\n" |
| 419 | f"Architectures: {arch}\n" |
| 420 | ) |
| 421 | |
| 422 | |
| 423 | def _read_os_release() -> dict[str, str]: |
| 424 | path = Path("/etc/os-release") |
| 425 | if not path.exists(): |
| 426 | return {} |
| 427 | values: dict[str, str] = {} |
| 428 | for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): |
| 429 | if not line or line.startswith("#") or "=" not in line: |
| 430 | continue |
| 431 | key, value = line.split("=", 1) |
| 432 | values[key] = value.strip().strip('"') |
| 433 | return values |
| 434 | |
| 435 | |
| 436 | def _dpkg_architecture() -> str: |
| 437 | result = subprocess.run( |
| 438 | ["dpkg", "--print-architecture"], |
| 439 | check=False, |
| 440 | text=True, |
| 441 | capture_output=True, |
| 442 | timeout=8, |
| 443 | ) |
| 444 | if result.returncode == 0 and result.stdout.strip(): |
| 445 | return result.stdout.strip() |
| 446 | return "amd64" |
| 447 | |
| 448 | |
| 449 | def _cleanup_desktop_sessions(errors: list[str]) -> None: |
| 450 | try: |
| 451 | from plugins._desktop.helpers import desktop_session |
| 452 | |
| 453 | result = desktop_session.cleanup_stale_runtime_state() |
| 454 | errors.extend(str(item) for item in result.get("errors") or []) |
| 455 | except Exception as exc: |
| 456 | errors.append(f"Desktop cleanup failed: {exc}") |