Fix Office desktop runtime on arm64

Alessandro committed May 3, 2026 at 00:27 UTC 6d9dedb821765f68b2e9cc8bbf2f3b396b07e3b0
6 files changed +188 -24
docker/run/fs/ins/install_additional.sh
-2
@@ -101,8 +101,6 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
101 gvfs \
102 libglib2.0-bin \
103 xfce4-terminal \
104 - pulseaudio \
105 - pulseaudio-utils \
104 x11-xserver-utils \
105 xdotool \
106 xauth \
helpers/virtual_desktop.py
+4 -1
@@ -120,6 +120,9 @@ def session_url(token: str, *, title: str = "Desktop") -> str:
120 {
121 "path": base_path,
122 "title": title,
123 + "encoding": "jpeg",
124 + "quality": "85",
125 + "speed": "80",
126 "sharing": "true",
127 "clipboard": "true",
128 "clipboard_direction": "both",
@@ -127,7 +130,7 @@ def session_url(token: str, *, title: str = "Desktop") -> str:
130 "clipboard_preferred_format": "text/plain",
131 "printing": "true",
132 "file_transfer": "true",
130 - "sound": "true",
133 + "sound": "false",
134 "offscreen": "false",
135 "floating_menu": "false",
136 "xpramenu": "false",
plugins/_office/helpers/libreoffice_desktop.py
+5 -7
@@ -1035,8 +1035,6 @@ def collect_desktop_status() -> dict[str, Any]:
1035 "xfce4-terminal": shutil.which("xfce4-terminal") or "",
1036 "xfce4-settings-manager": shutil.which("xfce4-settings-manager") or "",
1037 "gio": shutil.which("gio") or "",
1038 - "pulseaudio": shutil.which("pulseaudio") or "",
1039 - "pactl": shutil.which("pactl") or "",
1038 }
1039 missing = [
1040 name
@@ -1046,8 +1044,6 @@ def collect_desktop_status() -> dict[str, Any]:
1044 "xfce4-terminal",
1045 "xfce4-settings-manager",
1046 "gio",
1049 - "pulseaudio",
1050 - "pactl",
1047 )
1048 if not binaries[name]
1049 ]
@@ -1152,10 +1148,12 @@ def _xpra_shadow_command(xpra: str, session: DesktopSession) -> list[str]:
1148 "--open-files=no",
1149 "--open-url=no",
1150 "--printing=yes",
1155 - "--audio=yes",
1156 - "--pulseaudio=auto",
1157 - "--speaker=on",
1151 + "--audio=no",
1152 + "--speaker=off",
1153 "--microphone=off",
1154 + "--encoding=jpeg",
1155 + "--quality=85",
1156 + "--speed=80",
1157 f"--bind-tcp=127.0.0.1:{session.xpra_port}",
1158 "--resize-display=yes",
1159 f"--log-dir={session.profile_dir}",
plugins/_office/hooks.py
+78 -6
@@ -15,6 +15,7 @@ XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources")
15 XPRA_KEYRING_FILE = Path("/usr/share/keyrings/xpra.asc")
16 XPRA_KEY_URL = "https://xpra.org/xpra.asc"
17 SUPERVISOR_FILE = Path("/etc/supervisor/conf.d/a0_office_collabora.conf")
18 +SUPERVISOR_PROGRAM = "a0_office_collabora"
19 RUNTIME_DIRS = [
20 Path("/a0/tmp/_office/collabora"),
21 Path("/a0/usr/plugins/_office/collabora"),
@@ -44,7 +45,9 @@ RUNTIME_PACKAGES = (
45 "libreoffice-impress",
46 "libreoffice-gtk3",
47 "python3-uno",
47 - "xpra",
48 + "xpra-server",
49 + "xpra-client",
50 + "xpra-client-gtk3",
51 "xpra-x11",
52 "xpra-html5",
53 "xfce4-session",
@@ -56,8 +59,6 @@ RUNTIME_PACKAGES = (
59 "gvfs",
60 "libglib2.0-bin",
61 "xfce4-terminal",
59 - "pulseaudio",
60 - "pulseaudio-utils",
62 "x11-xserver-utils",
63 "xdotool",
64 "xauth",
@@ -106,6 +107,7 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
107 except Exception as exc:
108 errors.append(f"{path}: {exc}")
109
110 + _retire_supervisor_program(errors)
111 _purge_packages(removed, errors, installed_packages=stale_packages)
112
113 try:
@@ -122,6 +124,7 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
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 _cleanup_desktop_sessions(errors)
130
@@ -158,6 +161,62 @@ def _kill_old_processes(errors: list[str]) -> None:
161 errors.append((result.stderr or result.stdout or "pkill coolwsd failed").strip())
162
163
164 +def _retire_supervisor_program(errors: list[str]) -> None:
165 + if not shutil.which("supervisorctl"):
166 + return
167 + status = _supervisorctl("status", SUPERVISOR_PROGRAM)
168 + status_output = _supervisor_output(status)
169 + if status.returncode != 0:
170 + if _supervisor_absent(status_output):
171 + return
172 + errors.append(status_output or f"supervisorctl status {SUPERVISOR_PROGRAM} failed")
173 + return
174 +
175 + stopped = _supervisorctl("stop", SUPERVISOR_PROGRAM)
176 + stopped_output = _supervisor_output(stopped)
177 + if stopped.returncode != 0 and not _supervisor_absent(stopped_output):
178 + errors.append(stopped_output or f"supervisorctl stop {SUPERVISOR_PROGRAM} failed")
179 + return
180 +
181 + removed = _supervisorctl("remove", SUPERVISOR_PROGRAM)
182 + removed_output = _supervisor_output(removed)
183 + if removed.returncode != 0 and not _supervisor_absent(removed_output):
184 + errors.append(removed_output or f"supervisorctl remove {SUPERVISOR_PROGRAM} failed")
185 + return
186 +
187 + for command in (("reread",), ("update",)):
188 + result = _supervisorctl(*command)
189 + output = _supervisor_output(result)
190 + if result.returncode != 0 and not _supervisor_absent(output):
191 + errors.append(output or f"supervisorctl {' '.join(command)} failed")
192 +
193 +
194 +def _supervisorctl(*args: str) -> subprocess.CompletedProcess[str]:
195 + return subprocess.run(
196 + ["supervisorctl", *args],
197 + check=False,
198 + text=True,
199 + capture_output=True,
200 + timeout=15,
201 + )
202 +
203 +
204 +def _supervisor_output(result: subprocess.CompletedProcess[str]) -> str:
205 + return (result.stderr or result.stdout or "").strip()
206 +
207 +
208 +def _supervisor_absent(output: str) -> bool:
209 + normalized = output.lower()
210 + return (
211 + "no such process" in normalized
212 + or "no such group" in normalized
213 + or "not running" in normalized
214 + or "unix:///var/run/supervisor.sock" in normalized
215 + or "connection refused" in normalized
216 + or "no such file" in normalized
217 + )
218 +
219 +
220 def _installed_packages(packages: tuple[str, ...]) -> list[str]:
221 if not shutil.which("dpkg-query"):
222 return []
@@ -210,7 +269,8 @@ def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> Non
269 if not _apt_update(errors):
270 return
271
213 - if "xpra" in missing and not _package_candidate_available("xpra"):
272 + xpra_missing = [package for package in missing if package.startswith("xpra")]
273 + if xpra_missing and not _package_candidates_available(xpra_missing):
274 previous_error_count = len(errors)
275 _ensure_xpra_repository(installed, errors)
276 if len(errors) > previous_error_count or not _apt_update(errors):
@@ -260,9 +320,15 @@ def _package_candidate_available(package: str) -> bool:
320 )
321 if result.returncode != 0:
322 return True
323 + if not result.stdout.strip():
324 + return False
325 return "Candidate: (none)" not in result.stdout
326
327
328 +def _package_candidates_available(packages: list[str]) -> bool:
329 + return all(_package_candidate_available(package) for package in packages)
330 +
331 +
332 def _ensure_xpra_repository(installed: list[str], errors: list[str]) -> None:
333 if not _package_installed("ca-certificates"):
334 result = subprocess.run(
@@ -303,12 +369,18 @@ def _xpra_repository_source() -> str:
369 codename = os_release.get("VERSION_CODENAME", "")
370 arch = _dpkg_architecture()
371
306 - if os_id == "kali":
372 + if os_id == "kali" and arch == "amd64":
373 uri = "https://xpra.org/beta"
374 suite = "sid"
309 - elif codename in {"sid", "forky"}:
375 + elif os_id == "kali":
376 + uri = "https://xpra.org"
377 + suite = "trixie"
378 + elif codename in {"sid", "forky"} and arch == "amd64":
379 uri = "https://xpra.org/beta"
380 suite = codename
381 + elif codename in {"sid", "forky"}:
382 + uri = "https://xpra.org"
383 + suite = "trixie"
384 else:
385 uri = "https://xpra.org"
386 suite = codename or "trixie"
tests/test_office_canvas_setup.py
+10 -4
@@ -228,7 +228,10 @@ def test_official_libreoffice_desktop_route_and_packages_are_declared():
228 assert "xpramenu" in primitive
229 assert "floating_menu" in primitive
230 assert '"file_transfer": "true"' in primitive
231 - assert '"sound": "true"' in primitive
231 + assert '"sound": "false"' in primitive
232 + assert '"encoding": "jpeg"' in primitive
233 + assert '"quality": "85"' in primitive
234 + assert '"speed": "80"' in primitive
235 assert '"printing": "true"' in primitive
236 assert "offscreen" in primitive
237 assert "xpra" in desktop
@@ -245,9 +248,12 @@ def test_official_libreoffice_desktop_route_and_packages_are_declared():
248 assert "--open-url=no" in desktop
249 assert "--printing=yes" in desktop
250 assert "--cursors=no" not in desktop
248 - assert "--audio=yes" in desktop
249 - assert "--speaker=on" in desktop
251 + assert "--audio=no" in desktop
252 + assert "--speaker=off" in desktop
253 assert "--microphone=off" in desktop
254 + assert "--encoding=jpeg" in desktop
255 + assert "--quality=85" in desktop
256 + assert "--speed=80" in desktop
257 assert "_restart_xpra_shadow(session)" not in desktop
258 assert 'result["reload"] = True' not in desktop
259 assert "MAX_SCREEN_WIDTH}x{MAX_SCREEN_HEIGHT}x24" in desktop
@@ -287,7 +293,7 @@ def test_official_libreoffice_desktop_route_and_packages_are_declared():
293 assert "libglib2.0-bin" in install
294 assert "xfce4-terminal" in install
295 assert "firefox-esr" not in install
290 - assert "pulseaudio" in install
296 + assert "pulseaudio" not in install
297 assert "x11-xserver-utils" in install
298 assert "xauth" in install
299 assert "Linux Desktop Interface" in linux_desktop_skill
tests/test_office_document_store.py
+91 -4
@@ -374,8 +374,6 @@ def test_official_libreoffice_desktop_status_and_url_contract(tmp_path, monkeypa
374 "xfce4-terminal",
375 "xfce4-settings-manager",
376 "gio",
377 - "pulseaudio",
378 - "pactl",
377 }
378 else "",
379 )
@@ -392,7 +390,10 @@ def test_official_libreoffice_desktop_status_and_url_contract(tmp_path, monkeypa
390 assert "xpramenu=false" in url
391 assert "floating_menu=false" in url
392 assert "file_transfer=true" in url
395 - assert "sound=true" in url
393 + assert "sound=false" in url
394 + assert "encoding=jpeg" in url
395 + assert "quality=85" in url
396 + assert "speed=80" in url
397 assert "printing=true" in url
398
399
@@ -742,6 +743,48 @@ def test_cleanup_hook_reruns_when_stale_packages_exist_after_old_marker(tmp_path
743 assert result["removed"] == ["coolwsd"]
744
745
746 +def test_cleanup_hook_removes_retired_supervisor_program_after_marker(tmp_path, monkeypatch):
747 + marker = tmp_path / "state" / "cleanup.done"
748 + marker.parent.mkdir(parents=True)
749 + marker.write_text("ok\n", encoding="utf-8")
750 + calls = []
751 +
752 + monkeypatch.setattr(hooks, "APT_SOURCE_FILE", tmp_path / "missing.sources")
753 + monkeypatch.setattr(hooks, "APT_KEYRING_FILE", tmp_path / "missing.gpg")
754 + monkeypatch.setattr(hooks, "SUPERVISOR_FILE", tmp_path / "missing.conf")
755 + monkeypatch.setattr(hooks, "RUNTIME_DIRS", [])
756 + monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
757 + monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
758 + monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
759 + monkeypatch.setattr(hooks, "_cleanup_desktop_sessions", lambda errors: None)
760 + monkeypatch.setattr(hooks.shutil, "which", lambda name: "/usr/bin/supervisorctl" if name == "supervisorctl" else "")
761 +
762 + def fake_supervisorctl(*args):
763 + calls.append(args)
764 + if args == ("status", hooks.SUPERVISOR_PROGRAM):
765 + return types.SimpleNamespace(
766 + returncode=0,
767 + stdout="a0_office_collabora BACKOFF can't find command\n",
768 + stderr="",
769 + )
770 + return types.SimpleNamespace(returncode=0, stdout="", stderr="")
771 +
772 + monkeypatch.setattr(hooks, "_supervisorctl", fake_supervisorctl)
773 +
774 + result = hooks.cleanup_stale_runtime_state()
775 +
776 + assert result["ok"] is True
777 + assert result["skipped"] is True
778 + assert result["errors"] == []
779 + assert calls == [
780 + ("status", hooks.SUPERVISOR_PROGRAM),
781 + ("stop", hooks.SUPERVISOR_PROGRAM),
782 + ("remove", hooks.SUPERVISOR_PROGRAM),
783 + ("reread",),
784 + ("update",),
785 + ]
786 +
787 +
788 def test_cleanup_hook_installs_missing_libreoffice_desktop_dependencies(monkeypatch):
789 calls = []
790 installed_state = {"xpra": False}
@@ -792,7 +835,7 @@ def test_cleanup_hook_enables_official_xpra_repo_when_kali_lacks_candidate(tmp_p
835 def fake_run(command, **kwargs):
836 calls.append(command)
837 if command[:2] == ["apt-cache", "policy"]:
795 - return types.SimpleNamespace(returncode=0, stdout="Candidate: (none)\n", stderr="")
838 + return types.SimpleNamespace(returncode=0, stdout="", stderr="")
839 if command[:2] == ["apt-get", "install"]:
840 installed_state["xpra"] = True
841 return types.SimpleNamespace(returncode=0, stdout="", stderr="")
@@ -812,6 +855,50 @@ def test_cleanup_hook_enables_official_xpra_repo_when_kali_lacks_candidate(tmp_p
855 assert calls[-1][:4] == ["apt-get", "install", "-y", "--no-install-recommends"]
856
857
858 +def test_cleanup_hook_uses_trixie_xpra_components_for_kali_arm64(tmp_path, monkeypatch):
859 + calls = []
860 + installed_state = {"xpra-server": False, "xpra-x11": False, "xpra-html5": False, "ca-certificates": True}
861 + keyring = tmp_path / "keyrings" / "xpra.asc"
862 + source = tmp_path / "sources.list.d" / "xpra.sources"
863 +
864 + monkeypatch.setattr(hooks.os, "geteuid", lambda: 0)
865 + monkeypatch.setattr(
866 + hooks.shutil,
867 + "which",
868 + lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "",
869 + )
870 + monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("xpra-server", "xpra-x11", "xpra-html5"))
871 + monkeypatch.setattr(hooks, "XPRA_KEYRING_FILE", keyring)
872 + monkeypatch.setattr(hooks, "XPRA_SOURCE_FILE", source)
873 + monkeypatch.setattr(hooks, "_download", lambda url: b"xpra-key")
874 + monkeypatch.setattr(hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"})
875 + monkeypatch.setattr(hooks, "_dpkg_architecture", lambda: "arm64")
876 + monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False))
877 +
878 + def fake_run(command, **kwargs):
879 + calls.append(command)
880 + if command[:2] == ["apt-cache", "policy"]:
881 + return types.SimpleNamespace(returncode=0, stdout="", stderr="")
882 + if command[:2] == ["apt-get", "install"]:
883 + for package in command[4:]:
884 + installed_state[package] = True
885 + return types.SimpleNamespace(returncode=0, stdout="", stderr="")
886 +
887 + monkeypatch.setattr(hooks.subprocess, "run", fake_run)
888 + installed = []
889 + errors = []
890 +
891 + hooks._ensure_runtime_dependencies(installed, errors)
892 +
893 + assert errors == []
894 + assert installed == ["xpra-server", "xpra-x11", "xpra-html5"]
895 + source_text = source.read_text(encoding="utf-8")
896 + assert "URIs: https://xpra.org\n" in source_text
897 + assert "Suites: trixie" in source_text
898 + assert "xpra" not in calls[-1]
899 + assert calls[-1][-3:] == ["xpra-server", "xpra-x11", "xpra-html5"]
900 +
901 +
902 def test_self_update_launch_invokes_office_cleanup(monkeypatch, tmp_path):
903 manager = load_self_update_manager()
904 calls = []