ituoke/internal/device/adb.go

494 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ""
}