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 }