ituoke/internal/device/service.go

415 lines
11 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 (
"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
}