#!/usr/bin/env python3 """ Android Emulator on Hugging Face Spaces Zero cost. Zero card. """ import os import sys import subprocess import threading import time import json import shutil import http.server import socketserver import urllib.request import logging # ============================================================ # CONFIG # ============================================================ PORT = 7860 # HF Spaces expose ce port WORK = "/app/android" VNC_PORT = 5900 NOVNC_PORT = 6080 logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") log = logging.getLogger("emu") LOG_BUFFER = [] class WebLog(logging.Handler): def emit(self, record): LOG_BUFFER.append(self.format(record)) if len(LOG_BUFFER) > 200: LOG_BUFFER.pop(0) wh = WebLog() wh.setFormatter(logging.Formatter("%(asctime)s %(message)s")) log.addHandler(wh) # ============================================================ # STATE # ============================================================ state = { "phase": "idle", "qemu_proc": None, "novnc_proc": None, } # ============================================================ # ANDROID IMAGE # ============================================================ def download_android(): iso = os.path.join(WORK, "android.iso") disk = os.path.join(WORK, "disk.qcow2") os.makedirs(WORK, exist_ok=True) if not os.path.exists(iso): log.info("Telechargement Android-x86...") state["phase"] = "downloading" # Android-x86 9.0 depuis SourceForge url = "https://sourceforge.net/projects/android-x86/files/Release%209.0/android-x86_64-9.0-r2.iso/download" subprocess.run([ "wget", "-q", "--show-progress", "-O", iso, url ], check=False) if os.path.exists(iso) and os.path.getsize(iso) > 1000: log.info(f"ISO telechargee: {os.path.getsize(iso) // 1048576}MB") else: log.error("Echec telechargement ISO") if os.path.exists(iso): os.remove(iso) return False if not os.path.exists(disk): log.info("Creation disque virtuel...") subprocess.run([ "qemu-img", "create", "-f", "qcow2", disk, "8G" ], check=True) return True # ============================================================ # QEMU # ============================================================ def start_qemu(): iso = os.path.join(WORK, "android.iso") disk = os.path.join(WORK, "disk.qcow2") kvm = os.path.exists("/dev/kvm") cmd = ["qemu-system-x86_64"] if kvm: cmd += ["-enable-kvm", "-cpu", "host"] log.info("KVM disponible") else: cmd += [ "-cpu", "qemu64", "-accel", "tcg,thread=multi", ] log.warning("Pas de KVM = LENT") cmd += [ "-m", "1024", "-smp", "2", "-drive", f"file={disk},format=qcow2,if=virtio", "-cdrom", iso, "-boot", "d", "-vnc", ":0", "-vga", "std", "-display", "none", "-usb", "-device", "usb-tablet", "-net", "nic,model=virtio", "-net", "user", "-audiodev", "none,id=noaudio", ] log.info("Demarrage QEMU...") log.info(f"CMD: {' '.join(cmd)}") proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) def reader(): for line in iter(proc.stdout.readline, b""): txt = line.decode("utf-8", errors="replace").strip() if txt: log.info(f"[QEMU] {txt}") threading.Thread(target=reader, daemon=True).start() return proc # ============================================================ # NOVNC # ============================================================ def start_novnc(): novnc_dir = "/opt/noVNC" cmd = [ sys.executable, "-m", "websockify", "--web", novnc_dir, str(NOVNC_PORT), f"localhost:{VNC_PORT}", ] log.info("Demarrage noVNC...") proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) return proc # ============================================================ # PIPELINE # ============================================================ def start_all(): try: state["phase"] = "downloading" ok = download_android() if not ok: state["phase"] = "error" return state["phase"] = "booting" state["qemu_proc"] = start_qemu() time.sleep(3) state["novnc_proc"] = start_novnc() state["phase"] = "running" log.info("EMULATEUR PRET") log.info(f"noVNC sur le port {NOVNC_PORT}") except Exception as e: log.error(f"Erreur: {e}") state["phase"] = "error" def stop_all(): if state["qemu_proc"]: state["qemu_proc"].terminate() state["qemu_proc"] = None if state["novnc_proc"]: state["novnc_proc"].terminate() state["novnc_proc"] = None state["phase"] = "idle" # ============================================================ # WEB # ============================================================ HTML = """ Android Emulator

Android Emulator

Limites Hugging Face Free : RAM 2GB, pas de KVM = tres lent (10-30 min pour boot Android). C'est un proof of concept.
Chargement...

Systeme

-

Emulateur

-

Ecran Android

Cliquez Demarrer

Logs

En attente...
""" class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): p = self.path.split("?")[0] if p == "/": self.send_response(200) self.send_header("Content-Type", "text/html") self.end_headers() self.wfile.write(HTML.encode()) elif p == "/api/status": qemu_running = False if state["qemu_proc"]: qemu_running = state["qemu_proc"].poll() is None try: with open("/proc/meminfo") as f: lines = f.readlines() total = int(lines[0].split()[1]) // 1024 avail = 0 for line in lines: if line.startswith("MemAvailable:"): avail = int(line.split()[1]) // 1024 ram = f"{total - avail}MB / {total}MB" except: ram = "?" data = { "phase": state["phase"], "kvm": os.path.exists("/dev/kvm"), "ram": ram, "qemu": shutil.which("qemu-system-x86_64") is not None, "iso": os.path.exists(os.path.join(WORK, "android.iso")), "logs": LOG_BUFFER[-30:], } self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps(data).encode()) elif p == "/api/start": if state["phase"] not in ("idle", "error"): msg = {"ok": False, "message": f"Deja en phase: {state['phase']}"} else: threading.Thread(target=start_all, daemon=True).start() msg = {"ok": True, "message": "Demarrage lance"} self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps(msg).encode()) elif p == "/api/stop": stop_all() msg = {"ok": True, "message": "Arrete"} self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps(msg).encode()) else: self.send_error(404) def log_message(self, *args): pass # ============================================================ # MAIN # ============================================================ def main(): log.info("=" * 50) log.info(" Android Emulator - Hugging Face Spaces") log.info(" Port: 7860") log.info("=" * 50) log.info(f"KVM: {os.path.exists('/dev/kvm')}") log.info(f"QEMU: {shutil.which('qemu-system-x86_64')}") server = socketserver.ThreadingTCPServer(("0.0.0.0", PORT), Handler) server.allow_reuse_address = True log.info(f"Serveur pret sur http://0.0.0.0:{PORT}") server.serve_forever() if __name__ == "__main__": main()