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 = []