#!/usr/bin/env python3
"""被控端螢幕擷取 agent。

連到 dongle 的 wss://<host>/ws/agent，dongle 要一張（{"op":"frame"}）才拍一張送回：
網頁開啟並連上 WebSocket 時 dongle 才開始要畫面，網頁每收完一張再要下一張，
網頁關閉後 dongle 送 {"op":"stop"}，這裡就停止擷取。

只傳有變動的區域（見 DeltaEncoder），點擊、打字後很快就看得到結果。

網頁可以暫停擷取（pause）：暫停期間不抓畫面、不回應畫面要求；目標電腦鎖定時偵測並通知網頁（is_locked）。

擷取方式：
  mss     Windows / macOS / X11
  portal  GNOME Wayland（xdg-desktop-portal ScreenCast + PipeWire + gst-launch-1.0）；
          第一次會跳出「分享螢幕」對話框，同意後記住授權
"""
from __future__ import annotations

import argparse
import asyncio
import base64
import contextlib
import io
import json
import os
import plistlib
import hashlib
import queue
import secrets
import select
import shutil
import signal
import socket
import ssl
import struct
import subprocess
import sys
import threading
import time
import types
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import websockets
from PIL import Image

MAGIC = b"KVMT"
PROTO_VERSION = 2  # 協定版本（shared/protocol.json 的 version；tests/host/test_protocol.py 對照）：連線時告訴 dongle，receiver 依它決定能不能用某個功能
H264_MAGIC = b"KVMH"  # H.264 的一個存取單元（一張畫面）：標頭同 KVMT（長條數欄位 = 是否關鍵影格、格式欄位 = 2），後面接 Annex B 的資料
FRAME_MAX = 480 * 1024  # 單張圖片上限
FORMATS = {"jpeg": 0, "webp": 1}  # 網頁可隨時切換；代碼寫在訊息標頭
TILE = 64  # 比對格子（像素）
FULL_RATIO = 0.6  # 變動面積超過此比例就送整張
STILL_IDLE = 0.3  # H.264 串流中畫面靜止這麼久（秒），就補一張 JPEG 4:4:4 的清晰畫面（見 MssCapture._h264_loop）
STILL_QUALITY = 95  # 清晰畫面與小範圍更新的 JPEG 品質（>= 80 用 4:4:4，文字邊緣不暈色）
SMALL_CHANGE = 0.15  # 清晰畫面之後，變動面積不超過此比例就改送 JPEG 差異（終端機、打字、游標閃爍），超過才回到 H.264（捲動、影片）
POLL = 0.04  # 畫面沒變時的輪詢間隔（秒）
CONSENT_NOTICE = ("Waiting for approval: click Allow in the screen-share dialog on the target PC "
                  "(it is not captured until someone approves)")
PAUSED_NOTICE = "Capture paused: the target screen is not being captured"
MAX_CREDIT = 4  # 排隊中的畫面要求上限（含視窗內的在途要求）；網頁的 WebRTC 視窗（kvm-rtc.js 的 WINDOW_MAX）不能超過它
QUALITY_MIN = 55  # 動態降品質的下限（再低文字就難讀了）
QUALITY_DOWN_BACKLOG = 96 * 1024  # DataChannel 送出佇列（要送出下一張時還沒送完的量）超過這個就降品質：傳輸跟不上，別再把大張的畫面塞進佇列
QUALITY_UP_BACKLOG = 24 * 1024  # 低於這個才慢慢升回使用者設定的品質
REFINE_DELAY = 0.3  # 畫面停止變動這麼久，若剛才送的是降品質的，就以原本的品質整張補一次
H264_FPS = 30
H264_BUFFER_MAX = 1024 * 1024  # H.264 的 DataChannel 送出佇列超過這個（約一秒的量）就丟畫面，等下一個關鍵影格再接上
H264_KEY_INT = 60  # 關鍵影格間隔（張）：丟畫面後最多等這麼久才接得回來
RTC_DRAIN_RECHECK = 0.5  # rtc_drain 等 bufferedamountlow 的保底逾時（秒）
RTC_BUFFER_MAX = 128 * 1024  # DataChannel 還沒送出的位元組超過這個就先不抓下一張：網頁的視窗再大，慢的連線上也不會排出好幾秒的延遲
CONFIG_DIR = Path.home() / ".config" / "esp32s3geek_kvm"
# 安裝腳本裝的位置（Linux：install_agent_service.sh；Windows：install_agent.bat）：只有這裡的複本會自動更新、也只有這裡的複本會被 receiver 更新
INSTALL_DIR = (Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "geek-kvm-agent" if sys.platform == "win32"
               else Path.home() / ".local" / "share" / "geek-kvm-agent")


def adapt_quality(current: int, base: int, backlog: int) -> int:
    """依 DataChannel 的送出佇列調整 JPEG／WebP 品質：佇列堆起來（傳輸比編碼慢）就一次降 10，下限 QUALITY_MIN（但不會比使用者設定的更低）；
    佇列幾乎空了才一次升 5，最多回到 base（使用者在網頁上設的品質）。兩者之間維持不變，避免來回震盪。"""
    if backlog > QUALITY_DOWN_BACKLOG:
        return max(min(QUALITY_MIN, base), current - 10)
    if backlog < QUALITY_UP_BACKLOG:
        return min(base, current + 5)
    return current


def h264_kbps(quality: int) -> int:
    """網頁的品質（10..95）→ H.264 位元率（kbps）：95 約 8 Mbps，50 約 4.8 Mbps。"""
    return 1000 + 75 * quality


_AUD = b"\x00\x00\x00\x01\x09"  # Access Unit Delimiter：x264 的 aud=1 讓每張畫面都以它開頭，靠它在位元組串流裡切出一張一張


def split_aus(buf: bytes) -> tuple:
    """Annex B 位元組串流 → (已完整的存取單元清單, 剩下還不完整的尾巴)。最後一張要等到下一個 AUD 出現才算完整。"""
    starts, i = [], buf.find(_AUD)
    while i >= 0:
        starts.append(i)
        i = buf.find(_AUD, i + 1)
    if not starts:
        return [], buf[-4:]  # 還沒看到 AUD：只留尾巴，免得半個起始碼被丟掉
    return [buf[a:b] for a, b in zip(starts, starts[1:], strict=False)], buf[starts[-1]:]


def is_key_au(au: bytes) -> bool:
    """這個存取單元有 IDR 切片（NAL 類型 5）：解碼器可以從這裡開始。"""
    i = au.find(b"\x00\x00\x01")
    while 0 <= i < len(au) - 3:
        if au[i + 3] & 31 == 5:
            return True
        i = au.find(b"\x00\x00\x01", i + 3)
    return False


# 硬體優先的候選（每個平台只列有機會的）：名稱 → (送進去的像素格式, 選項)。每一個都要通過 probe 才會用，所以選項寫錯、驅動不支援、沒有顯示卡都只是退回下一個，
# 最後一定是 libx264（軟體）。共通的需求：無 B 影格、低延遲、每張前面有 AUD、關鍵影格前面有 SPS/PPS（缺的由 h264_normalize 補）。
H264_ENCODERS = {
    "h264_nvenc": ("bgr0", {"preset": "p1", "tune": "ll", "zerolatency": "1", "delay": "0", "rc": "cbr", "aud": "1", "profile": "baseline", "forced-idr": "1"}),
    "h264_qsv": ("nv12", {"preset": "veryfast", "look_ahead": "0", "aud": "1"}),
    "h264_amf": ("nv12", {"usage": "ultralowlatency", "rc": "cbr", "profile": "constrained_baseline"}),
    "h264_videotoolbox": ("nv12", {"realtime": "1", "allow_sw": "0", "profile": "baseline"}),
    "libx264": ("yuv420p", {"tune": "zerolatency", "preset": "ultrafast", "profile": "baseline",
                            "x264-params": f"aud=1:repeat-headers=1:keyint={H264_KEY_INT}:min-keyint={H264_KEY_INT}:scenecut=0"}),
}
H264_HARDWARE_ORDER = {"win32": ["h264_nvenc", "h264_qsv", "h264_amf"], "darwin": ["h264_videotoolbox"], "linux": ["h264_nvenc", "h264_qsv"]}
H264_CHOICES = {"software": "libx264", "nvenc": "h264_nvenc", "qsv": "h264_qsv", "amf": "h264_amf", "videotoolbox": "h264_videotoolbox"}
_H264_AUD = b"\x00\x00\x00\x01\x09\xf0"
_h264_checked: dict = {}  # (編碼器, 輸出大小) → probe 結果：每個組合只試一次（NVENC 的同時連線數有限，不能每次重起都開一個來試）


def h264_nal_types(au: bytes) -> list:
    """Annex B 資料裡依序出現的 NAL 單元類型（7 = SPS、8 = PPS、5 = IDR、9 = AUD）。"""
    out, i = [], au.find(b"\x00\x00\x01")
    while 0 <= i < len(au) - 3:
        out.append(au[i + 3] & 31)
        i = au.find(b"\x00\x00\x01", i + 3)
    return out


def h264_normalize(au: bytes, key: bool, headers: bytes) -> bytes:
    """讓任何編碼器的輸出長得和 libx264 一樣：以 AUD 開頭（split_aus 靠它切張）、關鍵影格帶 SPS/PPS（網頁從 SPS 取 codec 字串）。
    headers = 編碼器的 extradata（Annex B 的 SPS+PPS）。已經符合的原樣回傳。"""
    types = h264_nal_types(au)
    if key and 7 not in types and headers:
        au = headers + au
    if not types or h264_nal_types(au)[0] != 9:
        au = _H264_AUD + au
    return au


def pyav_h264_ok() -> bool:
    """PyAV（內含 libx264）與 numpy 都裝了：Windows／macOS／X11 的 agent 就能自己編碼 H.264（Wayland 走 gst，見 PortalCapture）。"""
    try:
        import av
        import numpy  # noqa: F401

        return "libx264" in av.codecs_available
    except ImportError:
        return False


class H264Encoder:
    """PyAV 的 H.264 編碼：BGRA 畫面 → （縮放）→ 編碼器要的像素格式 → H.264。encode 回傳這張編出來的存取單元清單（已 h264_normalize）。
    name 是 H264_ENCODERS 的鍵；NVENC 吃 BGR0，色彩轉換交給顯示卡，CPU 不必做 swscale（沒縮放時）。"""

    def __init__(self, src: tuple, out: tuple, kbps: int, name: str = "libx264"):
        import av
        from fractions import Fraction

        self.src, self.out, self.n, self.name = src, out, 0, name
        self.pix_fmt, options = H264_ENCODERS[name]
        c = self.ctx = av.CodecContext.create(name, "w")
        c.width, c.height, c.pix_fmt = out[0], out[1], self.pix_fmt
        c.time_base, c.framerate = Fraction(1, H264_FPS), Fraction(H264_FPS, 1)
        c.bit_rate, c.gop_size, c.max_b_frames = kbps * 1000, H264_KEY_INT, 0
        c.options = dict(options)
        c.open()
        extra = bytes(c.extradata or b"")
        self.headers = extra if extra.startswith((b"\x00\x00\x01", b"\x00\x00\x00\x01")) else b""  # avcC 格式的不能直接用

    def encode(self, bgra: bytes) -> list:
        import av
        import numpy as np

        w, h = self.src
        frame = av.VideoFrame.from_ndarray(np.frombuffer(bgra, np.uint8).reshape(h, w, 4), format="bgra")
        frame = frame.reformat(width=self.out[0], height=self.out[1], format=self.pix_fmt)
        frame.pts, self.n = self.n, self.n + 1
        return [h264_normalize(bytes(p), p.is_keyframe, self.headers) for p in self.ctx.encode(frame)]


def h264_probe(name: str, src: tuple, out: tuple, kbps: int) -> bool:
    """這個編碼器在這台電腦上真的能用嗎：開起來、編十幾張會動的畫面，檢查輸出有 AUD、第一張是帶 SPS/PPS 的 IDR，而且 PyAV 的解碼器解得出正確大小的影像。
    任何例外或不符都回 False（驅動不支援、沒有顯示卡、選項不被接受、輸出格式不對）。"""
    try:
        import av
        import numpy as np

        enc = H264Encoder(src, out, kbps, name)
        w, h = src
        base = np.zeros((h, w, 4), np.uint8)
        aus = []
        for i in range(12):
            base[:, : max(1, (i + 1) * w // 16)] = (i * 20) & 255  # 逐格變大的色塊：每張都不同
            aus += enc.encode(base.tobytes())
        dec = av.CodecContext.create("h264", "r")
        frames = [f for au in aus for f in dec.decode(av.Packet(au))]
        types = h264_nal_types(aus[0]) if aus else []
        return len(aus) >= 6 and {5, 7, 8} <= set(types) and types[0] == 9 and bool(frames) and (frames[0].width, frames[0].height) == tuple(out)
    except Exception as e:
        print(f"H.264 encoder {name} is not usable here: {e!r}", flush=True)
        return False


def open_h264_encoder(src: tuple, out: tuple, kbps: int, prefer: str = "auto") -> H264Encoder:
    """依序試硬體編碼器（見 H264_HARDWARE_ORDER），第一個通過 h264_probe 的就用，都不行就用 libx264。
    prefer：auto、software（只用 libx264）或 H264_CHOICES 的鍵（指定一個，不行也退回 libx264）。"""
    if prefer == "auto":
        names = list(H264_HARDWARE_ORDER.get("linux" if sys.platform.startswith("linux") else sys.platform, []))
    else:
        names = [H264_CHOICES[prefer]] if prefer != "software" else []
    try:
        import av

        names = [n for n in names if n in av.codecs_available]
    except ImportError:
        names = []
    for name in [*names, "libx264"]:
        key = (name, tuple(out))
        if name != "libx264":
            if key not in _h264_checked:  # setdefault(key, probe(...)) 會每次都先跑 probe；這裡只在第一次跑
                _h264_checked[key] = h264_probe(name, src, out, kbps)
            if not _h264_checked[key]:
                continue
        try:
            enc = H264Encoder(src, out, kbps, name)
        except Exception as e:  # probe 過了卻開不起來（例如 NVENC 同時連線數已滿）：換下一個
            print(f"H.264 encoder {name} could not be opened: {e!r}", flush=True)
            _h264_checked[key] = False
            continue
        print(f"H.264 encoder: {name}" + ("" if name == "libx264" else " (hardware)"), flush=True)
        return enc
    raise RuntimeError("no H.264 encoder could be opened")


# ---- GStreamer（GNOME Wayland）的 H.264 編碼器：同樣硬體優先、通過 probe 才用、最後是 x264enc ----
GST_H264_CAPS = "video/x-h264,stream-format=byte-stream,alignment=au,profile=baseline"
_gst_checked: dict = {}


def gst_h264_candidates(kbps: int) -> list:
    """[(元素名, [元素名, 屬性…])]，硬體優先。屬性名稱依各外掛的文件；寫錯或版本不同只會讓 probe 失敗、退回下一個。"""
    nv = ["rc-mode=cbr", "zerolatency=true", f"gop-size={H264_KEY_INT}", "aud=true", "repeat-sequence-header=true"]
    return [
        ("nvh264enc", ["nvh264enc", f"bitrate={kbps}", *nv]),
        ("nvautogpuh264enc", ["nvautogpuh264enc", f"bitrate={kbps}", *nv]),
        ("vah264enc", ["vah264enc", f"bitrate={kbps}", "rate-control=cbr", f"key-int-max={H264_KEY_INT}", "b-frames=0", "aud=true"]),
        ("x264enc", ["x264enc", "tune=zerolatency", "speed-preset=ultrafast", f"bitrate={kbps}", f"key-int-max={H264_KEY_INT}", "bframes=0",
                     "option-string=aud=1:repeat-headers=1"]),
    ]


def gst_has_element(name: str) -> bool:
    try:
        return subprocess.run(["gst-inspect-1.0", name], capture_output=True, timeout=10).returncode == 0
    except (OSError, subprocess.SubprocessError):
        return False


def gst_probe(element: list, size: tuple) -> bool:
    """跑一小段測試畫面經這個編碼器，輸出要切得出 3 張以上、以 AUD 開頭、第一張有 SPS：確定整條管線（含 baseline 的限制）真的可用。"""
    w, h = size
    try:
        r = subprocess.run(["gst-launch-1.0", "-q", "videotestsrc", "num-buffers=20", "!", f"video/x-raw,width={w},height={h},framerate={H264_FPS}/1", "!",
                            "videoconvert", "!", f"video/x-raw,format=I420,width={w},height={h}", "!", *element, "!", GST_H264_CAPS, "!", "fdsink", "fd=1"],
                           capture_output=True, timeout=20)
    except (OSError, subprocess.SubprocessError):
        return False
    aus, _ = split_aus(r.stdout)
    return r.returncode == 0 and len(aus) >= 3 and aus[0].startswith(_AUD) and 7 in h264_nal_types(aus[0])


def pick_gst_h264(kbps: int, size: tuple, prefer: str = "auto", has=gst_has_element, probe=gst_probe) -> list:
    """用哪個 gst 編碼器：prefer=software 只用 x264enc；其他依 gst_h264_candidates 的順序，元素存在而且 probe 通過的第一個（結果依大小記住）。"""
    for name, element in gst_h264_candidates(kbps):
        if name != "x264enc" and (prefer == "software" or not has(name)):
            continue
        key = (name, tuple(size))
        if name != "x264enc":
            if key not in _gst_checked:
                _gst_checked[key] = probe(element, size)
            if not _gst_checked[key]:
                continue
        print(f"H.264 encoder: gst {name}" + ("" if name == "x264enc" else " (hardware)"), flush=True)
        return element
    return gst_h264_candidates(kbps)[-1][1]


def encode_image(img: Image.Image, quality: int, fmt: str) -> bytes:
    while True:
        buf = io.BytesIO()
        if fmt == "webp":
            img.save(buf, "WEBP", quality=quality, method=2)  # 實測 method 2 最划算：約比 JPEG 小 30%，編碼約 30ms
        else:
            img.save(buf, "JPEG", quality=quality, subsampling=0 if quality >= 80 else 2)  # 高品質用 4:4:4（文字邊緣不暈色）；降品質時改 4:2:0，再小約三成
        if buf.tell() <= FRAME_MAX or quality <= 20:
            return buf.getvalue()
        quality -= 15


class DeltaEncoder:
    """與上一張已送出的畫面逐格比對，只把變動區域編成圖片（JPEG 或 WebP）。

    每一列格子（TILE 高）取最左到最右的變動範圍包成一條長條，免得每格一張圖的檔頭負擔。
    訊息：b"KVMT" + 8 int32（擷取區 left/top/w/h、整個桌面 left/top/w/h）
          + 4 int32（畫面寬、高、長條數、格式 0=JPEG 1=WebP）+ 每條 5 int32（x, y, w, h, 長度）與圖片。
    """

    def __init__(self, quality: int):
        self.quality, self.fmt = quality, "jpeg"
        self.base_quality = quality  # 使用者設的品質；quality 是目前實際用的（動態降品質時比它低）
        self.degraded_sent = False  # 剛才送出的內容是降品質的：畫面停下來要補一次高品質的
        self.last_change = 0.0
        self.reset()

    def reset(self):
        self.prev = self.size = None  # 下一張送整張

    def _dirty_strips(self, cur: bytes, w: int, h: int) -> list:
        if cur == self.prev:  # 畫面完全沒變（靜止時最常見）：一次記憶體比對就結束，不必逐列逐格
            return []
        stride, strips = w * 3, []
        for ty in range(0, h, TILE):
            y1 = min(ty + TILE, h)
            lo = hi = None
            for y in range(ty, y1):
                a, b = cur[y * stride:(y + 1) * stride], self.prev[y * stride:(y + 1) * stride]
                if a == b:
                    continue
                for tx in range(0, w, TILE):
                    tx1 = min(tx + TILE, w)
                    if a[tx * 3:tx1 * 3] != b[tx * 3:tx1 * 3]:
                        lo = tx if lo is None else min(lo, tx)
                        hi = tx1 if hi is None else max(hi, tx1)
            if lo is not None:
                strips.append((lo, ty, hi, y1))
        return strips

    def encode(self, rgb: bytes, size: tuple, mon: tuple, desktop: tuple) -> bytes | None:
        """回傳要送的訊息；畫面沒變回 None。"""
        w, h = size
        if self.prev is None or self.size != size:
            strips = [(0, 0, w, h)]
        else:
            strips = self._dirty_strips(rgb, w, h)
            if not strips:
                return None
            if sum((x1 - x0) * (y1 - y0) for x0, y0, x1, y1 in strips) > FULL_RATIO * w * h:
                strips = [(0, 0, w, h)]
        self.last_strips, self.last_full = len(strips), strips == [(0, 0, w, h)]  # 效能統計用
        img = Image.frombuffer("RGB", size, rgb, "raw", "RGB", 0, 1)
        parts = []
        for x0, y0, x1, y1 in strips:
            data = encode_image(img.crop((x0, y0, x1, y1)), self.quality, self.fmt)
            parts.append(struct.pack("<5i", x0, y0, x1 - x0, y1 - y0, len(data)) + data)
        self.prev, self.size = rgb, size
        if self.quality < self.base_quality:
            self.degraded_sent = True
        return MAGIC + struct.pack("<12i", *mon, *desktop, w, h, len(parts), FORMATS[self.fmt]) + b"".join(parts)

    def changed_area(self, rgb: bytes, w: int, h: int) -> int:
        """和上一張已送出的畫面比，變動長條的總面積（像素）；沒有上一張就是整張。不編碼。"""
        if self.prev is None or self.size != (w, h):
            return w * h
        return sum((x1 - x0) * (y1 - y0) for x0, y0, x1, y1 in self._dirty_strips(rgb, w, h))

    def heartbeat(self, mon: tuple, desktop: tuple) -> bytes:
        """畫面靜止時的空更新，讓網頁知道仍在線。"""
        return MAGIC + struct.pack("<12i", *mon, *desktop, *self.size, 0, FORMATS[self.fmt])


HEARTBEAT_LEN = len(MAGIC) + 12 * 4  # 畫面靜止時的空更新（沒有任何長條）：只有標頭


class PerfWindow:
    """一段時間內每張畫面各階段的耗時平均（毫秒），用來找延遲出在哪一段。add() 每送出一張呼叫一次，snapshot() 取平均並歸零。
    grab/enc/age：抓取、比對加壓縮、畫面在管線裡放的時間（capture_update）；idle：等畫面變動的輪詢時間（畫面靜止時很大，不是延遲）；
    drain：DataChannel 送出佇列太長而等待的時間；send：送出；kb：每張大小；buf：送出後 DataChannel 還沒送完的 KB；
    strips：長條數；full：整張（不是只送變動）的比例；q：目前的品質（動態降品質後會比設定低）。"""

    FIELDS = ("grab", "enc", "age", "idle", "drain", "send", "kb", "buf", "strips", "q")

    def __init__(self):
        self.reset(time.monotonic())

    def reset(self, now: float):
        self.t0, self.n, self.full = now, 0, 0
        self.sum = dict.fromkeys(self.FIELDS, 0.0)

    def add(self, full: bool = False, **m: float):
        self.n += 1
        self.full += bool(full)
        for k, v in m.items():
            self.sum[k] += v

    def snapshot(self, now: float, cpu: float) -> dict | None:
        if self.n == 0:
            return None
        out = {"fps": round(self.n / max(now - self.t0, 1e-6), 1), "full": round(100 * self.full / self.n), "cpu": round(cpu)}
        out.update({k: round(v / self.n, 1) for k, v in self.sum.items()})
        return out


class FrameStream:
    """一個畫面接收端的送畫面狀態：dongle（網頁、遠端網頁經它轉）或網頁直連的 WebRTC DataChannel。
    差異編碼只送變動區域，每個接收端都得收到完整連續的更新，所以各自有編碼器；
    畫面要求與排隊的額度也各算各的，兩邊同時看才不會互相搶畫面或漏掉更新。"""

    def __init__(self, quality: int):
        self.enc = DeltaEncoder(quality)
        self.task = None
        self.credit = 0  # 已有一張在做時，排隊等著的畫面要求數（讓兩張同時在途）
        self.frames = 0  # 累計送出的畫面張數（有內容的更新，不含心跳）：算 fps 回報給 dongle


class MssSource:
    """mss：螢幕清單與抓圖（Windows 是 GDI BitBlt，4K 上慢又吃 CPU；macOS／X11 夠用）。grab 回有 bgra、size、width、height 的物件。"""

    def __init__(self):
        import mss

        self.sct = mss.MSS() if hasattr(mss, "MSS") else mss.mss()  # mss 10 起改名
        self.monitors = self.sct.monitors

    def grab(self, m: dict, index: int):
        return self.sct.grab(m)

    def close(self):
        self.sct.close()


class DxgiSource(MssSource):
    """Windows 的 DXGI Desktop Duplication（dxcam）：比 GDI 快好幾倍、CPU 低很多，而且畫面沒變時根本不給新的一張（回 None，我們就回上一張）。
    螢幕的位置與大小仍向 mss 問（只在列清單時用）；「全部螢幕」（index 0）dxcam 不支援，那一種仍走 mss。沒有游標，與 mss 一樣。"""

    def __init__(self):
        import dxcam  # 沒裝就丟 ImportError，由 open_source 退回 mss

        super().__init__()
        self.dxcam, self.cams, self.last = dxcam, {}, {}

    def grab(self, m: dict, index: int):
        if not 1 <= index < len(self.monitors):
            return super().grab(m, index)
        cam = self.cams.get(index)
        if cam is None:
            cam = self.cams[index] = self.dxcam.create(output_idx=index - 1, output_color="BGRA")
        arr = cam.grab()
        deadline = time.monotonic() + 0.5
        while arr is None and index not in self.last and time.monotonic() < deadline:  # 第一張可能要等一下才有
            time.sleep(0.01)
            arr = cam.grab()
        if arr is not None:
            h, w = arr.shape[:2]
            self.last[index] = types.SimpleNamespace(bgra=arr.tobytes(), size=(w, h), width=w, height=h)
        elif index not in self.last:
            return super().grab(m, index)  # 取不到就用 GDI 那一張，不讓畫面空著
        return self.last[index]

    def close(self):
        for cam in self.cams.values():
            try:
                cam.release()
            except Exception:
                pass
        self.cams.clear()
        super().close()


def open_source(dxgi: bool):
    """要 DXGI 就試 DXGI（沒裝 dxcam、或這台電腦起不來都退回 mss，印一行原因）。"""
    if dxgi:
        try:
            return DxgiSource()
        except Exception as e:
            print(f"DXGI capture unavailable ({e!r}); using mss (GDI)", flush=True)
    return MssSource()


class MssCapture:
    """Windows / macOS / X11。mss 物件不可跨執行緒，所以在擷取執行緒內延遲建立。dxgi=True 時在 Windows 用 Desktop Duplication 抓圖（見 DxgiSource）。"""

    def __init__(self, monitor: int, max_width: int, dxgi: bool = False):
        self.monitor, self.max_width, self.dxgi = monitor, max_width, dxgi
        self.sct = None
        self.h264 = None  # (kbps, on_au)：設了就在 start() 起一條執行緒抓畫面、用 PyAV 編碼 H.264，on_au(au, key, geom, size) 在那條執行緒呼叫（同 PortalCapture）
        self.h264_encoder = "auto"  # auto＝有硬體編碼器就用（NVENC、QSV…，通過 probe 才算），software＝只用 libx264；其他見 H264_CHOICES
        self.on_still = None  # 選用：on_still(msg) 收 JPEG 格式（KVMT）的清晰畫面／小範圍更新，與 H.264 的畫面走同一條通道；沒設就只送 H.264
        self.still_lost = False  # 對方來不及收而丟掉了一則 on_still：JPEG 差異的基準不一致了，下一張要重來
        self._h264_thread = self._h264_stop = None

    def start(self):
        if self.h264 is not None:
            self._h264_stop = threading.Event()
            self._h264_thread = threading.Thread(target=self._h264_loop, args=(*self.h264, self._h264_stop), daemon=True)
            self._h264_thread.start()
            return
        self.sct = open_source(self.dxgi)

    def stop(self):
        if self._h264_thread is not None:
            self._h264_stop.set()
            self._h264_thread.join(5)
            self._h264_thread = None
            return
        if self.sct:
            self.sct.close()
            self.sct = None

    def close(self):
        self.stop()

    def h264_alive(self) -> bool:
        return self._h264_thread is not None and self._h264_thread.is_alive()

    def _rgb(self, raw, out: tuple) -> bytes:
        img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
        return (img if raw.size == out else img.resize(out, Image.LANCZOS)).tobytes()

    def _h264_loop(self, kbps: int, on_au, stop: threading.Event):
        """抓畫面 → 送出。mss 物件要在使用它的執行緒內建立。出任何錯就結束這條執行緒：h264_alive() 變 False，agent 會重起，連續失敗就退回 JPEG。

        兩種畫面混著用，因為 H.264 在細字上會糊（4:2:0、有位元率上限），而 JPEG 4:4:4 的差異更新對文字很清楚、小範圍更新也很小：
          · 大範圍在變（捲動、影片、開視窗）→ H.264（blurry）。
          · 畫面靜止 STILL_IDLE 秒 → 補一張 JPEG 4:4:4 的整張清晰畫面（sharp）；之後變動面積不超過 SMALL_CHANGE → 只送 JPEG 的變動區域，
            所以終端機打字、游標閃爍都維持清晰；變動太大才回到 H.264。
          · 靜止時每秒送一張心跳（sharp 時是空的 JPEG 更新，blurry 時是一張很小的 H.264）；畫面沒變不編碼，一次記憶體比對就跳過。
        沒有 on_still（沒人收 JPEG）就只送 H.264。"""
        sct = open_source(self.dxgi)
        enc, prev, period = None, None, 1 / H264_FPS
        last_out = last_change = 0.0
        delta = DeltaEncoder(STILL_QUALITY)  # 清晰畫面的差異基準：sharp 時等於瀏覽器上看到的
        sharp, pending = False, False  # pending：送過 H.264，靜止後要補清晰畫面
        try:
            while not stop.is_set():
                t0 = time.monotonic()
                if self.still_lost:
                    self.still_lost, sharp = False, False
                    delta.reset()
                    pending = self.on_still is not None
                mons = sct.monitors
                index = self.monitor if 0 <= self.monitor < len(mons) else 1
                m, v = mons[index], mons[0]
                raw = sct.grab(m, index)
                data = raw.bgra
                geom = (m["left"], m["top"], m["width"], m["height"], v["left"], v["top"], v["width"], v["height"])
                changed = data != prev
                if enc is None or enc.src != raw.size:
                    ow = min(raw.width, self.max_width) // 2 * 2
                    enc = open_h264_encoder(raw.size, (ow, round(raw.height * ow / raw.width) // 2 * 2), kbps, self.h264_encoder)
                    delta.reset()
                    sharp, changed = False, True
                w, h = enc.out
                if changed:
                    prev, last_change = data, t0
                    if sharp and self.on_still is not None:
                        rgb = self._rgb(raw, enc.out)
                        if delta.changed_area(rgb, w, h) <= SMALL_CHANGE * w * h:  # 小範圍：只送變動的 JPEG
                            msg = delta.encode(rgb, enc.out, geom[:4], geom[4:])
                            if msg:
                                self.on_still(msg)
                                last_out = time.monotonic()
                            stop.wait(max(0.0, period - (time.monotonic() - t0)))
                            continue
                        sharp = False
                    for au in enc.encode(data):
                        last_out = time.monotonic()
                        on_au(au, is_key_au(au), geom, enc.out)
                    pending = self.on_still is not None
                elif pending and t0 - last_change >= STILL_IDLE:  # 靜止了：補清晰畫面
                    delta.reset()
                    self.on_still(delta.encode(self._rgb(raw, enc.out), enc.out, geom[:4], geom[4:]))
                    sharp, pending, last_out = True, False, time.monotonic()
                elif t0 - last_out >= 1.0:  # 心跳：網頁才知道還在線
                    if sharp:
                        self.on_still(delta.heartbeat(geom[:4], geom[4:]))
                        last_out = time.monotonic()
                    else:
                        for au in enc.encode(data):
                            last_out = time.monotonic()
                            on_au(au, is_key_au(au), geom, enc.out)
                stop.wait(max(0.0, period - (time.monotonic() - t0)))
        except Exception as e:
            print(f"H.264 capture stopped: {e!r}", flush=True)
        finally:
            sct.close()


    def monitors(self) -> list:
        """[{index, x, y, w, h}]；index 0 為所有螢幕合併。"""
        sct = self.sct
        if sct is None:  # H.264 模式：抓畫面的執行緒自己有一個來源，這裡借一個短暫的（只用螢幕清單，不必 DXGI）
            sct = MssSource()
        try:
            return [{"index": i, "x": m["left"], "y": m["top"], "w": m["width"], "h": m["height"]} for i, m in enumerate(sct.monitors)]
        finally:
            if sct is not self.sct:
                sct.close()

    def configure(self, max_width: int | None = None, monitor: int | None = None):
        if max_width is not None:
            self.max_width = max_width
        if monitor is not None:
            self.monitor = monitor  # 可能在擷取開始前收到，範圍在 grab 時檢查

    def grab(self) -> tuple:
        """回傳 (RGB bytes, (w, h), 擷取區, 整個桌面)。"""
        mons = self.sct.monitors
        index = self.monitor if 0 <= self.monitor < len(mons) else 1
        m, v = mons[index], mons[0]
        raw = self.sct.grab(m, index)
        img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
        if img.width > self.max_width:
            img = img.resize((self.max_width, round(img.height * self.max_width / img.width)), Image.LANCZOS)
        return (img.tobytes(), img.size, (m["left"], m["top"], m["width"], m["height"]),
                (v["left"], v["top"], v["width"], v["height"]))


def _portal(iface: str):
    from jeepney import DBusAddress

    return DBusAddress("/org/freedesktop/portal/desktop", bus_name="org.freedesktop.portal.Desktop",
                       interface=f"org.freedesktop.portal.{iface}")


class PortalCapture:
    """GNOME Wayland：xdg-desktop-portal 的 RemoteDesktop session，同時提供
    螢幕串流（ScreenCast → PipeWire → gst-launch 縮放後以原始 RGB 從 stdout 讀出）與剪貼簿。
    背景程式在 Wayland 拿不到焦點，wl-copy 會卡住，剪貼簿只能經 portal 寫入。
    只擷取一個螢幕；多螢幕時滑鼠絕對座標只對應這個螢幕。

    session 在 agent 執行期間保留；網頁關閉時只停 gst，不再擷取。
    輸入一律走 dongle 的 USB HID，portal 要求至少選一種裝置才選鍵盤，實際不使用。"""

    TOKEN_FILE = CONFIG_DIR / "portal_remote_desktop_token"

    def __init__(self, max_width: int):
        self.max_width = max_width
        self.router = self.session = self.gst = None
        self.cond = threading.Condition()
        self.latest_t = 0.0  # 最後一張畫面進來的時間
        self.latest = self.clip_text = None
        self.clipboard = False
        self.h264_encoder = "auto"  # 同 MssCapture.h264_encoder
        self.on_frame = None  # 選用：每收到一張新畫面就呼叫（讀取執行緒）；agent 靠它在畫面沒變時睡著，不必每 40 ms 抓一次來比對
        self.h264 = None  # (kbps, on_au)：設了就在目標電腦上編碼 H.264（優先硬體，見 pick_gst_h264）送出，不走下面的 RGB + JPEG；on_au(au, key, geom, size) 在讀取執行緒呼叫

    def _call(self, iface: str, method: str, sig: str, *args):
        from jeepney import MessageType, new_method_call

        reply = self.router.send_and_get_reply(new_method_call(_portal(iface), method, sig, args), timeout=10)
        if reply.header.message_type == MessageType.error:  # 例如 session 已被 GNOME 結束
            raise RuntimeError(f"portal {method} failed: {reply.body[0] if reply.body else 'error'}")
        return reply

    def _request(self, iface: str, method: str, sig: str, *args) -> dict:
        """呼叫 portal 的 Request 型方法，等 Response 訊號回來。"""
        from jeepney import MatchRule
        from jeepney.bus_messages import message_bus

        token = "kvm" + secrets.token_hex(4)
        sender = self.router.unique_name[1:].replace(".", "_")
        path = f"/org/freedesktop/portal/desktop/request/{sender}/{token}"
        rule = MatchRule(type="signal", interface="org.freedesktop.portal.Request",
                         member="Response", path=path)
        self.router.send_and_get_reply(message_bus.AddMatch(rule))
        args[-1]["handle_token"] = ("s", token)
        with self.router.filter(rule) as queue:
            self._call(iface, method, sig, *args)
            code, results = queue.get(timeout=120).body  # 可能在等使用者按允許
        if code != 0:
            raise RuntimeError(f"portal {method} rejected or cancelled (code {code})")
        return results

    def _open_session(self):
        from jeepney.io.threading import DBusRouter, open_dbus_connection

        # 執行緒安全的 router：擷取與剪貼簿執行緒共用（portal 只接受建立 session 的連線操作它）
        self.router = DBusRouter(open_dbus_connection(bus="SESSION", enable_fds=True))
        res = self._request("RemoteDesktop", "CreateSession", "a{sv}",
                            {"session_handle_token": ("s", "kvm" + secrets.token_hex(4))})
        self.session = res["session_handle"][1]

        dev = {"types": ("u", 1), "persist_mode": ("u", 2)}  # 鍵盤（不使用）；記住授權直到撤銷
        if self.TOKEN_FILE.exists():
            dev["restore_token"] = ("s", self.TOKEN_FILE.read_text().strip())
        self._request("RemoteDesktop", "SelectDevices", "oa{sv}", self.session, dev)
        self._request("ScreenCast", "SelectSources", "oa{sv}", self.session, {
            "types": ("u", 1),        # 螢幕
            "multiple": ("b", False),
            "cursor_mode": ("u", 2),  # 游標畫進畫面
        })
        self._call("Clipboard", "RequestClipboard", "oa{sv}", self.session, {})  # 必須在 Start 之前
        res = self._request("RemoteDesktop", "Start", "osa{sv}", self.session, "", {})
        if "restore_token" in res:
            CONFIG_DIR.mkdir(parents=True, exist_ok=True)
            self.TOKEN_FILE.write_text(res["restore_token"][1])
        self.clipboard = res.get("clipboard_enabled", ("b", False))[1]

        self.node, props = res["streams"][1][0]
        w, h = props.get("size", ("(ii)", (0, 0)))[1]
        x, y = props.get("position", ("(ii)", (0, 0)))[1]
        self.geom = (x, y, w, h)
        if self.clipboard:
            threading.Thread(target=self._clipboard_loop, daemon=True).start()

    def _clipboard_loop(self):
        """有程式要貼上時 portal 發 SelectionTransfer，把文字寫進它給的 fd。"""
        from jeepney import MatchRule
        from jeepney.bus_messages import message_bus

        rule = MatchRule(type="signal", interface="org.freedesktop.portal.Clipboard", member="SelectionTransfer")
        self.router.send_and_get_reply(message_bus.AddMatch(rule))
        with self.router.filter(rule, bufsize=16) as q:
            while self.session:
                try:
                    session, _mime, serial = q.get(timeout=1).body
                except queue.Empty:
                    continue
                if session != self.session:
                    continue
                ok = False
                try:
                    fd = self._call("Clipboard", "SelectionWrite", "ou", self.session, serial).body[0].to_raw_fd()
                    with os.fdopen(fd, "wb") as f:
                        f.write((self.clip_text or "").encode())
                    ok = True
                finally:
                    self._call("Clipboard", "SelectionWriteDone", "oub", self.session, serial, ok)

    def get_clipboard(self) -> str:
        if not self.clipboard:
            raise RuntimeError("clipboard not available (start the screen view first and allow remote desktop)")
        fd = self._call("Clipboard", "SelectionRead", "os", self.session, "text/plain;charset=utf-8").body[0].to_raw_fd()
        return read_fd(fd)

    def monitors(self) -> list:
        return []  # 由 GNOME 的遠端桌面對話框選擇

    def configure(self, max_width: int | None = None, monitor: int | None = None):
        if max_width is not None and max_width != self.max_width:
            self.max_width = max_width
            if self.gst:  # gst 的輸出尺寸在啟動時固定，重開管線（portal session 保留）
                self.stop()
                self.start()

    def set_clipboard(self, text: str):
        if not self.clipboard:
            raise RuntimeError("clipboard not available (start the screen view first and allow remote desktop)")
        self.clip_text = text
        self._call("Clipboard", "SetSelection", "oa{sv}", self.session,
                   {"mime_types": ("as", ["text/plain;charset=utf-8", "text/plain"])})

    def _ensure_session(self):
        if self.session is None:
            try:
                self._open_session()
            except BaseException:
                self.close()  # 建到一半（例如沒按允許而逾時）就整個重來
                raise

    def start(self):
        self._ensure_session()
        try:
            fd = self._call("ScreenCast", "OpenPipeWireRemote", "oa{sv}", self.session, {}).body[0].to_raw_fd()
        except RuntimeError as e:
            # session 在 agent 執行期間被 GNOME 結束（例如鎖定螢幕、在系統選單停止分享）：重建一次
            print(f"{e}; reopening the remote desktop session", flush=True)
            self.close()
            self._ensure_session()
            fd = self._call("ScreenCast", "OpenPipeWireRemote", "oa{sv}", self.session, {}).body[0].to_raw_fd()
        node, (_, _, w, h) = self.node, self.geom

        ow = min(w, self.max_width) // 2 * 2
        oh = round(h * ow / w) // 2 * 2
        self.size = (ow, oh)
        if self.h264 is not None:
            self._start_h264(fd, node, ow, oh)
            return
        # videorate 要加 drop-only=true：只有 max-rate 時（文件說 implies drop-only，實測不是）它會把上一張扣住、等下一張到了才輸出，
        # 畫面靜止、只有零星變動（例如只有滑鼠在動）時，那一張要等到下次變動或 keepalive 重送才出來，看起來慢好幾拍（實測：來源 0 秒產生的畫面 2 秒後才出）
        self.gst = subprocess.Popen(
            ["gst-launch-1.0", "-q", "pipewiresrc", f"fd={fd}", f"path={node}", "do-timestamp=true",
             "keepalive-time=1000", "!", "videorate", "drop-only=true", "max-rate=15", "!", "videoconvert", "!", "videoscale", "!",
             f"video/x-raw,format=RGB,width={ow},height={oh}", "!", "fdsink", "fd=1"],
            stdout=subprocess.PIPE, pass_fds=(fd,))
        os.close(fd)
        threading.Thread(target=self._reader, args=(self.gst.stdout, ow * oh * 3), daemon=True).start()

    def _start_h264(self, fd: int, node, ow: int, oh: int):
        """gst：縮放 → I420 → x264（zerolatency、無 B 影格，每張前面有 AUD、每個關鍵影格前面有 SPS/PPS）→ Annex B 從 stdout 讀出。
        畫面沒變時 PipeWire 只在有變動與每秒一次 keepalive 時出畫面，所以靜止時只有每秒一張很小的更新。"""
        kbps, _ = self.h264
        encoder = pick_gst_h264(kbps, (ow, oh), self.h264_encoder)  # 硬體編碼器（NVENC、VA）通過自我測試才用，否則 x264enc
        self.gst = subprocess.Popen(
            ["gst-launch-1.0", "-q", "pipewiresrc", f"fd={fd}", f"path={node}", "do-timestamp=true", "keepalive-time=1000", "!",
             "videorate", "drop-only=true", f"max-rate={H264_FPS}", "!", "videoscale", "!", "videoconvert", "!",
             f"video/x-raw,format=I420,width={ow},height={oh}", "!", *encoder, "!", GST_H264_CAPS, "!", "fdsink", "fd=1"],
            stdout=subprocess.PIPE, pass_fds=(fd,))
        os.close(fd)
        threading.Thread(target=self._h264_reader, args=(self.gst, self.h264[1]), daemon=True).start()

    def _h264_reader(self, gst, on_au):
        buf = b""
        while chunk := os.read(gst.stdout.fileno(), 1 << 16):
            aus, buf = split_aus(buf + chunk)
            for au in aus:
                on_au(au, is_key_au(au), self.geom, self.size)

    def h264_alive(self) -> bool:
        return self.gst is not None and self.gst.poll() is None

    def _reader(self, pipe, frame_size: int):
        # 原始 RGB 串流：每 frame_size bytes 一張
        while len(buf := pipe.read(frame_size)) == frame_size:
            with self.cond:
                self.latest, self.latest_t = buf, time.monotonic()
                self.cond.notify_all()
            if self.on_frame is not None:
                self.on_frame()  # 通知 agent 的事件迴圈：有新的一張（在讀取執行緒呼叫）

    def stop(self):
        if self.gst:
            self.gst.terminate()
            self.gst.wait()
        self.gst = self.latest = None

    def close(self):
        from jeepney import DBusAddress, new_method_call

        self.stop()
        session, self.session, self.clipboard = self.session, None, False  # 先清掉，剪貼簿執行緒會結束
        try:
            if session:
                addr = DBusAddress(session, bus_name="org.freedesktop.portal.Desktop",
                                   interface="org.freedesktop.portal.Session")
                self.router.send_and_get_reply(new_method_call(addr, "Close"), timeout=10)
        finally:  # Close 逾時或出錯也要關掉連線，否則每次重試漏一個 fd
            router, self.router = self.router, None
            if router:
                router.close()
                router.conn.close()

    def grab(self) -> tuple:
        with self.cond:
            if not self.cond.wait_for(lambda: self.latest is not None, timeout=10):
                raise RuntimeError("no frame from PipeWire")
            # keepalive-time 讓畫面不動時每秒也會重送一張；久沒新畫面表示串流已停（例如鎖定螢幕時 GNOME 結束了
            # session，gst 死掉或卡住），不能一直回舊畫面，丟例外讓上層重建 session
            if time.monotonic() - self.latest_t > 5:
                raise RuntimeError("PipeWire stream stalled")
            self.frame_age = time.monotonic() - self.latest_t
            return self.latest, self.size, self.geom, self.geom


def set_clipboard(text: str):
    """寫入目標電腦（本機）剪貼簿；失敗或逾時丟例外（背景程式在 GNOME Wayland 拿不到焦點，wl-copy 會卡住）。"""
    if sys.platform == "win32":
        subprocess.run(["clip"], input=("\ufeff" + text).encode("utf-16-le"), check=True, timeout=3)  # BOM 讓 clip 以 Unicode 讀
    elif sys.platform == "darwin":
        subprocess.run(["pbcopy"], input=text.encode(), check=True, timeout=3, env={**os.environ, "LANG": "en_US.UTF-8"})
    elif os.environ.get("WAYLAND_DISPLAY"):
        subprocess.run(["wl-copy"], input=text.encode(), check=True, timeout=3)
    else:
        try:
            subprocess.run(["xclip", "-selection", "clipboard"], input=text.encode(), check=True, timeout=3)
        except FileNotFoundError:
            subprocess.run(["xsel", "--clipboard", "--input"], input=text.encode(), check=True, timeout=3)


def read_fd(fd: int, timeout: float = 3, limit: int = 1 << 20) -> str:
    """讀 fd 到 EOF（有逾時，剪貼簿擁有者不回應時不會卡住）。"""
    data = b""
    with os.fdopen(fd, "rb") as f:
        while len(data) < limit:
            if not select.select([f], [], [], timeout)[0]:
                break
            chunk = f.read1(65536)
            if not chunk:
                break
            data += chunk
    return data.decode(errors="replace")


def target_os(platform: str = sys.platform) -> str:
    """這台（被控端）電腦的作業系統，回報給 dongle：linux／windows／macos（其他回 other）。"""
    return {"linux": "linux", "win32": "windows", "darwin": "macos"}.get(platform, "other")


def target_hostname() -> str:
    """這台（被控端）電腦的名稱，回報給 dongle 顯示在網頁上；取不到回空字串。"""
    try:
        return socket.gethostname().strip()
    except OSError:
        return ""


def get_clipboard() -> str:
    """讀目標電腦（本機）剪貼簿文字。"""
    if sys.platform == "win32":
        cmd = ["powershell", "-NoProfile", "-Command",
               "[Console]::OutputEncoding=[Text.Encoding]::UTF8; Get-Clipboard -Raw"]
    elif sys.platform == "darwin":
        cmd = ["pbpaste"]
    elif os.environ.get("WAYLAND_DISPLAY"):
        cmd = ["wl-paste", "--no-newline"]
    else:
        cmd = ["xclip", "-selection", "clipboard", "-o"]
    return subprocess.run(cmd, capture_output=True, check=True, timeout=3).stdout.decode(errors="replace")


def is_locked() -> bool:
    """目標電腦是否鎖定（鎖定時一般行程抓不到畫面，只會黑畫面或失敗）。偵測不到一律當作沒鎖。"""
    try:
        if sys.platform == "win32":
            import ctypes

            u32 = ctypes.windll.user32
            u32.OpenInputDesktop.argtypes = [ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong]
            u32.OpenInputDesktop.restype = ctypes.c_void_p
            u32.CloseDesktop.argtypes = [ctypes.c_void_p]
            h = u32.OpenInputDesktop(0, False, 0x0100)  # DESKTOP_SWITCHDESKTOP：鎖定畫面與 UAC 在 secure desktop，開不了
            if h:
                u32.CloseDesktop(h)
            return not h
        if sys.platform == "darwin":
            out = subprocess.run(["ioreg", "-n", "Root", "-d1", "-a"], capture_output=True, check=True, timeout=3).stdout
            return any(u.get("CGSSessionScreenIsLocked") for u in plistlib.loads(out).get("IOConsoleUsers", []))

        def loginctl(*args):
            return subprocess.run(["loginctl", *args], capture_output=True, text=True, timeout=3).stdout.strip()

        # systemd user service 沒有 XDG_SESSION_ID，改問使用者的圖形 session
        sid = os.environ.get("XDG_SESSION_ID") or loginctl("show-user", str(os.getuid()), "-p", "Display", "--value")
        return bool(sid) and loginctl("show-session", sid, "-p", "LockedHint", "--value") == "yes"
    except Exception:
        return False


def usb_power_state() -> str:
    """Linux：dongle 在這台電腦上的 USB 電源狀態（runtime_status：active、suspended…）；其他平台或找不到回空字串。排錯用（鎖定時叫不醒）"""
    for d in Path("/sys/bus/usb/devices").glob("*"):
        try:  # 介面項目（如 1-3:1.0）沒有 idVendor：逐個容錯，不能一個失敗就整個放棄
            if (d / "idVendor").read_text().strip() == f"{USB_VID:04x}" and "KVM" in (d / "product").read_text():
                return (d / "power" / "runtime_status").read_text().strip()
        except OSError:
            continue
    return ""


class FileReceiver:
    """網頁傳來的檔案：先寫 .part，完成才改名；同名自動加編號。"""

    MAX_SIZE = 200 * 1024 * 1024
    MAX_OPEN = 4  # 同時進行的傳輸上限：每個佔一個檔案描述子與一個 .part 檔
    OPS = ("file_begin", "file_chunk", "file_end", "file_abort")

    def __init__(self, folder: Path):
        self.folder, self.files = folder, {}  # id -> (檔案, .part 路徑, 最終路徑, 宣告的大小, 已寫入的位元組)

    def _drop(self, fid, keep_part: bool = False):
        """關掉並（預設）刪掉一個進行中的傳輸；沒有這個 id 就什麼都不做。"""
        entry = self.files.pop(fid, None)
        if entry:
            entry[0].close()
            if not keep_part:
                entry[1].unlink(missing_ok=True)

    def abort_all(self):
        """連線斷了：沒傳完的檔案一律丟掉，不留 .part 也不漏檔案描述子。"""
        for fid in list(self.files):
            self._drop(fid)

    def handle(self, cmd: dict) -> list:
        """處理 file_* 指令，回傳要送回網頁的訊息。"""
        op, fid = cmd["op"], cmd.get("id")
        try:
            if op == "file_begin":
                size = int(cmd.get("size", 0))
                if not 0 <= size <= self.MAX_SIZE:
                    raise ValueError(f"file too large (max {self.MAX_SIZE // 1024 // 1024} MB)")
                self._drop(fid)  # 同一個 id 重新開始：舊的那份不能漏掉
                if len(self.files) >= self.MAX_OPEN:
                    raise ValueError("too many transfers in progress")
                self.folder.mkdir(parents=True, exist_ok=True)
                name = Path(str(cmd.get("name", ""))).name.strip() or "file"
                final = self.folder / name
                for i in range(1, 1000):
                    if not final.exists():
                        break
                    final = self.folder / f"{Path(name).stem} ({i}){Path(name).suffix}"
                part = final.with_name(final.name + ".part")
                self.files[fid] = (open(part, "wb"), part, final, size, 0)  # noqa: SIM115（關閉由 _drop／file_end 負責）
                return [{"op": "file_ack", "id": fid, "seq": -1}]
            f, part, final, size, written = self.files[fid]
            if op == "file_chunk":
                data = base64.b64decode(cmd["data"], validate=True)
                if written + len(data) > size:  # 超過開始時宣告的大小：硬碟不能由對方決定要寫多少
                    raise ValueError("more data than the announced size")
                f.write(data)
                self.files[fid] = (f, part, final, size, written + len(data))
                return [{"op": "file_ack", "id": fid, "seq": cmd["seq"]}]
            del self.files[fid]
            f.close()
            if op == "file_end":
                part.rename(final)
                print(f"file saved: {final}", flush=True)
                return [{"op": "file_ack", "id": fid, "done": True, "path": str(final)}]
            part.unlink(missing_ok=True)  # file_abort
            return []
        except Exception as e:
            self._drop(fid)
            return [{"op": "file_ack", "id": fid, "error": str(e)}]


# 貼上鍵：macOS 為 Cmd+V，其他為 Ctrl+V（HID usage）
PASTE_KEYS = [0xE3, 0x19] if sys.platform == "darwin" else [0xE0, 0x19]


def apply_settings(cap, encs, cmd: dict):
    """網頁的擷取設定：格式、品質、最大寬度（0 = 原尺寸）、螢幕。encs：所有串流的編碼器。"""
    for enc in encs:
        if cmd.get("format") in FORMATS:
            enc.fmt = cmd["format"]
        if isinstance(cmd.get("quality"), int):
            enc.base_quality = enc.quality = cmd["quality"]
        enc.reset()  # 下一次送整張，立刻看到效果
    width = cmd.get("max_width")
    monitor = cmd.get("monitor")
    if (isinstance(width, int) or isinstance(monitor, int)) and hasattr(cap, "configure"):
        cap.configure(max_width=(width or 100000) if isinstance(width, int) else None,
                      monitor=monitor if isinstance(monitor, int) else None)


def capture_update(cap, enc):
    """抓一張並編碼；最後一項是各階段耗時（毫秒）：grab = 取得畫面，enc = 比對變動加壓縮，age = 這張畫面在 PipeWire／gst 管線裡放了多久才被取用（mss 是 0）"""
    t0 = time.monotonic()
    rgb, size, mon, desktop = cap.grab()
    t1 = time.monotonic()
    payload = enc.encode(rgb, size, mon, desktop)
    t2 = time.monotonic()
    return payload, mon, desktop, {"grab": (t1 - t0) * 1000, "enc": (t2 - t1) * 1000, "age": getattr(cap, "frame_age", 0.0) * 1000,
                                   "strips": getattr(enc, "last_strips", 0), "full": getattr(enc, "last_full", False)}


USB_VID = 0x303A  # Espressif（dongle 的 USB VID）
USB_PID = 0x4009  # esp_tinyusb 預設給「CDC + HID」複合裝置的 PID：Windows 的序列埠清單常常沒有 product／interface 字串，只能靠 VID:PID 認
USB_FRAME_MAX = 16 * 1024
WS_MAX_MESSAGE = 1 << 20  # dongle 經 WebSocket 送來的訊息上限：貼上文字、檔案塊、SDP 都在幾 KB 內。Wi-Fi 上不驗證 dongle 的憑證，冒充者不能讓 agent 為一則超大訊息吃光記憶體
DOWNLOAD_MAX = 4 * 1024 * 1024  # agent 更新檔（screen_agent.py、requirements.txt）的大小上限


class AuthRejected(Exception):
    """dongle 拒絕這個 agent：token 錯或已有其他 agent（Wi-Fi 與 USB 共用判斷）。"""

    def __init__(self, status: int):
        super().__init__(status)
        self.status_code = status


def find_usb_port() -> str | None:
    """找 dongle 的 CDC-ACM 序列埠（"KVM Agent" 介面）；沒裝 pyserial 或沒插 dongle 回 None。"""
    try:
        from serial.tools import list_ports
    except ImportError:
        return None
    for p in list_ports.comports():
        if p.vid == USB_VID and ("KVM" in f"{p.product} {p.interface} {p.description}" or p.pid == USB_PID):
            return p.device
    return None


class SerialLink:
    """USB CDC-ACM 上的 agent 連線，介面與 websockets 連線相同（send / async for / close）。

    序列埠是位元組串流，每則訊息加 8 bytes 標頭：b"KV" + 類型（1 文字、2 二進位首段、3 續段）
    + 是否最後一段 + 長度（uint32 LE）。二進位畫面照樣分段送，dongle 收一段轉一段。"""

    def __init__(self, port: str):
        import serial

        # 打開時拉起 DTR：dongle 據此知道有程式在讀；關閉（含程式當掉）時 DTR 放開，dongle 就當 agent 離線
        self.ser = serial.Serial(port, timeout=0.2, write_timeout=5)
        self.loop = asyncio.get_running_loop()
        self.queue: asyncio.Queue = asyncio.Queue()
        self.wlock = asyncio.Lock()
        self.closed = False
        threading.Thread(target=self._reader, daemon=True).start()

    @staticmethod
    def frame(kind: int, fin: bool, data: bytes) -> bytes:
        return b"KV" + struct.pack("<BBI", kind, fin, len(data)) + data

    def _reader(self):
        buf = b""
        try:
            while not self.closed:
                buf += self.ser.read(self.ser.in_waiting or 1)
                while True:
                    i = buf.find(b"KV")
                    if i < 0:
                        buf = buf[-1:]  # 可能是 "KV" 的前半
                        break
                    buf = buf[i:]
                    if len(buf) < 8:
                        break
                    kind, fin, n = struct.unpack_from("<BBI", buf, 2)
                    if kind not in (1, 2, 3) or fin > 1 or n > USB_FRAME_MAX:
                        buf = buf[2:]  # 不是合法標頭，往後找
                        continue
                    if len(buf) < 8 + n:
                        break
                    payload, buf = buf[8:8 + n], buf[8 + n:]
                    if kind == 1:  # dongle 只會送文字訊息給 agent
                        self.loop.call_soon_threadsafe(self.queue.put_nowait, payload.decode(errors="replace"))
        except Exception:
            pass  # 序列埠被拔掉或關閉
        finally:
            self.loop.call_soon_threadsafe(self.queue.put_nowait, None)

    def __aiter__(self):
        return self

    async def __anext__(self) -> str:
        msg = await self.queue.get()
        if msg is None:
            raise StopAsyncIteration
        return msg

    async def send(self, msg):
        if isinstance(msg, str):
            data = self.frame(1, True, msg.encode())
        else:  # bytes 或分段 list：首段 2、其餘 3，最後一段 fin
            parts = [msg] if isinstance(msg, (bytes, bytearray)) else msg
            data = b"".join(self.frame(2 if i == 0 else 3, i == len(parts) - 1, p) for i, p in enumerate(parts))
        async with self.wlock:
            await self.loop.run_in_executor(None, self.ser.write, data)

    async def close(self):
        if not self.closed:
            self.closed = True
            await self.loop.run_in_executor(None, self.ser.close)


def save_token(token: str):  # agent 不再自動存 token（目標電腦上的程式讀得到那個檔案）；留給使用者與腳本自己存
    """將取得的 token 寫入設定檔，供日後或命令列工具使用。"""
    if not token:
        return
    try:
        f = CONFIG_DIR / "token"
        if not f.exists() or f.read_text().strip() != token.strip():
            CONFIG_DIR.mkdir(parents=True, exist_ok=True)
            f.write_text(token.strip())
    except Exception:
        pass


async def fetch_usb_agent_key(port: str | None = None, timeout: float = 2.0) -> str | None:
    """經 USB 向 dongle 要 agent 專用金鑰：它只能連 /ws/agent（不能刷韌體、改設定、重開機），所以目標電腦上的程式拿到它也做不了壞事。
    沒有 dongle、沒裝 pyserial、舊韌體不認得（不回）都回 None。每次連線前問一次，不存起來。"""
    port = port or find_usb_port()
    if port is None:
        return None
    try:
        link = SerialLink(port)
    except Exception:
        return None
    try:
        reply = await usb_request(link, {"op": "get_agent_key"}, "agent_key", timeout)
    except Exception:
        return None
    finally:
        await link.close()
    key = reply.get("key") if reply else None
    return key if isinstance(key, str) and len(key) >= 8 else None


async def fetch_usb_token(port: str | None = None, timeout: float = 1.0) -> str | None:
    """透過 USB CDC-ACM 向 dongle 詢問萬能 token（舊韌體才用得到；新韌體要有人在 dongle 上長按 BOOT 3 秒才給，沒按就回 None）。"""
    port = port or find_usb_port()
    if port is None:
        return None
    try:
        link = SerialLink(port)
    except Exception:
        return None

    async def wait_reply() -> str | None:
        async for msg in link:
            try:
                reply = json.loads(msg)
                if reply.get("op") == "token" and isinstance(reply.get("token"), str):
                    return reply["token"]
            except Exception:
                pass
        return None

    try:
        await link.send(json.dumps({"op": "get_token"}))
        return await asyncio.wait_for(wait_reply(), timeout)
    except Exception:
        return None
    finally:
        await link.close()


async def usb_request(link: SerialLink, req: dict, op: str, timeout: float = 3) -> dict | None:
    """送一則訊息，等 op 相同的回覆；逾時或序列埠關閉回 None"""
    await link.send(json.dumps(req))

    async def wait():
        async for msg in link:
            try:
                reply = json.loads(msg)
            except Exception:
                continue
            if reply.get("op") == op:
                return reply
        return None

    try:
        return await asyncio.wait_for(wait(), timeout)
    except asyncio.TimeoutError:
        return None


def sha256_file(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else ""


def install_agent_files(here: Path, agent: bytes, req: bytes | None) -> None:
    """（阻塞，在執行緒裡呼叫）把新的 agent 與（有的話）requirements.txt 裝到 here 旁邊：語法有問題的不裝、相依套件先在目前的 Python 環境補裝
    （失敗就整個不更新），再換檔。任何問題都丟例外，舊檔不動；暫存檔一律清掉。"""
    compile(agent, str(here), "exec")  # 語法錯誤的檔案裝了 agent 一重啟就起不來
    new_agent, new_req = here.with_name(here.name + ".new"), here.with_name("requirements.txt.new")
    try:
        if req is not None:
            new_req.write_bytes(req)
            subprocess.run([sys.executable, "-m", "pip", "install", "--quiet", "--disable-pip-version-check", "-r", str(new_req)],
                           check=True, timeout=900)
            os.replace(new_req, here.with_name("requirements.txt"))
        new_agent.write_bytes(agent)
        os.replace(new_agent, here)
    finally:
        for f in (new_agent, new_req):
            f.unlink(missing_ok=True)


def restart_process() -> None:
    """用新的程式重新啟動。Windows 的 os.execv 是「另開一個新程序、舊的立刻結束」：run_agent.bat 的重啟迴圈會當舊的結束了而再開一個，
    變成兩個 agent，所以直接結束，讓迴圈重新啟動；其他系統就地換成新程式（systemd 看到的還是同一個程序）。"""
    print("agent updated; restarting", flush=True)
    if sys.platform == "win32":
        os._exit(0)
    os.execv(sys.executable, [sys.executable, "-u", *sys.argv])


_HEX = set("0123456789abcdef")


def agent_update_args(cmd: dict) -> tuple | str:
    """receiver 經 dongle 送來的 agent_update 指令：回 (sha256, url, req_sha256, req_url)，不合法回一句原因。"""
    def hex64(v) -> bool:
        return isinstance(v, str) and len(v) == 64 and set(v) <= _HEX

    def http(v) -> bool:
        return isinstance(v, str) and v.startswith(("http://", "https://")) and len(v) <= 400

    sha, url, req_sha, req_url = cmd.get("sha256"), cmd.get("url"), cmd.get("req_sha256") or "", cmd.get("req_url") or ""
    if not hex64(sha) or not http(url) or (req_sha and not (hex64(req_sha) and http(req_url))):
        return "the update request is malformed"
    return sha, url, req_sha, req_url


def download_verified(url: str, sha256: str, size: int = 0, limit: int = DOWNLOAD_MAX) -> bytes:
    """（阻塞）下載並驗證 SHA-256（與大小）。雜湊是從已驗證的控制通道拿到的，所以即使網址被換掉或走明文 HTTP，換了內容也過不了這一關。"""
    import urllib.request

    if not url.startswith(("http://", "https://")):
        raise ValueError("not an http(s) url")
    with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "geekkvm-agent"}), timeout=30) as r:
        data = r.read(limit + 1)
    if len(data) > limit or (size and len(data) != size):
        raise ValueError(f"unexpected size ({len(data)} bytes)")
    if hashlib.sha256(data).hexdigest() != sha256:
        raise ValueError("checksum mismatch")
    return data


async def fetch_usb_tls_cert(port: str | None = None, timeout: float = 2.0) -> str | None:
    """經 USB（實體連線，冒充不了）向 dongle 要它目前的 HTTPS 憑證（PEM）。沒有 dongle、沒裝 pyserial、舊韌體不認得這個指令都回 None。
    每次連線前都問一次，不存起來：dongle 換了憑證（換 SD 卡、重新產生）、或插到另一台電腦，下一次問到的就是對的。"""
    port = port or find_usb_port()
    if port is None:
        return None
    try:
        link = SerialLink(port)
    except Exception:
        return None
    try:
        reply = await usb_request(link, {"op": "tls_cert"}, "tls_cert", timeout)
    except Exception:
        return None
    finally:
        await link.close()
    pem = reply.get("pem") if reply else None
    return pem if isinstance(pem, str) and "BEGIN CERTIFICATE" in pem else None


def pinned_ssl_context(pem: str) -> ssl.SSLContext:
    """只信任 pem 這張憑證（自簽的 dongle 憑證本身就是信任錨）的 TLS 設定。
    不檢查名字：信任的是這張憑證本身，而 dongle 的 IP 會變、也可能用 mDNS 名稱連；冒充者沒有對應的私鑰，過不了握手。
    VERIFY_X509_PARTIAL_CHAIN：不必走到根，葉憑證在信任清單裡就算數（使用者自己的憑證是由 CA 簽的葉憑證時也能用）。"""
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_REQUIRED
    ctx.verify_flags |= ssl.VERIFY_X509_PARTIAL_CHAIN
    ctx.load_verify_locations(cadata=pem)
    return ctx


async def fetch_usb_info(port: str | None = None, timeout: float = 2.0) -> dict | None:
    """向插在這台電腦上的 dongle 問它是誰、在哪（device 名稱、mdns、ip、mac）；沒有 dongle、沒裝 pyserial 或舊韌體不回就是 None"""
    port = port or find_usb_port()
    if port is None:
        return None
    try:
        link = SerialLink(port)
    except Exception:
        return None
    try:
        info = await usb_request(link, {"op": "info"}, "info", timeout)
        return info
    except Exception:
        return None
    finally:
        await link.close()


async def open_usb(port: str, token: str = "") -> SerialLink:
    """打開序列埠：先看要不要更新 agent，再送 auth。USB 不驗 token（舊韌體要：被拒絕 token 時改用 get_token 取得再驗證）。"""
    link = SerialLink(port)

    async def wait_reply():
        nonlocal token
        asked_token = False
        async for msg in link:
            try:
                reply = json.loads(msg)
            except Exception:
                continue
            op = reply.get("op")
            if op == "token" and isinstance(reply.get("token"), str):
                token = reply["token"]
                await link.send(json.dumps({"op": "auth", "token": token}))
                continue
            if op == "auth":
                if reply.get("ok"):
                    return
                if reply.get("error") == "token" and not asked_token:  # 舊韌體：USB 也要 token
                    asked_token = True
                    await link.send(json.dumps({"op": "get_token"}))
                    continue
                raise AuthRejected(401 if reply.get("error") == "token" else 409)
        raise ConnectionError("serial port closed")

    try:
        await link.send(json.dumps({"op": "auth", "token": token} if token else {"op": "auth"}))
        await asyncio.wait_for(wait_reply(), 5)
        return link
    except BaseException:
        await link.close()
        raise


class Agent:
    """連到 dongle 的 wss://<host>/ws/agent，依 dongle 轉來的指令擷取畫面、貼上、讀剪貼簿、收檔案。
    斷線後 2 秒重連；擷取只在有網頁觀看時進行。"""

    rtc_codec, h264_task, h264_gen = "jpeg", None, 0  # H.264 直連的狀態（__init__ 有說明）；類別預設值讓沒走 H.264 的路徑不必先設定它們

    def __init__(self, args, cap):
        self.args, self.cap = args, cap
        self.streams = {name: FrameStream(args.quality) for name in ("dongle", "rtc")}
        self.files = FileReceiver(Path(args.download_dir).expanduser())
        self.pool = ThreadPoolExecutor(1)  # 擷取相關呼叫都在同一條執行緒（mss 物件不可跨執行緒）
        self.frame_evt = None  # 有新畫面時被設起來（見 start_capture 與 PortalCapture.on_frame）
        self.ws = None
        self.rtc_pc = self.rtc = None  # WebRTC：連線與 DataChannel（網頁選了 WebRTC 才有）
        self.rtc_want_h264 = False  # 網頁的 offer 說它能解 H.264（WebCodecs）
        self.rtc_codec = "jpeg"  # 這條 DataChannel 實際送的："h264"（x264 編碼的影片）或 "jpeg"（差異更新的圖片）
        self.h264_ok = None  # 這台電腦能不能送 H.264（GNOME portal + gst 的 x264enc，或 mss + PyAV 的 libx264）；第一次用到才檢查
        self.h264_task = None  # 管 gst 管線起停與鎖定偵測的 task（畫面本身由 h264_au 直接送）
        self.h264_wake, self.h264_restart = asyncio.Event(), False
        self.h264_gen = 0  # 每次起停加一：舊管線殘留的畫面回呼認得出自己過期了
        self.h264_last = self.h264_started = 0.0
        self.h264_need_key = False  # 丟過畫面：要等下一個關鍵影格才能接上
        self.updating = False  # receiver 要求的 agent 更新進行中
        self.last_rx, self.pong_seen = 0.0, False  # dongle 最後一次來訊的時間；舊韌體不回 pong 就不啟用看門狗
        self.transport = args.transport  # 網頁可在執行中改
        self.fails = 0  # 連續連線失敗次數（run() 維護），connect() 據此不重複印「改走另一個通道」
        self.via = ""  # 目前實際使用的通道："wifi" | "usb"
        self.usb_try_at, self.usb_wait = 0.0, 5  # 偏好 USB 卻退到 Wi-Fi 時，下次回頭試 USB 的時間與間隔（秒）
        self.active = False  # 正在擷取（有網頁在看）
        self.paused = False  # 網頁按了「暫停擷取」：不抓畫面、不回應畫面要求，直到繼續或沒有人在看
        self.locked, self.lock_check = False, 0.0  # 目標電腦是否鎖定、上次偵測的時間
        self.capture_delay = 2  # 擷取失敗後的重試間隔（秒）：連續失敗 2、4、8… 最多 30，成功後歸 2
        self.tasks = set()  # 背景 task 須保留參考，否則可能執行到一半被回收
        self.n, self.t0 = 0, 0.0

    async def in_pool(self, fn, *args):
        return await asyncio.get_running_loop().run_in_executor(self.pool, fn, *args)

    def spawn(self, coro) -> asyncio.Task:
        task = asyncio.create_task(coro)
        self.tasks.add(task)
        task.add_done_callback(self.tasks.discard)
        return task

    async def send(self, obj: dict):
        await self.ws.send(json.dumps(obj))

    async def run(self):
        ctx = ssl.create_default_context(cafile=self.args.cafile)
        if not self.args.cafile:  # 裝置是自簽憑證
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
        fails, last_err = 0, ""  # 連續失敗次數（連上就歸零）決定重試間隔；同一個錯誤只印一次，沒插 dongle 時 log 不會被洗版
        try:
            while True:
                retry = 2  # 連線正常結束（沒有例外）：很快重連
                try:
                    async with self.connect(ctx) as (ws, via):
                        fails, last_err, self.fails = 0, "", 0
                        self.ws, self.last_rx, self.pong_seen = ws, time.monotonic(), False
                        self.paused = False  # 新連線：dongle 記得暫停狀態，會補送
                        print(f"connected to the dongle via {via}, waiting for a viewer", flush=True)
                        # dongle 顯示在網頁上並回報給 MQTT receiver；agent_sha256 讓 receiver 知道這台的 agent 是不是最新版
                        await self.send({"op": "os", "name": target_os(), "hostname": target_hostname(),
                                         "agent_sha256": sha256_file(Path(__file__).resolve()), "proto": PROTO_VERSION})
                        keep = self.spawn(self.keepalive())
                        video = self.spawn(self.report_video())
                        try:
                            async for msg in ws:
                                self.last_rx = time.monotonic()
                                await self.handle(json.loads(msg) if isinstance(msg, str) else {}, msg)
                        finally:
                            keep.cancel()
                            video.cancel()
                except Exception as e:
                    status = rejected_status(e)  # 猜錯 token 會讓 dongle 停頓，不要頻繁重試
                    retry = retry_delay(status, fails)
                    fails += 1
                    self.fails = fails
                    err = describe_error(e)
                    if err != last_err:
                        print(f"error: {err}; retrying (every {retry}s, backing off to {retry_delay(None, 99)}s while it stays unreachable)", flush=True)
                        last_err = err
                finally:
                    self.files.abort_all()  # 斷線：沒傳完的檔案丟掉
                    await self.rtc_close()
                    await self.stop_capture()
                await self.sleep_until_usb(retry)
        finally:
            await self.in_pool(self.cap.close)

    def usb_present(self) -> bool:
        return os.path.exists(self.args.usb_port) if self.args.usb_port else find_usb_port() is not None

    async def sleep_until_usb(self, secs: float):
        """重連前的等待。退避拉長到 30 秒時，剛插上 dongle 不該還要等：等待期間每秒看一次 USB，
        原本沒有、現在出現了就提早結束（原本就插著卻連不上，例如 409，照常等滿）。只走 Wi-Fi 時不看 USB。"""
        if secs <= 1 or self.transport == self.args.transport == "wifi" or await self.in_pool(self.usb_present):
            return await asyncio.sleep(secs)
        end = time.monotonic() + secs
        while (left := end - time.monotonic()) > 0:
            await asyncio.sleep(min(1, left))
            if await self.in_pool(self.usb_present):
                print("dongle's USB port appeared; reconnecting now", flush=True)
                await asyncio.sleep(1)  # 讓序列埠完成枚舉
                return

    @contextlib.asynccontextmanager
    async def connect(self, ctx):
        """依 --transport 連上 dongle。auto：先走 Wi-Fi，連不上才走 USB（實測整張畫面 Wi-Fi 約
        690 KB/s、USB 約 200 KB/s；USB 用在電腦連不到 dongle 網路時當備援）。"""
        order = {"auto": ["wifi", "usb"], "wifi": ["wifi", "usb"], "usb": ["usb", "wifi"]}[self.transport]
        if self.transport == self.args.transport == "wifi":
            order = ["wifi"]  # 命令列明確指定時不退而求其次
        if self.transport == self.args.transport == "usb":
            order = ["usb"]
        # 只連插在這台電腦上的 dongle：它可能被拔插到別台電腦，所以每次連線前都向它問現在的位址（USB），
        # 不使用預設名稱或上次的 IP，免得連到舊的或別支 dongle
        usb_port = self.args.usb_port or find_usb_port()
        if usb_port is None:
            raise ConnectionError("no dongle on USB: plug the dongle into this computer")
        info = await fetch_usb_info(usb_port)
        if info is None:
            raise ConnectionError("the dongle on USB did not answer: update its firmware")
        host = info.get("ip") or info.get("mdns") or ""
        if not host:
            order = ["usb"]  # dongle 還沒連上 Wi-Fi：只能走 USB
        elif not self.fails:
            print(f"dongle on USB: {info.get('device')} ({info.get('mac')}) at {host}", flush=True)
        # 經 Wi-Fi 連 dongle 之前，先經 USB 拿它的憑證，連線時只信任這一張：區網裡冒充 dongle 的人過不了握手，也就拿不到 token 與畫面。
        # 舊韌體不認得這個指令：退回不驗證（印警告）；--require-pin 則直接拒絕
        wifi_ctx, pinned, legacy_firmware = ctx, False, False
        if "wifi" in order and not self.args.cafile:
            pem = await fetch_usb_tls_cert(usb_port)
            if pem is not None:
                wifi_ctx, pinned = pinned_ssl_context(pem), True
            else:
                legacy_firmware = True
                if self.args.require_pin:
                    order = [h for h in order if h != "wifi"]
                    if not order:
                        raise ConnectionError("--require-pin: the dongle did not give its certificate over USB (update its firmware); refusing to connect over Wi-Fi unverified")
                elif not self.fails:
                    print("warning: the dongle's firmware cannot give its certificate over USB, so the Wi-Fi connection is not verified; update the dongle's firmware", flush=True)
        token = self.args.token or ""
        if not token and usb_port and "wifi" in order:
            # agent 專用金鑰（只能連 /ws/agent）；舊韌體不認得，才退回萬能 token（這條路也不再把 token 存到磁碟：目標電腦上的程式讀得到那個檔案）
            token = "" if legacy_firmware else await fetch_usb_agent_key(usb_port) or ""  # 舊韌體連憑證都不給：不必再等它回 get_agent_key
            if not token:
                token = await fetch_usb_token(usb_port) or ""
        if not token and "wifi" in order:
            token = default_token()
        for i, how in enumerate(order):
            last = i == len(order) - 1
            try:
                if how == "wifi":
                    # 憑證確認過了：token 放在 Authorization 標頭（在加密的連線裡，不會出現在網址與任何記錄）；
                    # 沒確認的舊韌體只認網址裡的 ?token=（它也看不懂標頭）
                    if pinned:
                        conn = await websockets.connect(f"wss://{host}/ws/agent", ssl=wifi_ctx, max_size=WS_MAX_MESSAGE, ping_interval=None, open_timeout=10,
                                                        additional_headers={"Authorization": f"Bearer {token}"})
                    else:
                        conn = await websockets.connect(f"wss://{host}/ws/agent?token={token}", ssl=wifi_ctx, max_size=WS_MAX_MESSAGE, ping_interval=None, open_timeout=10)
                    via = f"Wi-Fi {host}"
                    self.via = "wifi"
                else:
                    port = self.args.usb_port or find_usb_port()
                    if port is None:
                        raise ConnectionError("dongle's USB serial port not found (pyserial installed? dongle plugged in?)")
                    conn, via = await open_usb(port, token), f"USB {port}"
                    self.via = "usb"
            except Exception as e:
                if last or rejected_status(e) in (401, 409):  # token 錯或已有 agent：換通道也一樣
                    raise
                if not self.fails:
                    print(f"{how} unavailable ({describe_error(e)}); trying {order[i + 1]}", flush=True)
                continue
            try:
                yield conn, via
            finally:
                await conn.close()
            return

    async def set_paused(self, on: bool):
        """網頁的「暫停擷取」：停掉擷取（portal 的串流、mss 的物件都釋放），暫停期間不回應畫面要求。
        繼續時 dongle 會重新要畫面，從完整畫面開始（start_capture 會重置差異編碼器）"""
        if on == self.paused:
            return
        self.paused = on
        if on:
            await self.stop_capture()
        print("capture paused" if on else "capture resumed", flush=True)
        await self.send({"op": "notice", "text": PAUSED_NOTICE if on else ""})

    async def handle(self, cmd: dict, raw):
        op = cmd.get("op")
        if op == "stop":
            self.paused = False  # 沒有人在看了：下次有人開網頁就正常擷取，不會因為上次按了暫停而一片黑
        if op == "frame" and self.paused:
            return
        if op == "pause":
            await self.set_paused(cmd.get("on") is True)
        elif op == "frame":
            self.request_frame("dongle")  # 遠端網頁（經 MQTT）的視窗會同時要好幾張：忙時排隊接著送
        elif op == "paste" and isinstance(cmd.get("text"), str):
            self.spawn(self.paste(cmd["text"]))
        elif op == "get_clipboard":
            self.spawn(self.send_clipboard())
        elif op in FileReceiver.OPS:
            for reply in self.files.handle(cmd):
                await self.send(reply)
        elif op == "settings":
            await self.in_pool(apply_settings, self.cap, [st.enc for st in self.streams.values()], cmd)
            want = cmd.get("transport")
            if want in ("auto", "usb", "wifi") and want != self.transport:
                self.transport = want
                print(f"switching transport to {want}", flush=True)
                await self.ws.close()  # run() 會以新的通道重連，dongle 重連後會再送一次 settings
                return
            if self.active:
                await self.send({"op": "transport", "via": self.via})
            enc = self.streams["dongle"].enc
            print(f"settings: {enc.fmt} q{enc.quality} width {getattr(self.cap, 'max_width', '?')}",
                  flush=True)
            if self.rtc_codec == "h264":  # 位元率跟著品質：重起管線
                self.h264_restart = True
                self.h264_wake.set()
        elif op == "rtc_offer" and isinstance(cmd.get("sdp"), str):
            self.spawn(self.rtc_offer(cmd["sdp"], cmd.get("codec") == "h264"))
        elif op == "agent_update" and not self.updating:
            self.spawn(self.agent_update(cmd))
        elif op == "pong":
            self.pong_seen = True
        elif op == "refresh":
            self.streams["dongle"].enc.reset()  # 新的觀看者加入：下一次送整張
        elif op == "stop" and (self.active or self.h264_task):
            await self.stop_capture()
            print("viewer closed: capture stopped", flush=True)
        elif op == "bye":  # dongle 要求重新連線（例如 USB 閒置逾時）
            await self.ws.close()
        elif op == "error":
            print("dongle error:", raw, flush=True)

    async def rtc_close(self):
        pc, self.rtc_pc, self.rtc = self.rtc_pc, None, None
        self.rtc_codec = "jpeg"
        if hasattr(self.cap, "h264"):
            self.cap.h264 = None  # 之後有人從 dongle 看時走原本的 RGB + JPEG
        if pc:
            await pc.close()

    def h264_available(self) -> bool:
        if self.h264_ok is None:
            self.h264_ok = (isinstance(self.cap, PortalCapture) and shutil.which("gst-launch-1.0") is not None
                            and subprocess.run(["gst-inspect-1.0", "x264enc"], capture_output=True).returncode == 0) \
                or (isinstance(self.cap, MssCapture) and pyav_h264_ok())
        return self.h264_ok

    async def rtc_offer(self, sdp: str, want_h264: bool = False):
        """網頁的 WebRTC offer（SDP 經 dongle 轉來，已含 ICE candidate）：回 answer 並接下網頁開的
        DataChannel，之後畫面直接走它，不經 dongle，所以也不必同網段（NAT 穿透靠 STUN）。"""
        await self.rtc_close()
        try:
            from aiortc import RTCConfiguration, RTCIceServer, RTCPeerConnection, RTCSessionDescription
        except ImportError:
            await self.send({"op": "rtc_answer", "error": "WebRTC needs aiortc on the target PC "
                             "(pip install -r host/requirements-webrtc.txt)"})
            return
        self.rtc_want_h264 = want_h264
        servers = [RTCIceServer(**s) for s in ice_servers(self.args)]
        pc = self.rtc_pc = RTCPeerConnection(RTCConfiguration(servers))

        @pc.on("datachannel")
        def on_channel(ch):
            self.rtc = ch
            self.streams["rtc"].enc.reset()  # 新的 DataChannel 從整張畫面開始
            self.rtc_codec = "h264" if self.rtc_want_h264 and self.h264_available() else "jpeg"
            print(f"WebRTC: channel from the web page ({self.rtc_codec})", flush=True)
            ch.send(json.dumps({"op": "codec", "name": self.rtc_codec}))  # 網頁據此決定要不要回 "a"、用哪個解碼
            if self.rtc_codec == "h264":
                self.spawn(self.h264_begin())

            @ch.on("message")
            def on_message(m):  # JPEG：網頁收完一張就直接回 "a"（不必再繞 dongle，也能預先多給一張額度）；H.264：解不下去時回 "k" 要關鍵影格
                if m == "k":
                    self.h264_restart = True
                    self.h264_wake.set()
                elif self.rtc_codec != "h264":
                    self.rtc_ack()

            @ch.on("close")
            def on_close():
                if self.rtc is ch:
                    self.rtc = None
                    was_h264, self.rtc_codec = self.rtc_codec == "h264", "jpeg"
                    if hasattr(self.cap, "h264"):
                        self.cap.h264 = None
                    if was_h264:  # 停掉 H.264 管線；網頁隨後會經 dongle 要畫面，走回 RGB + JPEG
                        self.spawn(self.stop_capture())
                    print("WebRTC: channel closed, frames go through the dongle", flush=True)

        @pc.on("connectionstatechange")
        async def on_state():
            if pc.connectionState in ("failed", "closed") and self.rtc_pc is pc:
                await self.rtc_close()

        await pc.setRemoteDescription(RTCSessionDescription(sdp, "offer"))
        await pc.setLocalDescription(await pc.createAnswer())  # aiortc 在這裡收集完 candidate
        await self.send({"op": "rtc_answer", "sdp": pc.localDescription.sdp})

    def rtc_ack(self):
        if not self.paused:
            self.request_frame("rtc")

    def request_frame(self, name: str):
        """某個接收端要一張：閒置就開始送，在忙就記成額度（有上限）。H.264 是連續串流，不逐張要：這裡只確保管線的管理 task 在跑。"""
        if self.rtc_codec == "h264":
            if (self.h264_task is None or self.h264_task.done()) and not self.paused:
                self.h264_task = self.spawn(self.h264_run())
            return
        st = self.streams[name]
        if st.task is None or st.task.done():
            st.task = self.spawn(self.frame_cycle(name))
        elif st.credit < MAX_CREDIT:
            st.credit += 1

    async def frame_cycle(self, name: str):
        """送一張；若接收端預先多給了額度，接著送下一張（編碼與傳輸、網頁解碼重疊）。"""
        await self.send_frame(name)
        st = self.streams[name]
        if st.credit > 0:
            st.credit -= 1
            st.task = self.spawn(self.frame_cycle(name))

    async def rtc_drain(self):
        """DataChannel 的送出佇列太長就等它送一些出去再抓下一張：畫面在輪到它時才抓，才是新的，也不會編了一堆舊畫面。
        等的是通道的 bufferedamountlow 事件（佇列降到 RTC_BUFFER_MAX 以下才發），不輪詢；
        保底逾時：通道直接關閉不會發這個事件，最多 RTC_DRAIN_RECHECK 秒後會再檢查一次狀態。"""
        ch = self.rtc
        if ch is None or ch.readyState != "open" or ch.bufferedAmount <= RTC_BUFFER_MAX:
            return
        low = asyncio.Event()
        ch.bufferedAmountLowThreshold = RTC_BUFFER_MAX
        ch.on("bufferedamountlow", low.set)
        try:
            while ch.readyState == "open" and ch.bufferedAmount > RTC_BUFFER_MAX:
                low.clear()
                with contextlib.suppress(asyncio.TimeoutError):
                    await asyncio.wait_for(low.wait(), RTC_DRAIN_RECHECK)
        finally:
            ch.remove_listener("bufferedamountlow", low.set)

    def send_rtc(self, data: bytes) -> bool:
        """經 DataChannel 送出一張畫面；每段 1 byte 旗標（1 = 後面還有）加最多 16 KB，網頁組回整張。"""
        ch = self.rtc
        if ch is None or ch.readyState != "open":
            return False
        try:
            step = 16 * 1024
            for i in range(0, len(data), step):
                ch.send(bytes([i + step < len(data)]) + data[i:i + step])
            return True
        except Exception:
            return False

    def back_to_usb(self) -> bool:
        """選了 USB 卻因 dongle 重開機（序列埠重新枚舉）退到 Wi-Fi 時，埠回來就換回去；
        連續失敗的話間隔加倍（最多 2 分鐘），免得串流一直被打斷。"""
        if self.via == "usb":
            self.usb_wait = 5
            return False
        now = time.monotonic()
        if self.transport != "usb" or now < self.usb_try_at or not (self.args.usb_port or find_usb_port()):
            return False
        self.usb_try_at, self.usb_wait = now + self.usb_wait, min(self.usb_wait * 2, 120)
        return True

    def perf_window(self, name: str) -> PerfWindow:
        return self.__dict__.setdefault("perf", {}).setdefault(name, PerfWindow())

    def report_perf(self):
        """各階段耗時的 2 秒平均：WebRTC 通時經 DataChannel 傳給網頁（網頁自己再加上收到到畫出的時間），-v 時也印在 log。
        cpu 是這個 agent 程序用掉的 CPU（一個核心 = 100）：接近 100 表示瓶頸在 agent 的 Python（編碼、aiortc），不是網路"""
        now, cpu_now = time.monotonic(), time.process_time()
        last_cpu = self.__dict__.get("perf_cpu", (cpu_now, now))
        cpu = 100 * (cpu_now - last_cpu[0]) / max(now - last_cpu[1], 1e-6)
        self.perf_cpu = (cpu_now, now)
        for name, window in self.__dict__.get("perf", {}).items():
            snap = window.snapshot(now, cpu)
            window.reset(now)
            if snap is None:
                continue
            if self.args.verbose:
                print(f"perf {name}: " + " ".join(f"{k}={v}" for k, v in snap.items()), flush=True)
            if name == "rtc" and self.rtc is not None and self.rtc.readyState == "open":
                try:
                    self.rtc.send(json.dumps({"op": "perf", **snap}))
                except Exception:
                    pass

    async def report_video(self):
        """告訴 dongle 實際送出的畫面張數（內容更新，不含心跳）：WebRTC 直連時畫面不經 dongle，
        dongle 自己數不到，Status 的 video fps 要靠這個。每 2 秒取樣，但只在數字有變時才送；數字不是 0 時
        每 4 秒補送一次（dongle 6 秒沒收到就當 0）。全 0 又沒變：不送，dongle 逾時後自己當 0。"""
        last = {n: s.frames for n, s in self.streams.items()}
        t0 = time.monotonic()
        sent, sent_at = None, 0.0
        while True:
            await asyncio.sleep(2)
            self.report_perf()
            now = time.monotonic()
            msg, last, t0 = self.video_message(last, now - t0), {n: s.frames for n, s in self.streams.items()}, now
            busy = bool(msg["dongle"] or msg["rtc"] or msg["rtc_open"])
            if msg == sent and not (busy and now - sent_at >= 4):
                continue
            try:
                await self.send(msg)
            except Exception:
                return  # 連線已斷；重連後會重新啟動
            sent, sent_at = msg, now

    def video_message(self, last: dict, dt: float) -> dict:
        """過去 dt 秒各串流送出的張數 → 要回報給 dongle 的訊息（last = 上次的累計張數）。"""
        fps = {n: round((s.frames - last[n]) / dt, 1) for n, s in self.streams.items()}
        return {"op": "video", "dongle": fps["dongle"], "rtc": fps["rtc"], "rtc_open": self.rtc is not None and self.rtc.readyState == "open"}

    async def keepalive(self):
        """每 5 秒 ping；dongle 會回 pong，超過 20 秒完全沒來訊就當它已重開機並主動斷線重連。
        dongle 重開機不會送 FIN，不這樣做的話 TCP 要等重傳逾時（可能數分鐘）才發現連線已死。"""
        while True:
            await asyncio.sleep(5)
            try:
                if self.pong_seen and time.monotonic() - self.last_rx > 20:
                    raise ConnectionError("dongle stopped answering")
                await self.ws.send('{"op":"ping"}')
                if self.back_to_usb():
                    print("USB is preferred and the dongle's port is back; reconnecting over it", flush=True)
                    await self.ws.close()  # run() 會以 USB 重連
                    return
            except Exception as e:
                print(f"{describe_error(e)}; reconnecting", flush=True)
                transport = getattr(self.ws, "transport", None)
                if transport is not None:
                    transport.abort()  # 對死掉的連線 close() 會等對方回應，直接中止
                else:
                    await self.ws.close()
                return

    async def stop_capture(self):
        if self.h264_task:
            self.h264_task.cancel()
            self.h264_task = None
        self.h264_gen += 1
        for st in self.streams.values():
            st.credit = 0
            if st.task:
                st.task.cancel()
        if self.active:
            await self.in_pool(self.cap.stop)
            self.active = False

    async def start_capture(self):
        print("viewer connected: starting capture (a share dialog may appear)", flush=True)
        t_start = time.monotonic()
        if hasattr(self.cap, "on_frame"):  # 新畫面通知：從讀取執行緒回到事件迴圈
            loop = asyncio.get_running_loop()
            self.frame_evt = evt = asyncio.Event()
            self.cap.on_frame = lambda: loop.call_soon_threadsafe(evt.set)
        start = asyncio.ensure_future(self.in_pool(self.cap.start))
        waiting = False
        try:
            await asyncio.wait_for(asyncio.shield(start), 1.5)
        except asyncio.TimeoutError:  # 1.5 秒還沒好：多半卡在 GNOME 的「分享畫面」對話框，告訴網頁
            waiting = True
            await self.send({"op": "notice", "text": CONSENT_NOTICE})
            await start
        if waiting:
            await self.send({"op": "notice", "text": ""})
        for st in self.streams.values():
            st.enc.reset()  # 新的網頁從整張畫面開始
        self.active, self.n, self.t0 = True, 0, time.monotonic()
        print(f"capture started in {(self.t0 - t_start) * 1000:.0f} ms", flush=True)
        print("viewer connected: capturing", flush=True)
        await self.send({"op": "transport", "via": self.via})
        await self.send({"op": "monitors", "list": self.cap.monitors(), "current": getattr(self.cap, "monitor", None)})

    # ---- H.264 直連：目標電腦上的 gst 編碼，agent 只切開、經 DataChannel 轉送；網頁用 WebCodecs 解碼 ----

    def h264_au(self, gen: int, au: bytes, key: bool, geom: tuple, size: tuple):
        """gst 吐出一張（event loop 執行緒）：直接送。送出佇列堆太高就丟，直到下一個關鍵影格才接上（P 影格丟了後面的就沒法解）。"""
        ch = self.rtc
        if gen != self.h264_gen or ch is None or ch.readyState != "open" or self.rtc_codec != "h264":
            return
        self.h264_last = time.monotonic()
        if ch.bufferedAmount > H264_BUFFER_MAX:
            self.h264_need_key = True
        if self.h264_need_key and not (key and ch.bufferedAmount <= H264_BUFFER_MAX):
            return
        header = H264_MAGIC + struct.pack("<12i", *(geom if len(geom) == 8 else geom * 2), *size, int(key), 2)  # geom：擷取區（4 個）加整個桌面（4 個）；只有擷取區時兩者相同
        if self.send_rtc(header + au):
            self.h264_need_key = False
            self.streams["rtc"].frames += 1
            self.perf_window("rtc").add(kb=len(au) / 1024, buf=ch.bufferedAmount / 1024)
        else:
            self.h264_need_key = True

    async def h264_nap(self, secs: float):
        try:
            await asyncio.wait_for(self.h264_wake.wait(), secs)
        except asyncio.TimeoutError:
            pass
        self.h264_wake.clear()

    async def h264_start(self):
        self.h264_gen += 1
        gen, loop = self.h264_gen, asyncio.get_running_loop()
        self.cap.h264 = (h264_kbps(self.streams["rtc"].enc.base_quality),
                         lambda au, key, geom, size: loop.call_soon_threadsafe(self.h264_au, gen, au, key, geom, size))
        if hasattr(self.cap, "on_still"):  # 讀不到清晰畫面的來源（gst）沒有這個屬性：只送 H.264
            self.cap.still_lost = False
            self.cap.on_still = lambda msg: loop.call_soon_threadsafe(self.h264_still, gen, msg)
        self.h264_need_key = False
        self.h264_last = self.h264_started = time.monotonic()
        await self.start_capture()

    def h264_still(self, gen: int, msg: bytes):
        """抓圖執行緒送來的 JPEG（清晰畫面、小範圍更新、心跳）：和 H.264 的畫面走同一條通道，網頁依標頭的魔術字（KVMT／KVMH）分辨。
        送出佇列太滿就不送、並通知抓圖執行緒重來（JPEG 差異少了一則，基準就不一致了）。"""
        ch = self.rtc
        if gen != self.h264_gen or ch is None or ch.readyState != "open" or self.rtc_codec != "h264":
            return
        if ch.bufferedAmount > H264_BUFFER_MAX // 2 or not self.send_rtc(msg):
            self.cap.still_lost = True
            return
        self.h264_last = time.monotonic()

    async def h264_stop(self):
        self.h264_gen += 1
        if self.active:
            await self.in_pool(self.cap.stop)
            self.active = False

    async def h264_begin(self):
        await self.stop_capture()  # 先停掉 RGB + JPEG 的管線（這條通道從現在起只送 H.264）
        self.h264_task = self.spawn(self.h264_run())

    async def h264_fallback(self, why: str):
        """H.264 一直起不來（gst 一直死、或沒輸出）：這條通道改回 JPEG，網頁收到 codec 訊息後開始回 "a" 要畫面。"""
        print(f"H.264 unavailable ({why}); using JPEG over WebRTC", flush=True)
        self.rtc_codec = "jpeg"
        await self.h264_stop()
        self.cap.h264 = None
        ch = self.rtc
        if ch is not None and ch.readyState == "open":
            ch.send(json.dumps({"op": "codec", "name": "jpeg", "why": why}))

    async def h264_run(self):
        """管理 gst 管線：鎖定時停（GNOME 會拒絕分享）、解鎖後起、設定改變或網頁要關鍵影格時重起、死掉或太久沒輸出時重起。
        連續失敗三次就退回 JPEG。通道關了就收尾。"""
        fails = 0
        while self.rtc is not None and self.rtc.readyState == "open" and self.rtc_codec == "h264" and not self.paused:
            try:
                await self.check_lock()
                if self.locked:
                    await self.h264_stop()
                    await self.h264_nap(1)
                    continue
                if self.h264_restart:
                    self.h264_restart = False
                    await self.h264_stop()
                if not self.active:
                    try:
                        await self.h264_start()
                    except asyncio.CancelledError:
                        raise
                    except Exception as e:
                        # portal 本身的問題（逾時、被拒絕）：JPEG 也一樣會遇到，不算 H.264 的失敗，不退回 JPEG；照 JPEG 的方式通知網頁、退避重試
                        delay = self.capture_delay
                        print(f"capture error: {e!r}; retry in {delay}s (usb {usb_power_state() or 'n/a'})", flush=True)
                        await self.h264_stop()
                        await self.send({"op": "notice", "text": f"Screen capture failed on the target ({e}); retrying in {delay} s"})
                        self.capture_delay = min(delay * 2, 30)
                        await self.h264_nap(delay)
                        continue
                    if self.capture_delay != 2:
                        self.capture_delay = 2
                        await self.send({"op": "notice", "text": ""})
                elif not self.cap.h264_alive() or time.monotonic() - self.h264_last > 5:
                    raise RuntimeError("gst stopped producing frames")
                elif fails and time.monotonic() - self.h264_started > 10:
                    fails = 0
            except asyncio.CancelledError:
                raise
            except Exception as e:
                fails += 1
                print(f"H.264 pipeline: {e!r} ({fails}/3)", flush=True)
                await self.h264_stop()
                if fails >= 3:
                    await self.h264_fallback(str(e))
                    return
                await asyncio.sleep(fails)
                continue
            await self.h264_nap(0.5)
        await self.h264_stop()  # 通道關了或暫停：收尾（被取消時由 stop_capture 負責）

    async def next_frame(self, enc: DeltaEncoder) -> tuple:
        """擷取並只編碼變動區域；畫面沒變就持續輪詢，最多 2 秒後送心跳（不含畫面）。回傳 (訊息, 耗時)，耗時見 capture_update，另加 idle（等畫面變動）"""
        start = time.monotonic()
        deadline = start + 2
        evt = getattr(self, "frame_evt", None) if hasattr(self.cap, "on_frame") else None  # 有「新畫面」通知的來源（PipeWire）才能睡著等；其他來源只能輪詢
        while True:
            if evt is not None:
                evt.clear()  # 一定要在抓之前清：抓完到睡著之間來的新畫面才不會漏
            payload, mon, desktop, timing = await self.in_pool(capture_update, self.cap, enc)
            timing["idle"] = max(0.0, (time.monotonic() - start) * 1000 - timing["grab"] - timing["enc"])
            if payload is not None:
                enc.last_change = time.monotonic()
                return payload, timing
            if enc.degraded_sent and time.monotonic() - enc.last_change > REFINE_DELAY:
                enc.reset()  # 畫面停了：剛才送的是降品質的，以原本的品質整張補一次
                enc.quality, enc.degraded_sent = enc.base_quality, False
                continue
            now = time.monotonic()
            if now > deadline:
                return enc.heartbeat(mon, desktop), timing
            if evt is None:
                await asyncio.sleep(POLL)
                continue
            wait = deadline - now  # 睡到有新畫面；但心跳與「停下來補高品質」的時間到了也要醒來
            if enc.degraded_sent:
                wait = min(wait, max(0.0, enc.last_change + REFINE_DELAY - now))
            with contextlib.suppress(asyncio.TimeoutError):
                await asyncio.wait_for(evt.wait(), wait + 0.001)

    async def check_lock(self):
        """每秒最多偵測一次；狀態改變時通知網頁（空字串清掉訊息），解鎖後下一張送整張。"""
        now = time.monotonic()
        if now - self.lock_check < 1:
            return
        self.lock_check = now
        locked = await self.in_pool(is_locked)
        waited = time.monotonic() - now
        if waited > 1:  # 偵測要排在擷取（grab 最多等 10 秒新畫面）後面：這裡慢，鎖定／解鎖就晚被發現
            print(f"lock check waited {waited:.1f}s for the capture thread", flush=True)
        if locked == self.locked:
            return
        self.locked = locked
        print(f"target {'locked' if locked else 'unlocked'} (usb {usb_power_state() or 'n/a'}, viewers' frames "
              f"{'stopped' if locked else 'resume'}; lock check took {waited * 1000:.0f} ms)", flush=True)
        for st in self.streams.values():
            st.enc.reset()
        text = ("Target is locked: its screen can't be captured, but the keyboard still works. "
                "Type the password to unlock (this also shows for a UAC prompt)") if locked else ""
        await self.send({"op": "notice", "text": text})

    async def send_frame(self, name: str):
        """回應一次畫面要求（name：哪個接收端）：第一次先開始擷取，之後只送變動區域。
        在獨立 task 執行，等畫面變化時不會擋住檔案、剪貼簿等其他訊息。"""
        while True:
            try:
                await self.check_lock()
                if self.locked:  # 鎖定時 GNOME 一定拒絕分享：只輪詢鎖定狀態（已通知網頁），不碰 portal，解鎖後馬上恢復
                    if self.active:
                        t = time.monotonic()
                        await self.in_pool(self.cap.stop)
                        self.active = False
                        print(f"capture stopped for the lock in {(time.monotonic() - t) * 1000:.0f} ms", flush=True)
                    await asyncio.sleep(2)
                    continue
                if not self.active:
                    await self.start_capture()
                drain_ms = 0.0
                if name == "rtc":
                    ch, enc = self.rtc, self.streams[name].enc
                    enc.quality = adapt_quality(enc.quality, enc.base_quality, ch.bufferedAmount if ch is not None and ch.readyState == "open" else 0)
                    t = time.monotonic()
                    await self.rtc_drain()
                    drain_ms = (time.monotonic() - t) * 1000
                last, timing = await self.next_frame(self.streams[name].enc)
                if self.capture_delay != 2:  # 從失敗中恢復：清掉「擷取失敗」的訊息
                    self.capture_delay = 2
                    await self.send({"op": "notice", "text": ""})
                break
            except asyncio.CancelledError:
                raise
            except Exception as e:
                # 擷取失敗（例如螢幕鎖定時 GNOME 拒絕分享）：保持連線，通知網頁並逐步拉長間隔重試。
                # 不斷線重連，免得 dongle 每幾秒就記一筆斷線紀錄
                delay = self.capture_delay
                print(f"capture error: {e!r}; retry in {delay}s (usb {usb_power_state() or 'n/a'})", flush=True)
                if self.active:
                    await self.in_pool(self.cap.stop)
                    self.active = False
                why = "the screen-share request was declined or timed out" if "rejected or cancelled" in str(e) else str(e)
                try:
                    await self.send({"op": "notice", "text": f"Screen capture failed on the target ({why}); retrying in {delay} s"})
                except Exception:
                    return  # 連線已斷，重連後 dongle 會再要畫面
                self.capture_delay = min(delay * 2, 30)
                await asyncio.sleep(delay)
        # 分段送出：dongle 收一段轉一段，段與段之間才能處理點擊/按鍵
        delivered = True
        t_send = time.monotonic()
        if name == "rtc":
            delivered = self.send_rtc(last)
            if not delivered:  # DataChannel 已關：這張沒送出去，下一張要從整張開始
                self.streams[name].enc.reset()
        else:
            chunk = self.args.chunk_kb * 1024
            await self.ws.send([last[i:i + chunk] for i in range(0, len(last), chunk)])
        if delivered and len(last) > HEARTBEAT_LEN:
            self.streams[name].frames += 1
            rtc = self.rtc if name == "rtc" else None
            self.perf_window(name).add(
                full=timing["full"], grab=timing["grab"], enc=timing["enc"], age=timing["age"], idle=timing["idle"], drain=drain_ms,
                send=(time.monotonic() - t_send) * 1000, kb=len(last) / 1024, strips=timing["strips"],
                buf=(rtc.bufferedAmount / 1024 if rtc is not None and rtc.readyState == "open" else 0.0), q=self.streams[name].enc.quality)
        self.n += 1
        if self.args.verbose and self.n % 10 == 0:
            rate = self.n / (time.monotonic() - self.t0)
            print(f"{self.n} updates, {rate:.1f}/s, last {len(last) // 1024} KB", flush=True)

    async def paste(self, text: str):
        """網頁送來的文字寫進剪貼簿，再請 dongle 按貼上鍵：任何語言都能貼。"""
        try:
            # GNOME Wayland（portal）經 portal 寫剪貼簿；其他平台用系統工具
            await asyncio.get_running_loop().run_in_executor(None, getattr(self.cap, "set_clipboard", set_clipboard), text)
            await asyncio.sleep(0.05)  # 等剪貼簿擁有者就緒
            await self.send({"op": "keys", "usages": PASTE_KEYS})
            print(f"pasted {len(text)} chars", flush=True)
        except Exception as e:
            await self.send({"op": "notice", "text": f"paste failed on target: {e}"})
            print(f"paste failed: {e!r}", flush=True)

    async def confirm_over_usb(self, sha: str, req_sha: str) -> bool:
        """Wi-Fi 上的 dongle 沒有驗證（自簽憑證），區網裡有人冒充它就能對 agent 下指令；所以經 Wi-Fi 收到「更新自己」時，
        再從 USB 線（實體連線，冒充不了）問真正的 dongle：這個更新是不是剛才才從 MQTT 收到、轉給它的？"""
        port = self.args.usb_port or find_usb_port()
        if port is None:
            return False
        try:
            link = SerialLink(port)
        except Exception:
            return False
        try:
            reply = await usb_request(link, {"op": "agent_update_check", "sha256": sha, "req_sha256": req_sha}, "agent_update_check", 3)
            return bool(reply and reply.get("ok") is True)
        except Exception:
            return False
        finally:
            await link.close()

    async def agent_update(self, cmd: dict):
        """receiver 網頁的「Update agent」：下載新的 screen_agent.py（與有變的 requirements.txt）、驗證雜湊、裝好、重新啟動。
        進度與失敗原因用 notice 回報（receiver 的事件清單看得到）。只更新安裝版；只接受經 USB 確認過的指令。"""
        async def say(text: str):
            print(f"agent update: {text}", flush=True)
            try:
                await self.send({"op": "notice", "text": "Agent update: " + text})
            except Exception:
                pass

        args = agent_update_args(cmd)
        if isinstance(args, str):
            return await say(args)
        sha, url, req_sha, req_url = args
        here = Path(__file__).resolve()
        req_file = here.with_name("requirements.txt")
        if here.parent != INSTALL_DIR.resolve():
            return await say(f"this agent runs from {here.parent}, not from the installed folder {INSTALL_DIR}: only the installed copy is updated")
        if sha == sha256_file(here) and (not req_sha or req_sha == sha256_file(req_file)):
            return await say("already up to date")
        self.updating = True
        try:
            if self.via != "usb" and not await self.confirm_over_usb(sha, req_sha):
                return await say("refused: the dongle did not confirm this update over USB (is the dongle plugged into this PC?)")
            await say("downloading…")
            agent = await asyncio.to_thread(download_verified, url, sha, int(cmd.get("size") or 0))
            req = None
            if req_sha and req_sha != sha256_file(req_file):
                req = await asyncio.to_thread(download_verified, req_url, req_sha, int(cmd.get("req_size") or 0))
            await say("installing…" + (" (packages first, it can take minutes)" if req is not None else ""))
            await asyncio.to_thread(install_agent_files, here, agent, req)
        except Exception as e:
            return await say(f"failed, the old version keeps running: {e}")
        finally:
            self.updating = False
        await say("installed; restarting")
        await asyncio.sleep(0.5)  # 讓上面那則 notice 送得出去
        restart_process()

    async def send_clipboard(self):
        """回傳目標剪貼簿給網頁（最多 8 KB）。"""
        try:
            text = await asyncio.get_running_loop().run_in_executor(None, getattr(self.cap, "get_clipboard", get_clipboard))
            await self.send({"op": "clipboard", "text": text[:8192]})
        except Exception as e:
            await self.send({"op": "notice", "text": f"read clipboard failed on target: {e}"})


def _raise_interrupt(*_):
    raise KeyboardInterrupt


def rejected_status(e: Exception) -> int | None:
    """dongle 拒絕 WebSocket 握手時的 HTTP 狀態碼（websockets 新舊版的例外不同）。"""
    response = getattr(e, "response", None)
    return getattr(response, "status_code", None) or getattr(e, "status_code", None)


def retry_delay(status: int | None, fails: int) -> int:
    """斷線後多久重連（秒）。token 錯：30。其他（已有別的 agent 的 409、dongle 沒插或沒開機…）：連續失敗 n 次後 2、4、8… 最多 30。
    別讓多開的 agent 每 2 秒就逼 dongle 做一次 TLS 握手，拖慢正在串流的那個；沒插 dongle 時也不要一直做 DNS／TLS／掃 USB。
    剛重啟的 agent 常遇到舊連線還沒被 dongle 清掉，或 dongle 剛重開機，所以一開始仍要短。"""
    return 30 if status == 401 else min(30, 2 << fails)


def describe_error(e: Exception) -> str:
    status = rejected_status(e)
    if status == 401:
        return (f"token rejected (401): connect the dongle via USB to detect token automatically, "
                f"or put the token shown on the dongle's screen (T:) into {CONFIG_DIR / 'token'} or pass --token")
    if status == 409:
        return ("another screen agent is already connected to the dongle (409), e.g. the autostart service "
                "(to run this one instead: systemctl --user stop app-io.github.geekkvm.Agent@default.service)")
    return repr(e)


def ice_servers(args) -> list[dict]:
    """WebRTC 的 ICE 伺服器（RTCIceServer 的參數）：STUN 讓雙方找到各自的公開位址；
    兩邊都在嚴格的 NAT 後面（對稱型、電信業者的 CGNAT）時 STUN 打不通，要有 TURN 中繼。"""
    servers = [{"urls": args.stun}] if args.stun else []
    if args.turn:
        servers.append({"urls": [u.strip() for u in args.turn.split(",") if u.strip()],
                        "username": args.turn_user or None, "credential": args.turn_password or None})
    return servers


def default_token() -> str:
    f = CONFIG_DIR / "token"
    return os.environ.get("KVM_TOKEN") or (f.read_text().strip() if f.exists() else "")


def pick_backend() -> str:
    """--backend auto：GNOME Wayland → portal；Windows 且裝了 dxcam → dxcam（DXGI）；其餘 mss。"""
    if sys.platform.startswith("linux") and os.environ.get("WAYLAND_DISPLAY"):
        return "portal"
    if sys.platform == "win32":
        try:
            import dxcam  # noqa: F401

            return "dxcam"
        except ImportError:
            pass
    return "mss"


def run_bench(cap, quality: int, seconds: float = 10.0, fmt: str = "jpeg") -> dict:
    """（阻塞）量各階段的耗時，不連 dongle。cap 要已經 start()。回傳 {名稱: 毫秒或張數}：
    grab = 只抓畫面；live = 這段時間裡真的在跑 capture_update 的平均（畫面靜止時 enc 只是比對）；
    full / region / caret = 把整張、600x400、8x16 的區域改掉再編碼（模擬換頁、開視窗、打字）。"""
    def avg(f, n):
        t = time.perf_counter()
        for _ in range(n):
            out = f()
        return (time.perf_counter() - t) / n * 1000, out

    enc = DeltaEncoder(quality)
    enc.fmt = fmt
    rgb, size, mon, desk = cap.grab()
    w, h = size
    r = {"width": w, "height": h, "rgb": rgb}
    r["grab"], _ = avg(cap.grab, 20)

    enc.encode(rgb, size, mon, desk)  # 建立 prev
    r["same"], _ = avg(lambda: enc.encode(rgb, size, mon, desk), 20)  # 畫面完全沒變

    def changed(x0, y0, rw, rh):
        b = bytearray(rgb)
        for y in range(y0, min(y0 + rh, h)):
            i = (y * w + x0) * 3
            b[i:i + min(rw, w - x0) * 3] = bytes(x ^ 255 for x in rgb[i:i + min(rw, w - x0) * 3])
        return bytes(b)

    for name, (rw, rh) in {"caret": (8, 16), "region": (600, 400), "full": (w, h)}.items():
        cur = changed(max((w - rw) // 2, 0), max((h - rh) // 2, 0), rw, rh)
        n = 3 if name == "full" else 10
        total = 0.0
        for _ in range(n):
            enc.prev, enc.size = rgb, size  # 每次都和原畫面比
            t = time.perf_counter()
            msg = enc.encode(cur, size, mon, desk)
            total += time.perf_counter() - t
        r[name], r[name + "_kb"] = total / n * 1000, len(msg or b"") / 1024

    enc.reset()
    n, sums, changes, t_end = 0, {"grab": 0.0, "enc": 0.0, "age": 0.0}, 0, time.monotonic() + seconds
    while time.monotonic() < t_end:
        payload, _, _, m = capture_update(cap, enc)
        n += 1
        changes += payload is not None
        sums["grab"] += m["grab"]
        sums["enc"] += m["enc"]
        sums["age"] += m["age"]
        if payload is None:
            time.sleep(0.004)  # 畫面沒變就稍等：portal 的 grab 回的是最新一張，不等的話只是空轉燒 CPU
    r.update(live_n=n, live_changed=changes, live_fps=changes / seconds, live_grab=sums["grab"] / max(n, 1), live_enc=sums["enc"] / max(n, 1),
             live_age=sums["age"] / max(n, 1))
    return r


def bench_h264(rgb: bytes, size: tuple, prefer: str = "auto", frames: int = 30) -> dict:
    """（阻塞）同一個畫面用「自動選的編碼器」與軟體 libx264 各編一輪，回傳 {編碼器名稱: 每張毫秒}（含 BGRA 轉換與縮放前的處理，不含擷取）。
    畫面每張改一小塊，讓編碼器真的在編碼而不是重複同一張。沒有 PyAV 回 {}。"""
    if not pyav_h264_ok():
        return {}
    w, h = size
    bgra = bytearray(Image.frombytes("RGB", size, rgb).convert("RGBA").tobytes())
    out = {}
    for choice in dict.fromkeys([prefer, "software"]):
        enc = open_h264_encoder(size, (w // 2 * 2, h // 2 * 2), h264_kbps(95), choice)
        if enc.name in out:
            continue
        t = time.perf_counter()
        for i in range(frames):
            bgra[(i * 4096) % (len(bgra) - 4096):][:1024] = bytes(1024)
            enc.encode(bytes(bgra))
        out[enc.name] = (time.perf_counter() - t) / frames * 1000
    return out


def bench(args) -> int:
    """--bench：在這台電腦上量螢幕擷取與編碼各花多少時間（不需要 dongle），找出幀率的瓶頸在哪一段。10 秒內請操作一下畫面（捲動、拖視窗）。"""
    backend = pick_backend() if args.backend == "auto" else args.backend
    portal = backend == "portal"
    cap = PortalCapture(args.max_width) if portal else MssCapture(args.monitor, args.max_width, dxgi=backend == "dxcam")
    try:
        if portal:
            print("GNOME Wayland: allow the screen sharing request that appears (the same one the agent shows)…", flush=True)
        cap.start()
        print(f"capture: {backend}; python {sys.version.split()[0]}, Pillow {Image.__version__ if hasattr(Image, '__version__') else '?'}; "
              f"measuring for 10 s, quality {args.quality}: move windows / scroll now…", flush=True)
        rows = {fmt: run_bench(cap, args.quality, 10.0 if fmt == "jpeg" else 3.0, fmt) for fmt in ("jpeg", "webp")}
    except Exception as e:
        print(f"benchmark failed: {e!r}")
        return 1
    finally:
        cap.close()
    j = rows["jpeg"]
    print(f"\nscreen {j['width']}x{j['height']}   (all times in ms; fps = 1000 / ms)")
    if portal:  # grab 只是取走 gst 已經放好的最新一張，所以量不出擷取的速度；擷取的上限是 gst 管線的 videorate
        print("  capture: GNOME portal -> PipeWire -> gst-launch (the pipeline is set to max-rate=15; the live line shows what really arrives)")
    else:
        print(f"  grab (capture only)          {j['grab']:7.1f}   -> at most {1000 / max(j['grab'], 0.01):5.0f} fps from the capture alone")
    print(f"  picture unchanged            {j['same']:7.1f}   (comparison only)")
    for name, label in (("caret", "typing (8x16 changes)"), ("region", "window (600x400 changes)"), ("full", "whole screen changes")):
        print(f"  {label:28s} {j[name]:7.1f}   jpeg {j[name + '_kb']:6.0f} KB   webp {rows['webp'][name]:6.1f} ms {rows['webp'][name + '_kb']:6.0f} KB")
    for name, ms in bench_h264(j["rgb"], (j["width"], j["height"]), args.h264_encoder).items():
        print(f"  H.264 encode with {name:14s} {ms:7.1f}" + ("   (hardware)" if name != "libx264" else "   (software)"))
    print(f"  live: {j['live_changed']} changed pictures in 10 s = {j['live_fps']:.1f} fps (the picture only changes when you move things); "
          f"per grab: capture {j['live_grab']:.1f} + compare/encode {j['live_enc']:.1f}" + (f"; picture waited {j['live_age']:.0f} ms in the PipeWire pipeline" if j["live_age"] else ""))
    print()
    if portal:
        fps, per = j["live_fps"], j["live_grab"] + j["live_enc"]
        print(f"=> Measured {fps:.0f} changed pictures/s with {per:.0f} ms of work per picture (about {1000 / max(per, 0.1):.0f}/s possible): "
              + ("the capture pipeline limits the rate." if fps < 0.8 * min(15, 1000 / max(per, 0.1)) and fps <= 16
                 else "this PC is not the limit; if the picture is slow, look at the network path and the size of big updates (rows above)."))
    elif j["grab"] >= j["region"]:
        print("=> The screen capture is the slowest step (a faster capture library, e.g. dxcam on Windows, would raise the frame rate most).")
    elif j["full"] > 3 * j["grab"]:
        print("=> Encoding is the slowest step on big changes: lower --quality or --max-width, or use the H.264 / WebRTC video path.")
    else:
        print("=> Capture and encoding are both fast here: if the picture is still slow, the limit is the network path (see the dongle page's stats and F12 → kvmDiag()).")
    return 0


def diagnose(args) -> int:
    """--diagnose：一步一步檢查這台電腦上 agent 要能動的每個條件，印出報告（也存成檔案）。不會改任何東西、也不啟動 agent。
    順序就是 agent 實際做的事：Python 與套件 → 找 dongle 的 USB 序列埠 → 問 dongle 的位址 → 連上 dongle → 擷取螢幕 → 剪貼簿。"""
    import platform
    import socket

    report, fails = [], [0]

    def say(tag: str, text: str, hint: str = ""):
        report.append(f"[{tag}] {text}")
        if hint:
            report.extend("        " + h for h in hint.splitlines())
        if tag == "FAIL":
            fails[0] += 1

    def head(title: str):
        report.append("")
        report.append(f"== {title}")

    head("System")
    say("info", f"{platform.platform()}, Python {platform.python_version()} ({platform.architecture()[0]}) at {sys.executable}")
    say("info", f"agent {Path(__file__).resolve()} ({time.strftime('%Y-%m-%d %H:%M', time.localtime(Path(__file__).stat().st_mtime))})")
    say("info", f"console encoding {sys.stdout.encoding}, user {os.environ.get('USERNAME') or os.environ.get('USER')}")
    if sys.version_info < (3, 10):
        say("FAIL", "Python is older than 3.10", "Install Python 3.12 (python.org, or: winget install Python.Python.3.12) and reinstall the agent.")

    head("Packages")
    from importlib import metadata

    for mod, pkg, needed in (("mss", "mss", True), ("PIL", "Pillow", True), ("websockets", "websockets", True), ("serial", "pyserial", True), ("aiortc", "aiortc", False)):
        try:
            __import__(mod)
            say("ok", f"{pkg} {metadata.version(pkg)}")
        except Exception as e:
            if needed:
                say("FAIL", f"{pkg} cannot be imported: {e!r}", "Run:  python -m pip install -r requirements.txt   (with the agent's own Python)")
            else:
                say("warn", f"{pkg} is not available ({e!r})", "Only 'WebRTC direct' needs it; the screen still works through the dongle.")

    head("Dongle on USB")
    port = None
    try:
        from serial.tools import list_ports

        ports = list(list_ports.comports())
        usb_ports = [pt for pt in ports if pt.vid is not None]  # 內建的 COM1、ttyS* 等不是 USB，列出來只會洗版
        say("info", f"{len(ports)} serial port(s), {len(usb_ports)} of them on USB")
        for pt in usb_ports:
            say("info", f"{pt.device}  USB {pt.vid:04X}:{pt.pid:04X}  description={pt.description!r}  product={pt.product!r}  interface={pt.interface!r}")
        port = find_usb_port()
        if port:
            say("ok", f"the dongle's serial port is {port}")
        else:
            esp = [pt.device for pt in ports if pt.vid == USB_VID]
            hint = ("Plug the dongle into THIS PC with USB (the USB-A plug of the stick, not a charger). In Device Manager there should be a\n"
                    "'USB Serial Device (COMx)' under 'Ports (COM & LPT)' next to a 'USB Input Device' (the keyboard and mouse the dongle emulates).")
            if esp:
                hint = f"An Espressif device is present on {', '.join(esp)} but it is not this dongle's firmware (USB id {USB_VID:04X}:{USB_PID:04X} expected)."
            say("FAIL", "no serial port belongs to the dongle", hint)
    except ImportError:
        say("FAIL", "pyserial is not installed", "python -m pip install pyserial")
    except Exception as e:
        say("FAIL", f"listing the serial ports failed: {e!r}")

    info, token, dongle_pem = None, args.token or "", None

    async def ask_dongle():
        link = SerialLink(port)  # PermissionError = 別的程式（多半是已經在跑的 agent）佔著這個埠
        try:
            got = await usb_request(link, {"op": "info"}, "info", 3)
            tok = pem = None
            if got is not None:
                key = await usb_request(link, {"op": "get_agent_key"}, "agent_key", 2)  # 新韌體：agent 專用金鑰；舊韌體不回，退回萬能 token
                tok = {"token": key["key"], "agent_key": True} if key and isinstance(key.get("key"), str) else await usb_request(link, {"op": "get_token"}, "token", 3)
                cert = await usb_request(link, {"op": "tls_cert"}, "tls_cert", 2)
                pem = cert.get("pem") if cert and isinstance(cert.get("pem"), str) and "BEGIN CERTIFICATE" in cert["pem"] else None
            return got, tok, pem
        finally:
            await link.close()
            await asyncio.sleep(0.5)  # 讀取執行緒（每 0.2 秒檢查一次）要在事件迴圈關掉之前結束

    if port:
        try:
            info, tok, dongle_pem = asyncio.run(ask_dongle())
            if info is None:
                say("FAIL", "the dongle did not answer on its serial port",
                    "Its firmware may be too old (update it from its web page), or something else is using the port.")
            else:
                say("ok", f"the dongle answers: {info.get('device')}  MAC {info.get('mac')}  IP {info.get('ip') or '(not on Wi-Fi yet)'}  mdns {info.get('mdns')}")
                if tok and isinstance(tok.get("token"), str) and not token:
                    token = tok["token"]
                    say("ok", "got the agent key over USB" if tok.get("agent_key") else "got the token over USB (old firmware: update it so the agent gets a key that cannot flash or reconfigure the dongle)")
        except PermissionError as e:
            say("FAIL", f"cannot open {port}: {e}",
                "Another program has the port open, most likely an agent that is already running: stop it first\n"
                "(install_agent.bat /diagnose does that for you), then run this again.")
        except Exception as e:
            say("FAIL", f"talking to {port} failed: {e!r}")

    head("Connection to the dongle")
    host = (info or {}).get("ip") or (info or {}).get("mdns") or ""
    if not info:
        say("info", "skipped: the dongle was not found on USB")
    elif not host:
        say("warn", "the dongle is not on Wi-Fi yet", "The agent will use the USB cable only; check the dongle's LCD for the Wi-Fi state.")
    else:
        try:
            socket.create_connection((host, 443), timeout=5).close()
            say("ok", f"TCP to {host}:443")
            try:
                if dongle_pem:  # 與 agent 平常一樣：只信任 USB 上拿到的那張憑證，token 放標頭
                    ctx = pinned_ssl_context(dongle_pem)
                else:
                    ctx = ssl.create_default_context()
                    ctx.check_hostname, ctx.verify_mode = False, ssl.CERT_NONE
                    say("warn", "the dongle's firmware cannot give its certificate over USB: this check does not verify the dongle (update its firmware)")

                async def ws_probe():
                    if dongle_pem:
                        conn = websockets.connect(f"wss://{host}/ws/agent", ssl=ctx, max_size=WS_MAX_MESSAGE, ping_interval=None, open_timeout=8,
                                                  additional_headers={"Authorization": f"Bearer {token or default_token()}"})
                    else:
                        conn = websockets.connect(f"wss://{host}/ws/agent?token={token or default_token()}", ssl=ctx, max_size=WS_MAX_MESSAGE, ping_interval=None, open_timeout=8)
                    async with conn:
                        return True

                asyncio.run(ws_probe())
                say("ok", "the WebSocket to the dongle opens (an agent is accepted)")
            except Exception as e:
                code = rejected_status(e)
                why = {401: "the token is wrong", 409: "another agent is already connected to this dongle (close it: only one at a time)"}.get(code, describe_error(e))
                say("FAIL", f"the WebSocket was refused or failed: {why}")
        except OSError as e:
            say("FAIL", f"cannot reach {host}:443 over the network: {e}",
                "This PC and the dongle must be on the same network (no guest/isolated Wi-Fi, no VPN taking all traffic).\n"
                "The agent falls back to the USB cable, but that is slow; fix the network for a smooth picture.")

    head("Screen capture")
    dxgi = args.backend == "dxcam" or (args.backend == "auto" and pick_backend() == "dxcam")
    cap = MssCapture(args.monitor, args.max_width, dxgi=True) if dxgi else MssCapture(args.monitor, args.max_width)
    if sys.platform.startswith("linux") and os.environ.get("WAYLAND_DISPLAY") and args.backend not in ("mss", "dxcam"):
        say("info", "skipped: this Linux desktop (Wayland) is captured through the GNOME portal, which only works inside the running agent")
        cap = None
    try:
        if cap is None:
            raise StopIteration
        t = time.monotonic()
        cap.start()
        mons = cap.monitors()
        say("info", "monitors: " + ", ".join(f"#{m['index']} {m['w']}x{m['h']} at {m['x']},{m['y']}" for m in mons) + f"   (the agent captures #{args.monitor})")
        rgb, size, _, _ = cap.grab()
        ms = (time.monotonic() - t) * 1000
        if len(set(rgb[::997])) <= 1:
            say("FAIL", f"the captured picture ({size[0]}x{size[1]}) is a single color", "The screen is locked, or it is a protected window (DRM) / a remote-desktop session that is minimized or disconnected.")
        else:
            say("ok", f"captured {size[0]}x{size[1]} in {ms:.0f} ms")
        say("info", "the PC is locked right now" if is_locked() else "the PC is not locked")
    except StopIteration:
        pass
    except Exception as e:
        say("FAIL", f"screen capture failed: {e!r}", "Run the agent inside your own desktop session (the installer's Startup entry does); a service or an RDP-disconnected session cannot capture.")
    finally:
        try:
            cap and cap.close()
        except Exception:
            pass

    head("Clipboard")
    try:
        get_clipboard()
        say("ok", "the clipboard can be read")
    except Exception as e:
        say("warn", f"reading the clipboard failed: {e!r}", "Only the 'Copy from target' button needs this.")

    report.append("")
    report.append(f"{fails[0]} problem(s) found." if fails[0] else "Nothing wrong was found. If the picture is still missing, send the agent.log together with this report.")
    text = "\n".join(report)
    print(text, flush=True)
    try:
        CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        out = CONFIG_DIR / "diagnose.txt"
        out.write_text(text + "\n", encoding="utf-8")
        print(f"\n(saved to {out})", flush=True)
    except Exception:
        pass
    return 1 if fails[0] else 0


def main() -> int:
    p = argparse.ArgumentParser(description="Screen capture agent for Geek KVM (run on the target PC)")
    p.add_argument("--host", help=argparse.SUPPRESS)  # 已移除：只連插在這台電腦上的 dongle。留著只是讓舊的服務檔（ExecStart 帶 --host）不會因為參數錯誤而起不來
    p.add_argument("--token", help="dongle token (default: auto-detected from dongle via USB, "
                   "or $KVM_TOKEN / ~/.config/esp32s3geek_kvm/token)")
    p.add_argument("--cafile", help="verify the dongle with this PEM (default: the certificate the dongle hands over on USB, else no verification)")
    p.add_argument("--require-pin", action="store_true",
                   help="never connect over Wi-Fi unless the dongle's certificate came over USB (refuses firmware too old to give it)")
    p.add_argument("--transport", choices=["auto", "usb", "wifi"], default="auto",
                   help="auto: Wi-Fi, falling back to USB (CDC-ACM, needs pyserial) when the dongle can't be reached")
    p.add_argument("--usb-port", help="serial port of the dongle (default: detect by USB VID and name)")
    p.add_argument("--backend", choices=["auto", "mss", "dxcam", "portal"], default="auto",
                   help="screen capture: auto = GNOME portal on Wayland, DXGI (dxcam) on Windows when installed, else mss (GDI on Windows)")
    p.add_argument("--monitor", type=int, default=1, help="mss monitor index (0 = all monitors)")
    p.add_argument("--quality", type=int, default=95, help="JPEG quality")
    p.add_argument("--max-width", type=int, default=100000, help="downscale wider screens to this width (default: native)")
    p.add_argument("--stun", default="stun:stun.l.google.com:19302",
                   help="STUN server for WebRTC across NATs (empty = LAN only); WebRTC needs aiortc")
    p.add_argument("--turn", default=os.environ.get("KVM_TURN", ""),
                   help="TURN server(s) for WebRTC when STUN cannot get through, e.g. turn:turn.example.com:3478 "
                        "(comma separated for several; default: $KVM_TURN). The receiver needs the same server (TURN_URL)")
    p.add_argument("--turn-user", default=os.environ.get("KVM_TURN_USER", ""), help="TURN user name (default: $KVM_TURN_USER)")
    p.add_argument("--turn-password", default=os.environ.get("KVM_TURN_PASSWORD", ""),
                   help="TURN password (default: $KVM_TURN_PASSWORD; prefer the environment over the command line)")
    p.add_argument("--chunk-kb", type=int, default=4,
                   help="WebSocket fragment size; smaller = snappier clicks/typing while streaming, larger = more fps")
    p.add_argument("--download-dir", default=str(Path.home() / "Downloads"),
                   help="where files sent from the web UI are saved")
    p.add_argument("-v", "--verbose", action="store_true", help="print frame rate")
    p.add_argument("--bench", action="store_true", help="measure how long screen capture and encoding take on this PC (no dongle needed), print a report and exit")
    p.add_argument("--h264-encoder", choices=["auto", *H264_CHOICES], default="auto",
                   help="H.264 encoder for the WebRTC video path: auto = a hardware encoder (NVENC, QSV, AMF, VideoToolbox, or the GStreamer ones on Wayland) when it passes a self-test, else software x264")
    p.add_argument("--diagnose", action="store_true", help="check everything the agent needs on this PC (USB port, dongle, network, screen capture), print a report and exit")
    args = p.parse_args()
    for stream in (sys.stdout, sys.stderr):  # log 檔的編碼（Windows 是 cp1252／cp950）放不下的字元不能讓 agent 當掉
        try:
            stream.reconfigure(errors="replace")
        except Exception:
            pass
    if args.diagnose:
        return diagnose(args)
    if args.bench:
        return bench(args)
    if args.host:
        print("--host is ignored: the agent only connects to the dongle plugged into this computer", flush=True)

    backend = args.backend
    if backend == "auto":
        backend = pick_backend()
    cap = PortalCapture(args.max_width) if backend == "portal" else MssCapture(args.monitor, args.max_width, dxgi=backend == "dxcam")
    cap.h264_encoder = args.h264_encoder
    print(f"backend: {backend}", flush=True)
    # SIGTERM 也走正常收尾（關閉 portal session / gst-launch）
    signal.signal(signal.SIGTERM, _raise_interrupt)
    try:
        asyncio.run(Agent(args, cap).run())
    except KeyboardInterrupt:
        pass
    return 0


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