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