Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a519551
restapi: production-readiness (graceful shutdown, TLS, health, timeou…
danielpaulus Aug 7, 2026
a38c971
restapi: device-info endpoints (devicename, date, battery, diagnostic…
danielpaulus Aug 7, 2026
4f7a7ec
restapi: device-management endpoints (reboot, shutdown, erase, devmod…
danielpaulus Aug 7, 2026
ffa8d3a
restapi: file-transfer + crash-report endpoints (ios file ls/pull/pus…
danielpaulus Aug 7, 2026
f19e058
restapi: media endpoints (wallpaper, icon-layout, pasteboard)
danielpaulus Aug 7, 2026
c9a3228
restapi: profile + developer-image management (profile add/remove, im…
danielpaulus Aug 7, 2026
1c245d1
restapi: settings endpoints (assistivetouch, timeformat, wifi)
danielpaulus Aug 7, 2026
57d846c
restapi: streaming sysmontap endpoint (CPU usage)
danielpaulus Aug 7, 2026
d7fcf51
restapi: mdm/supervision endpoints (security-info, fetch-unlock-token…
danielpaulus Aug 7, 2026
6e88833
restapi: async-job subsystem for runtest/runwda/forward with per-job …
danielpaulus Aug 10, 2026
621c365
restapi: http proxy endpoints (ios httpproxy set/remove)
danielpaulus Aug 10, 2026
a4406e1
restapi: tunnel-agent endpoints (ios tunnel ls/stop/refresh/stopagent)
danielpaulus Aug 10, 2026
a0ee58d
restapi: handler validation tests for the new endpoints
danielpaulus Aug 10, 2026
96ba223
restapi: per-device request rate limiting
danielpaulus Aug 10, 2026
cd424aa
chore: update go.work.sum for x/time direct dep in restapi module
danielpaulus Aug 10, 2026
cbde822
restapi: harden uploads/jobs and add handler tests for #817
danielpaulus Aug 10, 2026
46d8a06
restapi: conform HTTP API to the SDK OpenAPI contract
danielpaulus Aug 11, 2026
86eac38
restapi: add diagnostics/network parity endpoints (#817)
danielpaulus Aug 11, 2026
ed7f628
restapi: add non-interactive WebInspector endpoints
danielpaulus Aug 11, 2026
080c95e
refactor: extract reusable ios/uidriver from cmd_ui.go
danielpaulus Aug 11, 2026
8bf77fc
restapi: add AFC fsync + provisioning cloudconfig/skip-options endpoints
danielpaulus Aug 11, 2026
630f6d2
restapi: add accessibility & GPX location endpoints (#817 w1b)
danielpaulus Aug 11, 2026
3531278
Merge remote-tracking branch 'origin/refactor/uidriver-extract' into …
danielpaulus Aug 11, 2026
5a64a56
Merge remote-tracking branch 'origin/feat/restapi-w1a-diagnostics' in…
danielpaulus Aug 11, 2026
13a5042
Merge remote-tracking branch 'origin/feat/restapi-w1b-a11y' into feat…
danielpaulus Aug 11, 2026
706888c
Merge remote-tracking branch 'origin/feat/restapi-w1c-fsync' into fea…
danielpaulus Aug 11, 2026
35269f6
Merge remote-tracking branch 'origin/feat/restapi-w1d-webinspector' i…
danielpaulus Aug 11, 2026
b8bd1b7
feat(restapi): add UI-automation endpoints proxying to WDA/DeviceKit
danielpaulus Aug 11, 2026
f12a269
feat(restapi): add codesigning + prepare endpoints behind a Signer in…
danielpaulus Aug 11, 2026
6672799
Merge remote-tracking branch 'origin/feat/restapi-w4-sign' into featu…
danielpaulus Aug 11, 2026
be47144
feat(pcap,restapi): add streaming pcap capture core and REST endpoint
danielpaulus Aug 11, 2026
7acbce7
feat(restapi): add UI video + mjpeg screenshot streaming endpoints
danielpaulus Aug 11, 2026
e60c207
Merge remote-tracking branch 'origin/feat/restapi-w3b-pcap' into work…
danielpaulus Aug 11, 2026
138c617
feat(restapi): ephemeral loopback port by default + discovery file
danielpaulus Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
456 changes: 97 additions & 359 deletions cmd_ui.go

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions go.work.sum
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
github.com/google/go-github/v56 v56.0.0 h1:TysL7dMa/r7wsQi44BjqlwaHvwlFlqkK8CtBWCX3gb4=
github.com/google/go-github/v56 v56.0.0/go.mod h1:D8cdcX98YWJvi7TLo7zM4/h8ZTx6u6fwGEkCdisopo0=
Expand Down
127 changes: 79 additions & 48 deletions ios/instruments/screenshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package instruments
import (
"bufio"
"bytes"
"context"
"fmt"
"github.com/danielpaulus/go-ios/ios"
dtx "github.com/danielpaulus/go-ios/ios/dtx_codec"
Expand Down Expand Up @@ -51,70 +52,100 @@ func (d *ScreenshotService) TakeScreenshot() ([]byte, error) {
return imageBytes, nil
}

// MJPEG server code
var (
consumers sync.Map
conversionQueue = make(chan []byte, 20)
)

func StartMJPEGStreamingServer(device ios.DeviceEntry, port string) error {
conn, err := NewScreenshotService(device)
// pngToJPEG decodes a PNG frame (as returned by TakeScreenshot) and re-encodes
// it as JPEG at the given quality. It is the shared conversion step used by both
// the built-in MJPEG server and any external MJPEG consumer (for example the
// REST API).
func pngToJPEG(pngBytes []byte, quality int) ([]byte, error) {
img, err := png.Decode(bytes.NewReader(pngBytes))
if err != nil {
return err
return nil, fmt.Errorf("pngToJPEG: decode png: %w", err)
}
defer conn.Close()

go startScreenshotting(conn)
go startConversionQueue()
http.HandleFunc("/", mjpegHandler)
location := fmt.Sprintf("0.0.0.0:%s", port)
golog.Info("starting server, open your browser here", "module", logModule, "udid", device.Properties.SerialNumber, "host", "0.0.0.0", "port", port, "url", fmt.Sprintf("http://%s/", location))
return http.ListenAndServe(location, nil)
var b bytes.Buffer
w := bufio.NewWriter(&b)
if err := jpeg.Encode(w, img, &jpeg.Options{Quality: quality}); err != nil {
return nil, fmt.Errorf("pngToJPEG: encode jpeg: %w", err)
}
if err := w.Flush(); err != nil {
return nil, fmt.Errorf("pngToJPEG: flush jpeg: %w", err)
}
return b.Bytes(), nil
}

func startConversionQueue() {
var opt jpeg.Options
opt.Quality = 80

// StreamJPEGFrames is the reusable frame source shared by every MJPEG consumer.
// It takes screenshots on conn in a loop, converts each PNG to a JPEG frame at
// the given quality (a quality <= 0 selects the default of 80), and calls emit
// with the raw JPEG bytes for every frame. The emit callback owns the slice for
// the duration of the call; it must not retain it across calls.
//
// It runs until ctx is canceled or a screenshot/conversion error occurs, then
// returns. A screenshot error is returned; ctx cancellation returns nil. The
// caller owns conn and its lifecycle.
func StreamJPEGFrames(ctx context.Context, conn *ScreenshotService, quality int, emit func([]byte)) error {
if quality <= 0 {
quality = 80
}
for {
pngBytes := <-conversionQueue
if err := ctx.Err(); err != nil {
return nil
}
start := time.Now()
img, err := png.Decode(bytes.NewReader(pngBytes))
pngBytes, err := conn.TakeScreenshot()
if err != nil {
golog.Warn("failed decoding png", "module", logModule, "error", err)
continue
// Stop the streaming loop instead of killing the host process; a
// screenshot failure must not take down a caller embedding go-ios.
golog.Error("screenshot failed, stopping screenshot loop", "module", logModule, "error", err)
return err
}
var b bytes.Buffer
foo := bufio.NewWriter(&b)
err = jpeg.Encode(foo, img, &opt)
golog.Debug("shot done", "module", logModule, "seconds", time.Since(start).Seconds())
jpg, err := pngToJPEG(pngBytes, quality)
if err != nil {
golog.Warn("failed encoding jpg", "module", logModule, "error", err)
golog.Warn("failed converting frame", "module", logModule, "error", err)
continue
}
elapsed := time.Since(start)
golog.Debug("conversion done", "module", logModule, "seconds", elapsed.Seconds())
consumers.Range(func(key, value interface{}) bool {
c := value.(chan []byte)
go func() { c <- b.Bytes() }()
return true
})
golog.Debug("conversion done", "module", logModule, "seconds", time.Since(start).Seconds())
// Re-check cancellation before emitting so a canceled context does not
// push a final frame to a gone consumer.
if ctx.Err() != nil {
return nil
}
emit(jpg)
}
}

func startScreenshotting(conn *ScreenshotService) {
for {
start := time.Now()
pngBytes, err := conn.TakeScreenshot()
// MJPEG server code
var consumers sync.Map

func StartMJPEGStreamingServer(device ios.DeviceEntry, port string) error {
conn, err := NewScreenshotService(device)
if err != nil {
return err
}
defer conn.Close()

// Fan a single frame source out to every connected mjpegHandler consumer,
// preserving the previous behavior (one screenshot loop shared by all
// clients registered in the consumers map).
go func() {
err := StreamJPEGFrames(context.Background(), conn, 80, func(jpg []byte) {
consumers.Range(func(key, value interface{}) bool {
c := value.(chan []byte)
// Copy: the callback does not own the slice past the call, and
// consumers receive asynchronously.
frame := append([]byte(nil), jpg...)
go func() { c <- frame }()
return true
})
})
if err != nil {
// Stop the streaming loop instead of killing the host process; a
// screenshot failure must not take down a caller embedding go-ios.
golog.Error("screenshot failed, stopping screenshot loop", "module", logModule, "error", err)
return
golog.Error("mjpeg frame source stopped", "module", logModule, "udid", device.Properties.SerialNumber, "error", err)
}
elapsed := time.Since(start)
golog.Debug("shot done", "module", logModule, "seconds", elapsed.Seconds())
conversionQueue <- pngBytes
}
}()

http.HandleFunc("/", mjpegHandler)
location := fmt.Sprintf("0.0.0.0:%s", port)
golog.Info("starting server, open your browser here", "module", logModule, "udid", device.Properties.SerialNumber, "host", "0.0.0.0", "port", port, "url", fmt.Sprintf("http://%s/", location))
return http.ListenAndServe(location, nil)
}

const (
Expand Down
54 changes: 54 additions & 0 deletions ios/instruments/screenshot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package instruments

import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"testing"
)

// makePNG builds a tiny valid PNG for the conversion tests.
func makePNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
for x := 0; x < 4; x++ {
for y := 0; y < 4; y++ {
img.Set(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 128, A: 255})
}
}
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return b.Bytes()
}

// TestPngToJPEG asserts the shared PNG->JPEG conversion step (used by both the
// built-in MJPEG server and external consumers) produces a decodable JPEG.
func TestPngToJPEG(t *testing.T) {
pngBytes := makePNG(t)

jpg, err := pngToJPEG(pngBytes, 80)
if err != nil {
t.Fatalf("pngToJPEG: %v", err)
}
if len(jpg) == 0 {
t.Fatal("pngToJPEG returned no bytes")
}
img, err := jpeg.Decode(bytes.NewReader(jpg))
if err != nil {
t.Fatalf("output is not valid jpeg: %v", err)
}
if img.Bounds().Dx() != 4 || img.Bounds().Dy() != 4 {
t.Fatalf("unexpected jpeg bounds: %v", img.Bounds())
}
}

// TestPngToJPEGRejectsGarbage asserts a non-PNG input errors instead of panicking.
func TestPngToJPEGRejectsGarbage(t *testing.T) {
if _, err := pngToJPEG([]byte("not a png"), 80); err == nil {
t.Fatal("expected error decoding non-png input")
}
}
111 changes: 90 additions & 21 deletions ios/pcap/pcap.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package pcap

import (
"bytes"
"context"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -55,28 +56,98 @@ func (iph *IOSPacketHeader) ToString() string {
return fmt.Sprintf("%v", *iph)
}

// Start captures network traffic from the device into a pcap file in the
// current working directory and blocks until the connection fails. It is the
// entry point used by the `ios pcap` CLI command and keeps its historical
// behavior; it is implemented on top of the streaming capture core.
func Start(device ios.DeviceEntry) error {
intf, err := ios.ConnectToService(device, "com.apple.pcapd")
if err != nil {
return err
}
defer intf.Close()
plistCodec := ios.NewPlistCodec()
fname := fmt.Sprintf("dump-%d.pcap", time.Now().Unix())
if Pid > 0 {
fname = fmt.Sprintf("dump-%d-%d.pcap", Pid, time.Now().Unix())
} else if ProcName != "" {
fname = fmt.Sprintf("dump-%s-%d.pcap", ProcName, time.Now().Unix())
}
f, err := createPcap(fname)
f, err := os.OpenFile(fname, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o755)
if err != nil {
return err
}
defer f.Close()
golog.Info("create pcap file", "module", logModule, "udid", device.Properties.SerialNumber, "path", fname)
// The CLI captures until the connection fails (or the process is killed),
// so there is no cancellation context to honor here.
return Stream(context.Background(), device, f)
}

// Stream captures network traffic from the device and writes a valid pcap
// stream (global header followed by per-packet records) to w until ctx is
// canceled or the connection fails. On cancellation the underlying device
// connection is closed to unblock the pending read and Stream returns nil,
// leaving w a valid, finalized pcap stream. It never leaks goroutines.
func Stream(ctx context.Context, device ios.DeviceEntry, w io.Writer) error {
intf, err := ios.ConnectToService(device, "com.apple.pcapd")
if err != nil {
return err
}
defer intf.Close()
if err := writePcapHeader(w); err != nil {
return err
}
golog.Info("pcap capture started", "module", logModule, "udid", device.Properties.SerialNumber)
err = capture(ctx, intf, w)
if err != nil {
return err
}
golog.Info("pcap capture stopped", "module", logModule, "udid", device.Properties.SerialNumber)
return nil
}

// captureConn is the subset of ios.DeviceConnectionInterface the capture loop
// needs. Keeping it small makes the capture core testable against an in-memory
// fake connection.
type captureConn interface {
Reader() io.Reader
Close() error
}

// capture reads packets from conn and streams pcap records to w until ctx is
// done or reading fails. When ctx is done, the connection is closed to unblock
// the pending read and capture returns nil, leaving w a valid pcap stream. A
// read error that occurs while ctx is still active is surfaced to the caller.
func capture(ctx context.Context, conn captureConn, w io.Writer) error {
done := make(chan struct{})
// closedByWatchdog is set (before watchdogDone is closed) only when the
// watchdog closed conn because ctx was canceled, so a read error caused by
// that close is classified as a clean shutdown rather than a failure.
var closedByWatchdog bool
watchdogDone := make(chan struct{})
go func() {
defer close(watchdogDone)
select {
case <-ctx.Done():
closedByWatchdog = true
conn.Close()
case <-done:
}
}()
// Wait for the watchdog to finish before returning so its conn.Close()
// never races the caller's own deferred close of the same connection.
defer func() {
close(done)
<-watchdogDone
}()
plistCodec := ios.NewPlistCodec()
for {
b, err := plistCodec.Decode(intf.Reader())
b, err := plistCodec.Decode(conn.Reader())
if err != nil {
// A read error after the watchdog closed conn is the expected way a
// canceled capture unblocks; report it as a clean stop. Block on
// watchdogDone first so closedByWatchdog is observed race-free.
if ctx.Err() != nil {
<-watchdogDone
if closedByWatchdog {
return nil
}
}
return err
}
decodedBytes, err := fromBytes(b)
Expand All @@ -88,7 +159,7 @@ func Start(device ios.DeviceEntry) error {
return err
}
if len(packet) > 0 {
err = writePacket(f, iph, packet)
err = writePacket(w, iph, packet)
if err != nil {
return err
}
Expand Down Expand Up @@ -127,21 +198,17 @@ type PcaprecHdrS struct {
OrigLen int `struc:"uint32,little"` /* actual length of packet */
}

func createPcap(name string) (*os.File, error) {
f, err := os.OpenFile(name, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o755)
if err != nil {
return nil, err
}
// Write `pcap_hdr_s` with little endin to file.
f.Write([]byte{
// writePcapHeader writes the little endian `pcap_hdr_s` global header to w.
func writePcapHeader(w io.Writer) error {
_, err := w.Write([]byte{
0xd4, 0xc3, 0xb2, 0xa1, 0x02, 0x00, 0x04, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xff, 0xff, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
})
return f, nil
return err
}

func writePacket(f *os.File, iph IOSPacketHeader, packet []byte) error {
func writePacket(w io.Writer, iph IOSPacketHeader, packet []byte) error {
phs := &PcaprecHdrS{
iph.TsSec,
iph.TsUsec,
Expand All @@ -153,9 +220,11 @@ func writePacket(f *os.File, iph IOSPacketHeader, packet []byte) error {
if err != nil {
return err
}
f.Write(buf.Bytes())
f.Write(packet)
return nil
if _, err := w.Write(buf.Bytes()); err != nil {
return err
}
_, err = w.Write(packet)
return err
}

func getPacket(buf []byte) (iph IOSPacketHeader, packet []byte, err error) {
Expand Down
Loading
Loading