kvm:虚拟机管理

命令说明

  kvm list                                    列出所有虚拟机 + 开机自启列表
  kvm info  [虚拟机]                          看静态配置 + 运行信息(缺省取 list 第一个)
  kvm create [虚拟机] <目录|qcow2路径> [方案]   创建(定义)虚拟机,创建后为 off,不自动运行
  kvm switch [虚拟机] [方案] [on|off|run]      切换 xml 方案(缺省方案=虚拟机名.xml;缺省 run)
  kvm run|boot|start|up|on    [虚拟机]         开机(已开机不报错)
  kvm stop|down|off           [虚拟机]         关机(ACPI 优雅)
  kvm reboot|restart          [虚拟机]         重启
  kvm poweroff|destroy        [虚拟机]         强制断电
  kvm autostart [--disable]   [虚拟机]         设/取消 开机自启
  kvm rm [-f]                 [虚拟机]         卸载虚拟机实例(卸域 + 卸存储池;物理目录保留,默认二次确认)
  kvm login|desktop           [虚拟机]         进入图形桌面
  kvm exec|console            [虚拟机]         进入文本控制台
  kvm ls                      [虚拟机]         列出客机桌面上的文件(缺省取 list 第一个)
  kvm cp|copy|get|pull        [虚拟机] [文件名]  客机桌面 → <虚拟机xml目录>/desktop/(通配符请加引号)
  kvm paste|send|push         [虚拟机] [文件名]  <虚拟机xml目录>/desktop/ → 客机桌面(同上)
  kvm usb [list]              [虚拟机]         列出宿主 USB 设备 + 是否已挂给客机
  kvm usb <设备>              [虚拟机]         切换:已挂→拔回,未挂→挂上(设备=序号或 VID:PID)
  kvm usb add|rm <设备>       [虚拟机]         显式指定挂上 / 拔回

  方案名: 空=默认(<虚拟机>.xml) | 例如 half / full  →  <虚拟机>-<方案>.xml

/etc/nixos/modules/system/service/qemu.nix

#!/usr/bin/env bash
# ============================================================
#  kvm.sh —— 本机 KVM/libvirt 虚拟机管理命令
#  约定: 一台虚拟机 = /s500data/kvm/<虚拟机名>/ 一个子目录
#        方案文件: <虚拟机名>.xml(默认) 或 <虚拟机名>-<方案名>.xml
#        系统盘:   <虚拟机名>.qcow2
#  建议: alias kvm=/s500data/kvm/kvm.sh   之后即可用 `kvm <命令>`
# ============================================================
set -uo pipefail

KVM_ROOT="/s500data/kvm"
CONN="qemu:///system"
V="virsh -c $CONN"

die(){ echo "错误: $*" >&2; exit 1; }

# ---------- 辅助 ----------
scheme_xml(){ # <vm> [scheme] -> 方案 xml 路径
  if [ -z "${2:-}" ]; then echo "$KVM_ROOT/$1/$1.xml"; else echo "$KVM_ROOT/$1/$1-$2.xml"; fi
}
xml_name(){ sed -n 's:.*<name>\(.*\)</name>.*:\1:p' "$1" 2>/dev/null | head -1; }

list_vms(){ # 所有虚拟机(含 .qcow2 或 .xml 的子目录),按名排序
  local d
  for d in "$KVM_ROOT"/*/; do
    [ -d "$d" ] || continue
    if compgen -G "${d}*.qcow2" >/dev/null || compgen -G "${d}*.xml" >/dev/null; then
      basename "$d"
    fi
  done | sort
}
dom_of_vm(){ # 该虚拟机当前已定义的域(按“磁盘路径归属”识别),无则空
  local vm="$1" d
  while read -r d; do
    [ -n "$d" ] || continue
    if $V dumpxml --inactive "$d" 2>/dev/null | grep -q "source file='$KVM_ROOT/$vm/"; then
      echo "$d"; return 0
    fi
  done < <($V list --all --name 2>/dev/null | sed '/^$/d')
  return 1
}
vm_for(){ # 目标虚拟机:给了就用;没给取 list 第一个
  if [ -n "${1:-}" ]; then echo "$1"; return; fi
  local f; f="$(list_vms | head -1)"
  [ -n "$f" ] || die "没有可用虚拟机"
  echo "$f"
}
avail_schemes(){ ls "$KVM_ROOT/$1/$1"*.xml 2>/dev/null | xargs -n1 basename 2>/dev/null | tr '\n' ' '; }

usage(){
cat <<'EOF'
kvm.sh —— 虚拟机管理

  kvm list                                    列出所有虚拟机 + 开机自启列表
  kvm info  [虚拟机]                          看静态配置 + 运行信息(缺省取 list 第一个)
  kvm create [虚拟机] <目录|qcow2路径> [方案]   创建(定义)虚拟机,创建后为 off,不自动运行
  kvm switch [虚拟机] [方案] [on|off|run]      切换 xml 方案(缺省方案=虚拟机名.xml;缺省 run)
  kvm run|boot|start|up|on    [虚拟机]         开机(已开机不报错)
  kvm stop|down|off           [虚拟机]         关机(ACPI 优雅)
  kvm reboot|restart          [虚拟机]         重启
  kvm poweroff|destroy        [虚拟机]         强制断电
  kvm autostart [--disable]   [虚拟机]         设/取消 开机自启
  kvm rm [-f]                 [虚拟机]         卸载虚拟机实例(卸域 + 卸存储池;物理目录保留,默认二次确认)
  kvm login|desktop           [虚拟机]         进入图形桌面
  kvm exec|console            [虚拟机]         进入文本控制台
  kvm ls                      [虚拟机]         列出客机桌面上的文件(缺省取 list 第一个)
  kvm cp|copy|get|pull        [虚拟机] [文件名]  客机桌面 → <虚拟机xml目录>/desktop/(通配符请加引号)
  kvm paste|send|push         [虚拟机] [文件名]  <虚拟机xml目录>/desktop/ → 客机桌面(同上)
  kvm usb [list]              [虚拟机]         列出宿主 USB 设备 + 是否已挂给客机
  kvm usb <设备>              [虚拟机]         切换:已挂→拔回,未挂→挂上(设备=序号或 VID:PID)
  kvm usb add|rm <设备>       [虚拟机]         显式指定挂上 / 拔回

  方案名: 空=默认(<虚拟机>.xml) | 例如 half / full  →  <虚拟机>-<方案>.xml
EOF
}

# ---------- 命令 ----------
cmd_list(){
  printf "%-14s %-20s %-9s %-6s %s\n" "虚拟机" "当前方案/域" "状态" "自启" "磁盘"
  local vm cur st as disk
  for vm in $(list_vms); do
    cur="$(dom_of_vm "$vm" || true)"
    if [ -n "$cur" ]; then
      st="$($V domstate "$cur" 2>/dev/null)"; [ "$st" = running ] && st="运行中" || st="已关机"
      as="$($V dominfo "$cur" 2>/dev/null | awk '/^Autostart:/{print $2}')"
      [ "$as" = enable ] && as="是" || as="否"
    else
      st="未定义"; as="-"; cur="—"
    fi
    disk="$(ls "$KVM_ROOT/$vm"/*.qcow2 2>/dev/null | xargs -n1 basename 2>/dev/null | tr '\n' ' ')"
    printf "%-14s %-20s %-9s %-6s %s\n" "$vm" "$cur" "$st" "$as" "${disk:-—}"
  done
  echo
  echo "【开机自启列表】"
  $V list --autostart --all 2>/dev/null
}

cmd_info(){
  local vm cur X
  vm="$(vm_for "${1:-}")"
  echo "==================== 虚拟机: $vm ===================="
  echo "实例目录: $KVM_ROOT/$vm"
  cur="$(dom_of_vm "$vm" || true)"
  if [ -n "$cur" ]; then
    echo "当前域  : $cur  ($($V domstate "$cur" 2>/dev/null))"
    X="$($V dumpxml --inactive "$cur" 2>/dev/null)"
  else
    echo "当前域  : (未定义)"
    X="$(cat "$KVM_ROOT/$vm/$vm.xml" 2>/dev/null)"
  fi
  [ -n "$X" ] || die "无可用配置(既未定义域,也无默认 $vm.xml)"

  echo "---- 静态配置 (KVM 设定) ----"
  echo "$X" | sed -n 's/^[[:space:]]*//;s/^\(<name>\|<uuid>\|<vcpu[^>]*>\|<memory[^>]*>\|<currentMemory[^>]*>\|.*machine=[^ ]*\|<source file=.*\|<model type=.*\|<mac address=.*\|<graphics type=.*\|<source network=.*\)/\1/p'
  echo "---- 运行信息 ----"
  if [ -n "$cur" ] && [ "$($V domstate "$cur" 2>/dev/null)" = running ]; then
    $V domstats "$cur" 2>/dev/null | grep -E "state.state|cpu.time|vcpu.current|vcpu.maximum|balloon.rss|block.1.(rd|wr)_bytes|net.0.(rx|tx).bytes" | sed 's/^/  /'
    echo "  -- 磁盘 --"; $V domblklist "$cur" 2>/dev/null | sed 's/^/  /'
    echo "  -- 网卡 --"; $V domiflist "$cur" 2>/dev/null | sed 's/^/  /'
    echo "  -- IP  --"; $V domifaddr "$cur" --source lease 2>/dev/null | sed 's/^/  /'
  else
    echo "  (未运行)"
  fi
  echo "---- 存储池 / 卷 ----"
  $V pool-list --all 2>/dev/null | sed 's/^/  /'
  $V vol-list "$vm" 2>/dev/null | sed 's/^/  /'
  echo "---- 网络 ----"
  $V net-list --all 2>/dev/null | sed 's/^/  /'
  ip -4 addr show virbr0 2>/dev/null | awk '/inet /{print "  virbr0 "$2}'
}

cmd_create(){
  local a1="${1:-}" a2="${2:-}" a3="${3:-}" vm path sc dir disk xml cur
  [ -n "$a1" ] || die "用法: kvm create [虚拟机] <目录|qcow2> [方案]"
  if [[ "$a1" == */* || -e "$a1" ]]; then vm=""; path="$a1"; sc="$a2"; else vm="$a1"; path="$a2"; sc="$a3"; fi
  [ -n "$path" ] || die "缺少路径"
  if [ -d "$path" ]; then dir="${path%/}"; else dir="$(dirname "$path")"; fi
  [ -n "$vm" ] || vm="$(basename "$dir")"
  if [ -d "$path" ]; then disk="$dir/$vm.qcow2"; else disk="$path"; fi
  xml="$(scheme_xml "$vm" "$sc")"
  [ -d "$dir" ] || die "目录不存在: $dir"
  [ -f "$disk" ] || die "磁盘不存在: $disk"
  [ -f "$xml" ] || die "方案文件不存在: $xml  (现有: $(avail_schemes "$vm"))"
  if ! $V pool-info "$vm" >/dev/null 2>&1; then
    $V pool-define-as "$vm" dir --target "$dir" >/dev/null 2>&1
    $V pool-build "$vm" >/dev/null 2>&1; $V pool-start "$vm" >/dev/null 2>&1; $V pool-autostart "$vm" >/dev/null 2>&1
    echo "已建存储池: $vm → $dir"
  fi
  cur="$(dom_of_vm "$vm" || true)"
  if [ -n "$cur" ]; then
    [ "$($V domstate "$cur" 2>/dev/null)" = running ] && die "虚拟机 $vm 正在运行($cur),请先 stop/poweroff 再 create"
    $V undefine "$cur" --nvram >/dev/null 2>&1 || $V undefine "$cur" >/dev/null 2>&1
  fi
  $V define "$xml" >/dev/null || die "define 失败: $xml"
  echo "已创建: $vm  方案=${sc:-默认}  域='$(xml_name "$xml")'  状态=off(create 不自动运行)"
}

cmd_switch(){
  local vm="${1:-}" sc="${2:-}" mode="${3:-}"
  case "$sc" in on|off|run|start) mode="$sc"; sc="";; esac
  [ -n "$mode" ] || mode="run"
  vm="$(vm_for "$vm")"
  local dir="$KVM_ROOT/$vm" xml cur dn
  xml="$(scheme_xml "$vm" "$sc")"
  [ -f "$xml" ] || die "找不到方案文件: $xml  (现有: $(avail_schemes "$vm"))"
  dn="$(xml_name "$xml")"; [ -n "$dn" ] || die "方案文件缺 <name>: $xml"
  cur="$(dom_of_vm "$vm" || true)"
  if [ -n "$cur" ]; then
    [ "$($V domstate "$cur" 2>/dev/null)" = running ] && $V destroy "$cur" >/dev/null 2>&1
    { $V undefine "$cur" --nvram >/dev/null 2>&1 || $V undefine "$cur" >/dev/null 2>&1; }
  fi
  $V define "$xml" >/dev/null || die "define 失败: $xml"
  echo "已切换: $vm  方案=${sc:-(默认)}  →  域 '$dn'  (${xml##*/})"
  if [ "$mode" = off ]; then echo "(off:切换后未启动)"; return 0; fi
  $V start "$dn" >/dev/null && echo "已启动 '$dn'"
}

# ---------- 图形控制台(脱离终端) ----------
# 背景:virt-viewer 以前用 `exec` 前台运行 ⇒ 命令"卡"在控制台,Ctrl+C / 关终端会把窗口一起带走。
# 现在:新会话(setsid)+ 断开 stdio ⇒ 终端立刻归还;Ctrl+C / 关终端只影响本地,
#       窗口与虚拟机都继续运行。
spawn_viewer(){
  local cur="$1" log
  log="${XDG_RUNTIME_DIR:-/tmp}/virt-viewer-$cur.log"
  command -v virt-viewer >/dev/null || die "缺 virt-viewer"
  # 每次都新起一个图形控制台(新会话 ⇒ 脱离终端):
  #   命令立即返回、不在终端刷输出;Ctrl+C / 关终端都不影响窗口与虚拟机。
  # 不做"已在运行就跳过"的判断 —— 进程表里的僵尸/同名进程会造成误判而什么都不开(实测踩过)。
  setsid virt-viewer -c "$CONN" "$cur" </dev/null >/dev/null 2>"$log" &
  disown 2>/dev/null || true
  sleep 1
  if grep -qiE "cannot open display|Authorization required|Unable to connect|Connection refused|Failed to connect" "$log" 2>/dev/null; then
    echo "警告: 图形控制台未打开(原因见 $log):" >&2; sed -n '1,3p' "$log" >&2
  else
    echo "已打开图形控制台(脱离终端): $cur —— Ctrl+C / 关终端都不影响它与虚拟机"
  fi
}
cmd_run(){
  local vm cur dx
  vm="$(vm_for "${1:-}")"
  cur="$(dom_of_vm "$vm" || true)"
  if [ -z "$cur" ]; then
    dx="$KVM_ROOT/$vm/$vm.xml"
    [ -f "$dx" ] || die "虚拟机 $vm 未定义,且无默认方案 $vm.xml(请先 create)"
    cur="$(xml_name "$dx")"; $V define "$dx" >/dev/null || die "define 失败: $dx"
    echo "未定义 → 已按默认方案 $vm.xml 定义域 '$cur'"
  fi
  if [ "$($V domstate "$cur" 2>/dev/null)" = running ]; then echo "虚拟机 $vm('$cur')已在运行"; else
    $V start "$cur" >/dev/null && echo "已启动 $vm('$cur')"
  fi
  spawn_viewer "$cur"
}

cmd_stop(){
  local vm cur; vm="$(vm_for "${1:-}")"; cur="$(dom_of_vm "$vm" || true)"
  [ -n "$cur" ] || die "虚拟机 $vm 未定义"
  [ "$($V domstate "$cur" 2>/dev/null)" = running ] || { echo "虚拟机 $vm 未运行"; return 0; }
  $V shutdown "$cur" >/dev/null && echo "已发送关机(ACPI): $vm('$cur')"
}
cmd_reboot(){
  local vm cur; vm="$(vm_for "${1:-}")"; cur="$(dom_of_vm "$vm" || true)"
  [ -n "$cur" ] || die "虚拟机 $vm 未定义"
  [ "$($V domstate "$cur" 2>/dev/null)" = running ] || die "虚拟机 $vm 未运行,无法重启"
  $V reboot "$cur" >/dev/null && echo "已重启: $vm('$cur')"
  spawn_viewer "$cur"
}
cmd_poweroff(){
  local vm cur; vm="$(vm_for "${1:-}")"; cur="$(dom_of_vm "$vm" || true)"
  [ -n "$cur" ] || die "虚拟机 $vm 未定义"
  [ "$($V domstate "$cur" 2>/dev/null)" = running ] || { echo "虚拟机 $vm 未运行"; return 0; }
  $V destroy "$cur" >/dev/null && echo "已强制断电: $vm('$cur')"
}
cmd_rm(){
  local force=0 vm dir cur disk sz ans
  while [ $# -gt 0 ]; do
    case "$1" in -f|--force|-y|--yes) force=1; shift ;; *) break ;; esac
  done
  vm="$(vm_for "${1:-}")"
  dir="$KVM_ROOT/$vm"
  cur="$(dom_of_vm "$vm" || true)"
  if [ "$force" != 1 ]; then
    echo "将卸载虚拟机实例 $vm (只卸 libvirt 注册;物理目录保留):"
    echo "  · 域定义${cur:+(当前 '$cur',在运行则先强制断电)}"
    echo "  · 存储池 $vm"
    if [ -d "$dir" ]; then
      disk="$(ls "$dir"/*.qcow2 2>/dev/null | xargs -n1 basename 2>/dev/null | tr '\n' ' ')"
      sz="$(du -sh "$dir" 2>/dev/null | cut -f1)"
      echo "  · 物理目录保留: $dir(磁盘 ${disk:-—},实占 ${sz:-—})—— 不动,由你手动维护"
    else
      echo "  · 物理目录不存在: $dir(仅清注册)"
    fi
    printf "确认?[y/N] "
    read -r ans
    case "$ans" in y|Y|yes|YES) ;; *) echo "已取消"; return 0 ;; esac
  fi
  if [ -n "$cur" ]; then
    [ "$($V domstate "$cur" 2>/dev/null)" = running ] && $V destroy "$cur" >/dev/null 2>&1
    $V undefine "$cur" --nvram >/dev/null 2>&1 || $V undefine "$cur" >/dev/null 2>&1
    echo "已移除域: $cur"
  else
    echo "无已定义域(跳过)"
  fi
  if $V pool-info "$vm" >/dev/null 2>&1; then
    $V pool-destroy "$vm" >/dev/null 2>&1
    $V pool-undefine "$vm" >/dev/null 2>&1
    echo "已移除存储池: $vm"
  fi
  echo "已卸载实例注册: $vm   (物理目录保留: $dir)"
}
cmd_autostart(){
  local disable=0 vm cur
  if [ "${1:-}" = "--disable" ]; then disable=1; shift; fi
  vm="$(vm_for "${1:-}")"; cur="$(dom_of_vm "$vm" || true)"
  [ -n "$cur" ] || die "虚拟机 $vm 未定义"
  if [ "$disable" = 1 ]; then $V autostart --disable "$cur" >/dev/null && echo "已取消开机自启: $vm('$cur')"
  else $V autostart "$cur" >/dev/null && echo "已设为开机自启: $vm('$cur')"; fi
}
cmd_login(){
  local vm cur; vm="$(vm_for "${1:-}")"; cur="$(dom_of_vm "$vm" || true)"
  [ -n "$cur" ] || die "虚拟机 $vm 未定义"
  [ "$($V domstate "$cur" 2>/dev/null)" = running ] || die "虚拟机 $vm 未运行,请先 run"
  echo "打开桌面: $vm('$cur')..."
  spawn_viewer "$cur"
}
cmd_console(){
  local vm cur; vm="$(vm_for "${1:-}")"; cur="$(dom_of_vm "$vm" || true)"
  [ -n "$cur" ] || die "虚拟机 $vm 未定义"
  [ "$($V domstate "$cur" 2>/dev/null)" = running ] || die "虚拟机 $vm 未运行,请先 run"
  echo "进入文本控制台: $vm('$cur')  (退出: Ctrl+] )"
  exec $V console "$cur"
}

# ---------- 文件互搬 / 客机桌面列表(经 qemu-guest-agent) ----------
# 客机侧固定是「当前用户桌面」;宿主侧固定是「虚拟机 xml 所在目录下的 desktop/」(不存在则自动创建)。
# 走 qemu-ga:不改 XML、不装软件、不重启(客机里 qemu-ga 随 virtio-win guest tools 已在跑)。
# 通配符 * 由两端各自展开;**文件名请加引号**(如 kvm cp '*.png'),否则会被本地 shell 提前展开。
# 性能:客机侧命令轮询 50ms 起指数退避(原先固定 sleep 2s/次);桌面探测合成单次调用。
ga_run(){ # <模式 ls|get|put> [参数1] [参数2]
  local mode="$1" a1="${2:-}" a2="${3:-}" vm pat cur vmdir
  # 参数1 若正好是已知虚拟机名 → 它作虚拟机、参数2 作文件名模式;否则参数1 作模式、虚拟机取第一个
  if [ -n "$a1" ] && list_vms | grep -qxF -- "$a1"; then vm="$a1"; pat="${a2:-*}"; else vm="$(vm_for)"; pat="${a1:-*}"; fi
  command -v python3 >/dev/null || die "缺 python3"
  vmdir="$KVM_ROOT/$vm/desktop"          # 本地落点 = 虚拟机 xml 所在目录下的 desktop/
  mkdir -p "$vmdir" || die "无法创建目录: $vmdir"
  # 找域 / 查运行状态一并交给 python(libvirt 绑定枚举,比 bash 反复调 virsh 快)
  KVM_GA_CONN="$CONN" KVM_GA_VM="$vm" KVM_GA_ROOT="$KVM_ROOT" KVM_GA_MODE="$mode" \
  KVM_GA_PAT="$pat" KVM_GA_VMDIR="$vmdir" KVM_GA_USER="${KVM_GA_USER:-}" \
  KVM_GA_DESKTOP="${KVM_GA_DESKTOP:-}" \
  python3 - <<'PYEOF'
# 经 qemu-guest-agent 与客机互搬文件 / 列客机桌面(由 kvm 的 ls / cp / paste 子命令调用)
# 入参走环境变量,避免把中文路径塞进命令行被 shell/locale 破坏
# 连接走 libvirt python 绑定(不是 virsh 子进程):绑定复用连接,单次 agent 命令 0.001s,
# virsh 每次新建进程 + 连 libvirtd 要 0.059s —— 一次 kvm ls 差 20~60 倍。
import os, sys, json, glob, time, base64, shlex
import libvirt, libvirt_qemu

CONN    = os.environ["KVM_GA_CONN"]                    # libvirt 连接 URI(qemu:///system)
VM      = os.environ["KVM_GA_VM"]                      # 虚拟机名(目录名)
ROOT    = os.environ["KVM_GA_ROOT"]                    # 虚拟机存储根
MODE    = os.environ["KVM_GA_MODE"]                    # ls | get | put
PAT     = os.environ["KVM_GA_PAT"]                     # 文件名通配符
VMDIR   = os.environ["KVM_GA_VMDIR"]                   # 宿主落点(虚拟机 xml 所在目录下的 desktop/)
USER    = os.environ.get("KVM_GA_USER", "").strip()    # 客机用户名,空=自动探测(仅 Windows 用)
DESKTOP = os.environ.get("KVM_GA_DESKTOP", "").strip() # 兜底:直接指定客机桌面绝对路径,任何 OS 都适用

_conn = libvirt.open(CONN)                             # 进程内复用
_dom  = None                                           # 域:首次用时按磁盘归属查找(见 dom())
CMD   = r"C:\Windows\System32\cmd.exe"                 # Windows 客机
SH    = "/bin/sh"                                      # POSIX 客机(linux / darwin)
CHUNK_RD = 1048576   # guest-file-read 的量:数据经通道返回,不受命令行参数大小限制
CHUNK_WR = 65536     # guest-file-write 的量:base64 要塞进命令行参数,Linux 单参数上限 128KB,取 64KB 稳妥
SYSDIRS  = {"all users", "default", "default user", "public"}
_OSID    = None      # 客机 OS 标识(mswindows / linux / darwin),本进程内只探一次

class AgentError(Exception):
    """qemu-agent 侧失败(文件被占用 / 路径不存在 / 通道不通…):可预期的业务失败,批量时逐条报告、不中断整批"""

def die(msg):
    print("错误: " + msg, file=sys.stderr); sys.exit(1)

def dom():
    """取本虚拟机对应的域:所有域里谁的 XML 引用了 <ROOT>/<VM>/ 下的盘;顺带确认在运行。
    放在 python 里做(绑定枚举域远快于 bash 里反复调 virsh dumpxml / domstate)。"""
    global _dom
    if _dom is None:
        for d in _conn.listAllDomains():
            if f"{ROOT}/{VM}/" in d.XMLDesc():
                _dom = d; break
        if _dom is None:
            raise AgentError(f"虚拟机 {VM} 未定义(没有域引用 {ROOT}/{VM}/)")
        if not _dom.isActive():
            raise AgentError(f"虚拟机 {VM} 未运行(qemu-guest-agent 需要运行中的域)")
    return _dom

def q(obj):
    # ensure_ascii=True 必须:非 ASCII 路径要转义成 \uXXXX,原样发 UTF-8 字节会解析失败
    try:
        r = libvirt_qemu.qemuAgentCommand(dom(), json.dumps(obj, ensure_ascii=True), 10, 0)
    except libvirt.libvirtError as e:
        raise AgentError(str(e)[:200])
    return json.loads(r)["return"]

def osid():
    """客机 OS 标识(guest-get-osinfo):mswindows / linux / darwin。本进程内只探一次。"""
    global _OSID
    if _OSID is None:
        _OSID = q({"execute": "guest-get-osinfo"})["id"]
    return _OSID

def exec_wait(path, args):
    """在客机里跑一条命令并等它结束,返回 (stdout_bytes, exitcode)。
    轮询固定 20ms:dir / ls 这类命令毫秒级就结束,指数退避会让首轮白等。"""
    pid = q({"execute": "guest-exec",
             "arguments": {"path": path, "arg": args, "capture-output": True}})["pid"]
    st = {}
    for _ in range(250):                               # 上限 5s
        time.sleep(0.02)
        st = q({"execute": "guest-exec-status", "arguments": {"pid": pid}})
        if st.get("exited"):
            return base64.b64decode(st.get("out-data") or b""), st.get("exitcode")
    raise AgentError("客机命令超时未结束: " + path + " " + " ".join(args)[:80])

def parse_dir_lines(lines):
    """把 Windows dir /a-d-h-s /-c 的输出行解析成 [(名字, 字节)];隐藏/系统文件不在其中"""
    files = []
    for ln in lines:
        p = ln.split(None, 3)                      # 形如: 2026/10/17 21:57 132767 文件名
        if len(p) < 4 or "/" not in p[0]:          # 跳过表头 / "N 个文件" 汇总行
            continue
        try:
            size = int(p[2])
        except ValueError:
            continue
        files.append((p[3].strip(), size))
    return files

def parse_find_lines(lines):
    """把 POSIX find -printf '%s\t%f\n' 的输出解析成 [(名字, 字节)]"""
    files = []
    for ln in lines:
        if "\t" not in ln:
            continue
        sz, name = ln.split("\t", 1)
        try:
            files.append((name.strip(), int(sz)))
        except ValueError:
            continue
    return files

def guest_join(desk, name):
    """按客机 OS 拼路径分隔符(Windows 反斜杠 / POSIX 正斜杠)"""
    return desk + ("\\" if "\\" in desk else "/") + name

def win_desktop_and_list(pat):
    """Windows:一次 guest-exec 同时拿到「用户桌面」与「该桌面下匹配的文件列表」。
    输出形如 @@KVMGA@@<用户目录> 紧跟该用户桌面的 dir 输出,可能多块(Public 等按系统目录过滤)。"""
    if USER:                                           # 指定了用户名就只发一次 dir
        desk = rf"C:\Users\{USER}\Desktop"
        out, _ = exec_wait(CMD, ["/c", "dir", "/a-d-h-s", "/-c", rf"{desk}\{pat}"])
        return desk, parse_dir_lines(out.decode("gbk", "replace").splitlines())
    out, _ = exec_wait(CMD, ["/c", "for", "/d", "%u", "in", r"(C:\Users\*)", "do", "@if",
                             "exist", r"%u\Desktop", "(echo", "@@KVMGA@@%u", "&", "dir",
                             "/a-d-h-s", "/-c", rf"%u\Desktop\{pat})"])
    for blk in out.decode("gbk", "replace").split("@@KVMGA@@")[1:]:
        lines = blk.splitlines()
        if not lines:
            continue
        path = lines[0].strip()                        # 形如 <盘>:\Users\<名>
        name = path.rsplit("\\", 1)[-1]
        low = name.lower()
        if not name or low in SYSDIRS or low.startswith("defaultuser"):
            continue
        return path + r"\Desktop", parse_dir_lines(lines[1:])
    die("客机里找不到用户桌面(可用环境变量 KVM_GA_USER=<用户名> 或 KVM_GA_DESKTOP=<路径> 指定)")

def posix_desktop_and_list(pat):
    """linux / darwin:桌面按 XDG 取(xdg-user-dir DESKTOP),退化到 ~/Desktop、~/桌面;
    find -printf 统一输出「字节<TAB>名」,与 Windows 侧解析结果同形。
    ※ 本机只有 Windows 客机,此分支尚无实测对象。"""
    script = (
        'd=$(xdg-user-dir DESKTOP 2>/dev/null); '
        '[ -z "$d" ] && [ -d "$HOME/Desktop" ] && d="$HOME/Desktop"; '
        '[ -z "$d" ] && [ -d "$HOME/\u684c\u9762" ] && d="$HOME/\u684c\u9762"; '
        '[ -n "$d" ] && [ -d "$d" ] || exit 3; '
        'echo "@@KVMGA@@$d"; '
        'find "$d" -maxdepth 1 -type f -name ' + shlex.quote(pat) + ' -printf "%s\\t%f\\n" 2>/dev/null'
    )
    out, rc = exec_wait(SH, ["-c", script])
    if rc == 3:
        die("客机里找不到用户桌面(可用环境变量 KVM_GA_DESKTOP=<路径> 指定)")
    blocks = out.decode("utf-8", "replace").split("@@KVMGA@@")[1:]
    if not blocks:
        die("客机里找不到用户桌面(可用环境变量 KVM_GA_DESKTOP=<路径> 指定)")
    lines = blocks[0].splitlines()
    return lines[0].strip(), parse_find_lines(lines[1:])

def desktop_and_list(pat):
    """统一入口:按客机 OS 定位用户桌面并列出匹配文件,返回 (桌面路径, [(名字, 字节)])。
    KVM_GA_DESKTOP 直接指定时任何 OS 都走它。"""
    if DESKTOP:                                    # 直接指定:仍按客机 OS 选命令(Windows 客机没有 /bin/sh)
        if osid() == "mswindows":
            out, rc = exec_wait(CMD, ["/c", "dir", "/a-d-h-s", "/-c", DESKTOP + "\\" + pat])
            if rc != 0 and not out.strip():
                die("KVM_GA_DESKTOP 指定的路径不可用: " + DESKTOP)
            return DESKTOP, parse_dir_lines(out.decode("gbk", "replace").splitlines())
        out, rc = exec_wait(SH, ["-c", 'find ' + shlex.quote(DESKTOP) + ' -maxdepth 1 -type f -name '
                                 + shlex.quote(pat) + ' -printf "%s\\t%f\\n" 2>/dev/null'])
        if rc not in (0, 1):
            die("KVM_GA_DESKTOP 指定的路径不可用: " + DESKTOP)
        return DESKTOP, parse_find_lines(out.decode("utf-8", "replace").splitlines())
    if osid() == "mswindows":
        return win_desktop_and_list(pat)
    return posix_desktop_and_list(pat)

def get_one(gpath, hpath):
    h = q({"execute": "guest-file-open",
           "arguments": {"path": gpath, "mode": "r"}})    # 返回值本身就是 handle
    n = 0
    try:
        with open(hpath, "wb") as f:
            while True:
                r = q({"execute": "guest-file-read", "arguments": {"handle": h, "count": CHUNK_RD}})
                if r.get("eof"):
                    break
                b = base64.b64decode(r["buf-b64"]); f.write(b); n += len(b)
    finally:
        q({"execute": "guest-file-close", "arguments": {"handle": h}})   # 失败路径也必须关,否则客机文件被锁
    return n

def put_one(hpath, gpath):
    h = q({"execute": "guest-file-open", "arguments": {"path": gpath, "mode": "w"}})
    n = 0
    try:
        with open(hpath, "rb") as f:
            while True:
                c = f.read(CHUNK_WR)
                if not c:
                    break
                q({"execute": "guest-file-write",
                   "arguments": {"handle": h, "buf-b64": base64.b64encode(c).decode()}})
                n += len(c)
    finally:
        q({"execute": "guest-file-close", "arguments": {"handle": h}})
    return n

def human(n):
    x = float(n)
    for u in ("B", "KB", "MB", "GB"):
        if x < 1024:
            return f"{x:.0f}{u}" if u == "B" else f"{x:.1f}{u}"
        x /= 1024
    return f"{x:.1f}TB"

def main():
    if MODE == "ls":
        desk, files = desktop_and_list(PAT)
        if not files:
            die(f"无匹配 {PAT}({desk})")
        print(f"客机桌面 {desk}")
        for name, size in files:
            print(f"  {human(size):>9}  {name}")
        print(f"共 {len(files)} 个文件")
        return 0

    if MODE == "get":
        desk, files = desktop_and_list(PAT)
        if not files:
            die(f"无匹配 {PAT}({desk})")
        fails = []
        for name, _ in files:
            try:
                n = get_one(guest_join(desk, name), os.path.join(VMDIR, name))
                print(f"  ↓ {name}  {human(n)}")
            except AgentError as e:
                fails.append(name); print(f"  ✗ {name}: {e}", file=sys.stderr)
        print(f"已拷出 {len(files)-len(fails)}/{len(files)} 个文件 → {VMDIR}")
        return 1 if fails else 0

    if MODE == "put":
        desk, _ = desktop_and_list("*")            # 只需要桌面路径
        srcs = [s for s in sorted(glob.glob(os.path.join(VMDIR, PAT))) if os.path.isfile(s)]
        if not srcs:
            die(f"无匹配 {PAT}({VMDIR})")
        fails = []
        for s in srcs:
            name = os.path.basename(s)
            try:
                n = put_one(s, guest_join(desk, name))
                print(f"  ↑ {name}  {human(n)}")
            except AgentError as e:
                fails.append(name); print(f"  ✗ {name}: {e}", file=sys.stderr)
        print(f"已拷入 {len(srcs)-len(fails)}/{len(srcs)} 个文件 → {desk}")
        return 1 if fails else 0

    die("未知模式 " + MODE)

if __name__ == "__main__":
    try:
        sys.exit(main())
    except AgentError as e:
        die(str(e))
PYEOF
}
cmd_ls(){    ga_run ls  "${1:-}" "${2:-}"; }
cmd_copy(){  ga_run get "${1:-}" "${2:-}"; }
cmd_paste(){ ga_run put "${1:-}" "${2:-}"; }


# ---------- USB 直通(运行时热插拔,不写进 XML)----------
# 为什么不写进 XML:设备不在时域会起不来(USB hostdev 没有「可缺失」语义);
# 而键鼠这类设备本来就该由运行时决定给谁 —— 与 gpu 的「状态不进世代」同一条思路。
cmd_usb(){
  # 参数形态(2026-10-18 精简):
  #   kvm usb                  → 列表
  #   kvm usb list             → 列表
  #   kvm usb <设备> [虚拟机]   → 切换:已挂就拔回、没挂就挂上
  #   kvm usb add <设备> [虚拟机] / kvm usb rm <设备> [虚拟机]  → 显式指定
  local a="${1:-list}" b="${2:-}" c="${3:-}" act="" spec="" vmarg="" vm dom
  case "$a" in
    list)   act=list;   vmarg="$b" ;;
    add|rm) act="$a";   spec="$b";  vmarg="$c" ;;
    *)      act=toggle; spec="$a";  vmarg="$b" ;;
  esac
  vm="$(vm_for "$vmarg")"
  # 同一块盘可能以不同「方案」定义成不同域名 ⇒ 一律解析成当前实际域名
  dom="$(dom_of_vm "$vm" || true)"; dom="${dom:-$vm}"
  python3 - "$act" "$spec" "$dom" "$V" <<'PY'
import subprocess, sys, glob, os, re, shlex

act, spec, vm, V = sys.argv[1], sys.argv[2], sys.argv[3], shlex.split(sys.argv[4])

def virsh(*args):
    r = subprocess.run(V + list(args), capture_output=True, text=True)
    return r.returncode, (r.stdout or "") + (r.stderr or "")

def devices():
    out = []
    for d in sorted(glob.glob('/sys/bus/usb/devices/*')):
        def rd(f):
            try:
                return open(os.path.join(d, f)).read().strip()
            except Exception:
                return ""
        vid, pid, prod = rd('idVendor'), rd('idProduct'), rd('product')
        if not vid or not prod:
            continue                      # 没有产品名的多半是根 hub / 接口节点
        if rd('bDeviceClass') == '09':
            continue                      # 跳过 hub
        out.append({
            'vid': vid.lower(), 'pid': pid.lower(), 'product': prod,
            'manuf': rd('manufacturer'), 'port': os.path.basename(d),
            'class': rd('bDeviceClass'),
        })
    return out

def attached():
    rc, xml = virsh('dumpxml', vm)
    res = set()
    for blk in re.findall(r"<hostdev[^>]*type='usb'.*?</hostdev>", xml, re.S):
        v = re.search(r"<vendor id='0x([0-9a-fA-F]+)'", blk)
        p = re.search(r"<product id='0x([0-9a-fA-F]+)'", blk)
        if v and p:
            res.add(f"{v.group(1).lower()}:{p.group(1).lower()}")
    return res

devs = devices()
if act == 'list':
    att = attached()
    print(f"{'#':<3} {'设备':<12} {'产品':<30} {'厂商':<22} {'端口':<12} 给客机")
    for i, d in enumerate(devs):
        key = f"{d['vid']}:{d['pid']}"
        mark = "✓ 已挂" if key in att else ""
        print(f"{i:<3} {key:<12} {d['product'][:29]:<30} {d['manuf'][:21]:<22} {d['port']:<12} {mark}")
    print(f"\n已挂给 {vm} 的 USB 设备:{len(att)} 个" + (f"({', '.join(sorted(att))})" if att else ""))
    print("切换:kvm usb <序号|VID:PID>      显式:kvm usb add|rm <序号|VID:PID>")
    sys.exit(0)

# ---- add / rm ----
if not spec:
    sys.exit("用法:kvm usb add|rm <序号|VID:PID> [虚拟机]")
target = None
if re.fullmatch(r'\d+', spec):
    idx = int(spec)
    if idx >= len(devs):
        sys.exit(f"序号越界:只有 {len(devs)} 个设备(kvm usb list 看序号)")
    target = devs[idx]
elif re.fullmatch(r'[0-9a-fA-F]{4}:[0-9a-fA-F]{4}', spec):
    v, p = spec.lower().split(':')
    target = next((d for d in devs if d['vid'] == v and d['pid'] == p), None)
else:
    sys.exit(f"无法识别的设备写法:{spec}(用序号或 VID:PID)")
if not target:
    sys.exit(f"宿主上找不到该设备:{spec}")

xml = (f"<hostdev mode='subsystem' type='usb' managed='yes'>\n"
       f"  <source><vendor id='0x{target['vid']}'/><product id='0x{target['pid']}'/></source>\n"
       f"</hostdev>\n")
tmp = "/tmp/kvm-usb-" + target['vid'] + target['pid'] + ".xml"
open(tmp, 'w').write(xml)

if act == 'toggle':
    act = 'rm' if f"{target['vid']}:{target['pid']}" in attached() else 'add'

op = 'attach-device' if act == 'add' else 'detach-device'
rc, out = virsh(op, vm, tmp, '--live')
name = f"{target['product']} ({target['vid']}:{target['pid']})"
if rc != 0:
    sys.exit(f"✗ {op} 失败:{out.strip()}")
print(("✓ 已挂给客机:" if act == 'add' else "✓ 已拔回宿主:") + name)
if act == 'add':
    print("  提示:这是运行时直通,客机重启后失效(要长期生效再改 XML 或写进启动流程)")
PY
}

# ---------- 分发 ----------
cmd="${1:-}"; [ $# -gt 0 ] && shift
case "$cmd" in
  list)                       cmd_list ;;
  info)                       cmd_info "${1:-}" ;;
  create)                     cmd_create "${1:-}" "${2:-}" "${3:-}" ;;
  switch|sw)                  cmd_switch "${1:-}" "${2:-}" "${3:-}" ;;
  run|boot|start|up|on)       cmd_run "${1:-}" ;;
  stop|down|off)              cmd_stop "${1:-}" ;;
  reboot|restart)             cmd_reboot "${1:-}" ;;
  poweroff|destroy|destory)   cmd_poweroff "${1:-}" ;;
  autostart)                  cmd_autostart "${1:-}" "${2:-}" ;;
  login|desktop|view)         cmd_login "${1:-}" ;;
  ls)                         cmd_ls "${1:-}" ;;
  cp|copy|get|pull)           cmd_copy "${1:-}" "${2:-}" ;;
  paste|send|push)            cmd_paste "${1:-}" "${2:-}" ;;
  exec|console)               cmd_console "${1:-}" ;;
  usb)                        cmd_usb "${1:-list}" "${2:-}" "${3:-}" ;;
  rm|del|remove)              cmd_rm "$@" ;;
  ""|-h|--help|help)          usage ;;
  *) echo "未知命令: $cmd"; echo; usage; exit 2 ;;
esac