androide-emu / main.py
Hugdu's picture
Create main.py
6cdc6a4 verified
Raw
History Blame Contribute Delete
13.5 kB
#!/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()