Handle optional Xpra codec gaps on ARM64

Treat local Xpra GUI client packages as best-effort during Office runtime preparation so ARM64 codec dependency gaps do not surface as startup warnings when the browser-hosted Desktop is already usable. Keep required Desktop Xpra packages strict, trim the ARM Docker fallback to the server/X11/html5 set, and add regression coverage for optional versus required xpra-codecs/libvpx9 failures.

Alessandro committed May 3, 2026 at 01:53 UTC 3df27ccec363c1d751cce427301af5d267563811
3 files changed +130 -7
docker/run/fs/ins/install_additional.sh
+1 -1
@@ -48,7 +48,7 @@ install_xpra_repo() {
48 if ! xpra_install_check; then
49 if [ "$arch" != "amd64" ]; then
50 echo "xpra packages are not installable from ${uri} ${suite} for ${arch}; falling back to https://xpra.org trixie"
51 - XPRA_PACKAGES=(xpra-server xpra-client xpra-client-gtk3 xpra-x11 xpra-html5)
51 + XPRA_PACKAGES=(xpra-server xpra-x11 xpra-html5)
52 configure_xpra_repo "https://xpra.org" "trixie" "$arch"
53 apt-get update
54 if ! xpra_install_check; then
plugins/_office/hooks.py
+47 -6
@@ -71,6 +71,13 @@ RUNTIME_PACKAGES = (
71 "fonts-noto-cjk",
72 "fonts-noto-color-emoji",
73 )
74 +# The browser-hosted Desktop needs the server, X11, and html5 pieces. Local
75 +# Xpra GUI clients are useful extras, but can pull codec packages that are not
76 +# consistently available across architectures.
77 +OPTIONAL_RUNTIME_PACKAGES = (
78 + "xpra-client",
79 + "xpra-client-gtk3",
80 +)
81 RETIRED_RUNTIME_PACKAGES = (
82 "firefox-esr",
83 )
@@ -269,8 +276,9 @@ def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> Non
276 if not _apt_update(errors):
277 return
278
272 - xpra_missing = [package for package in missing if package.startswith("xpra")]
273 - if xpra_missing and not _package_candidates_available(xpra_missing):
279 + required_missing, optional_missing = _split_runtime_packages(missing)
280 + required_xpra_missing = [package for package in required_missing if package.startswith("xpra")]
281 + if required_xpra_missing and not _package_candidates_available(required_xpra_missing):
282 previous_error_count = len(errors)
283 _ensure_xpra_repository(installed, errors)
284 if len(errors) > previous_error_count or not _apt_update(errors):
@@ -278,9 +286,33 @@ def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> Non
286 missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
287 if not missing:
288 return
289 + required_missing, optional_missing = _split_runtime_packages(missing)
290 +
291 + if required_missing and not _install_runtime_packages(required_missing, installed, errors):
292 + return
293 +
294 + if optional_missing:
295 + optional_xpra_missing = [package for package in optional_missing if package.startswith("xpra")]
296 + if optional_xpra_missing and not _package_candidates_available(optional_xpra_missing):
297 + return
298 + _install_runtime_packages(optional_missing, installed, errors, optional=True)
299 +
300 +
301 +def _split_runtime_packages(packages: list[str]) -> tuple[list[str], list[str]]:
302 + optional = [package for package in packages if package in OPTIONAL_RUNTIME_PACKAGES]
303 + required = [package for package in packages if package not in OPTIONAL_RUNTIME_PACKAGES]
304 + return required, optional
305 +
306
307 +def _install_runtime_packages(
308 + packages: list[str],
309 + installed: list[str],
310 + errors: list[str],
311 + *,
312 + optional: bool = False,
313 +) -> bool:
314 result = subprocess.run(
283 - ["apt-get", "install", "-y", "--no-install-recommends", *missing],
315 + ["apt-get", "install", "-y", "--no-install-recommends", *packages],
316 check=False,
317 text=True,
318 capture_output=True,
@@ -288,9 +320,18 @@ def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> Non
320 env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
321 )
322 if result.returncode == 0:
291 - installed.extend(missing)
292 - return
293 - errors.append((result.stderr or result.stdout or "apt-get install failed").strip())
323 + installed.extend(packages)
324 + return True
325 + output = (result.stderr or result.stdout or "apt-get install failed").strip()
326 + if optional and _is_xpra_codec_dependency_gap(output):
327 + return False
328 + errors.append(output)
329 + return False
330 +
331 +
332 +def _is_xpra_codec_dependency_gap(output: str) -> bool:
333 + normalized = output.lower()
334 + return "xpra-codecs" in normalized and "libvpx9" in normalized
335
336
337 def _apt_update(errors: list[str]) -> bool:
tests/test_office_document_store.py
+82
@@ -899,6 +899,88 @@ def test_cleanup_hook_uses_trixie_xpra_components_for_kali_arm64(tmp_path, monke
899 assert calls[-1][-3:] == ["xpra-server", "xpra-x11", "xpra-html5"]
900
901
902 +def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch):
903 + calls = []
904 + installed_state = {
905 + "xpra-server": True,
906 + "xpra-client": False,
907 + "xpra-client-gtk3": False,
908 + "xpra-x11": True,
909 + "xpra-html5": True,
910 + }
911 + codec_error = (
912 + "E: Unable to satisfy dependencies. Reached two conflicting assignments:\n"
913 + " 1. xpra-codecs:arm64=6.4.3-r0-1 is selected for install\n"
914 + " 2. xpra-codecs:arm64 Depends libvpx9 (>= 1.12.0)\n"
915 + " but none of the choices are installable: [no choices]"
916 + )
917 +
918 + monkeypatch.setattr(hooks.os, "geteuid", lambda: 0)
919 + monkeypatch.setattr(
920 + hooks.shutil,
921 + "which",
922 + lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "",
923 + )
924 + monkeypatch.setattr(
925 + hooks,
926 + "RUNTIME_PACKAGES",
927 + ("xpra-server", "xpra-client", "xpra-client-gtk3", "xpra-x11", "xpra-html5"),
928 + )
929 + monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False))
930 +
931 + def fake_run(command, **kwargs):
932 + calls.append(command)
933 + if command[:2] == ["apt-cache", "policy"]:
934 + return types.SimpleNamespace(returncode=0, stdout="Candidate: 6.4.3-r0-1\n", stderr="")
935 + if command[:2] == ["apt-get", "install"]:
936 + return types.SimpleNamespace(returncode=100, stdout="", stderr=codec_error)
937 + return types.SimpleNamespace(returncode=0, stdout="", stderr="")
938 +
939 + monkeypatch.setattr(hooks.subprocess, "run", fake_run)
940 + installed = []
941 + errors = []
942 +
943 + hooks._ensure_runtime_dependencies(installed, errors)
944 +
945 + assert installed == []
946 + assert errors == []
947 + assert calls[-1][-2:] == ["xpra-client", "xpra-client-gtk3"]
948 +
949 +
950 +def test_cleanup_hook_reports_required_xpra_codec_conflict(monkeypatch):
951 + codec_error = (
952 + "E: Unable to satisfy dependencies. Reached two conflicting assignments:\n"
953 + " 1. xpra-codecs:arm64=6.4.3-r0-1 is selected for install\n"
954 + " 2. xpra-codecs:arm64 Depends libvpx9 (>= 1.12.0)\n"
955 + " but none of the choices are installable: [no choices]"
956 + )
957 +
958 + monkeypatch.setattr(hooks.os, "geteuid", lambda: 0)
959 + monkeypatch.setattr(
960 + hooks.shutil,
961 + "which",
962 + lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "",
963 + )
964 + monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("xpra-server",))
965 + monkeypatch.setattr(hooks, "_package_installed", lambda package: False)
966 +
967 + def fake_run(command, **kwargs):
968 + if command[:2] == ["apt-cache", "policy"]:
969 + return types.SimpleNamespace(returncode=0, stdout="Candidate: 6.4.3-r0-1\n", stderr="")
970 + if command[:2] == ["apt-get", "install"]:
971 + return types.SimpleNamespace(returncode=100, stdout="", stderr=codec_error)
972 + return types.SimpleNamespace(returncode=0, stdout="", stderr="")
973 +
974 + monkeypatch.setattr(hooks.subprocess, "run", fake_run)
975 + installed = []
976 + errors = []
977 +
978 + hooks._ensure_runtime_dependencies(installed, errors)
979 +
980 + assert installed == []
981 + assert errors == [codec_error]
982 +
983 +
984 def test_self_update_launch_invokes_office_cleanup(monkeypatch, tmp_path):
985 manager = load_self_update_manager()
986 calls = []