package device import ( "bytes" "encoding/base64" "fmt" "image" _ "image/jpeg" _ "image/png" "net" "os" "os/exec" "path/filepath" "regexp" "runtime" "strconv" "strings" "time" ) // ============================================================ // [ADB 封装] 找 adb、执行命令、解析 devices / pair / connect / screencap // 查找顺序(企业打包优先用内置): // 1) SetAdbPath / ITOUKE_ADB // 2) 应用内置 third_party(.app Resources 或 exe 旁 platform-tools) // 3) 系统 PATH / 常见 SDK 路径(仅开发机方便) // ============================================================ // iTuoke owns this loopback-only ADB server; never reuse a daemon launched // by another app (macOS local-network authorization follows that process). const adbServerPort = "17891" var ( adbPathOverride string overrideSizeRe = regexp.MustCompile(`Override size:\s*(\d+)x(\d+)`) sizeRe = regexp.MustCompile(`Physical size:\s*(\d+)x(\d+)`) deviceLineRe = regexp.MustCompile(`^(\S+)\s+(device|offline|unauthorized|no permissions.*)(?:\s+(.*))?$`) ) // SetAdbPath 手动指定 adb 可执行文件(设置页可用) func SetAdbPath(p string) { adbPathOverride = strings.TrimSpace(p) } func adbFileName() string { if runtime.GOOS == "windows" { return "adb.exe" } return "adb" } func fileExists(p string) bool { st, err := os.Stat(p) return err == nil && !st.IsDir() } // bundledAdbCandidates 安装包内置路径(客户机不依赖系统 adb) func bundledAdbCandidates() []string { name := adbFileName() var out []string exe, err := os.Executable() if err == nil { if resolved, err2 := filepath.EvalSymlinks(exe); err2 == nil { exe = resolved } dir := filepath.Dir(exe) out = append(out, // 与可执行文件同级 filepath.Join(dir, "platform-tools", name), filepath.Join(dir, name), // macOS .app:Contents/MacOS/xxx → Contents/Resources/platform-tools/adb filepath.Join(dir, "..", "Resources", "platform-tools", name), filepath.Join(dir, "..", "Resources", name), ) } // 开发时从仓库根目录直接 go run / 调试 if cwd, err := os.Getwd(); err == nil { out = append(out, filepath.Join(cwd, "third_party", "platform-tools", name), filepath.Join(cwd, "..", "third_party", "platform-tools", name), ) } return out } func systemAdbCandidates() []string { var out []string if p, err := exec.LookPath(adbFileName()); err == nil { out = append(out, p) } home, _ := os.UserHomeDir() name := adbFileName() out = append(out, filepath.Join(home, "Library/Android/sdk/platform-tools", name), filepath.Join(home, "Android/Sdk/platform-tools", name), filepath.Join(home, "AppData/Local/Android/Sdk/platform-tools", name), "/opt/homebrew/bin/adb", "/usr/local/bin/adb", ) if matches, _ := filepath.Glob("/opt/homebrew/Caskroom/android-platform-tools/*/platform-tools/adb"); len(matches) > 0 { out = append(out, matches...) } if matches, _ := filepath.Glob("/usr/local/Caskroom/android-platform-tools/*/platform-tools/adb"); len(matches) > 0 { out = append(out, matches...) } return out } func resolveAdb() (string, error) { if adbPathOverride != "" { if fileExists(adbPathOverride) { return adbPathOverride, nil } return "", fmt.Errorf("自定义 adb 路径无效: %s", adbPathOverride) } if env := strings.TrimSpace(os.Getenv("ITOUKE_ADB")); env != "" { if fileExists(env) { return env, nil } } // 优先内置,避免客户机依赖系统安装 for _, c := range bundledAdbCandidates() { if fileExists(c) { if abs, err := filepath.Abs(c); err == nil { return abs, nil } return c, nil } } for _, c := range systemAdbCandidates() { if fileExists(c) { return c, nil } } return "", fmt.Errorf("未找到内置 adb(third_party/platform-tools)。请运行 scripts/sync-platform-tools.sh 后重新打包") } type adbClient struct { bin string } func newADB() (*adbClient, error) { bin, err := resolveAdb() if err != nil { return nil, err } return &adbClient{bin: bin}, nil } func (a *adbClient) command(args ...string) *exec.Cmd { scoped := append([]string{"-P", adbServerPort}, args...) cmd := exec.Command(a.bin, scoped...) // Do not let a shell's remote/shared-server override escape our namespace. for _, entry := range os.Environ() { if strings.HasPrefix(entry, "ADB_SERVER_SOCKET=") || strings.HasPrefix(entry, "ANDROID_ADB_SERVER_PORT=") { continue } cmd.Env = append(cmd.Env, entry) } return cmd } func (a *adbClient) run(timeout time.Duration, args ...string) (string, []byte, error) { cmd := a.command(args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if timeout <= 0 { timeout = 30 * time.Second } timer := time.AfterFunc(timeout, func() { if cmd.Process != nil { _ = cmd.Process.Kill() } }) defer timer.Stop() err := cmd.Run() out := stdout.Bytes() errText := strings.TrimSpace(stderr.String()) if err != nil { msg := strings.TrimSpace(string(out)) if errText != "" { if msg != "" { msg += " | " + errText } else { msg = errText } } if msg == "" { msg = err.Error() } return a.bin, out, fmt.Errorf("%s", msg) } return a.bin, out, nil } func (a *adbClient) runText(timeout time.Duration, args ...string) (string, error) { _, out, err := a.run(timeout, args...) return strings.TrimSpace(string(out)), err } // AdbInfo 供前端展示 type AdbInfo struct { Path string `json:"path"` Version string `json:"version"` OK bool `json:"ok"` Error string `json:"error,omitempty"` } func probeAdb() AdbInfo { bin, err := resolveAdb() if err != nil { return AdbInfo{OK: false, Error: err.Error()} } c := &adbClient{bin: bin} ver, err := c.runText(5*time.Second, "version") if err != nil { return AdbInfo{Path: bin, OK: false, Error: err.Error()} } // 取第一行 line := strings.SplitN(ver, "\n", 2)[0] return AdbInfo{Path: bin, Version: line, OK: true} } type rawDevice struct { Serial string State string Extras string } func (a *adbClient) listDevices() ([]rawDevice, error) { text, err := a.runText(10*time.Second, "devices", "-l") if err != nil { return nil, err } var list []rawDevice for _, line := range strings.Split(text, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "List of devices") { continue } m := deviceLineRe.FindStringSubmatch(line) if m == nil { continue } list = append(list, rawDevice{ Serial: m[1], State: m[2], Extras: strings.TrimSpace(m[3]), }) } return list, nil } // normalizeHostPort 清洗用户输入:全角冒号、空格、前缀文字 func normalizeHostPort(s string) string { s = strings.TrimSpace(s) s = strings.ReplaceAll(s, ":", ":") s = strings.ReplaceAll(s, " ", "") // 去掉误粘贴的「IP地址」等前缀 for _, p := range []string{"IP地址与端口", "IP地址", "IP:", "地址", "配对码配对"} { s = strings.TrimPrefix(s, p) } s = strings.TrimSpace(s) return s } func validateHostPort(address string) error { host, port, err := net.SplitHostPort(address) if err != nil || host == "" { return fmt.Errorf("地址格式应为 IP:端口,例如 192.168.0.108:40315") } number, err := strconv.Atoi(port) if err != nil || number < 1 || number > 65535 { return fmt.Errorf("端口必须是 1–65535 之间的数字") } numeric := true for _, ch := range host { if (ch < '0' || ch > '9') && ch != '.' { numeric = false break } } if numeric && net.ParseIP(host) == nil { return fmt.Errorf("IP 地址无效:%s。IPv4 每一段必须在 0–255 之间,请核对手机显示的地址", host) } return nil } func normalizePairCode(s string) string { s = strings.TrimSpace(s) s = strings.ReplaceAll(s, " ", "") s = strings.ReplaceAll(s, "-", "") return s } func (a *adbClient) ensureDaemon() { // start-server is idempotent. Restarting the daemon here would disconnect // every other device whenever one phone is paired or reconnected. _, _, _ = a.run(15*time.Second, "start-server") } func explainAdbErr(op, raw string) error { low := strings.ToLower(raw) macHint := "" if runtime.GOOS == "darwin" { macHint = " 若仍失败:打开「系统设置 → 隐私与安全性 → 本地网络」,打开 iTuoke;也可先在「终端」执行同命令验证。" } switch { case strings.Contains(low, "protocol fault") || strings.Contains(low, "couldn't read status"): return fmt.Errorf("%s失败(协议中断)。请确认:①「使用配对码配对」弹窗里的 IP:端口;②弹窗保持打开;③关掉 VPN。%s 原始:%s", op, macHint, raw) case strings.Contains(low, "connection refused") || strings.Contains(low, "failed to connect"): return fmt.Errorf("%s失败:连不上该地址,请检查同一 Wi‑Fi、端口是否抄错。%s 原始:%s", op, macHint, raw) case strings.Contains(low, "timed out") || strings.Contains(low, "timeout"): return fmt.Errorf("%s超时:手机配对窗口可能已关闭,请重新点「使用配对码配对」。%s 原始:%s", op, macHint, raw) default: return fmt.Errorf("%s", raw) } } // probeLocalHost 用本进程拨一次 TCP,触发 macOS「本地网络」授权弹窗(子进程 adb 有时不会弹) func probeLocalHost(hostPort string) error { hostPort = normalizeHostPort(hostPort) d := net.Dialer{Timeout: 3 * time.Second} conn, err := d.Dial("tcp4", hostPort) if err != nil { // 拨不通不一定是权限问题,也可能端口错;仍返回错误供上层提示 return fmt.Errorf("无法访问 %s:%v(请确认配对弹窗开着、同一 Wi‑Fi;Mac 请检查「本地网络」权限)", hostPort, err) } _ = conn.Close() return nil } func (a *adbClient) pair(hostPort, code string) (string, error) { hostPort = normalizeHostPort(hostPort) code = normalizePairCode(code) if hostPort == "" || code == "" { return "", fmt.Errorf("请填写配对 IP:端口 和配对码") } if !strings.Contains(hostPort, ":") { return "", fmt.Errorf("配对地址格式应为 IP:端口,来自手机「使用配对码配对设备」弹窗") } if err := validateHostPort(hostPort); err != nil { return "", err } // 先探测局域网(触发 macOS「本地网络」授权;拨不通则 pairing 必挂) if err := probeLocalHost(hostPort); err != nil { return "", err } a.ensureDaemon() // adb pair ip:port code out, err := a.runText(60*time.Second, "pair", hostPort, code) combined := out if err != nil { if combined == "" { combined = err.Error() } else if !strings.Contains(combined, err.Error()) { combined = combined + " | " + err.Error() } return out, explainAdbErr("配对", combined) } low := strings.ToLower(out) if strings.Contains(low, "failed") || strings.Contains(low, "error") || strings.Contains(low, "protocol fault") { return out, explainAdbErr("配对", out) } if strings.Contains(low, "successfully") || strings.Contains(out, "成功") { return out, nil } if out == "" { return "配对成功,请再用「IP 地址与端口」去连接", nil } return out, nil } func (a *adbClient) connect(hostPort string) (string, error) { hostPort = normalizeHostPort(hostPort) if hostPort == "" { return "", fmt.Errorf("请填写连接 IP:端口") } if !strings.Contains(hostPort, ":") { return "", fmt.Errorf("格式应为 IP:端口,例如 192.168.1.8:5555(无线调试主界面那一行)") } if err := validateHostPort(hostPort); err != nil { return "", err } a.ensureDaemon() out, err := a.runText(30*time.Second, "connect", hostPort) if err != nil { msg := out if msg == "" { msg = err.Error() } return out, explainAdbErr("连接", msg) } low := strings.ToLower(out) if strings.Contains(low, "cannot") || strings.Contains(low, "failed") || strings.Contains(low, "refused") || strings.Contains(low, "protocol fault") { return out, explainAdbErr("连接", out) } return out, nil } func (a *adbClient) disconnect(serialOrHost string) (string, error) { serialOrHost = strings.TrimSpace(serialOrHost) if serialOrHost == "" { return a.runText(10*time.Second, "disconnect") } return a.runText(10*time.Second, "disconnect", serialOrHost) } func (a *adbClient) getProp(serial, key string) string { out, err := a.runText(8*time.Second, "-s", serial, "shell", "getprop", key) if err != nil { return "" } return strings.TrimSpace(out) } func (a *adbClient) getSize(serial string) (int, int) { out, err := a.runText(8*time.Second, "-s", serial, "shell", "wm", "size") if err == nil { m := overrideSizeRe.FindStringSubmatch(out) if m == nil { m = sizeRe.FindStringSubmatch(out) } if m != nil { w, _ := strconv.Atoi(m[1]) h, _ := strconv.Atoi(m[2]) return w, h } } return 0, 0 } func (a *adbClient) screencapPNG(serial string) ([]byte, error) { _, out, err := a.run(45*time.Second, "-s", serial, "exec-out", "screencap", "-p") if err != nil { return nil, err } if len(out) < 100 { return nil, fmt.Errorf("截屏数据过短,设备可能未授权或离线") } // 有些旧设备会把 \n 写成 \r\n,PNG 损坏;尝试修复 if !(len(out) >= 8 && out[0] == 0x89 && out[1] == 0x50) { fixed := bytes.ReplaceAll(out, []byte("\r\n"), []byte("\n")) if len(fixed) >= 8 && fixed[0] == 0x89 && fixed[1] == 0x50 { out = fixed } } return out, nil } func pngToFrame(serial string, png []byte) (FrameSnapshot, error) { cfg, format, err := image.DecodeConfig(bytes.NewReader(png)) w, h := 0, 0 mime := "image/png" if err == nil { w, h = cfg.Width, cfg.Height if format == "jpeg" { mime = "image/jpeg" } } return FrameSnapshot{ DeviceID: serial, MimeType: mime, Base64: base64.StdEncoding.EncodeToString(png), Width: w, Height: h, Timestamp: time.Now().UnixMilli(), }, nil } func inferMode(serial string) ConnMode { if strings.Contains(serial, ":") { return ConnLAN } return ConnUSB } func modelFromExtras(extras, fallback string) string { // extras 形如: product:xxx model:Pixel_6 device:xxx transport_id:1 for _, part := range strings.Fields(extras) { if strings.HasPrefix(part, "model:") { return strings.ReplaceAll(strings.TrimPrefix(part, "model:"), "_", " ") } } if fallback != "" { return fallback } return "" }