feat: add USB/PC/SC discovery support and an automated deployment installation script

This commit is contained in:
MengMengCode
2026-08-20 00:29:30 +08:00
parent 63553eaf2b
commit be40e324db
6 changed files with 168 additions and 12 deletions
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。
+102
View File
@@ -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{
+19 -3
View File
@@ -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)
}
}
+21
View File
@@ -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 {
+23 -7
View File
@@ -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