- New sidebar views: GSI Loader (DSU + permanent fastboot flash), APK Audit, Firmware, Intent Lab, Screen Mirror - New backend modules: backend_gsi.go, backend_apkaudit*.go, backend_firmware.go, backend_intent.go, backend_magisk.go, backend_applock.go, backend_scrcpy.go, backend_payload.go, backend_privacy.go, backend_overview.go, backend_bootinfo.go, backend_flasher.go, backend_transfer.go, backend_filehttp.go - App Lock / danger-gate infrastructure (DangerGate, LockGate, applock.ts) - Privileged uninstall via embedded Android helper dex (android-helper/) - nfpm packaging version bump to 1.2.0
226 lines
6.6 KiB
Go
226 lines
6.6 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
// Screen mirroring via scrcpy. We don't embed scrcpy's video (that would mean
|
|
// reimplementing its client); instead we launch the system scrcpy, which opens
|
|
// its own movable/resizable window with full touch+keyboard control. ATK is the
|
|
// control panel: options + start/stop, and a scrcpy:stopped event when its
|
|
// window closes so the UI can reset.
|
|
|
|
type ScrcpyOptions struct {
|
|
MaxSize int `json:"maxSize"` // longest edge in px; 0 = original
|
|
BitRateMbps int `json:"bitRateMbps"` // video bitrate in Mbps
|
|
MaxFps int `json:"maxFps"` // 0 = unlimited
|
|
StayAwake bool `json:"stayAwake"`
|
|
TurnScreenOff bool `json:"turnScreenOff"`
|
|
ShowTouches bool `json:"showTouches"`
|
|
AlwaysOnTop bool `json:"alwaysOnTop"`
|
|
Fullscreen bool `json:"fullscreen"`
|
|
Borderless bool `json:"borderless"` // hide the WM title bar / decorations
|
|
Record bool `json:"record"`
|
|
Detached bool `json:"detached"` // keep the mirror alive after ATK closes
|
|
NoAudio bool `json:"noAudio"`
|
|
ViewOnly bool `json:"viewOnly"` // --no-control
|
|
VideoCodec string `json:"videoCodec"` // "", h264, h265, av1
|
|
Orientation string `json:"orientation"` // "", 0, 90, 180, 270
|
|
}
|
|
|
|
var (
|
|
scrcpyMu sync.Mutex
|
|
scrcpyCmd *exec.Cmd
|
|
scrcpyDetached bool
|
|
)
|
|
|
|
// ScrcpyAvailable returns the scrcpy version string, or an error if not found.
|
|
func (a *App) ScrcpyAvailable() (string, error) {
|
|
p, err := exec.LookPath("scrcpy")
|
|
if err != nil {
|
|
return "", fmt.Errorf("scrcpy not found — install with: sudo apt install scrcpy")
|
|
}
|
|
out, err := exec.Command(p, "--version").Output()
|
|
if err != nil {
|
|
return "scrcpy", nil
|
|
}
|
|
return strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]), nil
|
|
}
|
|
|
|
// ScrcpyRunning reports whether ATK is currently managing a mirror it launched.
|
|
// We deliberately track only our own process (not a system-wide scrcpy scan):
|
|
// scanning produced false "Stop" states from processes caught mid-exit, and the
|
|
// view never re-polled. A fresh launch always shows Start.
|
|
func (a *App) ScrcpyRunning() bool {
|
|
scrcpyMu.Lock()
|
|
defer scrcpyMu.Unlock()
|
|
return scrcpyCmd != nil
|
|
}
|
|
|
|
// StartScrcpy launches scrcpy in its own window with the given options.
|
|
func (a *App) StartScrcpy(opts ScrcpyOptions) error {
|
|
scrcpyMu.Lock()
|
|
tracked := scrcpyCmd != nil
|
|
scrcpyMu.Unlock()
|
|
if tracked {
|
|
return fmt.Errorf("a mirror is already running")
|
|
}
|
|
|
|
p, err := exec.LookPath("scrcpy")
|
|
if err != nil {
|
|
return fmt.Errorf("scrcpy not found — install with: sudo apt install scrcpy")
|
|
}
|
|
|
|
// Empty title so the WM title bar shows no text. (Omitting --window-title
|
|
// would make scrcpy fall back to the device model name, which is still text.)
|
|
args := []string{"--window-title", ""}
|
|
if opts.Borderless {
|
|
args = append(args, "--window-borderless")
|
|
}
|
|
if opts.MaxSize > 0 {
|
|
args = append(args, "--max-size", strconv.Itoa(opts.MaxSize))
|
|
}
|
|
if opts.BitRateMbps > 0 {
|
|
args = append(args, "--video-bit-rate", strconv.Itoa(opts.BitRateMbps)+"M")
|
|
}
|
|
if opts.MaxFps > 0 {
|
|
args = append(args, "--max-fps", strconv.Itoa(opts.MaxFps))
|
|
}
|
|
if opts.StayAwake {
|
|
args = append(args, "--stay-awake")
|
|
}
|
|
if opts.TurnScreenOff {
|
|
args = append(args, "--turn-screen-off")
|
|
}
|
|
if opts.ShowTouches {
|
|
args = append(args, "--show-touches")
|
|
}
|
|
if opts.AlwaysOnTop {
|
|
args = append(args, "--always-on-top")
|
|
}
|
|
if opts.Fullscreen {
|
|
args = append(args, "--fullscreen")
|
|
}
|
|
if opts.NoAudio {
|
|
args = append(args, "--no-audio")
|
|
}
|
|
if opts.ViewOnly {
|
|
args = append(args, "--no-control")
|
|
}
|
|
if opts.VideoCodec != "" {
|
|
args = append(args, "--video-codec="+opts.VideoCodec)
|
|
}
|
|
if opts.Orientation != "" {
|
|
args = append(args, "--capture-orientation="+opts.Orientation)
|
|
}
|
|
if opts.Record {
|
|
path, derr := a.SelectSaveFile("scrcpy-recording.mp4")
|
|
if derr != nil {
|
|
return fmt.Errorf("save dialog failed: %w", derr)
|
|
}
|
|
if path == "" {
|
|
return fmt.Errorf("recording cancelled")
|
|
}
|
|
args = append(args, "--record", path)
|
|
}
|
|
|
|
// Clean up any orphan mirror (e.g. left over from a crash/hard-kill of a
|
|
// previous ATK) so Start always yields exactly one window, never a stack.
|
|
if pk, perr := exec.LookPath("pkill"); perr == nil {
|
|
exec.Command(pk, "-x", "scrcpy").Run()
|
|
}
|
|
|
|
cmd := exec.Command(p, args...)
|
|
setCommandSysProcAttr(cmd)
|
|
// Inherit the desktop session (DISPLAY/WAYLAND_DISPLAY) and point scrcpy at
|
|
// the same adb ATK resolved, so it doesn't depend on adb being on PATH.
|
|
env := os.Environ()
|
|
if adbPath, aerr := a.getBinaryPath("adb"); aerr == nil {
|
|
env = append(env, "ADB="+adbPath)
|
|
}
|
|
cmd.Env = env
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("failed to start scrcpy: %w", err)
|
|
}
|
|
|
|
scrcpyMu.Lock()
|
|
scrcpyCmd = cmd
|
|
scrcpyDetached = opts.Detached
|
|
scrcpyMu.Unlock()
|
|
|
|
// Reap the process and tell the UI when the window is closed.
|
|
go func() {
|
|
cmd.Wait()
|
|
scrcpyMu.Lock()
|
|
scrcpyCmd = nil
|
|
scrcpyDetached = false
|
|
scrcpyMu.Unlock()
|
|
runtime.EventsEmit(a.ctx, "scrcpy:stopped", nil)
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// CaptureScreenshot grabs the device's current screen as a PNG and saves it to
|
|
// a user-chosen path. Independent of scrcpy — works whenever a device is
|
|
// connected. Uses `adb exec-out screencap -p` (raw bytes, no CRLF mangling).
|
|
// Returns the saved path, or "" if the user cancelled the save dialog.
|
|
func (a *App) CaptureScreenshot() (string, error) {
|
|
adbPath, err := a.getBinaryPath("adb")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
cmd := exec.Command(adbPath, "exec-out", "screencap", "-p")
|
|
setCommandSysProcAttr(cmd)
|
|
var out, errb bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &errb
|
|
if err := cmd.Run(); err != nil {
|
|
msg := strings.TrimSpace(errb.String())
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return "", fmt.Errorf("%s", msg)
|
|
}
|
|
if out.Len() == 0 {
|
|
return "", fmt.Errorf("no screen data — is a device connected and unlocked?")
|
|
}
|
|
|
|
path, err := a.SelectSaveFile("screenshot-" + time.Now().Format("20060102-150405") + ".png")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if path == "" {
|
|
return "", nil
|
|
}
|
|
if err := os.WriteFile(path, out.Bytes(), 0o644); err != nil {
|
|
return "", fmt.Errorf("failed to save: %w", err)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// StopScrcpy terminates the running mirror session (closes the scrcpy window).
|
|
func (a *App) StopScrcpy() error {
|
|
scrcpyMu.Lock()
|
|
cmd := scrcpyCmd
|
|
scrcpyMu.Unlock()
|
|
if cmd != nil && cmd.Process != nil {
|
|
return cmd.Process.Kill()
|
|
}
|
|
// No tracked handle — kill a leftover/orphan scrcpy by name.
|
|
if p, err := exec.LookPath("pkill"); err == nil {
|
|
exec.Command(p, "-x", "scrcpy").Run()
|
|
}
|
|
return nil
|
|
}
|