From be40e324dbfaa7f579e51290b7ef3261fa86499d Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:29:30 +0800 Subject: [PATCH] feat: add USB/PC/SC discovery support and an automated deployment installation script --- README.md | 3 +- docs/README.zh-CN.md | 2 +- internal/pcsc/backend_linux.go | 102 ++++++++++++++++++++++++++++ internal/pcsc/usb_discovery.go | 22 +++++- internal/pcsc/usb_discovery_test.go | 21 ++++++ scripts/install.sh | 30 ++++++-- 6 files changed, 168 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index abab6de..8d1b579 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,8 @@ VoCat uses `qmicli` to verify that a QMI control channel is ready and `qmi-network` to manage packet-data sessions. The one-click installer installs and verifies the corresponding utilities automatically. For manual deployment, Debian/Ubuntu uses `apt install libqmi-utils`; Arch Linux uses -`pacman -S libqmi`, and Alpine uses `apk add qmi-utils`. +`pacman -S libqmi`, Alpine uses `apk add qmi-utils`, and OpenWrt uses +`opkg install qmi-utils`. `vocat doctor --repair-dji-qmi` checks for `qmicli` before changing any USB driver binding or asserting DTR. If the utility is unavailable, the command diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 193b45a..05a054e 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -192,7 +192,7 @@ VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服 VoCat 使用 `qmicli` 验证 QMI 控制通道是否就绪,并使用 `qmi-network` 管理 分组数据会话。一键安装脚本会自动安装并验证对应工具。手动部署时, Debian/Ubuntu 使用 `apt install libqmi-utils`;Arch Linux 使用 -`pacman -S libqmi`,Alpine 使用 `apk add qmi-utils`。 +`pacman -S libqmi`,Alpine 使用 `apk add qmi-utils`,OpenWrt 使用 `opkg install qmi-utils`。 `vocat doctor --repair-dji-qmi` 会在修改 USB 驱动绑定或触发 DTR 之前检查 `qmicli`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。 diff --git a/internal/pcsc/backend_linux.go b/internal/pcsc/backend_linux.go index a3bbe61..914756c 100644 --- a/internal/pcsc/backend_linux.go +++ b/internal/pcsc/backend_linux.go @@ -10,6 +10,7 @@ import ( "fmt" "net" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -44,9 +45,107 @@ func (backend *nativeBackend) dial(ctx context.Context) (*pcscdClient, error) { return nil, fmt.Errorf("%w: pcscd socket is not reachable: %w", ErrUnavailable, errors.Join(failures...)) } +func ensurePCSCDService(ctx context.Context) { + if os.Geteuid() != 0 { + return + } + if _, err := os.Stat("/run/systemd/system"); err == nil { + _ = exec.CommandContext(ctx, "systemctl", "start", "pcscd.socket").Run() + _ = exec.CommandContext(ctx, "systemctl", "start", "pcscd").Run() + } else if _, err := os.Stat("/etc/init.d/pcscd"); err == nil { + _ = exec.CommandContext(ctx, "/etc/init.d/pcscd", "start").Run() + } else if path, err := exec.LookPath("pcscd"); err == nil { + _ = exec.CommandContext(ctx, path).Start() + } +} + +func reauthorizeUSBDevice(sysRoot, usbPath string) { + if strings.Contains(usbPath, "..") || strings.Contains(usbPath, "/") || strings.Contains(usbPath, "\\") { + return + } + authPath := filepath.Join(filepath.Clean(sysRoot), "bus", "usb", "devices", usbPath, "authorized") + if _, err := os.Stat(authPath); err != nil { + return + } + _ = os.WriteFile(authPath, []byte("0\n"), 0o644) + time.Sleep(100 * time.Millisecond) + _ = os.WriteFile(authPath, []byte("1\n"), 0o644) +} + +func (backend *nativeBackend) waitForPCSCReaders(ctx context.Context, client *pcscdClient, physical []Reader, states []pcscdReaderState) []pcscdReaderState { + // First pass: wait up to 2 seconds for active driver negotiation. + pollDeadline := time.Now().Add(2 * time.Second) + if dl, ok := ctx.Deadline(); ok && dl.Before(pollDeadline) { + pollDeadline = dl + } + for len(states) < len(physical) && time.Now().Before(pollDeadline) { + select { + case <-ctx.Done(): + return states + case <-time.After(250 * time.Millisecond): + } + if updated, err := client.readers(ctx); err == nil { + states = updated + if len(states) >= len(physical) { + return states + } + } + } + if len(states) >= len(physical) { + return states + } + + // Second pass: if readers are still missing from pcscd, trigger a USB re-authorization + // on the physical devices in sysfs to reset any stalled CCID endpoints, then poll briefly. + reauthorized := false + for _, phys := range physical { + if phys.USBPath != "" { + reauthorizeUSBDevice(backend.sysRoot, phys.USBPath) + reauthorized = true + } + } + if !reauthorized { + return states + } + + retryDeadline := time.Now().Add(2 * time.Second) + if dl, ok := ctx.Deadline(); ok && dl.Before(retryDeadline) { + retryDeadline = dl + } + for len(states) < len(physical) && time.Now().Before(retryDeadline) { + select { + case <-ctx.Done(): + return states + case <-time.After(300 * time.Millisecond): + } + if updated, err := client.readers(ctx); err == nil { + states = updated + if len(states) >= len(physical) { + return states + } + } + } + return states +} + func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) { physical := discoverUSBSmartCardReaders(backend.sysRoot, "pcsc_driver_missing") client, err := backend.dial(ctx) + if err != nil && len(physical) > 0 { + ensurePCSCDService(ctx) + dialDeadline := time.Now().Add(1500 * time.Millisecond) + for time.Now().Before(dialDeadline) { + select { + case <-ctx.Done(): + break + case <-time.After(200 * time.Millisecond): + } + if c, dialErr := backend.dial(ctx); dialErr == nil { + client, err = c, nil + break + } + } + } if err != nil { if len(physical) > 0 { for index := range physical { @@ -61,6 +160,9 @@ func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) { if err != nil { return nil, err } + if len(physical) > 0 && len(states) < len(physical) { + states = backend.waitForPCSCReaders(ctx, client, physical, states) + } readers := make([]Reader, 0, len(states)) for _, state := range states { reader := Reader{ diff --git a/internal/pcsc/usb_discovery.go b/internal/pcsc/usb_discovery.go index 4050d44..06e1d42 100644 --- a/internal/pcsc/usb_discovery.go +++ b/internal/pcsc/usb_discovery.go @@ -67,17 +67,33 @@ func mergePCSCAndUSBReaders(readers, physical []Reader) []Reader { readers[0] = enrichPCSCReader(readers[0], physical[0]) return readers } - seen := make(map[string]bool, len(readers)) + matchedPhysical := make(map[string]bool, len(physical)) for i := range readers { - seen[readers[i].USBPath] = true for _, usbReader := range physical { if readers[i].USBPath == usbReader.USBPath { readers[i] = enrichPCSCReader(readers[i], usbReader) + matchedPhysical[usbReader.USBPath] = true } } } + // Secondary pass: if any pcsc reader is still prefixed with pcsc: (unresolved sysfs USB path), + // match with unmatched physical readers by VendorID/ProductID or if 1:1 remaining. + var remainingPhysical []Reader + for _, p := range physical { + if !matchedPhysical[p.USBPath] { + remainingPhysical = append(remainingPhysical, p) + } + } + for i := range readers { + if strings.HasPrefix(readers[i].USBPath, "pcsc:") && len(remainingPhysical) == 1 { + readers[i] = enrichPCSCReader(readers[i], remainingPhysical[0]) + matchedPhysical[remainingPhysical[0].USBPath] = true + remainingPhysical = nil + break + } + } for _, usbReader := range physical { - if !seen[usbReader.USBPath] { + if !matchedPhysical[usbReader.USBPath] { readers = append(readers, usbReader) } } diff --git a/internal/pcsc/usb_discovery_test.go b/internal/pcsc/usb_discovery_test.go index 8077099..55edf8d 100644 --- a/internal/pcsc/usb_discovery_test.go +++ b/internal/pcsc/usb_discovery_test.go @@ -50,6 +50,27 @@ func TestMergePCSCAndSingleUSBReaderEnrichesFallbackPath(t *testing.T) { } } +func TestMergePCSCAndMultipleUSBReadersWithFallbackPath(t *testing.T) { + readers := mergePCSCAndUSBReaders( + []Reader{ + {Name: "Identiv uTrust 00 00", USBPath: "1-2", CardPresent: true}, + {Name: "Generic Smart Card Reader 00 00", USBPath: "pcsc:Generic Smart Card Reader 00 00", CardPresent: true}, + }, + []Reader{ + {Name: "uTrust", USBPath: "1-2", VendorID: "04e6", ProductID: "5810", DiscoveryIssue: "pcsc_driver_missing"}, + {Name: "ESTKme-RED", USBPath: "1-1", VendorID: "0bda", ProductID: "0165", DiscoveryIssue: "pcsc_driver_missing"}, + }, + ) + if len(readers) != 2 { + t.Fatalf("len(readers) = %d, want 2", len(readers)) + } + for _, r := range readers { + if r.DiscoveryIssue != "" { + t.Errorf("reader %#v still has discovery issue %q", r, r.DiscoveryIssue) + } + } +} + func writeUSBTestFile(t *testing.T, path, value string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/scripts/install.sh b/scripts/install.sh index f21645e..42031ba 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -203,9 +203,14 @@ install_qmi_support() { if is_openwrt && command -v opkg >/dev/null 2>&1; then opkg update >/dev/null 2>&1 || true - if opkg_has_package libqmi; then - opkg install libqmi >/dev/null 2>&1 || true + local pkgs="" + opkg_has_package qmi-utils && pkgs="$pkgs qmi-utils" + opkg_has_package libqmi && pkgs="$pkgs libqmi" + if [ -z "$pkgs" ]; then + pkgs="qmi-utils libqmi" fi + # shellcheck disable=SC2086 + opkg install $pkgs >/dev/null 2>&1 || true elif command -v apt-get >/dev/null 2>&1; then apt-get update -qq || true DEBIAN_FRONTEND=noninteractive apt-get install -y libqmi-utils || true @@ -236,6 +241,7 @@ install_pcsc_support() { local packages="" opkg_has_package pcscd && packages="$packages pcscd" opkg_has_package ccid && packages="$packages ccid" + opkg_has_package libccid && packages="$packages libccid" if [ -n "$packages" ]; then # shellcheck disable=SC2086 opkg install $packages >/dev/null 2>&1 && installed=1 || true @@ -363,7 +369,7 @@ download_and_verify() { [ "$actual" = "$expected" ] || die "SHA-256 校验失败。" "SHA-256 verification failed." chmod 0755 "${VOCAT_TMP}/vocat" "${VOCAT_TMP}/vocat" version >/dev/null 2>&1 || die \ - "Downloaded binary cannot run on this system; keeping the installed version." \ + "下载的二进制文件无法在此系统上运行;未更改当前安装的版本。" \ "The downloaded binary cannot run on this host; the installed version was not changed." } @@ -392,8 +398,18 @@ INITIAL_ADMIN_PASSWORD="" bootstrap_admin() { local candidate="${1:-$BINARY_PATH}" local secret result - secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n') - [ -n "$secret" ] || die "Failed to generate a random secret." "Failed to generate a random secret." + if command -v od >/dev/null 2>&1; then + secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n') + elif command -v hexdump >/dev/null 2>&1; then + secret=$(hexdump -n 16 -e '16/1 "%02x"' /dev/urandom) + elif command -v openssl >/dev/null 2>&1; then + secret=$(openssl rand -hex 16 2>/dev/null || true) + elif command -v sha256sum >/dev/null 2>&1; then + secret=$(head -c 32 /dev/urandom | sha256sum | awk '{print substr($1, 1, 32)}') + else + secret=$(tr -dc 'a-f0-9' < /dev/urandom | head -c 32) + fi + [ -n "$secret" ] || die "生成随机密钥失败。" "Failed to generate a random secret." result=$(printf '%s\n' "$secret" | "$candidate" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \ die \ "待安装版本无法读取或升级现有数据库;当前程序尚未被替换,请检查数据库与版本兼容性。" \ @@ -505,7 +521,7 @@ write_service() { write_openwrt_init return fi - die "Unsupported service manager." "Neither systemd nor OpenWrt procd was detected." + die "不支持的服务管理器。" "Neither systemd nor OpenWrt procd was detected." } enable_and_start() { @@ -547,7 +563,7 @@ enable_and_start() { cp -a "${BINARY_PATH}.bak" "$BINARY_PATH" "$OPENWRT_INIT_PATH" restart || true fi - die "OpenWrt vocat service failed to start." "The OpenWrt vocat service failed to start." + die "OpenWrt vocat 服务启动失败。" "The OpenWrt vocat service failed to start." fi systemctl daemon-reload systemctl enable vocat