#!/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 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() 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_wallet": e_wall, "ecoin_qt": e_qt, "ecoin_daemon": e_dmn, "ecoin_dir": str(e_dir) if e_inst else "", } 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"], "ecoin-gui": ["--ecoin-gui"], "ecoin-wallet": ["--wallet-create"], "ecoin-connect": ["--wallet-connect"], } 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 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) 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()