415 lines
11 KiB
Go
415 lines
11 KiB
Go
package device
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// ============================================================
|
||
// [设备层 · 关键] DeviceService — 暴露给前端的 Wails 服务
|
||
// 无线流程:PairWireless(配对码) → ConnectWireless(IP:端口) → CaptureFrame
|
||
// ADB 命令封装见 adb.go
|
||
// ============================================================
|
||
|
||
type Service struct {
|
||
inputMu sync.Mutex
|
||
mu sync.RWMutex
|
||
devices map[string]DeviceInfo
|
||
disconnectedUSB map[string]bool // Logical session disconnect; the physical cable remains attached.
|
||
active string
|
||
stream *previewStream // scrcpy 低延迟预览流
|
||
}
|
||
|
||
func NewService() *Service {
|
||
s := &Service{
|
||
devices: map[string]DeviceInfo{},
|
||
}
|
||
// Recreate only our own daemon on app launch. A daemon left by a prior
|
||
// build can retain that build's macOS network authorization identity.
|
||
if adb, err := newADB(); err == nil {
|
||
_, _ = adb.runText(5*time.Second, "kill-server")
|
||
}
|
||
_ = s.refreshLocked()
|
||
return s
|
||
}
|
||
|
||
// ServiceShutdown is a Wails lifecycle hook, not a frontend action.
|
||
func (s *Service) ServiceShutdown() error {
|
||
s.StopPreviewStream()
|
||
adb, err := newADB()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = adb.runText(5*time.Second, "kill-server")
|
||
return err
|
||
}
|
||
|
||
func (s *Service) refreshLocked() error {
|
||
adb, err := newADB()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
raw, err := adb.listDevices()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
next := make(map[string]DeviceInfo, len(raw))
|
||
now := time.Now().Format(time.RFC3339)
|
||
for _, r := range raw {
|
||
online := r.State == "device"
|
||
state := r.State
|
||
if s.disconnectedUSB[r.Serial] {
|
||
online = false
|
||
state = "disconnected"
|
||
}
|
||
previous := s.devices[r.Serial]
|
||
model := modelFromExtras(r.Extras, "")
|
||
if model == "" && previous.Model != previous.ID {
|
||
model = previous.Model
|
||
}
|
||
if online && model == "" {
|
||
model = adb.getProp(r.Serial, "ro.product.model")
|
||
}
|
||
if model == "" {
|
||
model = r.Serial
|
||
}
|
||
w, h := 0, 0
|
||
if online {
|
||
w, h = adb.getSize(r.Serial)
|
||
}
|
||
next[r.Serial] = DeviceInfo{
|
||
ID: r.Serial,
|
||
Model: model,
|
||
Mode: inferMode(r.Serial),
|
||
Online: online,
|
||
Width: w,
|
||
Height: h,
|
||
State: state,
|
||
LastSeen: now,
|
||
}
|
||
}
|
||
for id := range s.disconnectedUSB {
|
||
if _, ok := next[id]; !ok {
|
||
delete(s.disconnectedUSB, id)
|
||
}
|
||
}
|
||
s.devices = next
|
||
if current, ok := s.devices[s.active]; !ok || !current.Online {
|
||
if !ok {
|
||
s.active = ""
|
||
}
|
||
for _, d := range copyDevices(s.devices) {
|
||
if d.Online {
|
||
s.active = d.ID
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// GetAdbInfo 检测本机 adb 是否可用
|
||
func (s *Service) GetAdbInfo() AdbInfo {
|
||
return probeAdb()
|
||
}
|
||
|
||
// RequestLocalNetworkAccess 触发 macOS「本地网络」授权(列表里没有 iTuoke 时点这个)
|
||
func (s *Service) RequestLocalNetworkAccess() ActionResult {
|
||
return triggerLocalNetworkAccess()
|
||
}
|
||
|
||
// OpenLocalNetworkSettings 打开系统设置 → 本地网络
|
||
func (s *Service) OpenLocalNetworkSettings() ActionResult {
|
||
return openLocalNetworkSettings()
|
||
}
|
||
|
||
// SetAdbExecutable 设置自定义 adb 路径
|
||
func (s *Service) SetAdbExecutable(path string) ActionResult {
|
||
SetAdbPath(path)
|
||
info := probeAdb()
|
||
if !info.OK {
|
||
return ActionResult{OK: false, Message: info.Error}
|
||
}
|
||
return ActionResult{OK: true, Message: info.Path + " · " + info.Version}
|
||
}
|
||
|
||
// ListDevices 刷新并返回设备列表
|
||
func (s *Service) ListDevices() ([]DeviceInfo, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if err := s.refreshLocked(); err != nil {
|
||
// 仍返回缓存,附带错误
|
||
out := copyDevices(s.devices)
|
||
return out, err
|
||
}
|
||
return copyDevices(s.devices), nil
|
||
}
|
||
|
||
// GetActiveDevice 当前选中设备
|
||
func (s *Service) GetActiveDevice() DeviceInfo {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
if d, ok := s.devices[s.active]; ok {
|
||
return d
|
||
}
|
||
return DeviceInfo{}
|
||
}
|
||
|
||
// SelectDevice 切换当前设备
|
||
func (s *Service) SelectDevice(deviceID string) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
delete(s.disconnectedUSB, deviceID)
|
||
if err := s.refreshLocked(); err != nil {
|
||
return err
|
||
}
|
||
if _, ok := s.devices[deviceID]; !ok {
|
||
return fmt.Errorf("设备不存在: %s", deviceID)
|
||
}
|
||
s.active = deviceID
|
||
return nil
|
||
}
|
||
|
||
// PairWireless 使用配对码配对(无线调试)
|
||
// 手机:开发者选项 → 无线调试 → 使用配对码配对设备
|
||
func (s *Service) PairWireless(req PairRequest) ActionResult {
|
||
adb, err := newADB()
|
||
if err != nil {
|
||
return ActionResult{OK: false, Message: err.Error()}
|
||
}
|
||
msg, err := adb.pair(req.HostPort, req.Code)
|
||
if err != nil {
|
||
return ActionResult{OK: false, Message: err.Error()}
|
||
}
|
||
return ActionResult{OK: true, Message: msg}
|
||
}
|
||
|
||
// ConnectWireless 用 IP:连接端口 连接(配对成功后)
|
||
func (s *Service) ConnectWireless(req ConnectRequest) ActionResult {
|
||
adb, err := newADB()
|
||
if err != nil {
|
||
return ActionResult{OK: false, Message: err.Error()}
|
||
}
|
||
// ADB may retain an offline transport and report "already connected".
|
||
// Remove only that stale wireless transport before trying the same address.
|
||
host := normalizeHostPort(req.HostPort)
|
||
if err := validateHostPort(host); err != nil {
|
||
return ActionResult{OK: false, Message: err.Error()}
|
||
}
|
||
if devices, listErr := adb.listDevices(); listErr == nil {
|
||
for _, d := range devices {
|
||
if d.Serial == host && d.State == "offline" {
|
||
if _, err := adb.disconnect(host); err != nil {
|
||
return ActionResult{OK: false, Message: "清理离线连接失败: " + err.Error()}
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
msg, err := adb.connect(host)
|
||
if err != nil {
|
||
return ActionResult{OK: false, Message: err.Error()}
|
||
}
|
||
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if err := s.refreshLocked(); err != nil {
|
||
return ActionResult{OK: false, Message: "连接后刷新设备失败: " + err.Error()}
|
||
}
|
||
if d, ok := s.devices[host]; ok && d.Online {
|
||
s.active = host
|
||
} else {
|
||
return ActionResult{OK: false, Message: "设备尚未在线,请检查手机无线调试中的连接地址与端口。" + msg}
|
||
}
|
||
return ActionResult{OK: true, Message: msg}
|
||
}
|
||
|
||
// DisconnectDevice closes the application session for USB and the ADB transport for wireless.
|
||
func (s *Service) DisconnectDevice(deviceID string) ActionResult {
|
||
deviceID = strings.TrimSpace(deviceID)
|
||
if deviceID == "" {
|
||
return ActionResult{OK: false, Message: "没有选中的设备"}
|
||
}
|
||
if inferMode(deviceID) == ConnUSB {
|
||
s.StopPreviewStream()
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if s.disconnectedUSB == nil {
|
||
s.disconnectedUSB = map[string]bool{}
|
||
}
|
||
s.disconnectedUSB[deviceID] = true
|
||
if d, ok := s.devices[deviceID]; ok {
|
||
d.Online, d.State = false, "disconnected"
|
||
s.devices[deviceID] = d
|
||
}
|
||
if s.active == deviceID {
|
||
s.active = ""
|
||
}
|
||
return ActionResult{OK: true, Message: "USB 会话已断开"}
|
||
}
|
||
adb, err := newADB()
|
||
if err != nil {
|
||
return ActionResult{OK: false, Message: err.Error()}
|
||
}
|
||
msg, err := adb.disconnect(deviceID)
|
||
if err != nil {
|
||
return ActionResult{OK: false, Message: err.Error()}
|
||
}
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if s.active == deviceID {
|
||
s.active = ""
|
||
}
|
||
_ = s.refreshLocked()
|
||
if msg == "" {
|
||
msg = "已断开"
|
||
}
|
||
return ActionResult{OK: true, Message: msg}
|
||
}
|
||
|
||
// CaptureFrame 截取当前帧(screencap -p)
|
||
func (s *Service) CaptureFrame(deviceID string) (FrameSnapshot, error) {
|
||
if strings.TrimSpace(deviceID) == "" {
|
||
s.mu.RLock()
|
||
deviceID = s.active
|
||
s.mu.RUnlock()
|
||
}
|
||
if deviceID == "" {
|
||
return FrameSnapshot{}, fmt.Errorf("没有选中的设备")
|
||
}
|
||
s.mu.RLock()
|
||
disconnected := s.disconnectedUSB[deviceID]
|
||
s.mu.RUnlock()
|
||
if disconnected {
|
||
return FrameSnapshot{}, fmt.Errorf("USB 会话已断开,请先连接设备")
|
||
}
|
||
|
||
adb, err := newADB()
|
||
if err != nil {
|
||
return FrameSnapshot{}, err
|
||
}
|
||
png, err := adb.screencapPNG(deviceID)
|
||
if err != nil {
|
||
return FrameSnapshot{}, err
|
||
}
|
||
frame, err := pngToFrame(deviceID, png)
|
||
if err != nil {
|
||
return FrameSnapshot{}, err
|
||
}
|
||
|
||
// 回写分辨率到缓存
|
||
s.mu.Lock()
|
||
if d, ok := s.devices[deviceID]; ok {
|
||
if frame.Width > 0 {
|
||
d.Width = frame.Width
|
||
}
|
||
if frame.Height > 0 {
|
||
d.Height = frame.Height
|
||
}
|
||
d.Online = true
|
||
d.LastSeen = time.Now().Format(time.RFC3339)
|
||
s.devices[deviceID] = d
|
||
}
|
||
s.mu.Unlock()
|
||
|
||
return frame, nil
|
||
}
|
||
|
||
// Tap 在设备上点击
|
||
func (s *Service) Tap(req TapRequest) error {
|
||
if err := s.validateInput(req.DeviceID, [2]float64{req.X, req.Y}); err != nil {
|
||
return err
|
||
}
|
||
s.inputMu.Lock()
|
||
defer s.inputMu.Unlock()
|
||
adb, err := newADB()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = adb.runText(
|
||
10*time.Second,
|
||
"-s", req.DeviceID,
|
||
"shell", "input", "tap",
|
||
fmt.Sprintf("%.0f", req.X),
|
||
fmt.Sprintf("%.0f", req.Y),
|
||
)
|
||
return explainInputError(err)
|
||
}
|
||
|
||
// Swipe 在设备上滑动;同一点按住可实现长按。
|
||
func (s *Service) Swipe(req SwipeRequest) error {
|
||
if err := s.validateInput(req.DeviceID, [2]float64{req.X1, req.Y1}, [2]float64{req.X2, req.Y2}); err != nil {
|
||
return err
|
||
}
|
||
if req.DurationMs < 100 || req.DurationMs > 1500 {
|
||
return fmt.Errorf("滑动时长应为 100–1500 毫秒")
|
||
}
|
||
s.inputMu.Lock()
|
||
defer s.inputMu.Unlock()
|
||
adb, err := newADB()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = adb.runText(10*time.Second, "-s", req.DeviceID, "shell", "input", "swipe",
|
||
fmt.Sprintf("%.0f", req.X1), fmt.Sprintf("%.0f", req.Y1),
|
||
fmt.Sprintf("%.0f", req.X2), fmt.Sprintf("%.0f", req.Y2), fmt.Sprint(req.DurationMs))
|
||
return explainInputError(err)
|
||
}
|
||
|
||
func (s *Service) validateInput(id string, points ...[2]float64) error {
|
||
s.mu.RLock()
|
||
d, ok := s.devices[id]
|
||
s.mu.RUnlock()
|
||
if !ok || !d.Online {
|
||
return fmt.Errorf("设备未在线,请先连接设备")
|
||
}
|
||
if d.Width <= 0 || d.Height <= 0 {
|
||
return fmt.Errorf("尚未获取设备屏幕尺寸,请刷新设备")
|
||
}
|
||
portrait, landscape := true, true
|
||
for _, p := range points {
|
||
if math.IsNaN(p[0]) || math.IsNaN(p[1]) || math.IsInf(p[0], 0) || math.IsInf(p[1], 0) || p[0] < 0 || p[1] < 0 {
|
||
return fmt.Errorf("操作坐标无效")
|
||
}
|
||
portrait = portrait && p[0] < float64(d.Width) && p[1] < float64(d.Height)
|
||
landscape = landscape && p[0] < float64(d.Height) && p[1] < float64(d.Width)
|
||
}
|
||
if !portrait && !landscape {
|
||
return fmt.Errorf("操作坐标超出设备屏幕")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func explainInputError(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
message := strings.ToLower(err.Error())
|
||
if strings.Contains(message, "inject_events") || strings.Contains(message, "injecting to another application") {
|
||
return fmt.Errorf("手机未允许远程输入。小米手机请在开发者选项中开启「USB 调试(安全设置)」后重试。原始错误:%w", err)
|
||
}
|
||
return err
|
||
}
|
||
|
||
func copyDevices(m map[string]DeviceInfo) []DeviceInfo {
|
||
out := make([]DeviceInfo, 0, len(m))
|
||
for _, d := range m {
|
||
out = append(out, d)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
if out[i].Online != out[j].Online {
|
||
return out[i].Online
|
||
}
|
||
if out[i].Mode != out[j].Mode {
|
||
return out[i].Mode == ConnUSB
|
||
}
|
||
return out[i].ID < out[j].ID
|
||
})
|
||
return out
|
||
}
|