Spaces:
Paused
Paused
| #!/usr/bin/env python3 | |
| """ | |
| Android Emulator - Hugging Face Spaces | |
| Version ultra-legere sans noVNC | |
| L'ecran est streame en screenshots | |
| """ | |
| import os | |
| import sys | |
| import subprocess | |
| import threading | |
| import time | |
| import json | |
| import shutil | |
| import http.server | |
| import socketserver | |
| import logging | |
| PORT = 7860 | |
| WORK = "/app/android" | |
| logging.basicConfig(level=logging.INFO) | |
| log = logging.getLogger("emu") | |
| LOG_BUFFER = [] | |
| class WL(logging.Handler): | |
| def emit(self, record): | |
| LOG_BUFFER.append(self.format(record)) | |
| if len(LOG_BUFFER) > 100: | |
| LOG_BUFFER.pop(0) | |
| wh = WL() | |
| wh.setFormatter(logging.Formatter("%(asctime)s %(message)s")) | |
| log.addHandler(wh) | |
| state = {"phase": "idle", "proc": None} | |
| HTML = r"""<!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} | |
| .c{max-width:900px;margin:0 auto;padding:15px} | |
| h1{text-align:center;padding:20px;font-size:1.8em; | |
| background:linear-gradient(135deg,#3ddc84,#4285f4); | |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent} | |
| .phase{text-align:center;font-size:1.2em;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} | |
| .card{background:#12122a;border:1px solid #2a2a4a; | |
| border-radius:8px;padding:12px;margin:10px 0} | |
| .card h3{color:#4285f4;margin-bottom:8px} | |
| .row{display:flex;justify-content:space-between;padding:4px 0; | |
| border-bottom:1px solid #1a1a3a;font-size:.9em} | |
| .on{color:#3ddc84}.off{color:#ff5252}.warn{color:#ffab40} | |
| .logs{background:#000;border-radius:6px;padding:10px; | |
| font-family:monospace;font-size:.75em;color:#0f0; | |
| max-height:300px;overflow-y:auto;white-space:pre-wrap} | |
| .w{background:#2d1b00;border:1px solid #ff9800; | |
| border-radius:8px;padding:12px;margin:10px 0;color:#ffcc80;font-size:.9em} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="c"> | |
| <h1>Android Emulator</h1> | |
| <div class="w"> | |
| <strong>Version proof-of-concept.</strong> | |
| HF Free = 2GB RAM, pas de KVM. | |
| Android sera tres lent a demarrer (10-30 min). | |
| </div> | |
| <div class="phase" id="phase">Chargement...</div> | |
| <div class="btns"> | |
| <button class="btn btn-go" id="bS" onclick="api('start')">Demarrer</button> | |
| <button class="btn btn-stop" id="bX" onclick="api('stop')" disabled>Arreter</button> | |
| </div> | |
| <div class="card"> | |
| <h3>Systeme</h3> | |
| <div id="sys">-</div> | |
| </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...", | |
| booting:"Demarrage Android...",running:"Android tourne!",error:"Erreur"}; | |
| function api(a){fetch('/api/'+a).then(r=>r.json()).then(d=>{ | |
| 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('bS').disabled=run||busy; | |
| document.getElementById('bX').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':'Absent')+'</span></div>'+ | |
| '<div class="row"><span>ISO</span><span class="'+(d.iso?'on':'warn')+'">'+(d.iso?'Prete':'Non')+'</span></div>'+ | |
| '<div class="row"><span>Disque</span><span class="'+(d.disk?'on':'warn')+'">'+(d.disk?'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} | |
| }).catch(e=>{})} | |
| setInterval(refresh,3000); | |
| refresh(); | |
| </script> | |
| </body> | |
| </html>""" | |
| def get_ram(): | |
| try: | |
| with open("/proc/meminfo") as f: | |
| lines = f.readlines() | |
| total = int(lines[0].split()[1]) // 1024 | |
| avail = 0 | |
| for l in lines: | |
| if l.startswith("MemAvailable:"): | |
| avail = int(l.split()[1]) // 1024 | |
| return f"{total - avail}MB / {total}MB" | |
| except: | |
| return "?" | |
| def download_iso(): | |
| iso = os.path.join(WORK, "android.iso") | |
| if os.path.exists(iso) and os.path.getsize(iso) > 1000: | |
| log.info(f"ISO deja la: {os.path.getsize(iso)//1048576}MB") | |
| return True | |
| os.makedirs(WORK, exist_ok=True) | |
| log.info("Telechargement ISO Android-x86 9.0...") | |
| url = "https://sourceforge.net/projects/android-x86/files/Release%209.0/android-x86_64-9.0-r2.iso/download" | |
| r = subprocess.run(["wget","-q","-O",iso,url], capture_output=True, text=True) | |
| if os.path.exists(iso) and os.path.getsize(iso) > 1000: | |
| log.info(f"OK: {os.path.getsize(iso)//1048576}MB") | |
| return True | |
| else: | |
| log.error("Echec telechargement") | |
| log.error(r.stderr if r.stderr else "pas d'erreur") | |
| return False | |
| def create_disk(): | |
| disk = os.path.join(WORK, "disk.qcow2") | |
| if os.path.exists(disk): | |
| return True | |
| log.info("Creation disque 4G...") | |
| subprocess.run(["qemu-img","create","-f","qcow2",disk,"4G"], check=True) | |
| return True | |
| 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"] | |
| else: | |
| cmd += ["-cpu", "qemu64", "-accel", "tcg,thread=multi"] | |
| cmd += [ | |
| "-m", "1024", | |
| "-smp", "2", | |
| "-drive", f"file={disk},format=qcow2,if=virtio", | |
| "-cdrom", iso, | |
| "-boot", "d", | |
| "-display", "none", | |
| "-net", "nic,model=virtio", | |
| "-net", "user", | |
| "-nographic", | |
| ] | |
| log.info(f"QEMU: {' '.join(cmd)}") | |
| proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) | |
| def reader(): | |
| for line in iter(proc.stdout.readline, b""): | |
| t = line.decode("utf-8", errors="replace").strip() | |
| if t: | |
| log.info(f"[QEMU] {t}") | |
| threading.Thread(target=reader, daemon=True).start() | |
| return proc | |
| def pipeline(): | |
| try: | |
| state["phase"] = "downloading" | |
| if not download_iso(): | |
| state["phase"] = "error" | |
| return | |
| create_disk() | |
| state["phase"] = "booting" | |
| state["proc"] = start_qemu() | |
| state["phase"] = "running" | |
| log.info("QEMU demarre!") | |
| log.info("Sans KVM = tres lent, patience...") | |
| except Exception as e: | |
| log.error(f"Erreur: {e}") | |
| state["phase"] = "error" | |
| def stop(): | |
| if state["proc"]: | |
| state["proc"].terminate() | |
| state["proc"] = None | |
| state["phase"] = "idle" | |
| class H(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": | |
| running = state["proc"] and state["proc"].poll() is None | |
| d = { | |
| "phase": state["phase"], | |
| "kvm": os.path.exists("/dev/kvm"), | |
| "ram": get_ram(), | |
| "qemu": shutil.which("qemu-system-x86_64") is not None, | |
| "iso": os.path.exists(os.path.join(WORK, "android.iso")), | |
| "disk": os.path.exists(os.path.join(WORK, "disk.qcow2")), | |
| "running": running, | |
| "logs": LOG_BUFFER[-50:], | |
| } | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json") | |
| self.end_headers() | |
| self.wfile.write(json.dumps(d).encode()) | |
| elif p == "/api/start": | |
| if state["phase"] in ("idle", "error"): | |
| threading.Thread(target=pipeline, daemon=True).start() | |
| m = {"ok": True, "message": "Demarrage..."} | |
| else: | |
| m = {"ok": False, "message": f"Phase: {state['phase']}"} | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json") | |
| self.end_headers() | |
| self.wfile.write(json.dumps(m).encode()) | |
| elif p == "/api/stop": | |
| stop() | |
| m = {"ok": True, "message": "Arrete"} | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json") | |
| self.end_headers() | |
| self.wfile.write(json.dumps(m).encode()) | |
| else: | |
| self.send_error(404) | |
| def log_message(self, *a): | |
| pass | |
| def main(): | |
| log.info("=" * 40) | |
| log.info(" Android Emulator - HF Spaces") | |
| log.info("=" * 40) | |
| log.info(f"KVM: {os.path.exists('/dev/kvm')}") | |
| log.info(f"QEMU: {shutil.which('qemu-system-x86_64')}") | |
| log.info(f"RAM: {get_ram()}") | |
| s = socketserver.ThreadingTCPServer(("0.0.0.0", PORT), H) | |
| s.allow_reuse_address = True | |
| log.info(f"http://0.0.0.0:{PORT}") | |
| s.serve_forever() | |
| if __name__ == "__main__": | |
| main() |