473 lines
13 KiB
Go
473 lines
13 KiB
Go
package device
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/base64"
|
||
"fmt"
|
||
"image"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
"net"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/ituoke/ituoke/internal/runtimeassets"
|
||
)
|
||
|
||
// ============================================================
|
||
// [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 {
|
||
return append(runtimeassets.Candidates(filepath.Join("platform-tools", adbFileName())), runtimeassets.Candidates(adbFileName())...)
|
||
}
|
||
|
||
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,请重新安装完整的 iTuoke 安装包")
|
||
}
|
||
|
||
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...)
|
||
configureToolCommand(cmd)
|
||
// 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 ""
|
||
}
|