Spaces:
Paused
Paused
| #!/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 = """<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Android Emulator</title> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| body{font-family:system-ui;background:#0a0a1a;color:#e0e0e0;min-height:100vh} | |
| .c{max-width:1200px;margin:0 auto;padding:15px} | |
| h1{text-align:center;padding:20px;font-size:2em; | |
| background:linear-gradient(135deg,#3ddc84,#4285f4); | |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent} | |
| .phase{text-align:center;font-size:1.3em;padding:10px;font-weight:bold} | |
| .idle{color:#666} | |
| .downloading{color:#ffab40} | |
| .booting{color:#ffab40} | |
| .running{color:#3ddc84} | |
| .error{color:#ff5252} | |
| .btns{display:flex;gap:10px;justify-content:center;padding:15px} | |
| .btn{padding:10px 25px;border:none;border-radius:6px;font-weight:bold; | |
| cursor:pointer;font-size:1em} | |
| .btn:disabled{opacity:.4;cursor:not-allowed} | |
| .btn-go{background:#3ddc84;color:#000} | |
| .btn-stop{background:#ff5252;color:#fff} | |
| .screen{width:100%;height:500px;border:2px solid #333; | |
| border-radius:10px;background:#000;margin:15px 0} | |
| .info{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:15px 0} | |
| .card{background:#12122a;border:1px solid #2a2a4a;border-radius:8px;padding:12px} | |
| .card h3{color:#4285f4;margin-bottom:8px;font-size:.95em} | |
| .row{display:flex;justify-content:space-between;padding:4px 0; | |
| border-bottom:1px solid #1a1a3a;font-size:.85em} | |
| .on{color:#3ddc84}.off{color:#ff5252}.warn{color:#ffab40} | |
| .logs{background:#000;border-radius:6px;padding:10px;margin:15px 0; | |
| font-family:monospace;font-size:.75em;color:#0f0; | |
| max-height:200px;overflow-y:auto;white-space:pre-wrap} | |
| .warn-box{background:#2d1b00;border:1px solid #ff9800;border-radius:8px; | |
| padding:15px;margin:15px 0;font-size:.9em;color:#ffcc80} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="c"> | |
| <h1>Android Emulator</h1> | |
| <div class="warn-box"> | |
| <strong>Limites Hugging Face Free :</strong> | |
| RAM 2GB, pas de KVM = tres lent (10-30 min pour boot Android). | |
| C'est un proof of concept. | |
| </div> | |
| <div class="phase" id="phase">Chargement...</div> | |
| <div class="btns"> | |
| <button class="btn btn-go" id="bStart" onclick="api('start')">Demarrer</button> | |
| <button class="btn btn-stop" id="bStop" onclick="api('stop')" disabled>Arreter</button> | |
| </div> | |
| <div class="info"> | |
| <div class="card"> | |
| <h3>Systeme</h3> | |
| <div id="sys">-</div> | |
| </div> | |
| <div class="card"> | |
| <h3>Emulateur</h3> | |
| <div id="emu">-</div> | |
| </div> | |
| </div> | |
| <h3 style="margin:10px 0">Ecran Android</h3> | |
| <iframe id="vnc" class="screen" src="about:blank" style="display:none"></iframe> | |
| <div id="ph" class="screen" style="display:flex;align-items:center;justify-content:center"> | |
| <p style="color:#555">Cliquez Demarrer</p> | |
| </div> | |
| <div class="card"> | |
| <h3>Logs</h3> | |
| <div class="logs" id="logs">En attente...</div> | |
| </div> | |
| </div> | |
| <script> | |
| const P={idle:"En attente",downloading:"Telechargement Android...", | |
| booting:"Demarrage (patience!)...",running:"Android tourne !", | |
| error:"Erreur"}; | |
| function api(a){fetch('/api/'+a).then(r=>r.json()).then(d=>{ | |
| if(d.message)addLog(d.message);setTimeout(refresh,1000)})} | |
| function refresh(){fetch('/api/status').then(r=>r.json()).then(d=>{ | |
| const p=document.getElementById('phase'); | |
| p.textContent=P[d.phase]||d.phase; | |
| p.className='phase '+d.phase; | |
| const run=d.phase==='running'||d.phase==='booting'; | |
| const busy=d.phase==='downloading'; | |
| document.getElementById('bStart').disabled=run||busy; | |
| document.getElementById('bStop').disabled=!run; | |
| document.getElementById('sys').innerHTML= | |
| '<div class="row"><span>KVM</span><span class="'+(d.kvm?'on':'off')+'">'+(d.kvm?'Oui':'Non')+'</span></div>'+ | |
| '<div class="row"><span>RAM</span><span>'+d.ram+'</span></div>'+ | |
| '<div class="row"><span>QEMU</span><span class="'+(d.qemu?'on':'off')+'">'+(d.qemu?'OK':'Non')+'</span></div>'; | |
| document.getElementById('emu').innerHTML= | |
| '<div class="row"><span>Phase</span><span>'+d.phase+'</span></div>'+ | |
| '<div class="row"><span>ISO</span><span class="'+(d.iso?'on':'warn')+'">'+(d.iso?'OK':'Non')+'</span></div>'; | |
| if(d.logs&&d.logs.length>0){ | |
| const l=document.getElementById('logs'); | |
| l.textContent=d.logs.join('\\n'); | |
| l.scrollTop=l.scrollHeight} | |
| if(d.phase==='running'&&!document.getElementById('vnc').src.includes('vnc')){ | |
| showVNC()} | |
| }).catch(e=>{})} | |
| function showVNC(){ | |
| const f=document.getElementById('vnc'); | |
| const p=document.getElementById('ph'); | |
| f.src=window.location.origin.replace('7860','6080')+'/vnc.html?autoconnect=true'; | |
| f.style.display='block'; | |
| p.style.display='none'} | |
| function addLog(m){ | |
| const l=document.getElementById('logs'); | |
| l.textContent+='\\n'+new Date().toLocaleTimeString()+' '+m; | |
| l.scrollTop=l.scrollHeight} | |
| setInterval(refresh,3000); | |
| refresh(); | |
| </script> | |
| </body> | |
| </html>""" | |
| 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() |