main
py 456 lines 13.9 KB
Raw
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}")