#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RemoteSys Agent —— 被控端核心（Windows / macOS / Linux）
账号 token 鉴权 + 反向隧道穿透 NAT + 屏幕采集(WebP/缩放/增量) + 键鼠注入。

依赖:  pip install mss Pillow websockets
"""
import argparse
import asyncio
import io
import json
import os
import platform
import socket
import struct
import sys
import time
import urllib.request
import uuid

try:
    import mss
    from PIL import Image, ImageChops
    import websockets
    from websockets.exceptions import InvalidStatus
except ImportError:
    print("[!] 缺少依赖，请先执行: pip install mss Pillow websockets")
    sys.exit(1)

AGENT_VER = "1.3.39"
DEFAULT_SERVER = os.environ.get("RS_SERVER", "http://remote.51hmkj.cn")

FRAME_VIDEO = 2
HDR = struct.Struct("<BIHHHHHHBI")   # type,seq,x,y,w,h,sw,sh,flags,plen
FLAG_FULL = 1
FLAG_WEBP = 4
FLAG_END = 8      # 本帧（可能含多个分块）的最后一个包，浏览器据此回 ack

TILE = 128
TOL = 12          # 差值小于该值视为噪声（动态壁纸等），不触发重传
_LUT = (bytes([0] * TOL + [255] * (256 - TOL))) * 3
# 画质预设： (缩放比, 编码质量, 帧率) —— 分辨率优先保持原生，靠质量与 diff 控带宽
PRESETS = {
    "smooth": (0.75, 50, 15),
    "std":    (1.00, 55, 18),
    "hd":     (1.00, 72, 24),
}
# 允许在途（未确认）帧数：>1 才能跑满带宽，又不至于堆积延迟
MAX_INFLIGHT = 2
# 自适应下限：避免一路降到"又糊又卡"
MIN_QUALITY = 46
MIN_SCALE = 0.75

DEBUG = os.environ.get("RS_DEBUG") == "1"
IS_WIN = platform.system() == "Windows"
IS_MAC = platform.system() == "Darwin"

# WebP 支持（Pillow 缺 libwebp 时自动回退 JPEG）
try:
    Image.init()
    HAS_WEBP = "WEBP" in Image.SAVE
except Exception:
    HAS_WEBP = False


# ------------------------------------------------------------------ 配置
def cfg_path():
    if IS_WIN:
        base = os.environ.get("APPDATA") or os.path.expanduser("~")
        d = os.path.join(base, "RemoteSys")
    else:
        d = os.path.expanduser("~/.remotesys")
    os.makedirs(d, exist_ok=True)
    return os.path.join(d, "agent.json")


def load_cfg():
    p = cfg_path()
    if os.path.exists(p):
        try:
            with open(p, encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            pass
    return {}


def save_cfg(cfg):
    with open(cfg_path(), "w", encoding="utf-8") as f:
        json.dump(cfg, f, ensure_ascii=False, indent=2)


def machine_id() -> str:
    """稳定的机器指纹：优先系统 MachineGuid / IOPlatformUUID，兜底 hostname+MAC。"""
    if IS_WIN:
        try:
            import winreg
            with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
                                r"SOFTWARE\Microsoft\Cryptography") as k:
                v, _ = winreg.QueryValueEx(k, "MachineGuid")
                return "w-" + v
        except Exception:
            pass
    if IS_MAC:
        try:
            import subprocess
            out = subprocess.run(["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
                                 capture_output=True, text=True, timeout=5).stdout
            for line in out.splitlines():
                if "IOPlatformUUID" in line:
                    return "m-" + line.split('"')[-2]
        except Exception:
            pass
    mac = uuid.getnode()
    return "d-" + uuid.uuid5(uuid.NAMESPACE_DNS, socket.gethostname() + str(mac)).hex


# ------------------------------------------------------------------ HTTP
def http_json(server, path, body=None, token=None, timeout=12):
    url = server.rstrip("/") + path
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method="POST" if body is not None else "GET")
    req.add_header("Content-Type", "application/json")
    if token:
        req.add_header("Authorization", "Bearer " + token)
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode())


def download_file(server, path, dest):
    url = server.rstrip("/") + path
    with urllib.request.urlopen(url, timeout=60) as r, open(dest, "wb") as f:
        f.write(r.read())


# ------------------------------------------------------------------ 屏幕采集
class Capturer:
    def __init__(self):
        self.sct = mss.mss()
        self.mon = self.sct.monitors[1]
        self.w = self.mon["width"] & ~1          # 真实屏幕宽
        self.h = self.mon["height"] & ~1
        self.scale = 1.0                          # 视图缩放
        self.quality = 55
        self.seq = 0
        self.prev = None                          # 上一帧（视图空间）raw bytes
        self.prev_img = None                      # 上一帧（视图空间）图像，用于 C 级差分

    def info(self):
        return {"w": self.w, "h": self.h}

    def view_size(self):
        return max(2, int(self.w * self.scale)) & ~1, max(2, int(self.h * self.scale)) & ~1

    def grab_img(self):
        """抓屏：直接用 PIL 的 BGRX 原始解码，避免多一次整屏拷贝。"""
        shot = self.sct.grab(self.mon)
        img = Image.frombuffer("RGB", (shot.width, shot.height), shot.bgra, "raw", "BGRX", 0, 1)
        cw, ch = shot.width & ~1, shot.height & ~1
        if (cw, ch) != img.size:
            img = img.crop((0, 0, cw, ch))
        return img

    def diff_frames(self, img_full, force_full=False):
        """返回 [(x,y,w,h,flags,payload), ...]，坐标为视图(缩放后)空间。"""
        if img_full.size != (self.w, self.h):
            self.w, self.h = img_full.size
            force_full = True
            self.prev = self.prev_img = None

        vw, vh = self.view_size()
        if self.scale < 0.999:
            img = img_full.resize((vw, vh), Image.BILINEAR)
        else:
            img = img_full
            vw, vh = img.size

        self.seq += 1
        raw = img.tobytes()

        # 1) 首帧 / 强制关键帧
        if force_full or self.prev is None or self.prev_img is None or len(raw) != len(self.prev):
            self.prev, self.prev_img = raw, img
            flags, payload = self._encode(img, self.quality)
            return [(0, 0, vw, vh, FLAG_FULL | FLAG_END | flags, payload)]

        # 2) 整屏字节全等 → 零成本早退（静止画面）
        if raw == self.prev:
            return []

        # 3) 带容差的差分（C 速度）：差值 < TOL 视为肉眼不可见噪声
        #    没有这一步，动态壁纸/渐变阴影的 1-2 级抖动会让整屏都被判成"变了"，
        #    于是每帧都发整帧 → 带宽打满 → 卡顿。
        mask = ImageChops.difference(img, self.prev_img).point(_LUT)
        bbox = mask.getbbox()
        self.prev, self.prev_img = raw, img
        if not bbox:
            return []

        bx0, by0, bx1, by1 = bbox
        if (bx1 - bx0) * (by1 - by0) > vw * vh * 0.55:
            rects = [(0, 0, vw, vh)]
        else:
            rects = self._dirty_bands(mask, bbox, vw, vh)
            if not rects:
                return []
            if len(rects) > 1 and sum(r[2] * r[3] for r in rects) > vw * vh * 0.55:
                rects = [(0, 0, vw, vh)]

        full = len(rects) == 1 and rects[0][2] == vw and rects[0][3] == vh
        out = []
        for i, (x, y, w, h) in enumerate(rects):
            flags, payload = self._encode(img.crop((x, y, x + w, y + h)), self.quality)
            if full:
                flags |= FLAG_FULL
            if i == len(rects) - 1:
                flags |= FLAG_END
            out.append((x, y, w, h, flags, payload))
        return out

    def _dirty_bands(self, mask, bbox, vw, vh):
        """在包围盒范围内按 128px 分块判定脏块，再把同一行的脏块合并成横条（减少编码次数）。"""
        bx0, by0, bx1, by1 = bbox
        ty0, ty1 = by0 // TILE, (by1 - 1) // TILE
        tx0, tx1 = bx0 // TILE, (bx1 - 1) // TILE
        rects = []
        for ty in range(ty0, ty1 + 1):
            y0 = ty * TILE
            th = min(TILE, vh - y0)
            cols = []
            for tx in range(tx0, tx1 + 1):
                x0 = tx * TILE
                tw = min(TILE, vw - x0)
                if mask.crop((x0, y0, x0 + tw, y0 + th)).getbbox():
                    cols.append(tx)
            if not cols:
                continue
            start = end = cols[0]
            for c in cols[1:]:
                if c - end <= 2:          # 间隔很小直接连成一条，少一次编码
                    end = c
                else:
                    rects.append(self._band_rect(start, end, y0, th, vw))
                    start = end = c
            rects.append(self._band_rect(start, end, y0, th, vw))
        return rects

    @staticmethod
    def _band_rect(tx_start, tx_end, y0, th, vw):
        x = tx_start * TILE
        return (x, y0, min(vw, (tx_end + 1) * TILE) - x, th)

    def _encode(self, img, quality):
        buf = io.BytesIO()
        if HAS_WEBP:
            # method=0：比 method=3 快 3.5 倍（实测 36ms → 10ms/1080p），体积仅略大
            img.save(buf, "WEBP", quality=quality, method=0)
            return FLAG_WEBP, buf.getvalue()
        img.save(buf, "JPEG", quality=quality, optimize=False, subsampling=2)
        return 0, buf.getvalue()


def pack_frame(seq, x, y, w, h, sw, sh, flags, payload):
    return HDR.pack(FRAME_VIDEO, seq, x, y, w, h, sw, sh, flags, len(payload)) + payload


# ------------------------------------------------------------------ 输入注入（Windows）
if IS_WIN:
    import ctypes
    from ctypes import wintypes

    user32 = ctypes.WinDLL("user32", use_last_error=True)

    INPUT_MOUSE, INPUT_KEYBOARD = 0, 1
    MOUSEEVENTF_MOVE, MOUSEEVENTF_ABSOLUTE = 0x0001, 0x8000
    MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP = 0x0002, 0x0004
    MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP = 0x0008, 0x0010
    MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP = 0x0020, 0x0040
    MOUSEEVENTF_WHEEL = 0x0800
    KEYEVENTF_KEYUP, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_UNICODE = 0x0002, 0x0001, 0x0004

    class MOUSEINPUT(ctypes.Structure):
        _fields_ = [("dx", wintypes.LONG), ("dy", wintypes.LONG),
                    ("mouseData", wintypes.DWORD), ("dwFlags", wintypes.DWORD),
                    ("time", wintypes.DWORD), ("dwExtraInfo", ctypes.c_void_p)]

    class KEYBDINPUT(ctypes.Structure):
        _fields_ = [("wVk", wintypes.WORD), ("wScan", wintypes.WORD),
                    ("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD),
                    ("dwExtraInfo", ctypes.c_void_p)]

    class HARDWAREINPUT(ctypes.Structure):
        _fields_ = [("uMsg", wintypes.DWORD), ("wParamL", wintypes.WORD), ("wParamH", wintypes.WORD)]

    class INPUT(ctypes.Structure):
        class _U(ctypes.Union):
            _fields_ = [("mi", MOUSEINPUT), ("ki", KEYBDINPUT), ("hi", HARDWAREINPUT)]
        _anonymous_ = ("u",)
        _fields_ = [("type", wintypes.DWORD), ("u", _U)]

    _send_fail_n = [0]

    def _send(inp):
        ok = user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT))
        if not ok and _send_fail_n[0] < 5:
            _send_fail_n[0] += 1
            print(f"[input] SendInput 被系统拒绝 err={ctypes.get_last_error()} "
                  f"(常见原因：目标窗口以管理员权限运行，而本程序是普通权限；"
                  f"可尝试以管理员身份重新启动客户端)")

    def enable_dpi():
        try:
            ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
        except Exception:
            try:
                ctypes.windll.shcore.SetProcessDpiAwareness(2)
            except Exception:
                pass

    class Input:
        def move(self, x, y, sw, sh):
            inp = INPUT(type=INPUT_MOUSE)
            inp.mi.dx = int(x * 65535 / max(sw - 1, 1))
            inp.mi.dy = int(y * 65535 / max(sh - 1, 1))
            inp.mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE
            _send(inp)

        def button(self, btn, down):
            flags = {0: (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
                     1: (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
                     2: (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP)}.get(btn)
            if not flags:
                return
            inp = INPUT(type=INPUT_MOUSE)
            inp.mi.dwFlags = flags[0] if down else flags[1]
            _send(inp)

        def wheel(self, delta):
            inp = INPUT(type=INPUT_MOUSE)
            inp.mi.dwFlags = MOUSEEVENTF_WHEEL
            inp.mi.mouseData = ctypes.c_ulong(delta & 0xFFFFFFFF).value
            _send(inp)

        def key(self, vk, down, extended=False):
            inp = INPUT(type=INPUT_KEYBOARD)
            inp.ki.wVk = vk
            inp.ki.dwFlags = (0 if down else KEYEVENTF_KEYUP) | (KEYEVENTF_EXTENDEDKEY if extended else 0)
            _send(inp)

        def unicode(self, ch):
            for down in (True, False):
                inp = INPUT(type=INPUT_KEYBOARD)
                inp.ki.wVk = 0
                inp.ki.wScan = ord(ch)
                inp.ki.dwFlags = KEYEVENTF_UNICODE | (0 if down else KEYEVENTF_KEYUP)
                _send(inp)
else:
    try:
        from pynput.mouse import Controller as MController, Button
        from pynput.keyboard import Controller as KController
    except ImportError:
        MController = None

    class Input:
        def __init__(self):
            self.m = MController() if MController else None
            self.k = KController() if MController else None

        def move(self, x, y, sw, sh):
            if self.m:
                self.m.position = (x, y)

        def button(self, btn, down):
            if not self.m:
                return
            b = {0: Button.left, 1: Button.middle, 2: Button.right}.get(btn, Button.left)
            (self.m.press if down else self.m.release)(b)

        def wheel(self, delta):
            if self.m:
                self.m.scroll(0, 1 if delta < 0 else -1)

        def key(self, vk, down, extended=False):
            pass

        def unicode(self, ch):
            if self.k:
                self.k.press(ch)
                self.k.release(ch)


# ------------------------------------------------------------------ 按键映射
VK = {
    "Enter": 0x0D, "Escape": 0x1B, "Backspace": 0x08, "Tab": 0x09, "Space": 0x20,
    "ArrowLeft": 0x25, "ArrowUp": 0x26, "ArrowRight": 0x27, "ArrowDown": 0x28,
    "ShiftLeft": 0xA0, "ShiftRight": 0xA1, "ControlLeft": 0xA2, "ControlRight": 0xA3,
    "AltLeft": 0xA4, "AltRight": 0xA5, "MetaLeft": 0x5B, "MetaRight": 0x5C,
    "CapsLock": 0x14, "Delete": 0x2E, "Home": 0x24, "End": 0x23,
    "PageUp": 0x21, "PageDown": 0x22, "Insert": 0x2D, "ContextMenu": 0x5D,
    "Minus": 0xBD, "Equal": 0xBB, "BracketLeft": 0xDB, "BracketRight": 0xDD,
    "Backslash": 0xDC, "Semicolon": 0xBA, "Quote": 0xDE, "Comma": 0xBC,
    "Period": 0xBE, "Slash": 0xBF, "Backquote": 0xC0, "PrintScreen": 0x2C,
    "NumpadEnter": 0x0D, "NumpadAdd": 0x6B, "NumpadSubtract": 0x6D,
    "NumpadMultiply": 0x6A, "NumpadDivide": 0x6F, "NumpadDecimal": 0x6E,
}
for _i in range(10):
    VK[f"Digit{_i}"] = 0x30 + _i
    VK[f"Numpad{_i}"] = 0x60 + _i
for _i in range(26):
    VK[f"Key{chr(65 + _i)}"] = 0x41 + _i
for _i in range(1, 13):
    VK[f"F{_i}"] = 0x6F + _i

EXTENDED = {"ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown", "Delete", "Home", "End",
            "PageUp", "PageDown", "Insert", "NumpadEnter", "NumpadDivide", "MetaLeft", "MetaRight"}


# ------------------------------------------------------------------ 剪贴板
def get_clipboard():
    try:
        if IS_WIN:
            import ctypes
            u = ctypes.windll.user32
            if not u.OpenClipboard(0):
                return ""
            try:
                h = u.GetClipboardData(13)  # CF_UNICODETEXT
                if not h:
                    return ""
                p = ctypes.windll.kernel32.GlobalLock(h)
                try:
                    return ctypes.wstring_at(p)
                finally:
                    ctypes.windll.kernel32.GlobalUnlock(h)
            finally:
                u.CloseClipboard()
        else:
            import subprocess
            return subprocess.run(["pbpaste"], capture_output=True, text=True, timeout=2).stdout
    except Exception:
        return ""


def set_clipboard(text):
    try:
        if IS_WIN:
            import ctypes
            u = ctypes.windll.user32
            k = ctypes.windll.kernel32
            if not u.OpenClipboard(0):
                return
            try:
                u.EmptyClipboard()
                data = ctypes.create_unicode_buffer(text)
                h = k.GlobalAlloc(0x0042, ctypes.sizeof(data))
                p = k.GlobalLock(h)
                ctypes.memmove(p, data, ctypes.sizeof(data))
                k.GlobalUnlock(h)
                u.SetClipboardData(13, h)
            finally:
                u.CloseClipboard()
        else:
            import subprocess
            subprocess.run(["pbcopy"], input=text, text=True, timeout=2)
    except Exception:
        pass


# ------------------------------------------------------------------ 保活 / 锁屏 / 解锁
_CAFFEINATE = None


def keep_awake():
    """抑制系统休眠与显示器关闭（间接避免自动锁屏）。

    注意：这里绝不能做阻塞等待（例如 subprocess.run 带 timeout）。macOS 的
    caffeinate 会一直挂着，等待它就会把 asyncio 事件循环堵死、画面直接卡停。
    """
    global _CAFFEINATE
    try:
        if IS_WIN:
            import ctypes
            ES_CONTINUOUS, ES_SYSTEM, ES_DISPLAY = 0x80000000, 0x00000001, 0x00000002
            ctypes.windll.kernel32.SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM | ES_DISPLAY)
        elif IS_MAC:
            import subprocess
            if _CAFFEINATE is None or _CAFFEINATE.poll() is not None:
                _CAFFEINATE = subprocess.Popen(
                    ["caffeinate", "-dims"], stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
    except Exception:
        pass


def allow_sleep():
    try:
        if IS_WIN:
            import ctypes
            ctypes.windll.kernel32.SetThreadExecutionState(0x80000000)
    except Exception:
        pass


def _logonui_running() -> bool:
    """LogonUI.exe 只在会话锁定/登录界面时存在——最可靠的锁屏信号。"""
    if not IS_WIN:
        return False
    import ctypes
    from ctypes import wintypes

    k32 = ctypes.windll.kernel32
    TH32CS_SNAPPROCESS = 0x2

    class PE32(ctypes.Structure):
        _fields_ = [("dwSize", wintypes.DWORD),
                    ("cntUsage", wintypes.DWORD),
                    ("th32ProcessID", wintypes.DWORD),
                    ("th32DefaultHeapID", ctypes.c_size_t),
                    ("th32ModuleID", wintypes.DWORD),
                    ("cntThreads", wintypes.DWORD),
                    ("th32ParentProcessID", wintypes.DWORD),
                    ("pcPriClassBase", ctypes.c_ssize_t),
                    ("dwFlags", wintypes.DWORD),
                    ("szExeFile", wintypes.WCHAR * 260)]

    snap = k32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
    if snap in (-1, 0xFFFFFFFFFFFFFFFF):
        return False
    try:
        e = PE32()
        e.dwSize = ctypes.sizeof(PE32)
        if k32.Process32FirstW(snap, ctypes.byref(e)):
            while True:
                if e.szExeFile.lower() == "logonui.exe":
                    return True
                if not k32.Process32NextW(snap, ctypes.byref(e)):
                    break
        return False
    finally:
        k32.CloseHandle(snap)


def lock_probe() -> dict:
    """逐信号采集锁屏状态（用于远程诊断与判定）。"""
    r = {"locked": False, "wts": None, "logonui": None, "desktop": None}
    if not IS_WIN:
        return r
    try:
        import ctypes
        from ctypes import wintypes

        wts = ctypes.WinDLL("wtsapi32")
        sid = ctypes.windll.kernel32.WTSGetActiveConsoleSessionId()
        if sid not in (0, 0xFFFFFFFF):
            buf = ctypes.c_void_p()
            n = wintypes.DWORD()
            if wts.WTSQuerySessionInformationW(None, sid, 25, ctypes.byref(buf),
                                               ctypes.byref(n)):
                try:
                    dw = ctypes.cast(buf, ctypes.POINTER(wintypes.DWORD))
                    r["wts"] = int(dw[3])   # WTSINFOEXW: Level/SessionId/SessionState/SessionFlags
                finally:
                    wts.WTSFreeMemory(buf)
    except Exception:
        pass
    try:
        r["logonui"] = bool(_logonui_running())
    except Exception:
        pass
    try:
        import ctypes
        u = ctypes.windll.user32
        h = u.OpenInputDesktop(0, False, 0x0001)  # DESKTOP_READOBJECTS
        if not h:
            r["desktop"] = "<无法打开>"
            r["locked"] = True
            return r
        try:
            buf = ctypes.create_unicode_buffer(256)
            need = ctypes.c_ulong(0)
            u.GetUserObjectInformationW(h, 2, buf, ctypes.sizeof(buf), ctypes.byref(need))
            r["desktop"] = buf.value
        finally:
            u.CloseDesktop(h)
    except Exception:
        r["desktop"] = "<异常>"
    # 判定：安全登录桌面 = 一定锁定；WTS 报 LOCK 且 LogonUI 在跑 = 锁定；
    # 只有单信号（WTS 或 LogonUI 其一）不采信，避免误报。
    if (r["desktop"] not in (None, "<异常>") and r["desktop"] != "Default") or r["desktop"] == "<无法打开>":
        r["locked"] = True
    elif r["wts"] is not None and r["logonui"]:
        locked_flags = (1 if platform.version().startswith("6.1") else 0)
        if r["wts"] == locked_flags:
            r["locked"] = True
    return r


def is_locked() -> bool:
    """检测本机会话是否处于锁屏。

    判定策略（lock_probe）：
    1. 输入桌面名 != Default —— 进入安全登录桌面，一定锁定；
    2. WTS SessionFlags==LOCK **且** LogonUI.exe 在跑 —— 双信号同时命中才判定
       （实测单信号会误报：WTS 在部分机器上恒报 LOCK、LogonUI 可能滞留）；
    3. 其余情况视为未锁定（宁可不弹解锁条，也不在正常使用时打扰）。
    """
    return lock_probe()["locked"]


def unlock_pipe_ready() -> bool:
    """解锁助手是否在运行（能连上管道）。"""
    if not IS_WIN:
        return False
    try:
        import ctypes
        k = ctypes.windll.kernel32
        h = k.CreateFileW(r"\\.\pipe\remotesys_unlock", 0xC0000000, 0, None, 3, 0, None)
        if h is None or h == -1 or h == 0xFFFFFFFF:
            return False
        k.CloseHandle(h)
        return True
    except Exception:
        return False


# 交给 SYSTEM 助手代发输入时用的事件标志（与助手侧 SendInput 常量一致）
_M_MOVE, _M_ABS, _M_WHEEL = 0x0001, 0x8000, 0x0800
_M_DOWN = (0x0002, 0x0008, 0x0020)          # 左 / 右 / 中键按下
_M_UP = (0x0004, 0x0010, 0x0040)            # 左 / 右 / 中键抬起
_K_KEYUP, _K_EXTENDED = 0x0002, 0x0001


def input_via_helper(events):
    """把键鼠输入交给 SYSTEM 助手，让它在安全桌面（登录界面）里代发。

    这样用户在网页上敲的密码才能真的进到 Windows 的密码框里 —— 普通用户
    进程的 SendInput 在安全桌面上会被系统直接拒绝。
    """
    return _pipe_cmd({"cmd": "input", "events": events})


def _pipe_cmd(req: dict, timeout=15.0, raw=False):
    """往 SYSTEM 解锁助手发一条管道请求并等应答（通用版）。

    raw=True 时不拆包，直接返回完整应答 dict（自检要看 info 结构）。
    """
    if not IS_WIN:
        return False, "仅 Windows"
    try:
        import ctypes
        k = ctypes.windll.kernel32
        GENERIC_RW, OPEN_EXISTING = 0xC0000000, 3
        h = k.CreateFileW(r"\\.\pipe\remotesys_unlock", GENERIC_RW, 0, None,
                          OPEN_EXISTING, 0, None)
        if (h is None or h == -1 or h == 0xFFFFFFFF) and ctypes.get_last_error() == 231:
            # 所有管道实例忙（如正在服务抓屏）：等待后重试
            k.WaitNamedPipeW(r"\\.\pipe\remotesys_unlock", 3000)
            h = k.CreateFileW(r"\\.\pipe\remotesys_unlock", GENERIC_RW, 0, None,
                              OPEN_EXISTING, 0, None)
        if h is None or h == -1 or h == 0xFFFFFFFF:
            return False, "未安装解锁助手"
        try:
            data = (json.dumps(req) + "\n").encode()
            written = ctypes.c_ulong(0)
            k.WriteFile(h, data, len(data), ctypes.byref(written), None)
            buf = ctypes.create_string_buffer(16384)
            read = ctypes.c_ulong(0)
            k.ReadFile(h, buf, 16383, ctypes.byref(read), None)
            txt = buf.raw[:read.value].decode("utf-8", "replace").strip()
            try:
                o = json.loads(txt)
                if raw:
                    return bool(o.get("ok")), o
                return bool(o.get("ok")), o.get("err") or o.get("msg") or ""
            except Exception:
                return bool(txt), txt
        finally:
            k.CloseHandle(h)
    except Exception as e:
        return False, str(e)


def unlock_via_helper(pwd: str, timeout=75.0):
    """把解锁请求交给本机 SYSTEM 助手（Pipe），由它在 Winlogon 桌面注入密码。"""
    if not IS_WIN:
        return False, "仅 Windows 支持解锁"
    ok, msg = _pipe_cmd({"cmd": "unlock", "pwd": pwd}, timeout=70.0)
    if not ok and "未安装解锁助手" in (msg or ""):
        return False, "未安装解锁助手（在客户端点『启用锁屏解锁』）"
    return ok, msg


def tap_via_helper():
    """唤醒安全桌面：往 Winlogon 敲一个键，让锁屏壁纸进入登录界面。"""
    if not IS_WIN:
        return False, "仅 Windows"
    try:
        import ctypes
        k = ctypes.windll.kernel32
        h = k.CreateFileW(r"\\.\pipe\remotesys_unlock", 0xC0000000, 0, None, 3, 0, None)
        if h is None or h == -1 or h == 0xFFFFFFFF:
            return False, "未安装解锁助手"
        try:
            data = (json.dumps({"cmd": "tap"}) + "\n").encode()
            written = ctypes.c_ulong(0)
            k.WriteFile(h, data, len(data), ctypes.byref(written), None)
            buf = ctypes.create_string_buffer(512)
            read = ctypes.c_ulong(0)
            k.ReadFile(h, buf, 511, ctypes.byref(read), None)
            txt = buf.raw[:read.value].decode("utf-8", "replace").strip()
            try:
                o = json.loads(txt)
                return bool(o.get("ok")), o.get("err") or ""
            except Exception:
                return False, txt or "无响应"
        finally:
            k.CloseHandle(h)
    except Exception as e:
        return False, str(e)


def secure_cap():
    """让 SYSTEM 助手抓安全桌面（Windows 登录界面）→ PIL Image / None。

    普通进程抓不到安全桌面（mss 只返回缓存帧），只有 SYSTEM+用户令牌
    落在 Winlogon 桌面内的子进程能 GDI 抓到 —— 详见 rs_unlock.capture_screen。
    """
    if not IS_WIN:
        return None
    try:
        import ctypes
        k = ctypes.windll.kernel32
        h = k.CreateFileW(r"\\.\pipe\remotesys_unlock", 0xC0000000, 0, None, 3, 0, None)
        if h is None or h == -1 or h == 0xFFFFFFFF:
            return None
        try:
            data = (json.dumps({"cmd": "cap"}) + "\n").encode()
            w = ctypes.c_ulong(0)
            k.WriteFile(h, data, len(data), ctypes.byref(w), None)
            buf = ctypes.create_string_buffer(1024)
            r = ctypes.c_ulong(0)
            k.ReadFile(h, buf, 1023, ctypes.byref(r), None)
            o = json.loads(buf.raw[:r.value].decode("utf-8", "replace").strip() or "{}")
            if not o.get("ok") or not o.get("path"):
                return None
            from PIL import Image
            img = Image.open(o["path"]).convert("RGB")
            img.load()
            return img
        finally:
            k.CloseHandle(h)
    except Exception:
        return None


# ------------------------------------------------------------------ 主循环
# 断线重连退避（秒）：前几次快速重试便于秒恢复，后续放慢避免打爆服务端
BACKOFF = [1, 2, 3, 5, 8, 10, 15]


class StopRetry(Exception):
    """鉴权失败/终端被移除等情况：停止重连。"""


class Agent:
    def __init__(self, server, token, mid, status_cb=None, update_cb=None, no_keep_awake=False):
        self.server = server.rstrip("/")
        self.token = token
        self.mid = mid
        self.no_keep_awake = no_keep_awake
        self.cap = Capturer()
        self.input = Input()
        self.client_online = False
        self.force_full = True
        self.secure = False            # 当前在安全登录桌面（Winlogon）
        self._cap_t = 0.0              # 上次助手抓屏时间（限流）
        self.ws = None
        self.code = ""
        self.acked = 0
        self.sent_seq = 0
        self.ack_seen = False          # 对端是否会回 ack（兼容老版本浏览器）
        self.ack_ok = True             # 是否启用窗口背压
        self.ack_grace = 0.0           # 宽限期：此期间未见 ack 也不判定对端不支持
        self.last_ack = 0.0
        self.preset = "std"
        self._apply_preset("std")
        self._cong = 0
        self._ok = 0
        self._slow_wins = 0
        self._fast_wins = 0
        self._win_bytes = 0
        self._fb_t = 0.0
        self.locked = False
        self.status_cb = status_cb or (lambda *a, **k: None)
        self.update_cb = update_cb or (lambda *a, **k: None)
        self.revoked = False

    def _apply_preset(self, p):
        self.preset = p if p in PRESETS else "std"
        self.cap.scale, self.cap.quality, self.fps = PRESETS[self.preset]
        self.quality_cur = self.cap.quality
        self.force_full = True

    def _status(self, state, detail=""):
        try:
            self.status_cb(state, detail)
        except Exception:
            pass

    def ws_url(self):
        base = self.server.replace("https://", "wss://").replace("http://", "ws://")
        return f"{base}/ws/agent?token={self.token}"

    async def run(self):
        self.retry = 0
        while True:
            try:
                self._status("connecting", self.server)
                await self._session()
            except StopRetry:
                raise
            except InvalidStatus as e:
                code = getattr(getattr(e, "response", None), "status_code", None) or \
                       getattr(e, "status_code", None)
                if code in (401, 4401):
                    self._status("unauth", "登录已过期，请重新登录")
                    raise StopRetry("token expired")
                if code in (403, 4403):
                    self._status("denied", "终端已被移除或绑定其他账号")
                    raise StopRetry("denied")
                self._status("offline", str(e)[:80])
            except Exception as e:
                self._status("offline", str(e)[:80])
                print(f"[!] 断开: {e}")
            # 指数退避：网络抖动时别把服务器打爆，稳定后（收到 welcome）计数会归零
            self.retry += 1
            wait = BACKOFF[min(self.retry - 1, len(BACKOFF) - 1)]
            print(f"[-] {wait}s 后重连（第 {self.retry} 次）…")
            await asyncio.sleep(wait)

    async def _session(self):
        async with websockets.connect(self.ws_url(), ping_interval=20, ping_timeout=20,
                                      max_size=None, open_timeout=15) as ws:
            self.ws = ws
            self.acked = 0
            self.cap.seq = 0
            await ws.send(json.dumps({
                "t": "hello",
                "mid": self.mid,
                "hostname": socket.gethostname(),
                "os": f"{platform.system()} {platform.release()}",
                "user": os.environ.get("USERNAME") or os.environ.get("USER") or "",
                "screen": self.cap.info(),
                "ver": AGENT_VER,
            }, ensure_ascii=False))
            self._status("wait_welcome", "")

            recv = asyncio.create_task(self._recv_loop(ws))
            send = asyncio.create_task(self._send_loop(ws))
            clip = asyncio.create_task(self._clip_loop(ws))
            mon = asyncio.create_task(self._monitor_loop(ws))
            done, pending = await asyncio.wait([recv, send, clip, mon],
                                               return_when=asyncio.FIRST_COMPLETED)
            for t in pending:
                t.cancel()
            if recv in done and recv.exception():
                raise recv.exception()

    async def _monitor_loop(self, ws):
        """保活 + 锁屏状态上报。"""
        while True:
            if not self.no_keep_awake:
                keep_awake()
            if self.client_online:
                lk = lock_probe()
                # 安全桌面（Winlogon，desktop 打不开）时 mss 只能抓到缓存帧，
                # 采集循环要切换到"助手抓屏"模式 —— 见 _send_loop
                self.secure = (lk.get("desktop") == "<无法打开>")
                if lk["locked"] != self.locked:
                    self.locked = lk["locked"]
                    try:
                        await ws.send(json.dumps({"t": "locked", **lk}))
                    except Exception:
                        pass
            await asyncio.sleep(1)

    async def _recv_loop(self, ws):
        async for msg in ws:
            if not isinstance(msg, str):
                continue
            try:
                o = json.loads(msg)
            except Exception:
                continue
            t = o.get("t")
            if t == "ping":                 # 服务端心跳 → 回 pong，让服务端确认这条连接还活着
                try:
                    await ws.send(json.dumps({"t": "pong"}))
                except Exception:
                    pass
                continue
            if DEBUG and t not in ("mouse", "key"):
                print(f"[recv] {t} {str(o)[:80]}")
            if t == "welcome":
                self.code = str(o.get("code") or "")
                self.retry = 0          # 稳定上线，重连计数归零
                self._status("online", self.code)
                up = o.get("update") or {}
                if up.get("outdated") or up.get("forced"):
                    self.update_cb(up)
            elif t == "denied":
                self._status("denied", o.get("reason") or "")
                raise StopRetry(o.get("reason") or "denied")
            elif t == "revoked":
                self.revoked = True
                self._status("denied", "终端已被账号删除，请重新登录")
                raise StopRetry("revoked")
            elif t == "client_join":
                self.client_online = True
                self.force_full = True
                self.ack_grace = time.time() + 3.0
                self.ack_ok = True
                self.acked = 0
                self.sent_seq = 0
                self.locked = is_locked()
                self._status("controlled", str(o.get("user") or ""))
                try:
                    await ws.send(json.dumps({"t": "locked", **lock_probe()}))
                except Exception:
                    pass
            elif t == "unlock":
                ok, msg = unlock_via_helper(str(o.get("pwd") or ""))
                try:
                    await ws.send(json.dumps({"t": "unlock_result", "ok": ok, "msg": msg},
                                             ensure_ascii=False))
                except Exception:
                    pass
            elif t == "lock":
                # 远程锁定被控端（SYSTEM 助手在目标会话里调 LockWorkStation）
                def _lock():
                    return _pipe_cmd({"cmd": "lock"})
                ok, msg = await asyncio.get_event_loop().run_in_executor(None, _lock)
                try:
                    await ws.send(json.dumps({"t": "lock_result", "ok": ok, "msg": msg},
                                             ensure_ascii=False))
                except Exception:
                    pass
            elif t == "selftest":
                # 远程体检：一次性把会话/令牌/桌面/输入链路的真相带回来
                def _st():
                    ok, o = _pipe_cmd({"cmd": "selftest"}, raw=True)
                    if isinstance(o, dict):
                        return ok, o.get("info") or o.get("err") or ""
                    return ok, str(o)
                ok, info = await asyncio.get_event_loop().run_in_executor(None, _st)
                try:
                    await ws.send(json.dumps({"t": "selftest_result", "ok": ok, "info": info},
                                             ensure_ascii=False))
                except Exception:
                    pass
            elif t == "logs":
                # 远程取助手日志（排查锁屏问题不必再让用户到机器上翻文件）
                def _lg():
                    ok, o = _pipe_cmd({"cmd": "logs"}, raw=True)
                    if isinstance(o, dict):
                        return ok, o.get("info") or o.get("err") or ""
                    return ok, str(o)
                ok, info = await asyncio.get_event_loop().run_in_executor(None, _lg)
                try:
                    await ws.send(json.dumps({"t": "logs_result", "ok": ok, "info": info},
                                             ensure_ascii=False))
                except Exception:
                    pass
            elif t == "wake":
                # 唤醒锁屏（壁纸 → Windows 登录界面）。注意：壁纸阶段（LockApp 跑在
                # Default 桌面）的三个锁定信号与解锁态完全相同，self.locked 可能是
                # False，所以这里绝不能按锁定状态过滤 —— 无条件执行，多点一次无害。
                # 优先 SYSTEM 助手（连安全桌面都能注入）；未装助手则直接 SendInput
                # （壁纸阶段在 Default 桌面，键盘/鼠标注入均可唤醒 LockApp）。
                def _wake():
                    ok, _ = tap_via_helper()
                    if not ok:
                        try:
                            self.input.key(0xA0, True)    # Shift down
                            self.input.key(0xA0, False)   # Shift up
                            self.input.move(960, 540, 1920, 1080)
                            self.input.button(0, True)    # 鼠标点击兜底
                            self.input.button(0, False)
                        except Exception:
                            pass
                await asyncio.get_event_loop().run_in_executor(None, _wake)
            elif t == "client_leave":
                self.client_online = False
                self._status("online", self.code)
            elif t == "ack":
                self.acked = max(self.acked, int(o.get("seq") or 0))
                self.ack_seen = True
                self.last_ack = time.time()
            elif t == "mouse":
                self._on_mouse(o)
            elif t == "key":
                self._on_key(o)
            elif t == "text":
                for ch in o.get("s", ""):
                    self.input.unicode(ch)
            elif t == "clip":
                set_clipboard(o.get("s", ""))
                self.last_clip = o.get("s", "")
            elif t == "ctrl":
                self._on_ctrl(o)

    def _on_ctrl(self, o):
        if o.get("preset"):
            self._apply_preset(str(o["preset"]))
            return
        if "quality" in o:
            self.cap.quality = max(20, min(90, int(o["quality"])))
            self.quality_cur = self.cap.quality
        if "fps" in o:
            self.fps = max(1, min(30, int(o["fps"])))
        if "scale" in o:
            self.cap.scale = max(0.5, min(1.0, float(o["scale"])))
            self.force_full = True

    def _abs_pos(self, x, y):
        """像素坐标 → SendInput 绝对坐标（0..65535 的归一化屏幕坐标）。"""
        return (max(0, min(65535, int(x * 65535 / max(1, self.cap.w - 1)))),
                max(0, min(65535, int(y * 65535 / max(1, self.cap.h - 1)))))

    def _mouse_events(self, o, x, y, a):
        """转成 SYSTEM 助手能直接 SendInput 的事件列表。"""
        dx, dy = self._abs_pos(x, y)
        evs = [["m", _M_MOVE | _M_ABS, dx, dy, 0]]
        b = o.get("b", 0)
        if a == "down":
            evs.append(["m", _M_DOWN[b] if 0 <= b < 3 else _M_DOWN[0], 0, 0, 0])
        elif a == "up":
            evs.append(["m", _M_UP[b] if 0 <= b < 3 else _M_UP[0], 0, 0, 0])
        elif a == "wheel":
            evs.append(["m", _M_WHEEL, 0, 0, int(o.get("d", 0))])
        return evs

    def _forward_secure(self, events):
        """锁屏/安全桌面下：普通进程的 SendInput 会被系统拦掉，
        必须交给目标会话里的 SYSTEM 助手代发（异步，不阻塞收帧循环）。"""
        if not events:
            return
        try:
            asyncio.get_event_loop().run_in_executor(
                None, lambda: input_via_helper(events))
        except Exception:
            pass

    def _on_mouse(self, o):
        # 浏览器下发的 x/y 已经是真实屏幕坐标（ready.screen 的 w/h 空间），
        # 直接用真实屏幕宽高归一化即可；旧实现除以视图缩放比，
        # 在"流畅"档(0.75)或自适应降档时鼠标会系统性偏向左上、点击落点错位。
        x, y = int(o["x"]), int(o["y"])
        a = o.get("a")
        if self.secure or self.locked:
            self._forward_secure(self._mouse_events(o, x, y, a))
            return
        if a == "move":
            self.input.move(x, y, self.cap.w, self.cap.h)
        elif a == "down":
            self.input.move(x, y, self.cap.w, self.cap.h)
            self.input.button(o.get("b", 0), True)
        elif a == "up":
            self.input.move(x, y, self.cap.w, self.cap.h)
            self.input.button(o.get("b", 0), False)
        elif a == "wheel":
            self.input.move(x, y, self.cap.w, self.cap.h)
            self.input.wheel(o.get("d", 0))

    def _on_key(self, o):
        code = o.get("code", "")
        down = bool(o.get("down"))
        if self.secure or self.locked:
            # 锁屏时按键必须走 SYSTEM 助手，否则打字根本进不了密码框
            if code in VK:
                flags = 0 if down else _K_KEYUP
                if code in EXTENDED:
                    flags |= _K_EXTENDED
                self._forward_secure([["k", VK[code], flags]])
            return
        if code in VK:
            self.input.key(VK[code], down, code in EXTENDED)
        elif o.get("k") and len(o["k"]) == 1:
            if down:
                self.input.unicode(o["k"])

    async def _send_loop(self, ws):
        loop = asyncio.get_event_loop()
        self.sent_seq = 0
        self._cong = self._ok = 0
        self._fb_t = time.time()
        while True:
            if not self.client_online:
                self.acked = self.sent_seq = 0
                self.cap.prev = self.cap.prev_img = None
                await asyncio.sleep(0.2)
                continue

            # 背压：在途未确认帧达到窗口上限就直接丢帧，绝不排队等待
            # （旧实现是「等 ack 到手再采下一帧」，帧率被锁死在 1/RTT，这是卡顿主因）
            if self.ack_ok and self.sent_seq - self.acked >= MAX_INFLIGHT:
                if not self.ack_seen and time.time() > self.ack_grace:
                    self.ack_ok = False        # 对端根本不回 ack（老版本），只能用纯速率控制
                    print("[i] 对端未回 ack，关闭窗口背压")
                else:
                    self._cong += 1
                    self._feedback()
                    await asyncio.sleep(0.004)
                    continue

            t0 = time.time()
            try:
                if self.secure:
                    # 安全桌面（Windows 登录界面）：mss 抓不到，走 SYSTEM 助手抓屏。
                    # 每次抓屏要拉起子进程（约 0.3~0.8s），限流到 ~2.5fps。
                    if time.time() - self._cap_t < 0.4:
                        await asyncio.sleep(0.05)
                        continue
                    img = await loop.run_in_executor(None, secure_cap)
                    self._cap_t = time.time()
                    if img is None:
                        # 未装助手：抓不到安全桌面，稍后重试（普通抓帧只会拿到缓存画面）
                        await asyncio.sleep(0.6)
                        continue
                    if img.size != (self.cap.w, self.cap.h):
                        img = img.resize((self.cap.w, self.cap.h), Image.BILINEAR)
                    frames = await loop.run_in_executor(
                        None, lambda: self.cap.diff_frames(img, force_full=self.force_full))
                    self.force_full = False
                else:
                    img = await loop.run_in_executor(None, self.cap.grab_img)
                    frames = await loop.run_in_executor(
                        None, lambda: self.cap.diff_frames(img, force_full=self.force_full))
                    self.force_full = False
                if frames:
                    sw, sh = self.cap.view_size()
                    seq = self.cap.seq
                    for (x, y, w, h, flags, payload) in frames:
                        await ws.send(pack_frame(seq, x, y, w, h, sw, sh, flags, payload))
                    self.sent_seq = seq
                    self._win_bytes += sum(len(p) for *_, p in frames)
                    if not self.ack_ok:
                        # 对端不回 ack：用虚拟确认把在途数压到 0，等效"每轮最多一帧"
                        self.acked = seq
                    if DEBUG:
                        nb = sum(len(p) for *_, p in frames)
                        print(f"[dbg] seq={seq} 包={len(frames)} {nb/1024:.1f}KB "
                              f"full={int(bool(frames[0][4] & FLAG_FULL))} "
                              f"scale={self.cap.scale} q={self.cap.quality} inflight={self.sent_seq-self.acked}")
                self._ok += 1
            except Exception as e:
                print(f"[!] 采集/发送异常: {e}")
                await asyncio.sleep(0.3)
                continue

            self._feedback()
            wait = max(0.0, 1.0 / self.fps - (time.time() - t0))
            if wait:
                await asyncio.sleep(wait)

    def _feedback(self):
        """每 2 秒评估一次链路状况：拥塞降质，通畅缓慢回升（避免一路降到又糊又卡）。"""
        now = time.time()
        if now - self._fb_t < 2.0:
            return
        cong, ok = self._cong, self._ok
        avg_kb = (self._win_bytes / ok / 1024) if ok else 0
        self._cong = self._ok = 0
        self._win_bytes = 0
        self._fb_t = now
        base_scale, base_q, _ = PRESETS[self.preset]

        # 拥塞信号要区分两种情况：
        #   ① 帧本身很大（真的带宽不够）→ 该降质
        #   ② 帧很小但仍撞窗口（只是 RTT/帧率到顶）→ 不该降质，否则白白糊掉
        congested = cong >= 10 and avg_kb >= 22
        # 迟滞：必须连续两个窗口都拥塞才降质，避免偶尔一次尖峰就把画质压下去
        if congested:
            self._slow_wins += 1
        else:
            self._slow_wins = max(0, self._slow_wins - 1)

        if self._slow_wins >= 2:
            self._slow_wins = 0
            self._fast_wins = 0
            if self.quality_cur > MIN_QUALITY:
                self.quality_cur = max(MIN_QUALITY, self.quality_cur - 8)
                self.cap.quality = self.quality_cur
                print(f"[i] 链路拥塞，画质降到 q={self.quality_cur}")
            elif self.cap.scale > MIN_SCALE:
                self.cap.scale = max(MIN_SCALE, round(self.cap.scale - 0.08, 2))
                self.force_full = True
                print(f"[i] 链路拥塞，分辨率降到 {int(self.cap.scale*100)}%")
        elif cong == 0 and ok >= self.fps * 0.6:
            self._fast_wins += 1
            if self._fast_wins >= 2:
                self._fast_wins = 0
                if self.quality_cur < base_q:
                    self.quality_cur = min(base_q, self.quality_cur + 4)
                    self.cap.quality = self.quality_cur
                elif self.cap.scale < base_scale:
                    self.cap.scale = min(base_scale, round(self.cap.scale + 0.05, 2))
                    self.force_full = True

    async def _clip_loop(self, ws):
        self.last_clip = ""
        while True:
            await asyncio.sleep(2)
            if not self.client_online:
                continue
            cur = get_clipboard()
            if cur and cur != self.last_clip and len(cur) < 200_000:
                self.last_clip = cur
                await ws.send(json.dumps({"t": "clip", "s": cur}, ensure_ascii=False))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--server", default=os.environ.get("RS_SERVER", DEFAULT_SERVER))
    ap.add_argument("--code", default=None, help="（仅调试）本地显示用识别码")
    args = ap.parse_args()

    if IS_WIN:
        enable_dpi()

    cfg = load_cfg()
    token = cfg.get("token")
    mid = cfg.get("mid")
    if not mid:
        mid = uuid.uuid4().hex
        cfg["mid"] = mid
        save_cfg(cfg)
    if not token:
        print("[!] 未登录：请先运行客户端并用账号登录")
        sys.exit(2)

    print("=" * 56)
    print("  RemoteSys Agent", AGENT_VER)
    print(f"  系统  : {platform.system()} {platform.release()}")
    print(f"  服务器: {args.server}")
    print("=" * 56)

    async def run():
        a = Agent(args.server, token, mid,
                  status_cb=lambda s, d: print(f"[{s}] {d}"),
                  update_cb=lambda up: print(f"[i] 发现新版本 {up.get('latest')}"))
        await a.run()

    try:
        asyncio.run(run())
    except StopRetry as e:
        print("[x] 停止重连:", e)


if __name__ == "__main__":
    main()
