#!/usr/bin/env python3
"""RCPU GPU miner - Linux 图形启动器 / desktop launcher.

和 Windows 版的 RCPU-GPU-Miner.exe 一样，这是一个“套在矿机外面的壳”：
填钱包地址 → 选池子 → 点开始。壳本身只负责启动/停止 rcpu-gpu 子进程、
转发它的日志和状态，真正的算力全在 rcpu-gpu 里。

为什么是本地网页而不是 GTK/Qt：
  * 纯 Python 标准库，任何发行版都能跑，不用装 python3-tk / PyGObject；
  * 矿机经常没装桌面，这时可以 ssh -L 4069:127.0.0.1:4069 从自己电脑打开；
  * 界面和 Windows 版一致：钱包 / 池子 / 开始 / 停止 / 检查显卡 / 日志。

用法:
    rcpu-gui                 # 打开浏览器界面
    rcpu-gui --no-browser    # 只起服务（服务器 / 无桌面环境）
    rcpu-gui --port 4069
"""
import argparse
import collections
import json
import os
import shlex
import shutil
import socket
import subprocess
import sys
import threading
import time
import urllib.request
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse

APP = "rcpu-gui"
VERSION = "1.0.1"
DEFAULT_POOLS = {
    "pplns": ("207.57.129.188", 8888),
    "solo": ("207.57.129.188", 8889),
}
CONFIG_DIR = os.path.join(os.environ.get("XDG_CONFIG_HOME") or
                          os.path.join(os.path.expanduser("~"), ".config"), "rcpu-gpu")
CONFIG_PATH = os.path.join(CONFIG_DIR, "launcher.json")


# ---------------------------------------------------------------- 进程管理

class Miner:
    """包着 rcpu-gpu 子进程：启动、停止、收集日志、读它的 /api/stats。"""

    def __init__(self, binary):
        self.binary = binary
        self.proc = None
        self.lock = threading.Lock()
        self.log = collections.deque(maxlen=3000)
        self.log_seq = 0
        self.started_at = 0.0
        self.args = []
        self.api_port = 4068
        self.restart = False
        self.stopping = False
        self._reader = None
        self._watchdog = None

    # -- 状态 ------------------------------------------------------------
    def running(self):
        return self.proc is not None and self.proc.poll() is None

    def stats(self):
        if not self.running():
            return None
        try:
            with urllib.request.urlopen("http://127.0.0.1:%d/api/stats" % self.api_port,
                                        timeout=0.6) as r:
                return json.loads(r.read().decode())
        except Exception:
            return None

    # -- 日志 ------------------------------------------------------------
    def _pump(self):
        proc = self.proc
        if proc is None or proc.stdout is None:
            return
        for raw in proc.stdout:
            line = raw.rstrip("\n")
            with self.lock:
                self.log_seq += 1
                self.log.append((self.log_seq, line))
        proc.wait()

    def tail(self, since=0):
        with self.lock:
            return [{"seq": s, "line": line} for s, line in self.log if s > since]

    # -- 控制 ------------------------------------------------------------
    def start(self, args, api_port):
        if self.running():
            return False, "已经在运行"
        if not os.path.exists(self.binary):
            return False, "找不到矿机程序 %s" % self.binary
        self.api_port = api_port
        self.args = list(args)
        self.stopping = False
        env = dict(os.environ)
        env["PYTHONUNBUFFERED"] = "1"
        try:
            self.proc = subprocess.Popen(
                [self.binary] + self.args,
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                stdin=subprocess.DEVNULL, text=True, bufsize=1, env=env)
        except Exception as exc:                      # noqa: BLE001
            self.proc = None
            return False, "启动失败: %s" % exc
        self.started_at = time.time()
        self._reader = threading.Thread(target=self._pump, daemon=True)
        self._reader.start()
        time.sleep(0.4)
        if not self.running():
            code = self.proc.returncode if self.proc else -1
            self.proc = None
            return False, "启动后立刻退出（代码 %s），看日志" % code
        return True, "已启动"

    def stop(self, timeout=6.0):
        if not self.running():
            self.proc = None
            return False, "没有在运行"
        self.stopping = True
        proc = self.proc
        proc.terminate()
        try:
            proc.wait(timeout=timeout)
        except subprocess.TimeoutExpired:
            proc.kill()
        self.proc = None
        return True, "已停止"

    def check_gpus(self):
        """调 --list-devices，返回设备名列表。"""
        try:
            out = subprocess.run([self.binary, "--list-devices"], capture_output=True,
                                 text=True, timeout=20)
        except Exception as exc:                      # noqa: BLE001
            return []
        devices = []
        for line in (out.stdout or "").splitlines():
            line = line.strip()
            if line.startswith("device "):
                devices.append(line.split(":", 1)[-1].strip())
        return devices


# ------------------------------------------------------------------ 配置

def load_config():
    cfg = {"wallet": "", "pool": "pplns", "lang": "zh", "devices": "", "max_temp": 78,
           "custom": ""}
    try:
        with open(CONFIG_PATH, encoding="utf-8") as f:
            cfg.update(json.load(f))
    except Exception:                                 # noqa: BLE001
        pass
    return cfg


def save_config(cfg):
    try:
        os.makedirs(CONFIG_DIR, exist_ok=True)
        with open(CONFIG_PATH, "w", encoding="utf-8") as f:
            json.dump(cfg, f, ensure_ascii=False, indent=2)
    except Exception as exc:                          # noqa: BLE001
        print("warning: cannot save config: %s" % exc, file=sys.stderr)


# -------------------------------------------------------------------- 页面

PAGE = r"""<!doctype html>
<html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>RCPU 显卡矿机</title>
<style>
:root{--bg:#0b0e12;--card:#151a21;--line:#242c36;--txt:#d7dee8;--dim:#8b97a6;
      --gold:#f0b90b;--green:#2ecc71;--red:#e74c3c}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--txt);
     font:14px/1.55 -apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
.wrap{max-width:760px;margin:0 auto;padding:28px 20px 40px}
header{display:flex;align-items:center;gap:12px;margin-bottom:18px}
.logo{width:38px;height:38px;border-radius:10px;background:linear-gradient(145deg,#f0b90b,#c98a00);
      display:grid;place-items:center;font-weight:700;color:#151a21;font-size:17px}
h1{font-size:17px;margin:0}
.sub{color:var(--dim);font-size:12px}
.pill{margin-left:auto;font-size:12px;padding:4px 10px;border-radius:999px;
      border:1px solid var(--line);color:var(--dim)}
.pill.on{color:#9be7b4;border-color:#24583a;background:#12241a}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px;margin-bottom:14px}
label{display:block;font-size:12px;color:var(--dim);margin-bottom:6px}
input,select{width:100%;background:#0d1117;border:1px solid var(--line);color:var(--txt);
             border-radius:8px;padding:10px 12px;font:inherit;outline:none}
input:focus,select:focus{border-color:var(--gold)}
.row{display:grid;grid-template-columns:1fr 220px;gap:12px}
.row3{display:grid;grid-template-columns:1fr 1fr 150px;gap:12px}
.btns{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}
button{border:0;border-radius:9px;padding:11px 20px;font:inherit;font-weight:600;cursor:pointer}
button:disabled{opacity:.45;cursor:default}
.primary{background:var(--gold);color:#10151b}
.primary:hover:not(:disabled){background:#ffc91f}
.ghost{background:#1c232c;color:var(--txt);border:1px solid var(--line)}
.ghost:hover:not(:disabled){border-color:#3a4654}
.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin:4px 0 0}
.stat{background:#10151b;border:1px solid var(--line);border-radius:10px;padding:10px 12px}
.stat b{display:block;font-size:17px;font-variant-numeric:tabular-nums;margin-top:2px}
.stat span{font-size:11px;color:var(--dim)}
#log{height:340px;overflow:auto;background:#070a0d;border:1px solid var(--line);border-radius:10px;
     padding:10px 12px;font:12px/1.5 ui-monospace,Consolas,"SF Mono",monospace;color:#a9b6c5;
     white-space:pre-wrap;word-break:break-all}
#log .ok{color:#79d99b}#log .bad{color:#ef8f8f}#log .dim{color:#5d6b7a}
.lang{margin-left:auto;display:flex;gap:6px}
.lang button{padding:5px 10px;font-size:12px;background:#1c232c;color:var(--dim);
             border:1px solid var(--line)}
.lang button.active{color:var(--gold);border-color:#4a3c10}
.hint{font-size:12px;color:var(--dim);margin-top:8px}
a{color:var(--gold)}
</style></head><body><div class="wrap">
<header>
  <div class="logo">R</div>
  <div><h1 data-i18n="title">RCPU 显卡矿机</h1>
       <div class="sub">Linux · Etchash · NVIDIA / AMD</div></div>
  <div class="pill" id="pill" data-i18n="idle">未运行</div>
</header>

<div class="card">
  <label data-i18n="wallet_label">钱包地址（rcpu1 开头）</label>
  <input id="wallet" spellcheck="false" placeholder="rcpu1q...">
  <div class="row" style="margin-top:12px">
    <div>
      <label data-i18n="pool_label">挖什么</label>
      <select id="pool">
        <option value="pplns" data-i18n="pool_pplns">Etchash PPLNS（8888，0.5% 费）</option>
        <option value="solo" data-i18n="pool_solo">Etchash SOLO（8889，0.5% 费）</option>
        <option value="custom" data-i18n="pool_custom">自定义矿池</option>
      </select>
      <input id="custom" spellcheck="false" placeholder="host:port"
             style="margin-top:8px;display:none">
    </div>
    <div>
      <label data-i18n="gpu_label">显卡</label>
      <select id="devices"><option value="" data-i18n="gpu_all">全部显卡</option></select>
    </div>
  </div>
  <div class="btns">
    <button class="primary" id="start" data-i18n="start">开始挖矿</button>
    <button class="ghost" id="stop" data-i18n="stop" disabled>停止</button>
    <button class="ghost" id="check" data-i18n="check">检查显卡</button>
    <div class="lang"><button data-lang="zh">中文</button><button data-lang="en">English</button></div>
  </div>
  <div class="hint" data-i18n="hint">第一次用先点“检查显卡”，能看到型号说明驱动正常。</div>
</div>

<div class="card">
  <div class="stats">
    <div class="stat"><span data-i18n="s_hash">总算力</span><b id="s_hash">-</b></div>
    <div class="stat"><span data-i18n="s_temp">温度</span><b id="s_temp">-</b></div>
    <div class="stat"><span data-i18n="s_acc">已接受</span><b id="s_acc">0</b></div>
    <div class="stat"><span data-i18n="s_rej">被拒绝</span><b id="s_rej">0</b></div>
    <div class="stat"><span data-i18n="s_up">运行时长</span><b id="s_up">-</b></div>
  </div>
</div>

<div class="card">
  <label data-i18n="log_label">运行日志</label>
  <div id="log"></div>
  <div class="hint"><span data-i18n="status_page">详细状态页</span>:
    <a id="apilink" href="#" target="_blank">http://127.0.0.1:4068/</a></div>
</div>
</div>

<script>
const T = {
 zh:{title:"RCPU 显卡矿机",idle:"未运行",running:"运行中",starting:"启动中",stopping:"停止中",
     wallet_label:"钱包地址（rcpu1 开头）",pool_label:"挖什么",
     pool_pplns:"Etchash PPLNS（8888，0.5% 费）",pool_solo:"Etchash SOLO（8889，0.5% 费）",pool_custom:"自定义矿池",
     gpu_label:"显卡",gpu_all:"全部显卡",start:"开始挖矿",stop:"停止",check:"检查显卡",
     hint:"第一次用先点“检查显卡”，能看到型号说明驱动正常。",
     s_hash:"总算力",s_temp:"温度",s_acc:"已接受",s_rej:"被拒绝",s_up:"运行时长",
     log_label:"运行日志",status_page:"详细状态页",
     need_wallet:"请先填钱包地址",no_gpu:"没找到 OpenCL 设备，先确认显卡驱动",
     gpu_found:"找到 %n 个显卡设备"},
 en:{title:"RCPU GPU Miner",idle:"idle",running:"mining",starting:"starting",stopping:"stopping",
     wallet_label:"Wallet address (starts with rcpu1)",pool_label:"What to mine",
     pool_pplns:"Etchash PPLNS (port 8888, 0.5% fee)",pool_solo:"Etchash SOLO (port 8889, 0.5% fee)",pool_custom:"Custom pool",
     gpu_label:"GPUs",gpu_all:"All GPUs",start:"Start mining",stop:"Stop",check:"Check GPU",
     hint:"Click “Check GPU” first: seeing a model name means the driver is fine.",
     s_hash:"Hashrate",s_temp:"Temp",s_acc:"Accepted",s_rej:"Rejected",s_up:"Uptime",
     log_label:"Log",status_page:"Details",
     need_wallet:"Enter your wallet address first",no_gpu:"No OpenCL device found, check the driver",
     gpu_found:"%n GPU device(s) found"}
};
let lang = localStorage.getItem("rcpu_lang") || "zh";
let cfg = null, running = false, lastSeq = 0, timer = null;

const $ = id => document.getElementById(id);
function t(k){ return (T[lang] && T[lang][k]) || k; }
function applyLang(){
  document.querySelectorAll("[data-i18n]").forEach(el=>{
    const s = t(el.dataset.i18n); if (s) el.textContent = s;
  });
  document.querySelectorAll("[data-lang]").forEach(b=>
    b.classList.toggle("active", b.dataset.lang === lang));
}
function fmtHash(h){
  if (h >= 1e6) return (h/1e6).toFixed(2)+" MH/s";
  if (h >= 1e3) return (h/1e3).toFixed(1)+" kH/s";
  return Math.round(h)+" H/s";
}
function fmtUp(s){ const m=Math.floor(s/60), h=Math.floor(m/60);
  return h ? h+"h "+(m%60)+"m" : m+"m "+(s%60)+"s"; }
function addLog(items){
  const box = $("log");
  let stick = box.scrollTop + box.clientHeight >= box.scrollHeight - 30;
  for (const it of items){
    lastSeq = Math.max(lastSeq, it.seq);
    const d = document.createElement("div");
    const l = it.line;
    if (/accepted|ACCEPT/i.test(l)) d.className="ok";
    else if (/reject|error|fail|FAIL/i.test(l)) d.className="bad";
    else if (/debug/i.test(l)) d.className="dim";
    d.textContent = l;
    box.appendChild(d);
  }
  while (box.childElementCount > 800) box.removeChild(box.firstChild);
  if (stick) box.scrollTop = box.scrollHeight;
}
async function call(path, body){
  const r = await fetch(path, {method: body===undefined?"GET":"POST",
    headers:{"Content-Type":"application/json"},
    body: body===undefined?undefined:JSON.stringify(body)});
  return r.json();
}
async function loadConfig(){
  cfg = await call("/api/config");
  $("wallet").value = cfg.wallet || "";
  $("pool").value = cfg.pool || "pplns";
  $("custom").value = cfg.custom || "";
  $("custom").style.display = $("pool").value === "custom" ? "block" : "none";
  lang = cfg.lang || lang; applyLang();
}
async function refresh(){
  const st = await call("/api/state");
  running = st.running;
  const pill = $("pill");
  pill.textContent = running ? t("running") : t("idle");
  pill.classList.toggle("on", running);
  $("start").disabled = running;
  $("stop").disabled = !running;
  $("wallet").disabled = running;
  $("pool").disabled = running;
  $("devices").disabled = running;
  const s = st.stats;
  if (s){
    $("s_hash").textContent = fmtHash(s.total_hashrate || 0);
    const temps = (s.gpus||[]).map(g=>g.temp).filter(x=>x>=0);
    $("s_temp").textContent = temps.length ? Math.max(...temps)+" °C" : "-";
    $("s_acc").textContent = s.accepted ?? 0;
    $("s_rej").textContent = s.rejected ?? 0;
    $("s_up").textContent = fmtUp(Math.round(s.uptime||0));
  } else if (!running){
    $("s_hash").textContent="-"; $("s_temp").textContent="-";
    $("s_up").textContent="-";
  }
  const logs = await call("/api/log?since="+lastSeq);
  addLog(logs.lines || []);
}
async function start(){
  const wallet = $("wallet").value.trim();
  if (!wallet){ alert(t("need_wallet")); return; }
  const res = await call("/api/start", {wallet, pool:$("pool").value,
                                        custom:$("custom").value,
                                        devices:$("devices").value, lang});
  if (!res.ok) alert(res.message);
  lastSeq = 0; $("log").innerHTML = "";
  refresh();
}
async function stop(){ await call("/api/stop", {}); refresh(); }
async function check(){
  const res = await call("/api/check", {});
  lastSeq = 0; $("log").innerHTML = "";
  if (!res.devices || !res.devices.length){
    addLog([{seq:1, line:t("no_gpu")}]); lastSeq = 1; return;
  }
  const sel = $("devices"), cur = sel.value;
  sel.innerHTML = '<option value="">'+t("gpu_all")+'</option>';
  res.devices.forEach((d,i)=>{
    const o = document.createElement("option"); o.value = String(i); o.textContent = d;
    sel.appendChild(o);
  });
  sel.value = cur;
  addLog(res.devices.map((d,i)=>({seq:i+1, line:"["+(i+1)+"] "+d})).concat(
    [{seq:res.devices.length+1, line:t("gpu_found").replace("%n", res.devices.length)}]));
  lastSeq = res.devices.length + 1;
}
$("pool").onchange = ()=>{ $("custom").style.display =
  $("pool").value === "custom" ? "block" : "none"; };
$("start").onclick = start; $("stop").onclick = stop; $("check").onclick = check;
document.querySelectorAll("[data-lang]").forEach(b=>b.onclick=async ()=>{
  lang = b.dataset.lang; localStorage.setItem("rcpu_lang", lang); applyLang();
  await call("/api/config", {lang});
});
loadConfig().then(()=>{ refresh(); timer = setInterval(refresh, 1000); });
</script></body></html>
"""


# ------------------------------------------------------------------ HTTP

class Handler(BaseHTTPRequestHandler):
    miner = None
    cfg = None
    api_port = 4068
    server_version = "rcpu-gui/" + VERSION

    def log_message(self, fmt, *args):                # 安静点，别刷屏
        pass

    def _send(self, code, body, ctype="application/json"):
        data = body.encode("utf-8") if isinstance(body, str) else body
        self.send_response(code)
        self.send_header("Content-Type", ctype + "; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(data)

    def _json(self, obj, code=200):
        self._send(code, json.dumps(obj, ensure_ascii=False))

    def do_GET(self):                                 # noqa: N802
        path = urlparse(self.path).path
        if path == "/":
            self._send(200, PAGE, "text/html")
            return
        if path == "/api/config":
            self._json(self.cfg)
            return
        if path == "/api/state":
            self._json({"running": self.miner.running(),
                        "stats": self.miner.stats(),
                        "args": " ".join(shlex.quote(a) for a in self.miner.args)})
            return
        if path == "/api/log":
            since = 0
            if "since=" in self.path:
                try:
                    since = int(self.path.split("since=")[1].split("&")[0])
                except ValueError:
                    since = 0
            self._json({"lines": self.miner.tail(since)})
            return
        self._send(404, "not found", "text/plain")

    def do_POST(self):                                # noqa: N802
        path = urlparse(self.path).path
        try:
            length = int(self.headers.get("Content-Length") or 0)
            body = json.loads(self.rfile.read(length) or b"{}")
        except Exception:                             # noqa: BLE001
            body = {}

        if path == "/api/start":
            ok, msg = self._start(body)
            self._json({"ok": ok, "message": msg})
            return
        if path == "/api/stop":
            ok, msg = self.miner.stop()
            self._json({"ok": ok, "message": msg})
            return
        if path == "/api/check":
            self._json({"devices": self.miner.check_gpus()})
            return
        if path == "/api/config":
            for key in ("wallet", "pool", "lang", "devices", "custom"):
                if key in body:
                    self.cfg[key] = body[key]
            save_config(self.cfg)
            self._json({"ok": True})
            return
        self._send(404, "not found", "text/plain")

    def _start(self, body):
        wallet = (body.get("wallet") or self.cfg.get("wallet") or "").strip()
        pool = body.get("pool") or self.cfg.get("pool") or "pplns"
        devices = body.get("devices") or ""
        if not wallet:
            return False, "需要钱包地址"
        if not wallet.lower().startswith("rcpu1"):
            # 不拦，只提醒：可能是测试地址
            pass
        if pool == "custom":
            spec = (body.get("custom") or self.cfg.get("custom") or "").strip()
            if ":" not in spec:
                return False, "自定义矿池要写成 host:port"
            host, port_s = spec.rsplit(":", 1)
            try:
                port = int(port_s)
            except ValueError:
                return False, "端口不是数字"
            self.cfg["custom"] = spec
        else:
            host, port = DEFAULT_POOLS.get(pool, DEFAULT_POOLS["pplns"])
        args = ["--user", wallet, "--pool", "%s:%d" % (host, port),
                "--api-port", str(self.api_port)]
        if devices:
            args += ["--devices", devices]
        extra = os.environ.get("RCPU_EXTRA_ARGS", "")
        if extra:
            args += shlex.split(extra)
        self.cfg.update({"wallet": wallet, "pool": pool, "devices": devices})
        save_config(self.cfg)
        ok, msg = self.miner.start(args, self.api_port)
        if ok:
            msg = "已启动 %s:%d" % (host, port)
        return ok, msg


def free_port(start):
    for port in range(start, start + 40):
        with socket.socket() as s:
            try:
                s.bind(("127.0.0.1", port))
                return port
            except OSError:
                continue
    return start


def find_binary(explicit):
    if explicit:
        return explicit
    here = os.path.dirname(os.path.abspath(__file__))
    for cand in (os.path.join(here, "rcpu-gpu"),
                 os.path.join(here, "..", "rcpu-gpu"),
                 shutil.which("rcpu-gpu") or "",
                 "/usr/local/bin/rcpu-gpu"):
        if cand and os.path.exists(cand):
            return os.path.abspath(cand)
    return "rcpu-gpu"


def main():
    ap = argparse.ArgumentParser(description="RCPU GPU miner Linux launcher")
    ap.add_argument("--port", type=int, default=4069, help="界面端口 (默认 4069)")
    ap.add_argument("--miner-api-port", type=int, default=4068, help="矿机状态接口端口")
    ap.add_argument("--bin", default=None, help="rcpu-gpu 路径")
    ap.add_argument("--no-browser", action="store_true", help="不自动打开浏览器")
    ap.add_argument("--version", action="version", version=APP + " " + VERSION)
    args = ap.parse_args()

    binary = find_binary(args.bin)
    if not (os.path.exists(binary) and os.access(binary, os.X_OK)):
        print("找不到可执行的 rcpu-gpu（用 --bin 指定，或先 make install）", file=sys.stderr)
        return 2

    cfg = load_config()
    Handler.miner = Miner(binary)
    Handler.cfg = cfg
    Handler.api_port = args.miner_api_port

    port = args.port or free_port(4069)
    httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
    url = "http://127.0.0.1:%d/" % port
    print("%s %s — 界面: %s" % (APP, VERSION, url))
    print("矿机: %s" % binary)
    if not args.no_browser:
        threading.Timer(0.6, lambda: webbrowser.open(url)).start()
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        Handler.miner.stop()
        httpd.server_close()
    return 0


if __name__ == "__main__":
    sys.exit(main())
