- Detección de ecoind via JSON-RPC (puerto 7474, lee ~/.ecoin/ecoin.conf) - Botones INICIAR/DETENER/ABRIR GUI/VER INFO en pestaña ECOIN - ABRIR GUI para ecoind antes de lanzar ecoin-qt (no pueden coexistir) - Compilación automática de ecoin-qt con qmake si no está compilado - Grid en tiempo real: balance ECO, bloques, conexiones, wallet, daemon - ecoin_rpc() helper para llamadas JSON-RPC al daemon - _start_ecoin/_stop_ecoin/_open_ecoin_gui/_ecoin_info en panel.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
532 lines
20 KiB
Python
Executable file
532 lines
20 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""
|
|
OASIS Control Panel v3 — WebKit2GTK
|
|
UI en HTML/CSS/JS dentro de ventana GTK nativa.
|
|
Sin interferencias del tema GTK — control visual total como Electron.
|
|
"""
|
|
|
|
import gi
|
|
gi.require_version('Gtk', '3.0')
|
|
|
|
# Intentar WebKit2 4.1 primero, luego 4.0
|
|
_wk = None
|
|
for _v in ('4.1', '4.0'):
|
|
try:
|
|
gi.require_version('WebKit2', _v)
|
|
_wk = _v
|
|
break
|
|
except ValueError:
|
|
pass
|
|
if _wk is None:
|
|
print("ERROR: WebKit2GTK no encontrado. Instala: gir1.2-webkit2-4.0")
|
|
raise SystemExit(1)
|
|
|
|
from gi.repository import Gtk, WebKit2, GLib, Gdk
|
|
|
|
import subprocess
|
|
import sys
|
|
import signal
|
|
import threading
|
|
import json
|
|
from pathlib import Path
|
|
|
|
# ── Rutas base ─────────────────────────────────────────────────────────────
|
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
|
INSTALLER_SH = SCRIPT_DIR / "installer.sh"
|
|
ECOIN_DIR = Path.home() / "ecoin"
|
|
|
|
# Estado persistente (mismo fichero que usa el installer original)
|
|
_STATE_FILE = Path.home() / ".config" / "oasis-installer" / "state"
|
|
|
|
# Candidatos donde puede estar OASIS instalado
|
|
_OASIS_CANDIDATES = [
|
|
Path.home() / "COFRE" / "CODERS" / "oasis",
|
|
Path.home() / "oasis",
|
|
Path.home() / "Projects" / "oasis",
|
|
Path.home() / "Proyectos" / "oasis",
|
|
Path.home() / "Documentos" / "oasis",
|
|
Path.home() / "Documents" / "oasis",
|
|
Path.home() / "src" / "oasis",
|
|
]
|
|
|
|
# ── Detección OASIS ────────────────────────────────────────────────────────
|
|
def _oasis_valid(d: Path) -> bool:
|
|
"""Flexible: cualquiera de estas señales indica que OASIS está ahí."""
|
|
return (
|
|
(d / "oasis.sh").is_file()
|
|
or (d / "src" / "server" / "server.js").is_file()
|
|
or (d / "src" / "server" / "node_modules").is_dir()
|
|
)
|
|
|
|
def _read_saved_dir() -> Path | None:
|
|
"""Lee la ruta guardada por el installer original."""
|
|
if _STATE_FILE.is_file():
|
|
for line in _STATE_FILE.read_text().splitlines():
|
|
if line.startswith("OASIS_DIR="):
|
|
p = Path(line[10:].strip())
|
|
if p.is_dir():
|
|
return p
|
|
return None
|
|
|
|
def find_oasis_dir() -> Path:
|
|
"""Devuelve la ruta de OASIS encontrada o ~/oasis como fallback."""
|
|
candidates = []
|
|
saved = _read_saved_dir()
|
|
if saved:
|
|
candidates.append(saved)
|
|
candidates.extend(_OASIS_CANDIDATES)
|
|
for d in candidates:
|
|
if d.is_dir() and _oasis_valid(d):
|
|
return d
|
|
return Path.home() / "oasis"
|
|
|
|
def oasis_installed() -> bool:
|
|
return _oasis_valid(find_oasis_dir())
|
|
|
|
def oasis_version() -> str:
|
|
pkg = find_oasis_dir() / "src" / "server" / "package.json"
|
|
if pkg.is_file():
|
|
try:
|
|
return json.loads(pkg.read_text()).get("version", "")
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
|
|
def oasis_running(port: int = 3000) -> bool:
|
|
"""Comprueba si hay algo escuchando en el puerto de OASIS (igual que el installer)."""
|
|
try:
|
|
r = subprocess.run(
|
|
["ss", "-lnt", f"sport = :{port}"],
|
|
capture_output=True, text=True, timeout=2
|
|
)
|
|
return str(port) in r.stdout
|
|
except Exception:
|
|
return False
|
|
|
|
# ── Detección ECOIN ────────────────────────────────────────────────────────
|
|
_ECOIN_CANDIDATES = [
|
|
Path.home() / "ecoin",
|
|
Path.home() / "COFRE" / "CODERS" / "ecoin",
|
|
Path.home() / "Projects" / "ecoin",
|
|
]
|
|
|
|
def find_ecoin_dir() -> Path:
|
|
for d in _ECOIN_CANDIDATES:
|
|
if (d / "ecoin" / "ecoin-qt").is_file() or \
|
|
(d / "ecoin" / "src" / "ecoind").is_file():
|
|
return d
|
|
return Path.home() / "ecoin"
|
|
|
|
def ecoin_installed() -> bool:
|
|
d = find_ecoin_dir()
|
|
return (d / "ecoin" / "ecoin-qt").is_file() or \
|
|
(d / "ecoin" / "src" / "ecoind").is_file()
|
|
|
|
def ecoin_wallet_exists() -> bool:
|
|
return (Path.home() / ".ecoin" / "wallet.dat").is_file()
|
|
|
|
def ecoin_rpc_config() -> dict:
|
|
"""Lee credenciales RPC de ~/.ecoin/ecoin.conf."""
|
|
conf = Path.home() / ".ecoin" / "ecoin.conf"
|
|
cfg = {"rpcuser": "", "rpcpassword": "", "rpcport": "7474", "rpchost": "127.0.0.1"}
|
|
if conf.is_file():
|
|
for line in conf.read_text().splitlines():
|
|
line = line.strip()
|
|
if line.startswith("#") or "=" not in line:
|
|
continue
|
|
k, v = line.split("=", 1)
|
|
k = k.strip()
|
|
if k in cfg:
|
|
cfg[k] = v.strip()
|
|
return cfg
|
|
|
|
def ecoin_rpc(method, params=None):
|
|
"""Llama al RPC de ecoind. Devuelve (result, error)."""
|
|
import http.client, base64 as _b64
|
|
cfg = ecoin_rpc_config()
|
|
auth = _b64.b64encode(f"{cfg['rpcuser']}:{cfg['rpcpassword']}".encode()).decode()
|
|
body = json.dumps({"method": method, "params": params or [], "id": 1}).encode()
|
|
try:
|
|
conn = http.client.HTTPConnection(cfg["rpchost"], int(cfg["rpcport"]), timeout=3)
|
|
conn.request("POST", "/", body,
|
|
{"Content-Type": "application/json",
|
|
"Authorization": f"Basic {auth}"})
|
|
resp = conn.getresponse()
|
|
data = json.loads(resp.read())
|
|
return data.get("result"), data.get("error")
|
|
except Exception as e:
|
|
return None, str(e)
|
|
|
|
def ecoin_running() -> bool:
|
|
result, _ = ecoin_rpc("getinfo")
|
|
return result is not None
|
|
|
|
def node_version() -> str:
|
|
try:
|
|
r = subprocess.run(["node", "--version"],
|
|
capture_output=True, text=True, timeout=3)
|
|
return r.stdout.strip()
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
# ── Panel principal ────────────────────────────────────────────────────────
|
|
class OasisPanel:
|
|
|
|
def __init__(self):
|
|
self._alive = True
|
|
self._oasis_proc = None # proceso OASIS activo
|
|
|
|
self.win = Gtk.Window()
|
|
self.win.set_title("SOLAR NET HUB")
|
|
self.win.set_default_size(390, 620)
|
|
self.win.set_resizable(False)
|
|
self.win.set_position(Gtk.WindowPosition.CENTER)
|
|
self.win.connect("destroy", self._on_destroy)
|
|
|
|
# Fondo negro en la ventana GTK para evitar parpadeo blanco al cargar
|
|
css = b"window { background-color: #000000; }"
|
|
provider = Gtk.CssProvider()
|
|
provider.load_from_data(css)
|
|
self.win.get_style_context().add_provider(
|
|
provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
|
)
|
|
|
|
# ── WebKit2 ───────────────────────────────────────────────────────
|
|
settings = WebKit2.Settings()
|
|
settings.set_enable_javascript(True)
|
|
settings.set_allow_file_access_from_file_urls(True)
|
|
settings.set_allow_universal_access_from_file_urls(True)
|
|
settings.set_enable_page_cache(False)
|
|
|
|
self.webview = WebKit2.WebView()
|
|
self.webview.set_settings(settings)
|
|
self.webview.set_background_color(Gdk.RGBA(0, 0, 0, 1))
|
|
|
|
# Bridge JS→Python: interceptar navegaciones a oasis://accion
|
|
self.webview.connect("decide-policy", self._on_policy)
|
|
|
|
self.win.add(self.webview)
|
|
|
|
html_file = SCRIPT_DIR / "ui" / "index.html"
|
|
self.webview.load_uri(f"file://{html_file}")
|
|
|
|
self.win.show_all()
|
|
|
|
# Poll inicial tras 1.5 s (tiempo para que cargue el HTML)
|
|
GLib.timeout_add(1500, self._initial_poll)
|
|
# Poll continuo cada 3 s
|
|
GLib.timeout_add_seconds(3, self._poll)
|
|
|
|
def _on_destroy(self, *_):
|
|
self._alive = False
|
|
Gtk.main_quit()
|
|
|
|
# ── Polling ───────────────────────────────────────────────────────────
|
|
def _initial_poll(self):
|
|
self._poll()
|
|
return False
|
|
|
|
def _poll(self):
|
|
threading.Thread(target=self._poll_thread, daemon=True).start()
|
|
return True
|
|
|
|
def _poll_thread(self):
|
|
o_dir = find_oasis_dir()
|
|
o_inst = _oasis_valid(o_dir)
|
|
o_run = oasis_running()
|
|
o_ver = oasis_version()
|
|
o_node = node_version()
|
|
|
|
e_dir = find_ecoin_dir()
|
|
e_inst = ecoin_installed()
|
|
e_wall = ecoin_wallet_exists()
|
|
e_qt = (e_dir / "ecoin" / "ecoin-qt").is_file()
|
|
e_dmn = (e_dir / "ecoin" / "src" / "ecoind").is_file()
|
|
e_run = ecoin_running()
|
|
|
|
# Info RPC solo si el daemon está corriendo
|
|
e_info = {}
|
|
e_balance = None
|
|
if e_run:
|
|
info, _ = ecoin_rpc("getinfo")
|
|
bal, _ = ecoin_rpc("getbalance")
|
|
e_info = info or {}
|
|
e_balance = bal
|
|
|
|
data = {
|
|
"oasis_installed": o_inst,
|
|
"oasis_running": o_run,
|
|
"oasis_version": o_ver,
|
|
"node_version": o_node,
|
|
"oasis_dir": str(o_dir) if o_inst else "",
|
|
"ecoin_installed": e_inst,
|
|
"ecoin_running": e_run,
|
|
"ecoin_wallet": e_wall,
|
|
"ecoin_qt": e_qt,
|
|
"ecoin_daemon": e_dmn,
|
|
"ecoin_dir": str(e_dir) if e_inst else "",
|
|
"ecoin_balance": e_balance,
|
|
"ecoin_blocks": e_info.get("blocks", None),
|
|
"ecoin_connections": e_info.get("connections", None),
|
|
}
|
|
GLib.idle_add(self._js, f"updateStatus({json.dumps(data)})")
|
|
|
|
# ── JS helpers ────────────────────────────────────────────────────────
|
|
def _js(self, code):
|
|
if self._alive:
|
|
self.webview.run_javascript(code, None, None, None)
|
|
return False
|
|
|
|
def _log(self, text):
|
|
if self._alive:
|
|
self._js(f"appendLog({json.dumps(text)})")
|
|
|
|
# ── Bridge JS→Python via navegación oasis://accion ───────────────────
|
|
def _on_policy(self, webview, decision, decision_type):
|
|
if decision_type == WebKit2.PolicyDecisionType.NAVIGATION_ACTION:
|
|
uri = decision.get_navigation_action().get_request().get_uri()
|
|
if uri.startswith("oasis://"):
|
|
action = uri[len("oasis://"):]
|
|
print(f"[bridge] action={action}", flush=True)
|
|
GLib.idle_add(self._dispatch, action)
|
|
decision.ignore()
|
|
return True
|
|
decision.use()
|
|
return False
|
|
|
|
def _dispatch(self, action):
|
|
installer_cmds = {
|
|
"oasis-install": ["--oasis-install"],
|
|
"ecoin-install": ["--ecoin-install"],
|
|
}
|
|
|
|
if action == "oasis-stop":
|
|
threading.Thread(target=self._kill_oasis, daemon=True).start()
|
|
GLib.idle_add(self._js, "switchTab('sistema')")
|
|
|
|
elif action == "oasis-start":
|
|
GLib.idle_add(self._js, "switchTab('sistema')")
|
|
threading.Thread(target=self._start_oasis, daemon=True).start()
|
|
|
|
elif action == "oasis-browser":
|
|
subprocess.Popen(["xdg-open", "http://localhost:3000"])
|
|
GLib.idle_add(self._log, "Abriendo http://localhost:3000 ...")
|
|
|
|
elif action == "ecoin-start":
|
|
GLib.idle_add(self._js, "switchTab('sistema')")
|
|
threading.Thread(target=self._start_ecoin, daemon=True).start()
|
|
|
|
elif action == "ecoin-stop":
|
|
GLib.idle_add(self._js, "switchTab('sistema')")
|
|
threading.Thread(target=self._stop_ecoin, daemon=True).start()
|
|
|
|
elif action == "ecoin-gui":
|
|
GLib.idle_add(self._js, "switchTab('sistema')")
|
|
threading.Thread(target=self._open_ecoin_gui, daemon=True).start()
|
|
|
|
elif action == "ecoin-info":
|
|
GLib.idle_add(self._js, "switchTab('sistema')")
|
|
threading.Thread(target=self._ecoin_info, daemon=True).start()
|
|
|
|
elif action in installer_cmds:
|
|
GLib.idle_add(self._js, "switchTab('sistema')")
|
|
args = installer_cmds[action]
|
|
threading.Thread(target=self._run_cmd, args=(args,), daemon=True).start()
|
|
|
|
def _run_cmd(self, args):
|
|
cmd = [str(INSTALLER_SH)] + args
|
|
GLib.idle_add(self._log, "$ " + " ".join(cmd))
|
|
try:
|
|
proc = subprocess.Popen(
|
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
text=True, bufsize=1
|
|
)
|
|
for line in iter(proc.stdout.readline, ""):
|
|
line = line.rstrip()
|
|
if line:
|
|
GLib.idle_add(self._log, line)
|
|
proc.wait()
|
|
GLib.idle_add(self._log, f"── fin (código {proc.returncode}) ──")
|
|
except Exception as e:
|
|
GLib.idle_add(self._log, f"[Error]: {e}")
|
|
GLib.idle_add(self._poll)
|
|
|
|
def _start_oasis(self):
|
|
import time
|
|
oasis_dir = find_oasis_dir()
|
|
GLib.idle_add(self._log, f"$ cd {oasis_dir} && bash oasis.sh")
|
|
try:
|
|
proc = subprocess.Popen(
|
|
["bash", "-lc", f"cd '{oasis_dir}' && bash oasis.sh"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
start_new_session=True,
|
|
)
|
|
except Exception as e:
|
|
GLib.idle_add(self._log, f"[Error]: {e}")
|
|
return
|
|
|
|
self._oasis_proc = proc
|
|
GLib.idle_add(self._log, f"PID {proc.pid} — esperando puerto 3000...")
|
|
|
|
# Leer salida del proceso y mostrarla en el log
|
|
def _read_output():
|
|
for line in iter(proc.stdout.readline, b""):
|
|
if self._alive:
|
|
GLib.idle_add(self._log, line.decode("utf-8", errors="replace").rstrip())
|
|
threading.Thread(target=_read_output, daemon=True).start()
|
|
|
|
# Esperar hasta 30s a que el puerto esté activo y abrir navegador
|
|
for i in range(30):
|
|
time.sleep(1)
|
|
if oasis_running():
|
|
GLib.idle_add(self._log, "Puerto 3000 activo — abriendo navegador")
|
|
subprocess.Popen(["xdg-open", "http://localhost:3000"])
|
|
GLib.idle_add(self._poll)
|
|
return
|
|
GLib.idle_add(self._log, "[Timeout] OASIS no levantó en 30s")
|
|
GLib.idle_add(self._poll)
|
|
|
|
# ── ECOIN actions ─────────────────────────────────────────────────────
|
|
def _start_ecoin(self):
|
|
if ecoin_running():
|
|
GLib.idle_add(self._log, "ecoind ya está corriendo.")
|
|
GLib.idle_add(self._poll)
|
|
return
|
|
e_dir = find_ecoin_dir()
|
|
ecoind = e_dir / "ecoin" / "src" / "ecoind"
|
|
if not ecoind.is_file():
|
|
GLib.idle_add(self._log, f"[Error]: ecoind no encontrado en {ecoind}")
|
|
return
|
|
GLib.idle_add(self._log, f"$ {ecoind} -daemon")
|
|
try:
|
|
subprocess.Popen(
|
|
[str(ecoind), "-daemon"],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
import time
|
|
for i in range(15):
|
|
time.sleep(1)
|
|
if ecoin_running():
|
|
GLib.idle_add(self._log, "ecoind iniciado correctamente.")
|
|
GLib.idle_add(self._poll)
|
|
return
|
|
GLib.idle_add(self._log, "[Timeout] ecoind no respondió en 15s.")
|
|
except Exception as e:
|
|
GLib.idle_add(self._log, f"[Error]: {e}")
|
|
GLib.idle_add(self._poll)
|
|
|
|
def _stop_ecoin(self):
|
|
GLib.idle_add(self._log, "Deteniendo ecoind...")
|
|
result, err = ecoin_rpc("stop")
|
|
if err:
|
|
GLib.idle_add(self._log, f"[Error RPC]: {err}")
|
|
else:
|
|
GLib.idle_add(self._log, "ecoind detenido.")
|
|
GLib.idle_add(self._poll)
|
|
|
|
def _open_ecoin_gui(self):
|
|
e_dir = find_ecoin_dir()
|
|
ecoin_qt = e_dir / "ecoin" / "ecoin-qt"
|
|
src_dir = e_dir / "ecoin"
|
|
|
|
if ecoin_qt.is_file():
|
|
# Si ecoind está corriendo, pararlo antes (no pueden coexistir)
|
|
if ecoin_running():
|
|
GLib.idle_add(self._log, "Parando ecoind antes de abrir la GUI...")
|
|
ecoin_rpc("stop")
|
|
import time
|
|
for _ in range(10):
|
|
time.sleep(1)
|
|
if not ecoin_running():
|
|
break
|
|
GLib.idle_add(self._log, f"$ {ecoin_qt}")
|
|
subprocess.Popen([str(ecoin_qt)], start_new_session=True)
|
|
GLib.idle_add(self._poll)
|
|
return
|
|
|
|
# No compilado — compilar primero
|
|
GLib.idle_add(self._log, "ecoin-qt no encontrado. Compilando...")
|
|
|
|
# Comprobar que qmake está disponible
|
|
qmake = None
|
|
for q in ("qmake", "qmake-qt5", "qmake6"):
|
|
r = subprocess.run(["which", q], capture_output=True)
|
|
if r.returncode == 0:
|
|
qmake = q
|
|
break
|
|
if not qmake:
|
|
GLib.idle_add(self._log, "[Error]: qmake no encontrado.")
|
|
GLib.idle_add(self._log, "Instala Qt5: sudo apt install qtbase5-dev qt5-qmake")
|
|
return
|
|
|
|
GLib.idle_add(self._log, f"$ cd {src_dir} && {qmake} && make -j$(nproc)")
|
|
try:
|
|
proc = subprocess.Popen(
|
|
["bash", "-c", f"cd '{src_dir}' && {qmake} && make -j$(nproc)"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
)
|
|
for line in iter(proc.stdout.readline, b""):
|
|
if self._alive:
|
|
GLib.idle_add(self._log, line.decode("utf-8", errors="replace").rstrip())
|
|
proc.wait()
|
|
except Exception as e:
|
|
GLib.idle_add(self._log, f"[Error compilando]: {e}")
|
|
return
|
|
|
|
if ecoin_qt.is_file():
|
|
GLib.idle_add(self._log, "Compilación completada. Lanzando ecoin-qt...")
|
|
subprocess.Popen([str(ecoin_qt)], start_new_session=True)
|
|
else:
|
|
GLib.idle_add(self._log, "[Error]: compilación falló. Revisa las dependencias.")
|
|
GLib.idle_add(self._log, "Dependencias necesarias:")
|
|
GLib.idle_add(self._log, " sudo apt install qtbase5-dev qt5-qmake libboost-all-dev libssl-dev libdb++-dev")
|
|
|
|
def _ecoin_info(self):
|
|
if not ecoin_running():
|
|
GLib.idle_add(self._log, "ecoind no está corriendo. Inicia el daemon primero.")
|
|
return
|
|
info, err = ecoin_rpc("getinfo")
|
|
if err:
|
|
GLib.idle_add(self._log, f"[RPC Error]: {err}")
|
|
return
|
|
bal, _ = ecoin_rpc("getbalance")
|
|
GLib.idle_add(self._log, "── ECOIN INFO ──────────────────")
|
|
GLib.idle_add(self._log, f" Balance: {bal} ECO")
|
|
GLib.idle_add(self._log, f" Bloques: {info.get('blocks')}")
|
|
GLib.idle_add(self._log, f" Conexiones: {info.get('connections')}")
|
|
GLib.idle_add(self._log, f" Versión: {info.get('version')}")
|
|
GLib.idle_add(self._log, f" Dificultad: {info.get('difficulty')}")
|
|
GLib.idle_add(self._log, "────────────────────────────────")
|
|
GLib.idle_add(self._poll)
|
|
|
|
def _kill_oasis(self):
|
|
GLib.idle_add(self._log, "Deteniendo OASIS...")
|
|
try:
|
|
import os, signal as sig
|
|
if self._oasis_proc and self._oasis_proc.poll() is None:
|
|
# Matar el grupo de procesos completo (bash -lc + node hijo)
|
|
os.killpg(os.getpgid(self._oasis_proc.pid), sig.SIGTERM)
|
|
self._oasis_proc.wait(timeout=5)
|
|
self._oasis_proc = None
|
|
GLib.idle_add(self._log, "OASIS detenido.")
|
|
else:
|
|
# Fallback: matar cualquier node backend.js corriendo
|
|
subprocess.run(["pkill", "-f", "node.*backend.js"], timeout=5)
|
|
GLib.idle_add(self._log, "OASIS detenido.")
|
|
except Exception as e:
|
|
GLib.idle_add(self._log, f"[Error al detener]: {e}")
|
|
GLib.idle_add(self._poll)
|
|
|
|
|
|
# ── Main ───────────────────────────────────────────────────────────────────
|
|
def main():
|
|
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
OasisPanel()
|
|
Gtk.main()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|