[ADD] todo qemu: offer the adb tunnel for scrcpy, X11-free

X11 carries raw pixels: 0.62 megapixel per frame even after shrinking the
screen, rendered in software. scrcpy receives an H.264 stream encoded BY the
device, so the emulator runs with no window at all — no X11 anywhere. The
tunnel menu gained a fourth kind, reusing the target list it already reads from
~/.ssh/config, ProxyJump included: the hard part was already solved there.

It forwards 5555, the emulator's own adb port, not 5037: tunnelling the adb
server would force the user to kill the one on their workstation, which holds
the same port. Verified through the tunnel from the hypervisor: an adb CNXN
handshake answers device::ro.product.name=sdk_gphone64_x86, exactly what adb
connect does. ss -ltn in the VM confirms 5554, 5555 and 5037 all listen on
127.0.0.1.

--- FR ---

X11 transporte des pixels bruts : 0,62 mégapixel par image même après réduction
de l'écran, en rendu logiciel. scrcpy reçoit un flux H.264 encodé PAR
l'appareil, si bien que l'émulateur tourne sans aucune fenêtre — plus de X11
nulle part. Le menu de tunnel gagne un quatrième type, qui réutilise la liste de
cibles qu'il lit déjà dans ~/.ssh/config, ProxyJump compris : le plus dur y
était déjà résolu.

Il redirige 5555, le port adb de l'émulateur lui-même, et non 5037 : tunneliser
le serveur adb obligerait à tuer celui du poste, qui occupe le même port.
Vérifié à travers le tunnel depuis l'hyperviseur : une poignée de main adb CNXN
répond device::ro.product.name=sdk_gphone64_x86, ce que fait exactement adb
connect. « ss -ltn » dans la VM confirme que 5554, 5555 et 5037 écoutent tous
sur 127.0.0.1.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-19 02:39:10 -04:00
parent 1dcd19b02f
commit 7a65568098
3 changed files with 151 additions and 2 deletions

View file

@ -1569,11 +1569,17 @@ class TODO:
print(f"\n {t('Remote desktop kind:')}")
print(f" [1] RDP 3389 (xrdp) *")
print(f" [2] VNC 5901 (TigerVNC, Arch)")
print(f" [3] {t('Hypervisor console (QEMU screen, no guest server)')}")
print(
f" [3] {t('Hypervisor console (QEMU screen, no guest server)')}"
)
print(f" [4] {t('Android emulator (adb 5555, then scrcpy)')}")
kind_answer = input(f"{t('Choice')} [1]: ").strip() or "1"
if kind_answer == "3":
self._qemu_console_tunnel(name, src)
return
if kind_answer == "4":
self._qemu_scrcpy_tunnel(name, src)
return
port, kind = (5901, "VNC") if kind_answer == "2" else (3389, "RDP")
local = port + 1
@ -1601,6 +1607,65 @@ class TODO:
)
print(f" {t('The tunnel stays open as long as that ssh runs.')}")
def _qemu_scrcpy_tunnel(self, name, src):
"""Tunnel adb vers l'émulateur Android d'une VM, pour scrcpy.
Pourquoi cette voie plutôt que « ssh -X » : par X11, chaque image de
l'écran traverse le réseau en pixels bruts — 0,62 Mpixel par image même
après réduction, en rendu logiciel. scrcpy, lui, reçoit un flux H.264
encodé PAR l'appareil et le décode sur le poste. L'émulateur tourne
alors SANS fenêtre : plus de X11 du tout, ni sur l'hôte ni dans la VM.
Le port est celui de l'émulateur, pas celui du serveur adb. Un émulateur
écoute sur 5554 (console) et 5555 (adb), tous deux sur le localhost de
la VM — vérifié par « ss -ltn ». C'est 5555 qu'il faut, et non 5037 :
tunneler le serveur adb obligerait à tuer celui du poste, qui occupe le
même port.
Vérifié de bout en bout à travers le tunnel : une poignée de main adb
(paquet CNXN) reçoit « device::ro.product.name=sdk_gphone64_x86 » de
l'émulateur lui-même — c'est exactement ce que fait « adb connect ».
"""
port = 5555
print(f"\n 📱 {t('Android emulator over adb + scrcpy')}")
print(f"\n {t('1. In the VM, start the emulator WITHOUT a window:')}")
emu = "$HOME/android/emulator/emulator"
if src == "ssh_config":
print(
f"\n ssh {name} '{emu} -avd erplibre"
" -no-window -no-audio -no-boot-anim'\n"
)
else:
ip = self._qemu_resolve_ips([name]).get(name)
if not ip:
print(f" {t('No IP for this VM; is it running?')}")
return
print(
f"\n ssh erplibre@{ip} '{emu} -avd erplibre"
" -no-window -no-audio -no-boot-anim'\n"
)
print(f" {t('2. Open the tunnel from YOUR workstation:')}")
if src == "ssh_config":
# « localhost » est résolu par le DERNIER saut, donc par la VM
# elle-même : le ProxyJump de ssh_config traverse les niveaux.
print(f"\n ssh -N -L {port}:localhost:{port} {name}\n")
print(f" {t('(through the ProxyJump already in ~/.ssh/config)')}")
else:
host, from_ssh = self._qemu_self_address()
user = os.environ.get("USER", "user")
if not from_ssh:
print(
f" ⚠ {t('Not in an SSH session: check the host address.')}"
)
print(f"\n ssh -N -L {port}:{ip}:{port} {user}@{host}\n")
print(f" {t('3. Then, still on your workstation:')}")
print(f"\n adb connect localhost:{port}")
print(f" scrcpy -s localhost:{port}\n")
print(f" {t('The tunnel stays open as long as that ssh runs.')}")
print(
f" {t('scrcpy on Debian/Ubuntu:')} sudo apt install scrcpy" " adb"
)
def _qemu_console_tunnel(self):
"""Tunnel vers l'ÉCRAN QEMU d'une VM, pas vers un serveur de l'invité.
@ -5980,7 +6045,13 @@ class TODO:
# Mesuré sur l'émulateur.
'ssh erplibre@$ip \\"$HOME/android/platform-tools/adb install -r -t '
f"{el_dir}/mobile/erplibre_home_mobile/android/app/build"
'/outputs/apk/debug/app-debug.apk\\""'
'/outputs/apk/debug/app-debug.apk\\""; '
# La voie scrcpy, nommée ici parce que c'est la première
# question qui vient après « ça se lance mais c'est lent » :
# X11 transporte des pixels bruts, scrcpy un flux H.264 encodé
# par l'appareil. Le détail du tunnel vit dans le menu
# « Remote desktop tunnel », choix 4.
+ f'echo " {t("smoother, without X11:")} TODO > Execute > Deploy > QEMU/KVM > tunnel > 4"'
)
def _qemu_after_remote_cmd(self, tools, prod=False):

View file

@ -7818,6 +7818,34 @@ TRANSLATIONS = {
"fr": "venv ERPLibre absent (installation incomplète)",
"en": "ERPLibre venv missing (incomplete install)",
},
"smoother, without X11:": {
"fr": "plus fluide, sans X11 :",
"en": "smoother, without X11:",
},
"Android emulator (adb 5555, then scrcpy)": {
"fr": "Émulateur Android (adb 5555, puis scrcpy)",
"en": "Android emulator (adb 5555, then scrcpy)",
},
"Android emulator over adb + scrcpy": {
"fr": "Émulateur Android par adb + scrcpy",
"en": "Android emulator over adb + scrcpy",
},
"1. In the VM, start the emulator WITHOUT a window:": {
"fr": "1. Dans la VM, démarrer l'émulateur SANS fenêtre :",
"en": "1. In the VM, start the emulator WITHOUT a window:",
},
"2. Open the tunnel from YOUR workstation:": {
"fr": "2. Ouvrir le tunnel depuis VOTRE poste :",
"en": "2. Open the tunnel from YOUR workstation:",
},
"3. Then, still on your workstation:": {
"fr": "3. Puis, toujours sur votre poste :",
"en": "3. Then, still on your workstation:",
},
"scrcpy on Debian/Ubuntu:": {
"fr": "scrcpy sur Debian/Ubuntu :",
"en": "scrcpy on Debian/Ubuntu:",
},
"Android emulator (Pixel)": {
"fr": "Émulateur Android (Pixel)",
"en": "Android emulator (Pixel)",

View file

@ -582,6 +582,56 @@ class TestPycharmFirstOpen(unittest.TestCase):
self.assertEqual(0, res.returncode, res.stderr)
class TestScrcpyTunnel(unittest.TestCase):
"""Tunnel adb pour scrcpy : la voie sans X11."""
def setUp(self):
self.todo = TODO.__new__(TODO)
def _printed(self, name, src):
import io
import contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
self.todo._qemu_scrcpy_tunnel(name, src)
return buf.getvalue()
def test_it_tunnels_the_emulator_port_not_the_adb_server(self):
"""5555 est l'adb de l'émulateur ; 5037 est le serveur adb, et le
tunneliser obligerait à tuer celui du poste, qui occupe le même port.
Vérifié dans la VM par « ss -ltn » : 5554, 5555 et 5037 écoutent tous
sur 127.0.0.1."""
out = self._printed("saut+vm", "ssh_config")
self.assertIn("-L 5555:localhost:5555", out)
self.assertNotIn("5037", out)
def test_it_starts_the_emulator_without_a_window(self):
"""Tout l'intérêt : plus de X11 nulle part, le flux est du H.264."""
out = self._printed("saut+vm", "ssh_config")
self.assertIn("-no-window", out)
self.assertNotIn("ssh -X", out)
def test_it_gives_the_three_workstation_commands(self):
out = self._printed("saut+vm", "ssh_config")
self.assertIn("adb connect localhost:5555", out)
self.assertIn("scrcpy -s localhost:5555", out)
self.assertIn("apt install scrcpy", out)
def test_it_uses_the_ssh_config_target_as_is(self):
"""« localhost » est résolu par le DERNIER saut : le ProxyJump déjà
écrit dans ~/.ssh/config traverse les niveaux, on ne le réinvente pas.
"""
out = self._printed("test-vm_02+erplibre-mobile-proof", "ssh_config")
self.assertIn("test-vm_02+erplibre-mobile-proof", out)
self.assertIn("ProxyJump", out)
def test_the_avd_step_points_at_it(self):
"""La question vient juste après « c'est trop lent » : la réponse doit
être à portée de journal."""
self.assertIn("tunnel > 4", self.todo._qemu_avd_remote_cmd())
class TestGnomeSiteExtensions(unittest.TestCase):
"""Extensions posées depuis extensions.gnome.org, par leur UUID."""