diff --git a/backend-go/adb/client.go b/backend-go/adb/client.go index ee20f04..0c92ae4 100644 --- a/backend-go/adb/client.go +++ b/backend-go/adb/client.go @@ -1 +1,81 @@ package adb + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + "time" +) + +type Client struct { + path string +} + +func New(adbPath string) *Client { + return &Client{path: adbPath} +} + +// run executes an adb command and returns stdout. +func (c *Client) run(ctx context.Context, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, c.path, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("adb %s: %s", strings.Join(args, " "), msg) + } + return strings.TrimSpace(stdout.String()), nil +} + +// Devices returns raw output of `adb devices -l`. +func (c *Client) Devices(ctx context.Context) (string, error) { + return c.run(ctx, "devices", "-l") +} + +// Shell runs a shell command on a specific device and returns stdout. +func (c *Client) Shell(ctx context.Context, deviceID, cmd string) (string, error) { + return c.run(ctx, "-s", deviceID, "shell", cmd) +} + +// Connect connects to a device over TCP/IP (wireless ADB). +func (c *Client) Connect(ctx context.Context, address string) (string, error) { + return c.run(ctx, "connect", address) +} + +// Disconnect disconnects a device. +func (c *Client) Disconnect(ctx context.Context, deviceID string) (string, error) { + return c.run(ctx, "disconnect", deviceID) +} + +// Pull copies a file from the device to a local path. +func (c *Client) Pull(ctx context.Context, deviceID, remotePath, localPath string) error { + _, err := c.run(ctx, "-s", deviceID, "pull", remotePath, localPath) + return err +} + +// Push copies a local file to the device. +func (c *Client) Push(ctx context.Context, deviceID, localPath, remotePath string) error { + _, err := c.run(ctx, "-s", deviceID, "push", localPath, remotePath) + return err +} + +// StartServer starts the ADB server if not already running. +func (c *Client) StartServer(ctx context.Context) error { + _, err := c.run(ctx, "start-server") + return err +} + +// Path returns the resolved ADB binary path. +func (c *Client) Path() string { + return c.path +} diff --git a/backend-go/device/info.go b/backend-go/device/info.go new file mode 100644 index 0000000..2ffcd93 --- /dev/null +++ b/backend-go/device/info.go @@ -0,0 +1,143 @@ +package device + +import ( + "context" + "fmt" + "strings" + + "git.achmad.dev/admin/droidscope/backend/adb" +) + +// FetchInfo retrieves static device information (fetched once on device select). +func FetchInfo(ctx context.Context, client *adb.Client, deviceID string) (*DeviceInfo, error) { + info := &DeviceInfo{ID: deviceID} + + shell := func(cmd string) string { + out, _ := client.Shell(ctx, deviceID, cmd) + return strings.TrimSpace(out) + } + + info.Manufacturer = shell("getprop ro.product.manufacturer") + info.Model = shell("getprop ro.product.model") + info.Product = shell("getprop ro.product.name") + info.AndroidVersion = shell("getprop ro.build.version.release") + info.SDKVersion = shell("getprop ro.build.version.sdk") + info.SecurityPatch = shell("getprop ro.build.version.security_patch") + info.BuildFingerprint = shell("getprop ro.build.fingerprint") + info.ABI = shell("getprop ro.product.cpu.abi") + info.SupportedABIs = shell("getprop ro.product.cpu.abilist") + info.SerialNumber = shell("getprop ro.serialno") + + info.ScreenResolution = shell("wm size | awk '/Physical/{print $3}'") + info.ScreenDensity = shell("wm density | awk '/Physical/{print $3}' | tr -d '\\n'") + + info.TotalRAMMB = parseRAM(shell("cat /proc/meminfo | grep MemTotal")) + info.TotalStorageGB, _ = parseStorage(shell("df /data 2>/dev/null | tail -1")) + + return info, nil +} + +// FetchLiveStats retrieves only the fields that change at runtime. +// Called on a 1-second poll interval. +func FetchLiveStats(ctx context.Context, client *adb.Client, deviceID string) (*DeviceLiveStats, error) { + shell := func(cmd string) string { + out, _ := client.Shell(ctx, deviceID, cmd) + return strings.TrimSpace(out) + } + + stats := &DeviceLiveStats{} + + stats.AvailableRAMMB = parseRAM(shell("cat /proc/meminfo | grep MemAvailable")) + + _, stats.AvailableStorageGB = parseStorage(shell("df /data 2>/dev/null | tail -1")) + + batteryOut := shell("dumpsys battery") + stats.BatteryLevel = parseBatteryField(batteryOut, "level") + stats.BatteryStatus = parseBatteryStatusCode(parseBatteryField(batteryOut, "status")) + + stats.ThermalStatus = parseThermalStatus(shell("dumpsys thermalservice 2>/dev/null | grep 'Current thermal status'")) + + ip := shell("ip addr show wlan0 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1") + if ip == "" { + ip = shell("ip route get 8.8.8.8 2>/dev/null | grep src | awk '{print $7}'") + } + stats.IPAddress = ip + + return stats, nil +} + +func parseRAM(line string) int { + fields := strings.Fields(line) + if len(fields) < 2 { + return 0 + } + return parseIntSafe(fields[1]) / 1024 +} + +func parseStorage(dfLine string) (total, available string) { + fields := strings.Fields(dfLine) + if len(fields) < 4 { + return "", "" + } + return formatGB(parseIntSafe(fields[1])), formatGB(parseIntSafe(fields[3])) +} + +func formatGB(kb int) string { + if kb == 0 { + return "" + } + gb := float64(kb) / 1024 / 1024 + if gb >= 1 { + return strings.TrimRight(strings.TrimRight( + strings.Replace(fmt.Sprintf("%.1f", gb), ".0", "", 1), "0"), ".") + " GB" + } + return fmt.Sprintf("%d MB", kb/1024) +} + +func parseBatteryField(dumpsys, field string) int { + for _, line := range strings.Split(dumpsys, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, field+":") { + return parseIntSafe(strings.TrimSpace(strings.TrimPrefix(line, field+":"))) + } + } + return 0 +} + +func parseBatteryStatusCode(code int) string { + switch code { + case 2: + return "charging" + case 3: + return "discharging" + case 4: + return "not charging" + case 5: + return "full" + default: + return "unknown" + } +} + +func parseThermalStatus(line string) string { + parts := strings.Split(line, ":") + if len(parts) < 2 { + return "none" + } + switch parseIntSafe(strings.TrimSpace(parts[1])) { + case 1: + return "light" + case 2: + return "moderate" + case 3: + return "severe" + case 4: + return "critical" + case 5: + return "emergency" + case 6: + return "shutdown" + default: + return "none" + } +} diff --git a/backend-go/device/manager.go b/backend-go/device/manager.go index 76a9bfa..c029f3c 100644 --- a/backend-go/device/manager.go +++ b/backend-go/device/manager.go @@ -1 +1,142 @@ package device + +import ( + "context" + "strings" + + "git.achmad.dev/admin/droidscope/backend/adb" +) + +type Manager struct { + adb *adb.Client + Client *adb.Client // exported for use by other packages +} + +func NewManager(client *adb.Client) *Manager { + return &Manager{adb: client, Client: client} +} + +// Info fetches static device information for the given device ID. +func (m *Manager) Info(ctx context.Context, deviceID string) (*DeviceInfo, error) { + return FetchInfo(ctx, m.adb, deviceID) +} + +// LiveStats fetches dynamic device stats for the given device ID. +func (m *Manager) LiveStats(ctx context.Context, deviceID string) (*DeviceLiveStats, error) { + return FetchLiveStats(ctx, m.adb, deviceID) +} + +// List returns all currently connected devices with their details. +func (m *Manager) List(ctx context.Context) ([]Device, error) { + raw, err := m.adb.Devices(ctx) + if err != nil { + return nil, err + } + serials := parseDeviceLines(raw) + devices := make([]Device, 0, len(serials)) + for serial, status := range serials { + d := Device{ + ID: serial, + Status: status, + ConnectionType: connectionType(serial), + } + if status == StatusOnline { + enrichDevice(ctx, m.adb, &d) + } + devices = append(devices, d) + } + return devices, nil +} + +// Connect connects to a wireless ADB device by address (host:port). +func (m *Manager) Connect(ctx context.Context, address string) error { + out, err := m.adb.Connect(ctx, address) + if err != nil { + return err + } + if strings.Contains(out, "failed") || strings.Contains(out, "error") { + return &Error{Message: out} + } + return nil +} + +// Disconnect disconnects a device. +func (m *Manager) Disconnect(ctx context.Context, deviceID string) error { + _, err := m.adb.Disconnect(ctx, deviceID) + return err +} + +// parseDeviceLines parses `adb devices -l` output into a serial→status map. +func parseDeviceLines(raw string) map[string]Status { + result := make(map[string]Status) + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "List of") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + serial := fields[0] + result[serial] = parseStatus(fields[1]) + } + return result +} + +func parseStatus(s string) Status { + switch s { + case "device": + return StatusOnline + case "offline": + return StatusOffline + case "unauthorized": + return StatusUnauthorized + case "recovery": + return StatusRecovery + default: + return StatusOffline + } +} + +func connectionType(serial string) ConnectionType { + if strings.Contains(serial, ":") { + return ConnectionWifi + } + return ConnectionUSB +} + +// enrichDevice fetches device properties via adb shell getprop. +func enrichDevice(ctx context.Context, client *adb.Client, d *Device) { + props := map[string]*string{ + "ro.product.model": &d.Model, + "ro.product.manufacturer": &d.Manufacturer, + "ro.build.version.release": &d.AndroidVersion, + "ro.product.cpu.abi": &d.ABI, + "ro.product.name": &d.Product, + } + for prop, dest := range props { + if val, err := client.Shell(ctx, d.ID, "getprop "+prop); err == nil { + *dest = strings.TrimSpace(val) + } + } + if level, err := client.Shell(ctx, d.ID, "dumpsys battery | grep level | tr -dc '0-9'"); err == nil { + d.BatteryLevel = parseIntSafe(strings.TrimSpace(level)) + } +} + +func parseIntSafe(s string) int { + n := 0 + for _, c := range s { + if c >= '0' && c <= '9' { + n = n*10 + int(c-'0') + } + } + return n +} + +type Error struct { + Message string +} + +func (e *Error) Error() string { return e.Message } diff --git a/backend-go/device/types.go b/backend-go/device/types.go new file mode 100644 index 0000000..576fe2d --- /dev/null +++ b/backend-go/device/types.go @@ -0,0 +1,65 @@ +package device + +type ConnectionType string + +const ( + ConnectionUSB ConnectionType = "usb" + ConnectionWifi ConnectionType = "wifi" +) + +type Status string + +const ( + StatusOnline Status = "online" + StatusOffline Status = "offline" + StatusUnauthorized Status = "unauthorized" + StatusRecovery Status = "recovery" +) + +type Device struct { + ID string `json:"id"` + Model string `json:"model"` + Product string `json:"product"` + AndroidVersion string `json:"androidVersion"` + ABI string `json:"abi"` + ConnectionType ConnectionType `json:"connectionType"` + Status Status `json:"status"` + BatteryLevel int `json:"batteryLevel"` + Manufacturer string `json:"manufacturer"` +} + +// DeviceLiveStats holds fields that change while the device is running. +// Polled separately from the static DeviceInfo. +type DeviceLiveStats struct { + AvailableRAMMB int `json:"availableRamMb"` + BatteryLevel int `json:"batteryLevel"` + BatteryStatus string `json:"batteryStatus"` + ThermalStatus string `json:"thermalStatus"` + AvailableStorageGB string `json:"availableStorageGb"` + IPAddress string `json:"ipAddress"` +} + +// DeviceInfo holds extended device details fetched on demand. +type DeviceInfo struct { + ID string `json:"id"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + Product string `json:"product"` + AndroidVersion string `json:"androidVersion"` + SDKVersion string `json:"sdkVersion"` + SecurityPatch string `json:"securityPatch"` + BuildFingerprint string `json:"buildFingerprint"` + ABI string `json:"abi"` + SupportedABIs string `json:"supportedAbis"` + ScreenResolution string `json:"screenResolution"` + ScreenDensity string `json:"screenDensity"` + TotalRAMMB int `json:"totalRamMb"` + AvailableRAMMB int `json:"availableRamMb"` + TotalStorageGB string `json:"totalStorageGb"` + AvailableStorageGB string `json:"availableStorageGb"` + BatteryLevel int `json:"batteryLevel"` + BatteryStatus string `json:"batteryStatus"` + ThermalStatus string `json:"thermalStatus"` + IPAddress string `json:"ipAddress"` + SerialNumber string `json:"serialNumber"` +} diff --git a/desktop/app.go b/desktop/app.go index af53038..a84075e 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -2,26 +2,97 @@ package main import ( "context" - "fmt" + "time" + + "git.achmad.dev/admin/droidscope/backend/adb" + "git.achmad.dev/admin/droidscope/backend/device" + "github.com/wailsapp/wails/v2/pkg/runtime" + + "git.achmad.dev/admin/droidscope/desktop/internal/adbembed" ) -// App struct type App struct { - ctx context.Context + ctx context.Context + deviceManager *device.Manager } -// NewApp creates a new App application struct func NewApp() *App { return &App{} } -// startup is called when the app starts. The context is saved -// so we can call the runtime methods func (a *App) startup(ctx context.Context) { a.ctx = ctx + + adbPath, err := adbembed.Resolve() + if err != nil { + runtime.LogErrorf(ctx, "ADB not found: %v", err) + return + } + runtime.LogInfof(ctx, "Using ADB at: %s", adbPath) + + client := adb.New(adbPath) + if err := client.StartServer(ctx); err != nil { + runtime.LogWarningf(ctx, "ADB start-server: %v", err) + } + + a.deviceManager = device.NewManager(client) + go a.pollDevices() } -// Greet returns a greeting for the given name -func (a *App) Greet(name string) string { - return fmt.Sprintf("Hello %s, It's show time!", name) +func (a *App) pollDevices() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + for { + select { + case <-a.ctx.Done(): + return + case <-ticker.C: + devices, err := a.deviceManager.List(a.ctx) + if err != nil { + runtime.LogWarningf(a.ctx, "device poll: %v", err) + continue + } + runtime.EventsEmit(a.ctx, "devices:changed", devices) + } + } +} + +// ListDevices returns the current list of connected devices. +func (a *App) ListDevices() ([]device.Device, error) { + if a.deviceManager == nil { + return nil, nil + } + return a.deviceManager.List(a.ctx) +} + +// ConnectDevice connects to a wireless ADB device by address (e.g. "192.168.1.5:5555"). +func (a *App) ConnectDevice(address string) error { + if a.deviceManager == nil { + return nil + } + return a.deviceManager.Connect(a.ctx, address) +} + +// DisconnectDevice disconnects a device by its serial ID. +func (a *App) DisconnectDevice(deviceID string) error { + if a.deviceManager == nil { + return nil + } + return a.deviceManager.Disconnect(a.ctx, deviceID) +} + +// GetDeviceInfo fetches static device information for the given device ID. +func (a *App) GetDeviceInfo(deviceID string) (*device.DeviceInfo, error) { + if a.deviceManager == nil { + return nil, nil + } + return a.deviceManager.Info(a.ctx, deviceID) +} + +// GetDeviceLiveStats fetches dynamic device stats (RAM, battery, thermal, storage, IP). +func (a *App) GetDeviceLiveStats(deviceID string) (*device.DeviceLiveStats, error) { + if a.deviceManager == nil { + return nil, nil + } + return a.deviceManager.LiveStats(a.ctx, deviceID) } diff --git a/desktop/go.mod b/desktop/go.mod index 856c7b2..3d82ec6 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -1,8 +1,13 @@ module git.achmad.dev/admin/droidscope/desktop -go 1.23.0 +go 1.26.2 -require github.com/wailsapp/wails/v2 v2.12.0 +require ( + git.achmad.dev/admin/droidscope/backend v0.0.0 + github.com/wailsapp/wails/v2 v2.12.0 +) + +replace git.achmad.dev/admin/droidscope/backend => ../backend-go require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect @@ -34,5 +39,3 @@ require ( golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect ) - -// replace github.com/wailsapp/wails/v2 v2.12.0 => /Users/achmad/go/pkg/mod diff --git a/desktop/internal/adbembed/bin/.gitkeep b/desktop/internal/adbembed/bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/desktop/internal/adbembed/bin/adb-darwin-amd64 b/desktop/internal/adbembed/bin/adb-darwin-amd64 new file mode 100755 index 0000000..3bc8b9d Binary files /dev/null and b/desktop/internal/adbembed/bin/adb-darwin-amd64 differ diff --git a/desktop/internal/adbembed/bin/adb-darwin-arm64 b/desktop/internal/adbembed/bin/adb-darwin-arm64 new file mode 100755 index 0000000..3bc8b9d Binary files /dev/null and b/desktop/internal/adbembed/bin/adb-darwin-arm64 differ diff --git a/desktop/internal/adbembed/bin/adb-linux-amd64 b/desktop/internal/adbembed/bin/adb-linux-amd64 new file mode 100755 index 0000000..c25af4d Binary files /dev/null and b/desktop/internal/adbembed/bin/adb-linux-amd64 differ diff --git a/desktop/internal/adbembed/embed.go b/desktop/internal/adbembed/embed.go new file mode 100644 index 0000000..933bdf6 --- /dev/null +++ b/desktop/internal/adbembed/embed.go @@ -0,0 +1,71 @@ +package adbembed + +import ( + "embed" + "fmt" + "os" + "path/filepath" + "runtime" +) + +//go:embed all:bin +var binFS embed.FS + +// Resolve returns the path to a usable adb binary. +// Order: embedded binary → ANDROID_HOME → PATH. +func Resolve() (string, error) { + if path, err := extractEmbedded(); err == nil { + return path, nil + } + return findOnSystem() +} + +func extractEmbedded() (string, error) { + name := embeddedName() + data, err := binFS.ReadFile("bin/" + name) + if err != nil || len(data) < 1024 { + return "", fmt.Errorf("no embedded binary") + } + + cacheDir, err := os.UserCacheDir() + if err != nil { + return "", err + } + dir := filepath.Join(cacheDir, "droidscope", "adb") + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + + dest := filepath.Join(dir, adbExeName()) + if err := os.WriteFile(dest, data, 0755); err != nil { + return "", err + } + return dest, nil +} + +func findOnSystem() (string, error) { + if home := os.Getenv("ANDROID_HOME"); home != "" { + candidate := filepath.Join(home, "platform-tools", adbExeName()) + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + // Fall back to PATH + return findInPath() +} + +func embeddedName() string { + os_ := runtime.GOOS + arch := runtime.GOARCH + if os_ == "windows" { + return fmt.Sprintf("adb-%s-%s.exe", os_, arch) + } + return fmt.Sprintf("adb-%s-%s", os_, arch) +} + +func adbExeName() string { + if runtime.GOOS == "windows" { + return "adb.exe" + } + return "adb" +} diff --git a/desktop/internal/adbembed/path_unix.go b/desktop/internal/adbembed/path_unix.go new file mode 100644 index 0000000..f4389e6 --- /dev/null +++ b/desktop/internal/adbembed/path_unix.go @@ -0,0 +1,11 @@ +//go:build !windows + +package adbembed + +import ( + "os/exec" +) + +func findInPath() (string, error) { + return exec.LookPath("adb") +} diff --git a/desktop/internal/adbembed/path_windows.go b/desktop/internal/adbembed/path_windows.go new file mode 100644 index 0000000..4fef5ec --- /dev/null +++ b/desktop/internal/adbembed/path_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package adbembed + +import ( + "os/exec" +) + +func findInPath() (string, error) { + return exec.LookPath("adb.exe") +} diff --git a/desktop/wails.json b/desktop/wails.json index 28c385e..3b49110 100644 --- a/desktop/wails.json +++ b/desktop/wails.json @@ -7,7 +7,7 @@ "frontend:dev:watcher": "npm run dev", "frontend:dev:serverUrl": "auto", "frontend:dir": "../frontend-react", - "wailsjsdir": "../frontend-react/src/wailsjs", + "wailsjsdir": "../frontend-react/src", "author": { "name": "achmad", "email": "anakinskywalk1@gmail.com"