From 3276230d52608eb715505457ea880031d406712d Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Fri, 2 Oct 2026 10:09:44 +0200 Subject: [PATCH 1/4] feat(ios): drive safari tabs with the webview commands on a real device --- README.md | 2 + cli/webview.go | 2 +- devices/ios_device_webview.go | 46 +++ devices/ios_webinspector.go | 658 +++++++++++++++++++++++++++++++ devices/ios_webinspector_test.go | 608 ++++++++++++++++++++++++++++ docs/openrpc.json | 2 +- docs/openrpc.md | 2 +- 7 files changed, 1317 insertions(+), 3 deletions(-) create mode 100644 devices/ios_webinspector.go create mode 100644 devices/ios_webinspector_test.go diff --git a/README.md b/README.md index c2de0cd4..9ac37b8f 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,8 @@ Example output for `agent status`: Inspect and interact with embedded webviews (`WKWebView` on iOS, `android.webkit.WebView` on Android) running inside native apps. +On a real iOS device the same commands also drive the tabs of Safari when it is the foreground app, through the device's Web Inspector service. This needs **Settings > Apps > Safari > Advanced > Web Inspector** turned on. Only the tab Safari is showing (`isVisible: true`) is sure to answer `eval` and the commands built on it. + ```bash # List embedded webviews in the foreground app mobilecli webview list --device diff --git a/cli/webview.go b/cli/webview.go index 1ed0938e..38857f1a 100644 --- a/cli/webview.go +++ b/cli/webview.go @@ -14,7 +14,7 @@ var webviewCmd = &cobra.Command{ var webviewListCmd = &cobra.Command{ Use: "list", Short: "List embedded webviews on a device", - Long: `Returns all embedded webviews currently visible in the foreground app. Browser apps (Safari, Chrome) are not included.`, + Long: `Returns all embedded webviews currently visible in the foreground app. When the foreground app is Safari on a real iOS device, returns its tabs instead. Chrome is not included.`, RunE: func(cmd *cobra.Command, args []string) error { return runViaDaemon("cli.webview.list", commands.WebViewListRequest{ DeviceID: deviceId, diff --git a/devices/ios_device_webview.go b/devices/ios_device_webview.go index c9112258..1c8caf2a 100644 --- a/devices/ios_device_webview.go +++ b/devices/ios_device_webview.go @@ -3,14 +3,42 @@ package devices import ( "encoding/json" "fmt" + "io" "time" + + "github.com/mobile-next/mobilecli/utils" ) // WebView feature for real iOS devices. These methods are thin JSON-RPC calls // over the injected agent (see ios_device_agent.go for how the agent is // injected and reached). They implement the WebViewable interface. +// +// Safari cannot take the agent, so when it is the foreground app its tabs are +// driven through the web inspector service instead (see ios_webinspector.go). + +// safariInForeground returns what drives the tabs of Safari, when Safari is the +// app in front. Not knowing the foreground app leaves it to the injected agent, +// which reports why it cannot be reached. +func (d *IOSDevice) safariInForeground() (safariWebViews, bool) { + if err := d.StartAgent(StartAgentConfig{}); err != nil { + utils.Verbose("could not start the device agent to look up the foreground app: %v", err) + return safariWebViews{}, false + } + activeApp, err := d.deviceKitClient.GetActiveAppInfo() + if err != nil { + utils.Verbose("could not look up the foreground app: %v", err) + return safariWebViews{}, false + } + if activeApp.BundleID != safariBundleID { + return safariWebViews{}, false + } + return safariWebViews{dial: func() (io.ReadWriteCloser, error) { return dialWebInspector(d.Udid) }}, true +} func (d *IOSDevice) ListWebViews() ([]WebViewInfo, error) { + if safari, ok := d.safariInForeground(); ok { + return safari.ListWebViews() + } result, err := d.agentCall("device.webview.list", nil) if err != nil { return nil, err @@ -33,21 +61,33 @@ func (d *IOSDevice) ListWebViews() ([]WebViewInfo, error) { } func (d *IOSDevice) WebViewGoto(webviewID, url string) error { + if safari, ok := d.safariInForeground(); ok { + return safari.WebViewGoto(webviewID, url) + } _, err := d.agentCall("device.webview.goto", map[string]any{"id": webviewID, "url": url}) return err } func (d *IOSDevice) WebViewReload(webviewID string) error { + if safari, ok := d.safariInForeground(); ok { + return safari.WebViewReload(webviewID) + } _, err := d.agentCall("device.webview.reload", map[string]any{"id": webviewID}) return err } func (d *IOSDevice) WebViewGoBack(webviewID string) error { + if safari, ok := d.safariInForeground(); ok { + return safari.WebViewGoBack(webviewID) + } _, err := d.agentCall("device.webview.goBack", map[string]any{"id": webviewID}) return err } func (d *IOSDevice) WebViewGoForward(webviewID string) error { + if safari, ok := d.safariInForeground(); ok { + return safari.WebViewGoForward(webviewID) + } _, err := d.agentCall("device.webview.goForward", map[string]any{"id": webviewID}) return err } @@ -65,6 +105,9 @@ func (d *IOSDevice) WebViewContent(webviewID string) (string, error) { } func (d *IOSDevice) WebViewEvaluate(webviewID, expression string, args []any) (any, error) { + if safari, ok := d.safariInForeground(); ok { + return safari.WebViewEvaluate(webviewID, expression, args) + } params := map[string]any{ "id": webviewID, "expression": ensureReturnExpression(expression), @@ -86,6 +129,9 @@ func (d *IOSDevice) WebViewEvaluate(webviewID, expression string, args []any) (a } func (d *IOSDevice) WebViewWaitForLoadState(webviewID, state string, timeoutMs int) error { + if safari, ok := d.safariInForeground(); ok { + return safari.WebViewWaitForLoadState(webviewID, state, timeoutMs) + } if timeoutMs <= 0 { timeoutMs = 30_000 } diff --git a/devices/ios_webinspector.go b/devices/ios_webinspector.go new file mode 100644 index 00000000..2ea38413 --- /dev/null +++ b/devices/ios_webinspector.go @@ -0,0 +1,658 @@ +package devices + +import ( + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strconv" + "strings" + "sync" + "time" + + goios "github.com/danielpaulus/go-ios/ios" +) + +// Safari cannot take the injected agent: it is not a debuggable build. A device +// does run a web inspector service, the one Safari on a Mac connects to, and it +// reports the tabs of Safari and relays the WebKit inspector protocol to them. +// The tabs are driven through that instead, behind the same webview commands. +// +// The service only reports Safari when Settings > Apps > Safari > Advanced > +// Web Inspector is turned on. + +const ( + webInspectorService = "com.apple.webinspector" + safariBundleID = "com.apple.mobilesafari" + + webInspectorWebPageType = "WIRTypeWebPage" + + safariFindTimeout = 5 * time.Second + safariCommandTimeout = 30 * time.Second + // a tab that does not answer is reported as hidden instead of holding up the list + safariVisibilityTimeout = 1 * time.Second + safariLoadStatePollEvery = 200 * time.Millisecond +) + +var errSafariTabNotFound = errors.New("webview not found") + +// safariWebViews drives the tabs of Safari as webviews, through the web +// inspector service that dial connects to. +type safariWebViews struct { + dial func() (io.ReadWriteCloser, error) + // zero means the default; tests shorten them + findSafariTimeout time.Duration + commandTimeout time.Duration +} + +// dialWebInspector connects to the web inspector service of a real device. +func dialWebInspector(udid string) (io.ReadWriteCloser, error) { + device, err := goios.GetDevice(udid) + if err != nil { + return nil, fmt.Errorf("device not found: %s: %w", udid, err) + } + conn, err := goios.ConnectToService(device, webInspectorService) + if err != nil { + return nil, fmt.Errorf("connect to %s: %w", webInspectorService, err) + } + return deviceServiceConn{conn}, nil +} + +// deviceServiceConn makes a go-ios service connection an io.ReadWriteCloser. +type deviceServiceConn struct { + conn goios.DeviceConnectionInterface +} + +func (c deviceServiceConn) Read(p []byte) (int, error) { return c.conn.Reader().Read(p) } +func (c deviceServiceConn) Write(p []byte) (int, error) { return c.conn.Writer().Write(p) } +func (c deviceServiceConn) Close() error { return c.conn.Close() } + +type webInspectorMessage struct { + selector string + argument map[string]any +} + +// webInspectorSession is one connection to the web inspector service. +type webInspectorSession struct { + conn io.ReadWriteCloser + plist goios.PlistCodecReadWriter + connectionID string + // messages is fed by a reader of its own, so that waiting for one can time out + messages chan webInspectorMessage + closeOnce sync.Once +} + +func newInspectorID() (string, error) { + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate web inspector id: %w", err) + } + return strings.ToUpper(fmt.Sprintf("%x-%x-%x-%x-%x", raw[0:4], raw[4:6], raw[6:8], raw[8:10], raw[10:16])), nil +} + +func (s safariWebViews) openSession() (*webInspectorSession, error) { + connectionID, err := newInspectorID() + if err != nil { + return nil, err + } + conn, err := s.dial() + if err != nil { + return nil, err + } + + session := &webInspectorSession{ + conn: conn, + plist: goios.NewPlistCodecReadWriter(conn, conn), + connectionID: connectionID, + messages: make(chan webInspectorMessage, 64), + } + go session.readMessages() + + if err := session.send("_rpc_reportIdentifier:", map[string]any{}); err != nil { + session.close() + return nil, err + } + return session, nil +} + +func (session *webInspectorSession) readMessages() { + defer close(session.messages) + for { + var raw map[string]any + if err := session.plist.Read(&raw); err != nil { + return + } + selector, _ := raw["__selector"].(string) + argument, _ := raw["__argument"].(map[string]any) + session.messages <- webInspectorMessage{selector: selector, argument: argument} + } +} + +func (session *webInspectorSession) close() { + session.closeOnce.Do(func() { + _ = session.conn.Close() + // let the reader finish instead of leaving it blocked on a full channel + go func() { + for range session.messages { + } + }() + }) +} + +func (session *webInspectorSession) send(selector string, argument map[string]any) error { + argument["WIRConnectionIdentifierKey"] = session.connectionID + if err := session.plist.Write(map[string]any{"__selector": selector, "__argument": argument}); err != nil { + return fmt.Errorf("send %s: %w", selector, err) + } + return nil +} + +var errInspectorTimedOut = errors.New("web inspector did not answer in time") + +// nextMessage waits for the next message from the device, until deadline. +func (session *webInspectorSession) nextMessage(deadline time.Time) (webInspectorMessage, error) { + timer := time.NewTimer(time.Until(deadline)) + defer timer.Stop() + select { + case message, ok := <-session.messages: + if !ok { + return webInspectorMessage{}, errors.New("web inspector connection closed") + } + return message, nil + case <-timer.C: + return webInspectorMessage{}, errInspectorTimedOut + } +} + +type inspectorApp struct { + id string + name string +} + +func inspectorAppWithBundleID(description map[string]any, bundleID string) (inspectorApp, bool) { + if description["WIRApplicationBundleIdentifierKey"] != bundleID { + return inspectorApp{}, false + } + id, _ := description["WIRApplicationIdentifierKey"].(string) + name, _ := description["WIRApplicationNameKey"].(string) + return inspectorApp{id: id, name: name}, id != "" +} + +// findSafari waits for the device to report Safari among its inspectable apps. +// The device lists the apps it knows on connect, and announces others after. +func (session *webInspectorSession) findSafari(timeout time.Duration) (inspectorApp, error) { + deadline := time.Now().Add(timeout) + for { + message, err := session.nextMessage(deadline) + if errors.Is(err, errInspectorTimedOut) { + return inspectorApp{}, errors.New("safari is not inspectable: open it, and turn on Settings > Apps > Safari > Advanced > Web Inspector on the device") + } + if err != nil { + return inspectorApp{}, err + } + + switch message.selector { + case "_rpc_reportConnectedApplicationList:": + apps, _ := message.argument["WIRApplicationDictionaryKey"].(map[string]any) + for _, description := range apps { + if app, found := inspectorAppWithBundleID(asStringMap(description), safariBundleID); found { + return app, nil + } + } + case "_rpc_applicationConnected:", "_rpc_applicationUpdated:": + if app, found := inspectorAppWithBundleID(message.argument, safariBundleID); found { + return app, nil + } + } + } +} + +func asStringMap(value any) map[string]any { + asMap, _ := value.(map[string]any) + return asMap +} + +type inspectorPage struct { + id uint64 + url string + title string +} + +// webPages asks an app for what it has that can be inspected, and returns the +// web pages among it, ordered by id. +func (session *webInspectorSession) webPages(app inspectorApp, timeout time.Duration) ([]inspectorPage, error) { + if err := session.send("_rpc_forwardGetListing:", map[string]any{"WIRApplicationIdentifierKey": app.id}); err != nil { + return nil, err + } + + deadline := time.Now().Add(timeout) + for { + message, err := session.nextMessage(deadline) + if err != nil { + return nil, fmt.Errorf("list the tabs of safari: %w", err) + } + if message.selector != "_rpc_applicationSentListing:" || message.argument["WIRApplicationIdentifierKey"] != app.id { + continue + } + + pages := []inspectorPage{} + listing, _ := message.argument["WIRListingKey"].(map[string]any) + for _, entry := range listing { + description := asStringMap(entry) + if description["WIRTypeKey"] != webInspectorWebPageType { + continue + } + id, ok := description["WIRPageIdentifierKey"].(uint64) + if !ok { + continue + } + url, _ := description["WIRURLKey"].(string) + title, _ := description["WIRTitleKey"].(string) + pages = append(pages, inspectorPage{id: id, url: url, title: title}) + } + sort.Slice(pages, func(i, j int) bool { return pages[i].id < pages[j].id }) + return pages, nil + } +} + +// inspectorTab is a session attached to one tab, ready to take commands. +type inspectorTab struct { + session *webInspectorSession + app inspectorApp + webviewID string + pageID uint64 + senderID string + // targetID names the page inside the tab: commands are not sent to the tab + // itself but wrapped in a message to this target + targetID string + lastCommandID int + timeout time.Duration +} + +// attach opens a session and attaches it to the tab with the given webview id. +// The caller closes the session of the returned tab. +func (s safariWebViews) attach(webviewID string, timeout time.Duration) (*inspectorTab, error) { + session, err := s.openSession() + if err != nil { + return nil, err + } + tab, err := s.attachOn(session, webviewID, timeout) + if err != nil { + session.close() + return nil, err + } + return tab, nil +} + +func (s safariWebViews) attachOn(session *webInspectorSession, webviewID string, timeout time.Duration) (*inspectorTab, error) { + app, err := session.findSafari(s.findTimeout()) + if err != nil { + return nil, err + } + pages, err := session.webPages(app, timeout) + if err != nil { + return nil, err + } + pageID, err := strconv.ParseUint(webviewID, 10, 64) + if err != nil || !hasInspectorPage(pages, pageID) { + return nil, fmt.Errorf("%w: %s", errSafariTabNotFound, webviewID) + } + + senderID, err := newInspectorID() + if err != nil { + return nil, err + } + tab := &inspectorTab{session: session, app: app, webviewID: webviewID, pageID: pageID, senderID: senderID, timeout: timeout} + if err := session.send("_rpc_forwardSocketSetup:", tab.addressed(map[string]any{"WIRAutomaticallyPause": false})); err != nil { + return nil, err + } + + // the tab introduces its targets once attached; the page is the one to talk to + deadline := time.Now().Add(timeout) + for { + event, err := tab.nextProtocolMessage(deadline, "attach") + if err != nil { + return nil, err + } + if event.Method == "Target.targetCreated" && event.Params.TargetInfo.Type == "page" { + tab.targetID = event.Params.TargetInfo.TargetID + return tab, nil + } + } +} + +func hasInspectorPage(pages []inspectorPage, id uint64) bool { + for _, page := range pages { + if page.id == id { + return true + } + } + return false +} + +func (tab *inspectorTab) addressed(argument map[string]any) map[string]any { + argument["WIRApplicationIdentifierKey"] = tab.app.id + argument["WIRPageIdentifierKey"] = tab.pageID + argument["WIRSenderKey"] = tab.senderID + return argument +} + +type inspectorProtocolError struct { + Message string `json:"message"` +} + +// inspectorProtocolMessage is a message of the WebKit inspector protocol: the +// reply to a command when it has an id, an event otherwise. +type inspectorProtocolMessage struct { + ID int `json:"id"` + Method string `json:"method"` + Result json.RawMessage `json:"result"` + Error *inspectorProtocolError `json:"error"` + Params struct { + TargetInfo struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + } `json:"targetInfo"` + // set on Target.dispatchMessageFromTarget: a protocol message of the target + Message string `json:"message"` + } `json:"params"` +} + +// nextProtocolMessage waits for the next protocol message the tab sends. +func (tab *inspectorTab) nextProtocolMessage(deadline time.Time, doing string) (inspectorProtocolMessage, error) { + for { + message, err := tab.session.nextMessage(deadline) + if errors.Is(err, errInspectorTimedOut) { + return inspectorProtocolMessage{}, fmt.Errorf("webview %s did not answer %s within %s", tab.webviewID, doing, tab.timeout) + } + if err != nil { + return inspectorProtocolMessage{}, err + } + if message.selector != "_rpc_applicationSentData:" || message.argument["WIRDestinationKey"] != tab.senderID { + continue + } + data, _ := message.argument["WIRMessageDataKey"].([]byte) + var protocolMessage inspectorProtocolMessage + if err := json.Unmarshal(data, &protocolMessage); err != nil { + return inspectorProtocolMessage{}, fmt.Errorf("parse web inspector message: %w", err) + } + return protocolMessage, nil + } +} + +// command sends one protocol command to the page of the tab and returns its result. +func (tab *inspectorTab) command(method string, params map[string]any) (json.RawMessage, error) { + tab.lastCommandID++ + commandID := tab.lastCommandID + command, err := json.Marshal(map[string]any{"id": commandID, "method": method, "params": params}) + if err != nil { + return nil, fmt.Errorf("marshal %s: %w", method, err) + } + + tab.lastCommandID++ + envelopeID := tab.lastCommandID + envelope, err := json.Marshal(map[string]any{ + "id": envelopeID, + "method": "Target.sendMessageToTarget", + "params": map[string]any{"targetId": tab.targetID, "message": string(command)}, + }) + if err != nil { + return nil, fmt.Errorf("marshal %s: %w", method, err) + } + if err := tab.session.send("_rpc_forwardSocketData:", tab.addressed(map[string]any{"WIRSocketDataKey": envelope})); err != nil { + return nil, err + } + + deadline := time.Now().Add(tab.timeout) + for { + message, err := tab.nextProtocolMessage(deadline, method) + if err != nil { + return nil, err + } + // the envelope is only answered with an error when it could not be delivered + if message.ID == envelopeID && message.Error != nil { + return nil, fmt.Errorf("%s: %s", method, message.Error.Message) + } + if message.Method != "Target.dispatchMessageFromTarget" { + continue + } + + var reply inspectorProtocolMessage + if err := json.Unmarshal([]byte(message.Params.Message), &reply); err != nil { + return nil, fmt.Errorf("parse %s reply: %w", method, err) + } + if reply.ID != commandID { + continue + } + if reply.Error != nil { + return nil, fmt.Errorf("%s: %s", method, reply.Error.Message) + } + return reply.Result, nil + } +} + +// inspectorRemoteObject is how the protocol hands back a javascript value: by +// value, or as a reference to an object that stays in the page. +type inspectorRemoteObject struct { + Result struct { + Value any `json:"value"` + ObjectID string `json:"objectId"` + Description string `json:"description"` + } `json:"result"` + WasThrown bool `json:"wasThrown"` +} + +// evaluateOnly runs a function body with args in the page, without waiting for +// a promise it returns, and hands back what it returned. +func (tab *inspectorTab) evaluateOnly(expression string) (inspectorRemoteObject, error) { + raw, err := tab.command("Runtime.evaluate", map[string]any{"expression": expression}) + if err != nil { + return inspectorRemoteObject{}, err + } + return thrownOrReturned(raw) +} + +func thrownOrReturned(raw json.RawMessage) (inspectorRemoteObject, error) { + var object inspectorRemoteObject + if err := json.Unmarshal(raw, &object); err != nil { + return inspectorRemoteObject{}, fmt.Errorf("parse evaluate result: %w", err) + } + if object.WasThrown { + return inspectorRemoteObject{}, errors.New(object.Result.Description) + } + return object, nil +} + +// functionCall is the javascript that runs body as a function called with args. +func functionCall(body string, args []any) (string, error) { + if args == nil { + args = []any{} + } + argsJSON, err := json.Marshal(args) + if err != nil { + return "", fmt.Errorf("marshal evaluate args: %w", err) + } + return "(function(){\n" + body + "\n}).apply(null, " + string(argsJSON) + ")", nil +} + +func (s safariWebViews) findTimeout() time.Duration { + if s.findSafariTimeout > 0 { + return s.findSafariTimeout + } + return safariFindTimeout +} + +func (s safariWebViews) timeout() time.Duration { + if s.commandTimeout > 0 { + return s.commandTimeout + } + return safariCommandTimeout +} + +func (s safariWebViews) ListWebViews() ([]WebViewInfo, error) { + session, err := s.openSession() + if err != nil { + return nil, err + } + defer session.close() + + safari, err := session.findSafari(s.findTimeout()) + if err != nil { + return nil, err + } + pages, err := session.webPages(safari, s.timeout()) + if err != nil { + return nil, err + } + + webviews := make([]WebViewInfo, len(pages)) + for i, page := range pages { + webviews[i] = WebViewInfo{ + ID: strconv.FormatUint(page.id, 10), + URL: page.url, + Title: page.title, + BundleID: safariBundleID, + ProcessName: safari.name, + } + } + + // the listing does not say which tab is in front, so ask each one + var wg sync.WaitGroup + for i := range webviews { + wg.Add(1) + go func(webview *WebViewInfo) { + defer wg.Done() + webview.IsVisible = s.isVisible(webview.ID) + }(&webviews[i]) + } + wg.Wait() + + return webviews, nil +} + +func (s safariWebViews) isVisible(webviewID string) bool { + visible, err := s.evaluate(webviewID, "return document.visibilityState === 'visible'", nil, safariVisibilityTimeout) + return err == nil && visible == true +} + +// navigate runs a function body that makes the tab leave its page. The page +// that answers is torn down by the navigation, so nothing is awaited from it. +func (s safariWebViews) navigate(webviewID, body string, args []any) error { + tab, err := s.attach(webviewID, s.timeout()) + if err != nil { + return err + } + defer tab.session.close() + + expression, err := functionCall(body, args) + if err != nil { + return err + } + _, err = tab.evaluateOnly(expression) + return err +} + +// WebKit has no command to navigate a page, so the page is told to do it. +func (s safariWebViews) WebViewGoto(webviewID, url string) error { + return s.navigate(webviewID, "window.location.href = arguments[0]", []any{url}) +} + +func (s safariWebViews) WebViewGoBack(webviewID string) error { + return s.navigate(webviewID, "history.back()", nil) +} + +func (s safariWebViews) WebViewGoForward(webviewID string) error { + return s.navigate(webviewID, "history.forward()", nil) +} + +func (s safariWebViews) WebViewReload(webviewID string) error { + tab, err := s.attach(webviewID, s.timeout()) + if err != nil { + return err + } + defer tab.session.close() + + _, err = tab.command("Page.reload", map[string]any{}) + return err +} + +func (s safariWebViews) WebViewContent(webviewID string) (string, error) { + result, err := s.WebViewEvaluate(webviewID, "return document.documentElement.outerHTML", nil) + if err != nil { + return "", err + } + content, ok := result.(string) + if !ok { + return "", fmt.Errorf("unexpected content type %T", result) + } + return content, nil +} + +func (s safariWebViews) WebViewEvaluate(webviewID, expression string, args []any) (any, error) { + return s.evaluate(webviewID, expression, args, s.timeout()) +} + +// evaluate runs expression the way the injected agent does: as the body of a +// function called with args, whose returned promise is awaited. +func (s safariWebViews) evaluate(webviewID, expression string, args []any, timeout time.Duration) (any, error) { + tab, err := s.attach(webviewID, timeout) + if err != nil { + return nil, err + } + defer tab.session.close() + + call, err := functionCall(ensureReturnExpression(expression), args) + if err != nil { + return nil, err + } + // WebKit cannot await while evaluating. Whatever the function returns is + // made a promise, which is handed back by reference and awaited separately. + promise, err := tab.evaluateOnly("Promise.resolve(" + call + ")") + if err != nil { + return nil, err + } + raw, err := tab.command("Runtime.awaitPromise", map[string]any{ + "promiseObjectId": promise.Result.ObjectID, + "returnByValue": true, + }) + if err != nil { + return nil, err + } + settled, err := thrownOrReturned(raw) + if err != nil { + return nil, err + } + return settled.Result.Value, nil +} + +func (s safariWebViews) WebViewWaitForLoadState(webviewID, state string, timeoutMs int) error { + if state == "" { + state = "load" + } + if timeoutMs <= 0 { + timeoutMs = webViewAgentDefaultTimeoutMs + } + hasLoaded := "return document.readyState === 'complete'" + if state == "domcontentloaded" { + hasLoaded = "return document.readyState === 'interactive' || document.readyState === 'complete'" + } + + deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + for { + // an evaluation fails while the tab swaps pages mid-navigation, so only + // an unknown tab ends the wait early + loaded, err := s.evaluate(webviewID, hasLoaded, nil, s.timeout()) + if errors.Is(err, errSafariTabNotFound) { + return err + } + if err == nil && loaded == true { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("waitForLoadState timed out waiting for '%s'", state) + } + time.Sleep(safariLoadStatePollEvery) + } +} diff --git a/devices/ios_webinspector_test.go b/devices/ios_webinspector_test.go new file mode 100644 index 00000000..bcc8f993 --- /dev/null +++ b/devices/ios_webinspector_test.go @@ -0,0 +1,608 @@ +package devices + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net" + "reflect" + "strings" + "sync" + "testing" + "time" + + goios "github.com/danielpaulus/go-ios/ios" +) + +type fakeInspectorApp struct { + ID string + BundleID string + Name string + // announcedLater apps are missing from the first application list and are + // reported on their own afterwards, as the device does for some of them + announcedLater bool +} + +type fakeInspectorPage struct { + ID uint64 + Type string + URL string + Title string +} + +type fakeInspectorCall struct { + PageID uint64 + Method string + Params map[string]any +} + +// fakeWebInspector plays the web inspector service of a device: it reports the +// inspectable apps and their pages, and answers the protocol commands sent to a page. +type fakeWebInspector struct { + t *testing.T + apps []fakeInspectorApp + pages map[string][]fakeInspectorPage + // answer returns the result of a protocol command, or a protocol error message + answer func(call fakeInspectorCall) (result any, protocolError string) + + mu sync.Mutex + calls []fakeInspectorCall +} + +var ( + safariApp = fakeInspectorApp{ID: "PID:525", BundleID: "com.apple.mobilesafari", Name: "Safari"} + fitnessApp = fakeInspectorApp{ID: "PID:1542", BundleID: "com.apple.fitcored", Name: "fitcored"} + examplePage = fakeInspectorPage{ID: 2, Type: "WIRTypeWebPage", URL: "https://example.com/", Title: "Example Domain"} +) + +func startFakeWebInspector(t *testing.T, apps ...fakeInspectorApp) *fakeWebInspector { + t.Helper() + inspector := &fakeWebInspector{t: t, apps: apps, pages: map[string][]fakeInspectorPage{}} + inspector.answer = func(fakeInspectorCall) (any, string) { return map[string]any{}, "" } + return inspector +} + +// safariShowing starts an inspector of a device whose Safari has the given tabs open +func safariShowing(t *testing.T, pages ...fakeInspectorPage) *fakeWebInspector { + t.Helper() + inspector := startFakeWebInspector(t, fitnessApp, safariApp) + inspector.pages[safariApp.ID] = pages + return inspector +} + +func (f *fakeWebInspector) safari() safariWebViews { + return safariWebViews{dial: f.dial} +} + +func (f *fakeWebInspector) dial() (io.ReadWriteCloser, error) { + client, device := net.Pipe() + go f.serve(device) + return client, nil +} + +func (f *fakeWebInspector) serve(conn net.Conn) { + defer func() { _ = conn.Close() }() + plist := goios.NewPlistCodecReadWriter(conn, conn) + send := func(selector string, argument map[string]any) { + _ = plist.Write(map[string]any{"__selector": selector, "__argument": argument}) + } + + for { + var message map[string]any + if err := plist.Read(&message); err != nil { + return + } + argument, _ := message["__argument"].(map[string]any) + appID, _ := argument["WIRApplicationIdentifierKey"].(string) + pageID, _ := argument["WIRPageIdentifierKey"].(uint64) + + switch message["__selector"] { + case "_rpc_reportIdentifier:": + send("_rpc_reportCurrentState:", map[string]any{"WIRAutomationAvailabilityKey": "WIRAutomationAvailabilityNotAvailable"}) + known := map[string]any{} + for _, app := range f.apps { + if !app.announcedLater { + known[app.ID] = app.description() + } + } + send("_rpc_reportConnectedApplicationList:", map[string]any{"WIRApplicationDictionaryKey": known}) + for _, app := range f.apps { + if app.announcedLater { + send("_rpc_applicationConnected:", app.description()) + } + } + + case "_rpc_forwardGetListing:": + listing := map[string]any{} + for _, page := range f.pages[appID] { + listing[fmt.Sprint(page.ID)] = map[string]any{ + "WIRPageIdentifierKey": page.ID, + "WIRTypeKey": page.Type, + "WIRURLKey": page.URL, + "WIRTitleKey": page.Title, + } + } + send("_rpc_applicationSentListing:", map[string]any{"WIRApplicationIdentifierKey": appID, "WIRListingKey": listing}) + + case "_rpc_forwardSocketSetup:": + sender := argument["WIRSenderKey"] + f.sendToInspector(send, sender, map[string]any{ + "method": "Target.targetCreated", + "params": map[string]any{"targetInfo": map[string]any{"targetId": "frame-1", "type": "frame"}}, + }) + f.sendToInspector(send, sender, map[string]any{ + "method": "Target.targetCreated", + "params": map[string]any{"targetInfo": map[string]any{"targetId": fmt.Sprintf("page-%d", pageID), "type": "page"}}, + }) + + case "_rpc_forwardSocketData:": + f.answerCommand(send, argument["WIRSenderKey"], pageID, argument["WIRSocketDataKey"].([]byte)) + } + } +} + +func (app fakeInspectorApp) description() map[string]any { + return map[string]any{ + "WIRApplicationIdentifierKey": app.ID, + "WIRApplicationBundleIdentifierKey": app.BundleID, + "WIRApplicationNameKey": app.Name, + } +} + +func (f *fakeWebInspector) sendToInspector(send func(string, map[string]any), sender any, message map[string]any) { + data, _ := json.Marshal(message) + send("_rpc_applicationSentData:", map[string]any{"WIRDestinationKey": sender, "WIRMessageDataKey": data}) +} + +// answerCommand unwraps a command addressed to the page target, and sends the +// answer back the way the device does: wrapped in a message from that target +func (f *fakeWebInspector) answerCommand(send func(string, map[string]any), sender any, pageID uint64, data []byte) { + var outer struct { + ID int `json:"id"` + Params struct { + TargetID string `json:"targetId"` + Message string `json:"message"` + } `json:"params"` + } + _ = json.Unmarshal(data, &outer) + var command struct { + ID int `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` + } + _ = json.Unmarshal([]byte(outer.Params.Message), &command) + + call := fakeInspectorCall{PageID: pageID, Method: command.Method, Params: command.Params} + f.mu.Lock() + f.calls = append(f.calls, call) + f.mu.Unlock() + + f.sendToInspector(send, sender, map[string]any{"id": outer.ID, "result": map[string]any{}}) + + result, protocolError := f.answer(call) + reply := map[string]any{"id": command.ID, "result": result} + if protocolError != "" { + reply = map[string]any{"id": command.ID, "error": map[string]any{"code": -32601, "message": protocolError}} + } + inner, _ := json.Marshal(reply) + f.sendToInspector(send, sender, map[string]any{ + "method": "Target.dispatchMessageFromTarget", + "params": map[string]any{"targetId": outer.Params.TargetID, "message": string(inner)}, + }) +} + +func (f *fakeWebInspector) callsOf(method string) []fakeInspectorCall { + f.mu.Lock() + defer f.mu.Unlock() + var matching []fakeInspectorCall + for _, call := range f.calls { + if call.Method == method { + matching = append(matching, call) + } + } + return matching +} + +func (f *fakeWebInspector) onlyCallOf(method string) fakeInspectorCall { + f.t.Helper() + calls := f.callsOf(method) + if len(calls) != 1 { + f.t.Fatalf("expected exactly one %s call, got %d", method, len(calls)) + } + return calls[0] +} + +// webkit has no way to await a promise while evaluating: it hands back a +// reference to the promise, which is then awaited with a second command +var promiseReference = map[string]any{ + "result": map[string]any{"type": "object", "objectId": "promise-1", "className": "Promise"}, + "wasThrown": false, +} + +// evaluatesTo makes every evaluation resolve to the given javascript value +func (f *fakeWebInspector) evaluatesTo(value any) { + f.answer = func(call fakeInspectorCall) (any, string) { + if call.Method == "Runtime.awaitPromise" { + return map[string]any{"result": map[string]any{"type": "object", "value": value}, "wasThrown": false}, "" + } + return promiseReference, "" + } +} + +// throws makes the command of the given method fail with a javascript error +func (f *fakeWebInspector) throws(method, description string) { + f.answer = func(call fakeInspectorCall) (any, string) { + if call.Method == method { + return map[string]any{ + "result": map[string]any{"type": "object", "subtype": "error", "description": description}, + "wasThrown": true, + }, "" + } + return promiseReference, "" + } +} + +func TestListingWebViewsReturnsTheTabsOfSafari(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.pages[fitnessApp.ID] = []fakeInspectorPage{{ID: 9, Type: "WIRTypeJavaScript", Title: "a script context"}} + inspector.evaluatesTo(true) + + webviews, err := inspector.safari().ListWebViews() + + if err != nil { + t.Fatalf("ListWebViews: %v", err) + } + expected := []WebViewInfo{{ + ID: "2", + URL: "https://example.com/", + Title: "Example Domain", + BundleID: "com.apple.mobilesafari", + ProcessName: "Safari", + IsVisible: true, + }} + if !reflect.DeepEqual(webviews, expected) { + t.Fatalf("expected %+v, got %+v", expected, webviews) + } +} + +func TestListingSkipsWhatSafariReportsThatIsNotAWebPage(t *testing.T) { + serviceWorker := fakeInspectorPage{ID: 7, Type: "WIRTypeServiceWorker", URL: "https://example.com/sw.js"} + inspector := safariShowing(t, serviceWorker, examplePage) + inspector.evaluatesTo(true) + + webviews, err := inspector.safari().ListWebViews() + + if err != nil { + t.Fatalf("ListWebViews: %v", err) + } + if len(webviews) != 1 || webviews[0].ID != "2" { + t.Fatalf("expected only the web page, got %+v", webviews) + } +} + +func TestTabsAreListedInTheOrderOfTheirIds(t *testing.T) { + inspector := safariShowing(t, + fakeInspectorPage{ID: 10, Type: "WIRTypeWebPage"}, + fakeInspectorPage{ID: 2, Type: "WIRTypeWebPage"}, + fakeInspectorPage{ID: 1, Type: "WIRTypeWebPage"}) + + webviews, err := inspector.safari().ListWebViews() + + if err != nil { + t.Fatalf("ListWebViews: %v", err) + } + ids := []string{} + for _, webview := range webviews { + ids = append(ids, webview.ID) + } + if !reflect.DeepEqual(ids, []string{"1", "2", "10"}) { + t.Fatalf("unexpected order %v", ids) + } +} + +func TestBackgroundTabOfSafariIsListedAsNotVisible(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.evaluatesTo(false) + + webviews, err := inspector.safari().ListWebViews() + + if err != nil { + t.Fatalf("ListWebViews: %v", err) + } + if len(webviews) != 1 || webviews[0].IsVisible { + t.Fatalf("expected one hidden tab, got %+v", webviews) + } +} + +func TestSafariWithoutTabsListsNoWebViews(t *testing.T) { + inspector := safariShowing(t) + + webviews, err := inspector.safari().ListWebViews() + + if err != nil { + t.Fatalf("ListWebViews: %v", err) + } + if webviews == nil || len(webviews) != 0 { + t.Fatalf("expected an empty list, got %#v", webviews) + } +} + +// The device reports some apps only after its first list of them. +func TestSafariIsFoundWhenTheDeviceAnnouncesItLater(t *testing.T) { + lateSafari := safariApp + lateSafari.announcedLater = true + inspector := startFakeWebInspector(t, fitnessApp, lateSafari) + inspector.pages[safariApp.ID] = []fakeInspectorPage{examplePage} + + webviews, err := inspector.safari().ListWebViews() + + if err != nil || len(webviews) != 1 { + t.Fatalf("expected the tab of safari, got %+v, %v", webviews, err) + } +} + +// Safari only shows up in the inspector when its Web Inspector setting is on. +func TestSafariThatIsNotInspectableFailsNamingTheSettingToTurnOn(t *testing.T) { + inspector := startFakeWebInspector(t, fitnessApp) + safari := inspector.safari() + safari.findSafariTimeout = 200 * time.Millisecond + + _, err := safari.ListWebViews() + + if err == nil || !strings.Contains(err.Error(), "Settings > Apps > Safari > Advanced > Web Inspector") { + t.Fatalf("expected an error naming the setting, got %v", err) + } +} + +func TestSafariEvaluateReturnsTheJavascriptValue(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.evaluatesTo(map[string]any{"title": "Example Domain"}) + + value, err := inspector.safari().WebViewEvaluate("2", "return {title: document.title}", nil) + + if err != nil { + t.Fatalf("WebViewEvaluate: %v", err) + } + if !reflect.DeepEqual(value, map[string]any{"title": "Example Domain"}) { + t.Fatalf("unexpected value %#v", value) + } +} + +// The agent runs the expression as a function body and awaits what it returns, +// so the same expression has to mean the same thing in a safari tab. +func TestSafariEvaluateRunsTheExpressionAsAFunctionBodyAndAwaitsIt(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.evaluatesTo("Example Domain") + + _, err := inspector.safari().WebViewEvaluate("2", "document.title", []any{"first", 2}) + + if err != nil { + t.Fatalf("WebViewEvaluate: %v", err) + } + expectedExpression := "Promise.resolve((function(){\nreturn (document.title)\n}).apply(null, [\"first\",2]))" + if expression := inspector.onlyCallOf("Runtime.evaluate").Params["expression"]; expression != expectedExpression { + t.Fatalf("unexpected expression %q", expression) + } + awaited := inspector.onlyCallOf("Runtime.awaitPromise").Params + if awaited["promiseObjectId"] != "promise-1" || awaited["returnByValue"] != true { + t.Fatalf("expected the returned promise to be awaited by value, got %v", awaited) + } +} + +func TestSafariEvaluateOfUndefinedReturnsNil(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.answer = func(call fakeInspectorCall) (any, string) { + if call.Method == "Runtime.awaitPromise" { + return map[string]any{"result": map[string]any{"type": "undefined"}, "wasThrown": false}, "" + } + return promiseReference, "" + } + + value, err := inspector.safari().WebViewEvaluate("2", "return undefined", nil) + + if err != nil || value != nil { + t.Fatalf("expected nil without error, got %#v, %v", value, err) + } +} + +func TestSafariEvaluateFailsWithTheErrorTheExpressionThrew(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.throws("Runtime.evaluate", "Error: boom") + + _, err := inspector.safari().WebViewEvaluate("2", "return (() => { throw new Error('boom') })()", nil) + + if err == nil || err.Error() != "Error: boom" { + t.Fatalf("expected the thrown error, got %v", err) + } +} + +func TestSafariEvaluateFailsWithTheReasonThePromiseRejected(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.throws("Runtime.awaitPromise", "Error: nope") + + _, err := inspector.safari().WebViewEvaluate("2", "return Promise.reject(new Error('nope'))", nil) + + if err == nil || err.Error() != "Error: nope" { + t.Fatalf("expected the rejection, got %v", err) + } +} + +func TestSafariCommandOnAnUnknownWebViewFailsAsNotFound(t *testing.T) { + inspector := safariShowing(t, examplePage) + + for _, unknownID := range []string{"99", "no-such-webview"} { + err := inspector.safari().WebViewReload(unknownID) + + if !errors.Is(err, errSafariTabNotFound) || !strings.Contains(err.Error(), unknownID) { + t.Fatalf("expected a not found error naming %q, got %v", unknownID, err) + } + } +} + +func TestSafariProtocolErrorFailsTheCommand(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.answer = func(fakeInspectorCall) (any, string) { return nil, "'Page.reload' was not found" } + + err := inspector.safari().WebViewReload("2") + + if err == nil || !strings.Contains(err.Error(), "'Page.reload' was not found") { + t.Fatalf("expected the protocol error, got %v", err) + } +} + +func TestSafariCommandOnATabThatNeverAnswersFailsSayingSo(t *testing.T) { + inspector := safariShowing(t, examplePage) + tabIsFrozen := make(chan struct{}) + t.Cleanup(func() { close(tabIsFrozen) }) + inspector.answer = func(fakeInspectorCall) (any, string) { + <-tabIsFrozen + return map[string]any{}, "" + } + safari := inspector.safari() + safari.commandTimeout = 200 * time.Millisecond + + _, err := safari.WebViewEvaluate("2", "return 1", nil) + + if err == nil || err.Error() != "webview 2 did not answer Runtime.evaluate within 200ms" { + t.Fatalf("expected an explanation of the timeout, got %v", err) + } +} + +// WebKit has no command to navigate a page, so the page is told to navigate +// itself. The url travels as an argument, not as javascript source. +func TestSafariGotoNavigatesTheTabToTheUrl(t *testing.T) { + inspector := safariShowing(t, examplePage) + + err := inspector.safari().WebViewGoto("2", "https://mobilewright.dev/?q='quoted'") + + if err != nil { + t.Fatalf("WebViewGoto: %v", err) + } + expression := inspector.onlyCallOf("Runtime.evaluate").Params["expression"].(string) + if expression != "(function(){\nwindow.location.href = arguments[0]\n}).apply(null, [\"https://mobilewright.dev/?q='quoted'\"])" { + t.Fatalf("unexpected expression %q", expression) + } +} + +// The page that answers is torn down by the navigation it starts, so nothing +// may be awaited from it afterwards. +func TestSafariNavigationDoesNotAwaitThePageItLeaves(t *testing.T) { + inspector := safariShowing(t, examplePage) + safari := inspector.safari() + + if err := safari.WebViewGoto("2", "https://mobilewright.dev/"); err != nil { + t.Fatalf("WebViewGoto: %v", err) + } + if err := safari.WebViewGoBack("2"); err != nil { + t.Fatalf("WebViewGoBack: %v", err) + } + if err := safari.WebViewGoForward("2"); err != nil { + t.Fatalf("WebViewGoForward: %v", err) + } + + if awaited := inspector.callsOf("Runtime.awaitPromise"); len(awaited) != 0 { + t.Fatalf("expected no promise to be awaited, got %v", awaited) + } +} + +func TestSafariReloadReloadsTheTab(t *testing.T) { + inspector := safariShowing(t, examplePage) + + err := inspector.safari().WebViewReload("2") + + if err != nil { + t.Fatalf("WebViewReload: %v", err) + } + inspector.onlyCallOf("Page.reload") +} + +func TestSafariGoBackAndGoForwardMoveThroughTheHistoryOfTheTab(t *testing.T) { + inspector := safariShowing(t, examplePage) + safari := inspector.safari() + + if err := safari.WebViewGoBack("2"); err != nil { + t.Fatalf("WebViewGoBack: %v", err) + } + if err := safari.WebViewGoForward("2"); err != nil { + t.Fatalf("WebViewGoForward: %v", err) + } + + evaluations := inspector.callsOf("Runtime.evaluate") + if len(evaluations) != 2 || + !strings.Contains(evaluations[0].Params["expression"].(string), "history.back()") || + !strings.Contains(evaluations[1].Params["expression"].(string), "history.forward()") { + t.Fatalf("expected history.back() then history.forward(), got %v", evaluations) + } +} + +func TestSafariContentReturnsTheHtmlOfTheDocument(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.evaluatesTo("hi") + + content, err := inspector.safari().WebViewContent("2") + + if err != nil { + t.Fatalf("WebViewContent: %v", err) + } + if content != "hi" { + t.Fatalf("unexpected content %q", content) + } +} + +func TestSafariWaitReturnsOnceTheDocumentHasLoaded(t *testing.T) { + inspector := safariShowing(t, examplePage) + polls := 0 + inspector.answer = func(call fakeInspectorCall) (any, string) { + if call.Method != "Runtime.awaitPromise" { + return promiseReference, "" + } + polls++ + return map[string]any{"result": map[string]any{"type": "boolean", "value": polls >= 3}, "wasThrown": false}, "" + } + + err := inspector.safari().WebViewWaitForLoadState("2", "load", 5000) + + if err != nil { + t.Fatalf("WebViewWaitForLoadState: %v", err) + } + if polls != 3 { + t.Fatalf("expected to stop polling once loaded, polled %d times", polls) + } +} + +func TestSafariWaitForDomContentLoadedAlsoAcceptsAnInteractiveDocument(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.evaluatesTo(true) + + err := inspector.safari().WebViewWaitForLoadState("2", "domcontentloaded", 5000) + + if err != nil { + t.Fatalf("WebViewWaitForLoadState: %v", err) + } + expression := inspector.onlyCallOf("Runtime.evaluate").Params["expression"].(string) + if !strings.Contains(expression, "'interactive'") { + t.Fatalf("expected the interactive state to be accepted, evaluated %q", expression) + } +} + +func TestSafariWaitTimesOutWhenTheDocumentNeverLoads(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.evaluatesTo(false) + + err := inspector.safari().WebViewWaitForLoadState("2", "load", 300) + + if err == nil || err.Error() != "waitForLoadState timed out waiting for 'load'" { + t.Fatalf("expected a timeout, got %v", err) + } +} + +func TestSafariWaitOnAnUnknownWebViewFailsWithoutWaitingForTheTimeout(t *testing.T) { + inspector := safariShowing(t, examplePage) + + err := inspector.safari().WebViewWaitForLoadState("99", "load", 60_000) + + if !errors.Is(err, errSafariTabNotFound) { + t.Fatalf("expected a not found error, got %v", err) + } +} diff --git a/docs/openrpc.json b/docs/openrpc.json index f2e9dbb2..b7ab7c04 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -1514,7 +1514,7 @@ { "name": "device.webview.list", "summary": "List webviews", - "description": "Returns all embedded webviews currently visible in the foreground app on the device. Browser apps (Safari, Chrome) are not included.", + "description": "Returns all embedded webviews currently visible in the foreground app on the device. When the foreground app is Safari on a real iOS device, returns its tabs instead. Chrome is not included.", "params": [ { "name": "deviceId", diff --git a/docs/openrpc.md b/docs/openrpc.md index 9fc4ed27..dad18fa4 100644 --- a/docs/openrpc.md +++ b/docs/openrpc.md @@ -1664,7 +1664,7 @@ Operation result **List webviews** -Returns all embedded webviews currently visible in the foreground app on the device. Browser apps (Safari, Chrome) are not included. +Returns all embedded webviews currently visible in the foreground app on the device. When the foreground app is Safari on a real iOS device, returns its tabs instead. Chrome is not included. #### Parameters From 7e456acb32960be0bcb5acf9d5b4002afcd663ea Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Fri, 2 Oct 2026 10:27:06 +0200 Subject: [PATCH 2/4] feat(ios): drive safari tabs on the simulator too, over one kept connection --- README.md | 2 +- cli/webview.go | 2 +- devices/ios_device_webview.go | 3 +- devices/ios_webinspector.go | 802 +++++++++++++++++++------------ devices/ios_webinspector_test.go | 135 +++++- devices/ios_webview.go | 45 ++ docs/openrpc.json | 2 +- docs/openrpc.md | 2 +- test/simulator.spec.ts | 101 ++++ 9 files changed, 761 insertions(+), 333 deletions(-) diff --git a/README.md b/README.md index 9ac37b8f..bc599bfe 100644 --- a/README.md +++ b/README.md @@ -337,7 +337,7 @@ Example output for `agent status`: Inspect and interact with embedded webviews (`WKWebView` on iOS, `android.webkit.WebView` on Android) running inside native apps. -On a real iOS device the same commands also drive the tabs of Safari when it is the foreground app, through the device's Web Inspector service. This needs **Settings > Apps > Safari > Advanced > Web Inspector** turned on. Only the tab Safari is showing (`isVisible: true`) is sure to answer `eval` and the commands built on it. +On iOS, real devices and simulators alike, the same commands also drive the tabs of Safari when it is the foreground app, through the device's Web Inspector service. A real device needs **Settings > Apps > Safari > Advanced > Web Inspector** turned on. Safari does not keep the tabs it is not showing running: a command on such a tab fails after 2 seconds, so use the tab listed with `isVisible: true`. ```bash # List embedded webviews in the foreground app diff --git a/cli/webview.go b/cli/webview.go index 38857f1a..43efac7d 100644 --- a/cli/webview.go +++ b/cli/webview.go @@ -14,7 +14,7 @@ var webviewCmd = &cobra.Command{ var webviewListCmd = &cobra.Command{ Use: "list", Short: "List embedded webviews on a device", - Long: `Returns all embedded webviews currently visible in the foreground app. When the foreground app is Safari on a real iOS device, returns its tabs instead. Chrome is not included.`, + Long: `Returns all embedded webviews currently visible in the foreground app. When the foreground app is Safari on iOS, returns its tabs instead. Chrome is not included.`, RunE: func(cmd *cobra.Command, args []string) error { return runViaDaemon("cli.webview.list", commands.WebViewListRequest{ DeviceID: deviceId, diff --git a/devices/ios_device_webview.go b/devices/ios_device_webview.go index 1c8caf2a..eb427d1d 100644 --- a/devices/ios_device_webview.go +++ b/devices/ios_device_webview.go @@ -32,7 +32,8 @@ func (d *IOSDevice) safariInForeground() (safariWebViews, bool) { if activeApp.BundleID != safariBundleID { return safariWebViews{}, false } - return safariWebViews{dial: func() (io.ReadWriteCloser, error) { return dialWebInspector(d.Udid) }}, true + inspector := webInspectorOf(d.Udid, func() (io.ReadWriteCloser, error) { return dialDeviceWebInspector(d.Udid) }) + return safariWebViews{inspector: inspector}, true } func (d *IOSDevice) ListWebViews() ([]WebViewInfo, error) { diff --git a/devices/ios_webinspector.go b/devices/ios_webinspector.go index 2ea38413..8f1012f8 100644 --- a/devices/ios_webinspector.go +++ b/devices/ios_webinspector.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "net" "sort" "strconv" "strings" @@ -20,8 +21,12 @@ import ( // reports the tabs of Safari and relays the WebKit inspector protocol to them. // The tabs are driven through that instead, behind the same webview commands. // -// The service only reports Safari when Settings > Apps > Safari > Advanced > -// Web Inspector is turned on. +// A real device only reports Safari when Settings > Apps > Safari > Advanced > +// Web Inspector is turned on. A simulator always does. +// +// One connection per device is kept open and shared by every command. The +// service answers a connection that stays open within milliseconds, but stalls +// the next one for seconds after a connection was closed on it. const ( webInspectorService = "com.apple.webinspector" @@ -31,24 +36,25 @@ const ( safariFindTimeout = 5 * time.Second safariCommandTimeout = 30 * time.Second - // a tab that does not answer is reported as hidden instead of holding up the list - safariVisibilityTimeout = 1 * time.Second + // a tab that runs answers within milliseconds. Safari does not keep the tabs + // it is not showing running, and such a tab accepts a command without ever + // answering it, so a tab is asked for a sign of life before anything else. + safariLivenessTimeout = 2 * time.Second + safariVisibilityTimeout = 1 * time.Second + safariLoadStatePollEvery = 200 * time.Millisecond ) -var errSafariTabNotFound = errors.New("webview not found") +var ( + errSafariTabNotFound = errors.New("webview not found") + errInspectorTimedOut = errors.New("web inspector did not answer in time") + errInspectorClosed = errors.New("web inspector connection closed") +) -// safariWebViews drives the tabs of Safari as webviews, through the web -// inspector service that dial connects to. -type safariWebViews struct { - dial func() (io.ReadWriteCloser, error) - // zero means the default; tests shorten them - findSafariTimeout time.Duration - commandTimeout time.Duration -} +type webInspectorDialer func() (io.ReadWriteCloser, error) -// dialWebInspector connects to the web inspector service of a real device. -func dialWebInspector(udid string) (io.ReadWriteCloser, error) { +// dialDeviceWebInspector connects to the web inspector service of a real device. +func dialDeviceWebInspector(udid string) (io.ReadWriteCloser, error) { device, err := goios.GetDevice(udid) if err != nil { return nil, fmt.Errorf("device not found: %s: %w", udid, err) @@ -60,6 +66,24 @@ func dialWebInspector(udid string) (io.ReadWriteCloser, error) { return deviceServiceConn{conn}, nil } +// dialSimulatorWebInspector connects to the web inspector of a simulator, which +// listens on a unix socket on this machine that its launchd names. +func dialSimulatorWebInspector(udid string) (io.ReadWriteCloser, error) { + output, err := runSimctl("spawn", udid, "launchctl", "getenv", "RWI_LISTEN_SOCKET") + if err != nil { + return nil, fmt.Errorf("find the web inspector socket of simulator %s: %w", udid, err) + } + socketPath := strings.TrimSpace(string(output)) + if socketPath == "" { + return nil, fmt.Errorf("simulator %s has no web inspector socket", udid) + } + conn, err := net.Dial("unix", socketPath) + if err != nil { + return nil, fmt.Errorf("connect to the web inspector of simulator %s: %w", udid, err) + } + return conn, nil +} + // deviceServiceConn makes a go-ios service connection an io.ReadWriteCloser. type deviceServiceConn struct { conn goios.DeviceConnectionInterface @@ -69,143 +93,190 @@ func (c deviceServiceConn) Read(p []byte) (int, error) { return c.conn.Reader() func (c deviceServiceConn) Write(p []byte) (int, error) { return c.conn.Writer().Write(p) } func (c deviceServiceConn) Close() error { return c.conn.Close() } -type webInspectorMessage struct { - selector string - argument map[string]any -} +// webInspectorClient keeps the one connection to the web inspector of a device, +// and connects again when it was lost. +type webInspectorClient struct { + dial webInspectorDialer -// webInspectorSession is one connection to the web inspector service. -type webInspectorSession struct { - conn io.ReadWriteCloser - plist goios.PlistCodecReadWriter - connectionID string - // messages is fed by a reader of its own, so that waiting for one can time out - messages chan webInspectorMessage - closeOnce sync.Once + mu sync.Mutex + session *webInspectorSession } -func newInspectorID() (string, error) { - raw := make([]byte, 16) - if _, err := rand.Read(raw); err != nil { - return "", fmt.Errorf("generate web inspector id: %w", err) +var ( + webInspectorClients = map[string]*webInspectorClient{} + webInspectorClientsMu sync.Mutex +) + +// webInspectorOf returns the client of the device with the given udid, shared +// by all commands of this process. +func webInspectorOf(udid string, dial webInspectorDialer) *webInspectorClient { + webInspectorClientsMu.Lock() + defer webInspectorClientsMu.Unlock() + client, ok := webInspectorClients[udid] + if !ok { + client = &webInspectorClient{dial: dial} + webInspectorClients[udid] = client } - return strings.ToUpper(fmt.Sprintf("%x-%x-%x-%x-%x", raw[0:4], raw[4:6], raw[6:8], raw[8:10], raw[10:16])), nil + return client } -func (s safariWebViews) openSession() (*webInspectorSession, error) { +func (c *webInspectorClient) connected() (*webInspectorSession, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.session != nil && !c.session.isClosed() { + return c.session, nil + } + connectionID, err := newInspectorID() if err != nil { return nil, err } - conn, err := s.dial() + conn, err := c.dial() if err != nil { return nil, err } + session := newWebInspectorSession(conn, connectionID) + if err := session.send("_rpc_reportIdentifier:", map[string]any{}); err != nil { + session.close() + return nil, err + } + c.session = session + return session, nil +} + +func (c *webInspectorClient) close() { + c.mu.Lock() + defer c.mu.Unlock() + if c.session != nil { + c.session.close() + } +} + +func newInspectorID() (string, error) { + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate web inspector id: %w", err) + } + return strings.ToUpper(fmt.Sprintf("%x-%x-%x-%x-%x", raw[0:4], raw[4:6], raw[6:8], raw[8:10], raw[10:16])), nil +} + +type inspectorApp struct { + id string + bundleID string + name string +} + +type inspectorPage struct { + id uint64 + url string + title string +} + +type inspectorListing struct { + // revision counts the listings the app has sent, to tell a new one from the last + revision int + pages []inspectorPage +} + +// webInspectorSession is one connection to the web inspector service. The +// device keeps reporting on it: which apps can be inspected, what they have +// open, and what the attached tabs say. A reader keeps that state current, and +// commands wait for the state they need. +type webInspectorSession struct { + conn io.ReadWriteCloser + plist goios.PlistCodecReadWriter + connectionID string + writeMu sync.Mutex + + mu sync.Mutex + apps map[string]inspectorApp + listings map[string]inspectorListing + tabs map[string]*inspectorTab + closed bool + // changed is closed, and replaced, whenever anything above changes + changed chan struct{} +} +func newWebInspectorSession(conn io.ReadWriteCloser, connectionID string) *webInspectorSession { session := &webInspectorSession{ conn: conn, plist: goios.NewPlistCodecReadWriter(conn, conn), connectionID: connectionID, - messages: make(chan webInspectorMessage, 64), + apps: map[string]inspectorApp{}, + listings: map[string]inspectorListing{}, + tabs: map[string]*inspectorTab{}, + changed: make(chan struct{}), } go session.readMessages() - - if err := session.send("_rpc_reportIdentifier:", map[string]any{}); err != nil { - session.close() - return nil, err - } - return session, nil + return session } -func (session *webInspectorSession) readMessages() { - defer close(session.messages) - for { - var raw map[string]any - if err := session.plist.Read(&raw); err != nil { - return - } - selector, _ := raw["__selector"].(string) - argument, _ := raw["__argument"].(map[string]any) - session.messages <- webInspectorMessage{selector: selector, argument: argument} - } +func (session *webInspectorSession) isClosed() bool { + session.mu.Lock() + defer session.mu.Unlock() + return session.closed } func (session *webInspectorSession) close() { - session.closeOnce.Do(func() { - _ = session.conn.Close() - // let the reader finish instead of leaving it blocked on a full channel - go func() { - for range session.messages { - } - }() - }) + _ = session.conn.Close() + session.mu.Lock() + defer session.mu.Unlock() + session.closed = true + session.notifyChange() +} + +// notifyChange wakes everyone waiting for the state to change. Called with mu held. +func (session *webInspectorSession) notifyChange() { + close(session.changed) + session.changed = make(chan struct{}) } func (session *webInspectorSession) send(selector string, argument map[string]any) error { argument["WIRConnectionIdentifierKey"] = session.connectionID + session.writeMu.Lock() + defer session.writeMu.Unlock() if err := session.plist.Write(map[string]any{"__selector": selector, "__argument": argument}); err != nil { + session.close() return fmt.Errorf("send %s: %w", selector, err) } return nil } -var errInspectorTimedOut = errors.New("web inspector did not answer in time") - -// nextMessage waits for the next message from the device, until deadline. -func (session *webInspectorSession) nextMessage(deadline time.Time) (webInspectorMessage, error) { +// await waits until reached reports true, which it is asked with mu held every +// time the state changes. +func (session *webInspectorSession) await(deadline time.Time, reached func() bool) error { timer := time.NewTimer(time.Until(deadline)) defer timer.Stop() - select { - case message, ok := <-session.messages: - if !ok { - return webInspectorMessage{}, errors.New("web inspector connection closed") + for { + session.mu.Lock() + if reached() { + session.mu.Unlock() + return nil } - return message, nil - case <-timer.C: - return webInspectorMessage{}, errInspectorTimedOut - } -} - -type inspectorApp struct { - id string - name string -} + if session.closed { + session.mu.Unlock() + return errInspectorClosed + } + changed := session.changed + session.mu.Unlock() -func inspectorAppWithBundleID(description map[string]any, bundleID string) (inspectorApp, bool) { - if description["WIRApplicationBundleIdentifierKey"] != bundleID { - return inspectorApp{}, false + select { + case <-changed: + case <-timer.C: + return errInspectorTimedOut + } } - id, _ := description["WIRApplicationIdentifierKey"].(string) - name, _ := description["WIRApplicationNameKey"].(string) - return inspectorApp{id: id, name: name}, id != "" } -// findSafari waits for the device to report Safari among its inspectable apps. -// The device lists the apps it knows on connect, and announces others after. -func (session *webInspectorSession) findSafari(timeout time.Duration) (inspectorApp, error) { - deadline := time.Now().Add(timeout) +func (session *webInspectorSession) readMessages() { for { - message, err := session.nextMessage(deadline) - if errors.Is(err, errInspectorTimedOut) { - return inspectorApp{}, errors.New("safari is not inspectable: open it, and turn on Settings > Apps > Safari > Advanced > Web Inspector on the device") - } - if err != nil { - return inspectorApp{}, err - } - - switch message.selector { - case "_rpc_reportConnectedApplicationList:": - apps, _ := message.argument["WIRApplicationDictionaryKey"].(map[string]any) - for _, description := range apps { - if app, found := inspectorAppWithBundleID(asStringMap(description), safariBundleID); found { - return app, nil - } - } - case "_rpc_applicationConnected:", "_rpc_applicationUpdated:": - if app, found := inspectorAppWithBundleID(message.argument, safariBundleID); found { - return app, nil - } + var message map[string]any + if err := session.plist.Read(&message); err != nil { + session.close() + return } + selector, _ := message["__selector"].(string) + session.handle(selector, asStringMap(message["__argument"])) } } @@ -214,129 +285,107 @@ func asStringMap(value any) map[string]any { return asMap } -type inspectorPage struct { - id uint64 - url string - title string -} - -// webPages asks an app for what it has that can be inspected, and returns the -// web pages among it, ordered by id. -func (session *webInspectorSession) webPages(app inspectorApp, timeout time.Duration) ([]inspectorPage, error) { - if err := session.send("_rpc_forwardGetListing:", map[string]any{"WIRApplicationIdentifierKey": app.id}); err != nil { - return nil, err - } +func (session *webInspectorSession) handle(selector string, argument map[string]any) { + session.mu.Lock() + defer session.mu.Unlock() - deadline := time.Now().Add(timeout) - for { - message, err := session.nextMessage(deadline) - if err != nil { - return nil, fmt.Errorf("list the tabs of safari: %w", err) + switch selector { + case "_rpc_reportConnectedApplicationList:": + session.apps = map[string]inspectorApp{} + for _, description := range asStringMap(argument["WIRApplicationDictionaryKey"]) { + session.rememberApp(asStringMap(description)) } - if message.selector != "_rpc_applicationSentListing:" || message.argument["WIRApplicationIdentifierKey"] != app.id { - continue + case "_rpc_applicationConnected:", "_rpc_applicationUpdated:": + session.rememberApp(argument) + case "_rpc_applicationDisconnected:": + appID, _ := argument["WIRApplicationIdentifierKey"].(string) + delete(session.apps, appID) + case "_rpc_applicationSentListing:": + appID, _ := argument["WIRApplicationIdentifierKey"].(string) + session.listings[appID] = inspectorListing{ + revision: session.listings[appID].revision + 1, + pages: webPagesOf(asStringMap(argument["WIRListingKey"])), } - - pages := []inspectorPage{} - listing, _ := message.argument["WIRListingKey"].(map[string]any) - for _, entry := range listing { - description := asStringMap(entry) - if description["WIRTypeKey"] != webInspectorWebPageType { - continue - } - id, ok := description["WIRPageIdentifierKey"].(uint64) - if !ok { - continue - } - url, _ := description["WIRURLKey"].(string) - title, _ := description["WIRTitleKey"].(string) - pages = append(pages, inspectorPage{id: id, url: url, title: title}) + case "_rpc_applicationSentData:": + destination, _ := argument["WIRDestinationKey"].(string) + data, _ := argument["WIRMessageDataKey"].([]byte) + if tab, attached := session.tabs[destination]; attached { + tab.handle(data) } - sort.Slice(pages, func(i, j int) bool { return pages[i].id < pages[j].id }) - return pages, nil + default: + return } + session.notifyChange() } -// inspectorTab is a session attached to one tab, ready to take commands. -type inspectorTab struct { - session *webInspectorSession - app inspectorApp - webviewID string - pageID uint64 - senderID string - // targetID names the page inside the tab: commands are not sent to the tab - // itself but wrapped in a message to this target - targetID string - lastCommandID int - timeout time.Duration -} - -// attach opens a session and attaches it to the tab with the given webview id. -// The caller closes the session of the returned tab. -func (s safariWebViews) attach(webviewID string, timeout time.Duration) (*inspectorTab, error) { - session, err := s.openSession() - if err != nil { - return nil, err - } - tab, err := s.attachOn(session, webviewID, timeout) - if err != nil { - session.close() - return nil, err +func (session *webInspectorSession) rememberApp(description map[string]any) { + id, _ := description["WIRApplicationIdentifierKey"].(string) + if id == "" { + return } - return tab, nil + bundleID, _ := description["WIRApplicationBundleIdentifierKey"].(string) + name, _ := description["WIRApplicationNameKey"].(string) + session.apps[id] = inspectorApp{id: id, bundleID: bundleID, name: name} } -func (s safariWebViews) attachOn(session *webInspectorSession, webviewID string, timeout time.Duration) (*inspectorTab, error) { - app, err := session.findSafari(s.findTimeout()) - if err != nil { - return nil, err - } - pages, err := session.webPages(app, timeout) - if err != nil { - return nil, err - } - pageID, err := strconv.ParseUint(webviewID, 10, 64) - if err != nil || !hasInspectorPage(pages, pageID) { - return nil, fmt.Errorf("%w: %s", errSafariTabNotFound, webviewID) - } - - senderID, err := newInspectorID() - if err != nil { - return nil, err - } - tab := &inspectorTab{session: session, app: app, webviewID: webviewID, pageID: pageID, senderID: senderID, timeout: timeout} - if err := session.send("_rpc_forwardSocketSetup:", tab.addressed(map[string]any{"WIRAutomaticallyPause": false})); err != nil { - return nil, err - } - - // the tab introduces its targets once attached; the page is the one to talk to - deadline := time.Now().Add(timeout) - for { - event, err := tab.nextProtocolMessage(deadline, "attach") - if err != nil { - return nil, err +// webPagesOf returns the web pages among what an app lists, ordered by id. +func webPagesOf(listing map[string]any) []inspectorPage { + pages := []inspectorPage{} + for _, entry := range listing { + description := asStringMap(entry) + if description["WIRTypeKey"] != webInspectorWebPageType { + continue } - if event.Method == "Target.targetCreated" && event.Params.TargetInfo.Type == "page" { - tab.targetID = event.Params.TargetInfo.TargetID - return tab, nil + id, ok := description["WIRPageIdentifierKey"].(uint64) + if !ok { + continue } + url, _ := description["WIRURLKey"].(string) + title, _ := description["WIRTitleKey"].(string) + pages = append(pages, inspectorPage{id: id, url: url, title: title}) } + sort.Slice(pages, func(i, j int) bool { return pages[i].id < pages[j].id }) + return pages } -func hasInspectorPage(pages []inspectorPage, id uint64) bool { - for _, page := range pages { - if page.id == id { - return true +// findSafari waits for the device to report Safari among its inspectable apps. +// The device lists the apps it knows on connect, and announces others after. +func (session *webInspectorSession) findSafari(timeout time.Duration) (inspectorApp, error) { + var safari inspectorApp + err := session.await(time.Now().Add(timeout), func() bool { + for _, app := range session.apps { + if app.bundleID == safariBundleID { + safari = app + return true + } } + return false + }) + if errors.Is(err, errInspectorTimedOut) { + return inspectorApp{}, errors.New("safari is not inspectable: open it, and on a real device turn on Settings > Apps > Safari > Advanced > Web Inspector") } - return false + return safari, err } -func (tab *inspectorTab) addressed(argument map[string]any) map[string]any { - argument["WIRApplicationIdentifierKey"] = tab.app.id - argument["WIRPageIdentifierKey"] = tab.pageID - argument["WIRSenderKey"] = tab.senderID - return argument +// webPages asks an app for what it has open, and returns the web pages among it. +func (session *webInspectorSession) webPages(app inspectorApp, timeout time.Duration) ([]inspectorPage, error) { + session.mu.Lock() + lastRevision := session.listings[app.id].revision + session.mu.Unlock() + + if err := session.send("_rpc_forwardGetListing:", map[string]any{"WIRApplicationIdentifierKey": app.id}); err != nil { + return nil, err + } + var pages []inspectorPage + err := session.await(time.Now().Add(timeout), func() bool { + listing := session.listings[app.id] + pages = listing.pages + return listing.revision > lastRevision + }) + if err != nil { + return nil, fmt.Errorf("list the tabs of safari: %w", err) + } + return pages, nil } type inspectorProtocolError struct { @@ -352,51 +401,95 @@ type inspectorProtocolMessage struct { Error *inspectorProtocolError `json:"error"` Params struct { TargetInfo struct { - TargetID string `json:"targetId"` - Type string `json:"type"` + TargetID string `json:"targetId"` + Type string `json:"type"` + IsProvisional bool `json:"isProvisional"` } `json:"targetInfo"` + OldTargetID string `json:"oldTargetId"` + NewTargetID string `json:"newTargetId"` // set on Target.dispatchMessageFromTarget: a protocol message of the target Message string `json:"message"` } `json:"params"` } -// nextProtocolMessage waits for the next protocol message the tab sends. -func (tab *inspectorTab) nextProtocolMessage(deadline time.Time, doing string) (inspectorProtocolMessage, error) { - for { - message, err := tab.session.nextMessage(deadline) - if errors.Is(err, errInspectorTimedOut) { - return inspectorProtocolMessage{}, fmt.Errorf("webview %s did not answer %s within %s", tab.webviewID, doing, tab.timeout) +// inspectorTab is the attachment of a session to one tab. +type inspectorTab struct { + session *webInspectorSession + app inspectorApp + webviewID string + pageID uint64 + senderID string + + // the fields below are guarded by session.mu + + // targetID names the page inside the tab: commands are not sent to the tab + // itself but wrapped in a message to this target. A navigation can replace it. + targetID string + lastCommandID int + replies map[int]inspectorProtocolMessage +} + +// handle takes in a protocol message the tab sent. Called with session.mu held. +func (tab *inspectorTab) handle(data []byte) { + var message inspectorProtocolMessage + if err := json.Unmarshal(data, &message); err != nil { + return + } + + switch message.Method { + case "Target.targetCreated": + if message.Params.TargetInfo.Type == "page" && !message.Params.TargetInfo.IsProvisional { + tab.targetID = message.Params.TargetInfo.TargetID } - if err != nil { - return inspectorProtocolMessage{}, err + case "Target.didCommitProvisionalTarget": + if message.Params.OldTargetID == tab.targetID { + tab.targetID = message.Params.NewTargetID } - if message.selector != "_rpc_applicationSentData:" || message.argument["WIRDestinationKey"] != tab.senderID { - continue + case "Target.dispatchMessageFromTarget": + var reply inspectorProtocolMessage + if err := json.Unmarshal([]byte(message.Params.Message), &reply); err == nil && reply.ID != 0 { + tab.replies[reply.ID] = reply } - data, _ := message.argument["WIRMessageDataKey"].([]byte) - var protocolMessage inspectorProtocolMessage - if err := json.Unmarshal(data, &protocolMessage); err != nil { - return inspectorProtocolMessage{}, fmt.Errorf("parse web inspector message: %w", err) + case "": + // the envelope a command travels in is only answered with an error when + // it could not be delivered. It was numbered right after its command. + if message.Error != nil { + tab.replies[message.ID-1] = message } - return protocolMessage, nil } } +func (tab *inspectorTab) addressed(argument map[string]any) map[string]any { + argument["WIRApplicationIdentifierKey"] = tab.app.id + argument["WIRPageIdentifierKey"] = tab.pageID + argument["WIRSenderKey"] = tab.senderID + return argument +} + +// detach stops the session from inspecting the tab. +func (tab *inspectorTab) detach() { + _ = tab.session.send("_rpc_forwardDidClose:", tab.addressed(map[string]any{})) + tab.session.mu.Lock() + defer tab.session.mu.Unlock() + delete(tab.session.tabs, tab.senderID) +} + // command sends one protocol command to the page of the tab and returns its result. -func (tab *inspectorTab) command(method string, params map[string]any) (json.RawMessage, error) { - tab.lastCommandID++ - commandID := tab.lastCommandID +func (tab *inspectorTab) command(method string, params map[string]any, timeout time.Duration) (json.RawMessage, error) { + tab.session.mu.Lock() + tab.lastCommandID += 2 + commandID, envelopeID := tab.lastCommandID-1, tab.lastCommandID + targetID := tab.targetID + tab.session.mu.Unlock() + command, err := json.Marshal(map[string]any{"id": commandID, "method": method, "params": params}) if err != nil { return nil, fmt.Errorf("marshal %s: %w", method, err) } - - tab.lastCommandID++ - envelopeID := tab.lastCommandID envelope, err := json.Marshal(map[string]any{ "id": envelopeID, "method": "Target.sendMessageToTarget", - "params": map[string]any{"targetId": tab.targetID, "message": string(command)}, + "params": map[string]any{"targetId": targetID, "message": string(command)}, }) if err != nil { return nil, fmt.Errorf("marshal %s: %w", method, err) @@ -405,32 +498,25 @@ func (tab *inspectorTab) command(method string, params map[string]any) (json.Raw return nil, err } - deadline := time.Now().Add(tab.timeout) - for { - message, err := tab.nextProtocolMessage(deadline, method) - if err != nil { - return nil, err - } - // the envelope is only answered with an error when it could not be delivered - if message.ID == envelopeID && message.Error != nil { - return nil, fmt.Errorf("%s: %s", method, message.Error.Message) + var reply inspectorProtocolMessage + err = tab.session.await(time.Now().Add(timeout), func() bool { + answer, answered := tab.replies[commandID] + if answered { + reply = answer + delete(tab.replies, commandID) } - if message.Method != "Target.dispatchMessageFromTarget" { - continue - } - - var reply inspectorProtocolMessage - if err := json.Unmarshal([]byte(message.Params.Message), &reply); err != nil { - return nil, fmt.Errorf("parse %s reply: %w", method, err) - } - if reply.ID != commandID { - continue - } - if reply.Error != nil { - return nil, fmt.Errorf("%s: %s", method, reply.Error.Message) - } - return reply.Result, nil + return answered + }) + if errors.Is(err, errInspectorTimedOut) { + return nil, fmt.Errorf("webview %s did not answer %s within %s", tab.webviewID, method, timeout) } + if err != nil { + return nil, err + } + if reply.Error != nil { + return nil, fmt.Errorf("%s: %s", method, reply.Error.Message) + } + return reply.Result, nil } // inspectorRemoteObject is how the protocol hands back a javascript value: by @@ -444,16 +530,6 @@ type inspectorRemoteObject struct { WasThrown bool `json:"wasThrown"` } -// evaluateOnly runs a function body with args in the page, without waiting for -// a promise it returns, and hands back what it returned. -func (tab *inspectorTab) evaluateOnly(expression string) (inspectorRemoteObject, error) { - raw, err := tab.command("Runtime.evaluate", map[string]any{"expression": expression}) - if err != nil { - return inspectorRemoteObject{}, err - } - return thrownOrReturned(raw) -} - func thrownOrReturned(raw json.RawMessage) (inspectorRemoteObject, error) { var object inspectorRemoteObject if err := json.Unmarshal(raw, &object); err != nil { @@ -465,6 +541,19 @@ func thrownOrReturned(raw json.RawMessage) (inspectorRemoteObject, error) { return object, nil } +// evaluateOnly runs javascript in the page, without waiting for a promise it +// returns, and hands back what it returned. +func (tab *inspectorTab) evaluateOnly(expression string, timeout time.Duration) (inspectorRemoteObject, error) { + raw, err := tab.command("Runtime.evaluate", map[string]any{"expression": expression}, timeout) + if err != nil { + return inspectorRemoteObject{}, err + } + return thrownOrReturned(raw) +} + +// safariSignOfLife is evaluated to find out whether a tab answers at all. +const safariSignOfLife = "1" + // functionCall is the javascript that runs body as a function called with args. func functionCall(body string, args []any) (string, error) { if args == nil { @@ -477,6 +566,15 @@ func functionCall(body string, args []any) (string, error) { return "(function(){\n" + body + "\n}).apply(null, " + string(argsJSON) + ")", nil } +// safariWebViews drives the tabs of Safari as webviews, through the web +// inspector of the device. +type safariWebViews struct { + inspector *webInspectorClient + // zero means the default; tests shorten them + findSafariTimeout time.Duration + livenessTimeout time.Duration +} + func (s safariWebViews) findTimeout() time.Duration { if s.findSafariTimeout > 0 { return s.findSafariTimeout @@ -484,30 +582,101 @@ func (s safariWebViews) findTimeout() time.Duration { return safariFindTimeout } -func (s safariWebViews) timeout() time.Duration { - if s.commandTimeout > 0 { - return s.commandTimeout +func (s safariWebViews) liveness() time.Duration { + if s.livenessTimeout > 0 { + return s.livenessTimeout } - return safariCommandTimeout + return safariLivenessTimeout } -func (s safariWebViews) ListWebViews() ([]WebViewInfo, error) { - session, err := s.openSession() +// safariTabs returns Safari and the tabs it has open. +func (s safariWebViews) safariTabs() (*webInspectorSession, inspectorApp, []inspectorPage, error) { + session, err := s.inspector.connected() + if err != nil { + return nil, inspectorApp{}, nil, err + } + safari, err := session.findSafari(s.findTimeout()) + if err != nil { + return nil, inspectorApp{}, nil, err + } + pages, err := session.webPages(safari, safariCommandTimeout) + if err != nil { + return nil, inspectorApp{}, nil, err + } + return session, safari, pages, nil +} + +// attach attaches to the tab with the given webview id, and makes sure it +// answers within livenessTimeout. The caller detaches the returned tab. +func (s safariWebViews) attach(webviewID string, livenessTimeout time.Duration) (*inspectorTab, error) { + session, safari, pages, err := s.safariTabs() if err != nil { return nil, err } - defer session.close() + pageID, err := strconv.ParseUint(webviewID, 10, 64) + if err != nil || !hasInspectorPage(pages, pageID) { + return nil, fmt.Errorf("%w: %s", errSafariTabNotFound, webviewID) + } + return session.attach(safari, webviewID, pageID, livenessTimeout) +} - safari, err := session.findSafari(s.findTimeout()) +func hasInspectorPage(pages []inspectorPage, id uint64) bool { + for _, page := range pages { + if page.id == id { + return true + } + } + return false +} + +func (session *webInspectorSession) attach(app inspectorApp, webviewID string, pageID uint64, livenessTimeout time.Duration) (*inspectorTab, error) { + senderID, err := newInspectorID() if err != nil { return nil, err } - pages, err := session.webPages(safari, s.timeout()) + tab := &inspectorTab{ + session: session, + app: app, + webviewID: webviewID, + pageID: pageID, + senderID: senderID, + replies: map[int]inspectorProtocolMessage{}, + } + session.mu.Lock() + session.tabs[senderID] = tab + session.mu.Unlock() + + notRunning := fmt.Errorf("webview %s did not answer within %s; it is probably a background tab, which safari does not keep running", webviewID, livenessTimeout) + + if err := session.send("_rpc_forwardSocketSetup:", tab.addressed(map[string]any{"WIRAutomaticallyPause": false})); err != nil { + tab.detach() + return nil, err + } + // the tab introduces its targets once attached; the page is the one to talk to + deadline := time.Now().Add(livenessTimeout) + err = session.await(deadline, func() bool { return tab.targetID != "" }) + if err == nil { + _, err = tab.command("Runtime.evaluate", map[string]any{"expression": safariSignOfLife}, time.Until(deadline)) + } + if err != nil { + tab.detach() + if errors.Is(err, errInspectorClosed) { + return nil, err + } + return nil, notRunning + } + return tab, nil +} + +func (s safariWebViews) ListWebViews() ([]WebViewInfo, error) { + session, safari, pages, err := s.safariTabs() if err != nil { return nil, err } webviews := make([]WebViewInfo, len(pages)) + // the listing does not say which tab is in front, so ask each one + var wg sync.WaitGroup for i, page := range pages { webviews[i] = WebViewInfo{ ID: strconv.FormatUint(page.id, 10), @@ -516,41 +685,44 @@ func (s safariWebViews) ListWebViews() ([]WebViewInfo, error) { BundleID: safariBundleID, ProcessName: safari.name, } - } - - // the listing does not say which tab is in front, so ask each one - var wg sync.WaitGroup - for i := range webviews { wg.Add(1) - go func(webview *WebViewInfo) { + go func(webview *WebViewInfo, pageID uint64) { defer wg.Done() - webview.IsVisible = s.isVisible(webview.ID) - }(&webviews[i]) + webview.IsVisible = session.isVisible(safari, webview.ID, pageID) + }(&webviews[i], page.id) } wg.Wait() return webviews, nil } -func (s safariWebViews) isVisible(webviewID string) bool { - visible, err := s.evaluate(webviewID, "return document.visibilityState === 'visible'", nil, safariVisibilityTimeout) +// isVisible asks a tab whether it is the one in front. A tab that does not +// answer is not running, so it is not the one in front either. +func (session *webInspectorSession) isVisible(app inspectorApp, webviewID string, pageID uint64) bool { + tab, err := session.attach(app, webviewID, pageID, safariVisibilityTimeout) + if err != nil { + return false + } + defer tab.detach() + + visible, err := tab.evaluate("return document.visibilityState === 'visible'", nil, safariVisibilityTimeout) return err == nil && visible == true } // navigate runs a function body that makes the tab leave its page. The page // that answers is torn down by the navigation, so nothing is awaited from it. func (s safariWebViews) navigate(webviewID, body string, args []any) error { - tab, err := s.attach(webviewID, s.timeout()) + tab, err := s.attach(webviewID, s.liveness()) if err != nil { return err } - defer tab.session.close() + defer tab.detach() expression, err := functionCall(body, args) if err != nil { return err } - _, err = tab.evaluateOnly(expression) + _, err = tab.evaluateOnly(expression, safariCommandTimeout) return err } @@ -568,13 +740,13 @@ func (s safariWebViews) WebViewGoForward(webviewID string) error { } func (s safariWebViews) WebViewReload(webviewID string) error { - tab, err := s.attach(webviewID, s.timeout()) + tab, err := s.attach(webviewID, s.liveness()) if err != nil { return err } - defer tab.session.close() + defer tab.detach() - _, err = tab.command("Page.reload", map[string]any{}) + _, err = tab.command("Page.reload", map[string]any{}, safariCommandTimeout) return err } @@ -591,32 +763,32 @@ func (s safariWebViews) WebViewContent(webviewID string) (string, error) { } func (s safariWebViews) WebViewEvaluate(webviewID, expression string, args []any) (any, error) { - return s.evaluate(webviewID, expression, args, s.timeout()) -} - -// evaluate runs expression the way the injected agent does: as the body of a -// function called with args, whose returned promise is awaited. -func (s safariWebViews) evaluate(webviewID, expression string, args []any, timeout time.Duration) (any, error) { - tab, err := s.attach(webviewID, timeout) + tab, err := s.attach(webviewID, s.liveness()) if err != nil { return nil, err } - defer tab.session.close() + defer tab.detach() + + return tab.evaluate(expression, args, safariCommandTimeout) +} +// evaluate runs expression the way the injected agent does: as the body of a +// function called with args, whose returned promise is awaited. +func (tab *inspectorTab) evaluate(expression string, args []any, timeout time.Duration) (any, error) { call, err := functionCall(ensureReturnExpression(expression), args) if err != nil { return nil, err } // WebKit cannot await while evaluating. Whatever the function returns is // made a promise, which is handed back by reference and awaited separately. - promise, err := tab.evaluateOnly("Promise.resolve(" + call + ")") + promise, err := tab.evaluateOnly("Promise.resolve("+call+")", timeout) if err != nil { return nil, err } raw, err := tab.command("Runtime.awaitPromise", map[string]any{ "promiseObjectId": promise.Result.ObjectID, "returnByValue": true, - }) + }, timeout) if err != nil { return nil, err } @@ -641,18 +813,32 @@ func (s safariWebViews) WebViewWaitForLoadState(webviewID, state string, timeout deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) for { - // an evaluation fails while the tab swaps pages mid-navigation, so only + // the tab does not answer while it swaps pages mid-navigation, so only // an unknown tab ends the wait early - loaded, err := s.evaluate(webviewID, hasLoaded, nil, s.timeout()) + loaded, err := s.hasLoaded(webviewID, hasLoaded) if errors.Is(err, errSafariTabNotFound) { return err } - if err == nil && loaded == true { + if err == nil && loaded { return nil } if time.Now().After(deadline) { + if err != nil { + return fmt.Errorf("waitForLoadState timed out waiting for '%s': %w", state, err) + } return fmt.Errorf("waitForLoadState timed out waiting for '%s'", state) } time.Sleep(safariLoadStatePollEvery) } } + +func (s safariWebViews) hasLoaded(webviewID, hasLoaded string) (bool, error) { + tab, err := s.attach(webviewID, s.liveness()) + if err != nil { + return false, err + } + defer tab.detach() + + loaded, err := tab.evaluate(hasLoaded, nil, s.liveness()) + return loaded == true, err +} diff --git a/devices/ios_webinspector_test.go b/devices/ios_webinspector_test.go index bcc8f993..78f40480 100644 --- a/devices/ios_webinspector_test.go +++ b/devices/ios_webinspector_test.go @@ -45,9 +45,14 @@ type fakeWebInspector struct { pages map[string][]fakeInspectorPage // answer returns the result of a protocol command, or a protocol error message answer func(call fakeInspectorCall) (result any, protocolError string) + // frozenPages accept commands without ever answering them, as the tabs + // safari is not showing do + frozenPages map[uint64]bool - mu sync.Mutex - calls []fakeInspectorCall + mu sync.Mutex + calls []fakeInspectorCall + connections []net.Conn + client *webInspectorClient } var ( @@ -58,8 +63,10 @@ var ( func startFakeWebInspector(t *testing.T, apps ...fakeInspectorApp) *fakeWebInspector { t.Helper() - inspector := &fakeWebInspector{t: t, apps: apps, pages: map[string][]fakeInspectorPage{}} + inspector := &fakeWebInspector{t: t, apps: apps, pages: map[string][]fakeInspectorPage{}, frozenPages: map[uint64]bool{}} inspector.answer = func(fakeInspectorCall) (any, string) { return map[string]any{}, "" } + inspector.client = &webInspectorClient{dial: inspector.dial} + t.Cleanup(inspector.client.close) return inspector } @@ -72,11 +79,28 @@ func safariShowing(t *testing.T, pages ...fakeInspectorPage) *fakeWebInspector { } func (f *fakeWebInspector) safari() safariWebViews { - return safariWebViews{dial: f.dial} + return safariWebViews{inspector: f.client} +} + +func (f *fakeWebInspector) connectionCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.connections) +} + +// loseConnection drops the connection from the device side, as happens when +// the device is unplugged or its web inspector restarts +func (f *fakeWebInspector) loseConnection() { + f.mu.Lock() + defer f.mu.Unlock() + _ = f.connections[len(f.connections)-1].Close() } func (f *fakeWebInspector) dial() (io.ReadWriteCloser, error) { client, device := net.Pipe() + f.mu.Lock() + f.connections = append(f.connections, device) + f.mu.Unlock() go f.serve(device) return client, nil } @@ -173,23 +197,34 @@ func (f *fakeWebInspector) answerCommand(send func(string, map[string]any), send } _ = json.Unmarshal([]byte(outer.Params.Message), &command) + f.sendToInspector(send, sender, map[string]any{"id": outer.ID, "result": map[string]any{}}) + if f.frozenPages[pageID] { + return + } + answerToTarget := func(reply map[string]any) { + inner, _ := json.Marshal(reply) + f.sendToInspector(send, sender, map[string]any{ + "method": "Target.dispatchMessageFromTarget", + "params": map[string]any{"targetId": outer.Params.TargetID, "message": string(inner)}, + }) + } + // the sign of life a tab is asked for before anything else is not a call a test cares about + if command.Method == "Runtime.evaluate" && command.Params["expression"] == safariSignOfLife { + answerToTarget(map[string]any{"id": command.ID, "result": map[string]any{"result": map[string]any{"type": "number", "value": 1}, "wasThrown": false}}) + return + } + call := fakeInspectorCall{PageID: pageID, Method: command.Method, Params: command.Params} f.mu.Lock() f.calls = append(f.calls, call) f.mu.Unlock() - f.sendToInspector(send, sender, map[string]any{"id": outer.ID, "result": map[string]any{}}) - result, protocolError := f.answer(call) reply := map[string]any{"id": command.ID, "result": result} if protocolError != "" { reply = map[string]any{"id": command.ID, "error": map[string]any{"code": -32601, "message": protocolError}} } - inner, _ := json.Marshal(reply) - f.sendToInspector(send, sender, map[string]any{ - "method": "Target.dispatchMessageFromTarget", - "params": map[string]any{"targetId": outer.Params.TargetID, "message": string(inner)}, - }) + answerToTarget(reply) } func (f *fakeWebInspector) callsOf(method string) []fakeInspectorCall { @@ -451,24 +486,84 @@ func TestSafariProtocolErrorFailsTheCommand(t *testing.T) { } } -func TestSafariCommandOnATabThatNeverAnswersFailsSayingSo(t *testing.T) { +// Safari does not keep the tabs it is not showing running, and such a tab +// accepts a command without ever answering it. +func TestSafariCommandOnATabThatDoesNotAnswerFailsQuicklySayingSo(t *testing.T) { inspector := safariShowing(t, examplePage) - tabIsFrozen := make(chan struct{}) - t.Cleanup(func() { close(tabIsFrozen) }) - inspector.answer = func(fakeInspectorCall) (any, string) { - <-tabIsFrozen - return map[string]any{}, "" - } + inspector.frozenPages[examplePage.ID] = true safari := inspector.safari() - safari.commandTimeout = 200 * time.Millisecond + safari.livenessTimeout = 200 * time.Millisecond _, err := safari.WebViewEvaluate("2", "return 1", nil) - if err == nil || err.Error() != "webview 2 did not answer Runtime.evaluate within 200ms" { + expected := "webview 2 did not answer within 200ms; it is probably a background tab, which safari does not keep running" + if err == nil || err.Error() != expected { t.Fatalf("expected an explanation of the timeout, got %v", err) } } +func TestTabThatDoesNotAnswerIsListedAsNotVisible(t *testing.T) { + backgroundTab := fakeInspectorPage{ID: 1, Type: "WIRTypeWebPage", URL: "https://example.org/"} + inspector := safariShowing(t, backgroundTab, examplePage) + inspector.frozenPages[backgroundTab.ID] = true + inspector.evaluatesTo(true) + + webviews, err := inspector.safari().ListWebViews() + + if err != nil { + t.Fatalf("ListWebViews: %v", err) + } + if len(webviews) != 2 || webviews[0].IsVisible || !webviews[1].IsVisible { + t.Fatalf("expected only the answering tab to be visible, got %+v", webviews) + } +} + +// The web inspector stalls a new connection for seconds after one was closed, +// so the connection is kept and shared. +func TestCommandsShareOneConnectionToTheWebInspector(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.evaluatesTo("Example Domain") + safari := inspector.safari() + + for range 3 { + if _, err := safari.WebViewEvaluate("2", "document.title", nil); err != nil { + t.Fatalf("WebViewEvaluate: %v", err) + } + } + + if connections := inspector.connectionCount(); connections != 1 { + t.Fatalf("expected a single connection, got %d", connections) + } +} + +func TestCommandConnectsAgainAfterTheConnectionWasLost(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.evaluatesTo("Example Domain") + safari := inspector.safari() + if _, err := safari.WebViewEvaluate("2", "document.title", nil); err != nil { + t.Fatalf("WebViewEvaluate: %v", err) + } + + inspector.loseConnection() + waitUntilTheClientNoticed(t, inspector.client) + title, err := safari.WebViewEvaluate("2", "document.title", nil) + + if err != nil || title != "Example Domain" { + t.Fatalf("expected the command to work on a new connection, got %v, %v", title, err) + } +} + +func waitUntilTheClientNoticed(t *testing.T, client *webInspectorClient) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for !client.session.isClosed() { + if time.Now().After(deadline) { + t.Fatal("the client never noticed the lost connection") + } + time.Sleep(10 * time.Millisecond) + } +} + // WebKit has no command to navigate a page, so the page is told to navigate // itself. The url travels as an argument, not as javascript source. func TestSafariGotoNavigatesTheTabToTheUrl(t *testing.T) { diff --git a/devices/ios_webview.go b/devices/ios_webview.go index 6a3e30bd..abc3ed2d 100644 --- a/devices/ios_webview.go +++ b/devices/ios_webview.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -173,6 +174,29 @@ func injectIOSAgent(pid int, dylibPath string) (int, error) { return 0, fmt.Errorf("could not parse port from lldb output:\n%s", out) } +// safariInForeground returns what drives the tabs of Safari, when Safari is the +// app in front. Safari cannot take the agent, so its tabs are driven through the +// web inspector of the simulator instead (see ios_webinspector.go). Not knowing +// the foreground app leaves it to the injected agent, which reports why. +func (s *SimulatorDevice) safariInForeground() (safariWebViews, bool) { + if s.deviceKitClient == nil { + if err := s.StartAgent(StartAgentConfig{}); err != nil { + utils.Verbose("could not start DeviceKit to look up the foreground app: %v", err) + return safariWebViews{}, false + } + } + activeApp, err := s.deviceKitClient.GetActiveAppInfo() + if err != nil { + utils.Verbose("could not look up the foreground app: %v", err) + return safariWebViews{}, false + } + if activeApp.BundleID != safariBundleID { + return safariWebViews{}, false + } + inspector := webInspectorOf(s.UDID, func() (io.ReadWriteCloser, error) { return dialSimulatorWebInspector(s.UDID) }) + return safariWebViews{inspector: inspector}, true +} + // ensureIOSAgentReady ensures the iOS agent is running inside the simulator // and returns the local TCP port to connect to. func (s *SimulatorDevice) ensureIOSAgentReady() (int, error) { @@ -232,16 +256,25 @@ func (s *SimulatorDevice) webViewAction(wvID, method string) error { // WebViewReload reloads the page in the given webview. func (s *SimulatorDevice) WebViewReload(wvID string) error { + if safari, ok := s.safariInForeground(); ok { + return safari.WebViewReload(wvID) + } return s.webViewAction(wvID, "device.webview.reload") } // WebViewGoBack navigates the given webview back in history. func (s *SimulatorDevice) WebViewGoBack(wvID string) error { + if safari, ok := s.safariInForeground(); ok { + return safari.WebViewGoBack(wvID) + } return s.webViewAction(wvID, "device.webview.goBack") } // WebViewGoForward navigates the given webview forward in history. func (s *SimulatorDevice) WebViewGoForward(wvID string) error { + if safari, ok := s.safariInForeground(); ok { + return safari.WebViewGoForward(wvID) + } return s.webViewAction(wvID, "device.webview.goForward") } @@ -261,6 +294,9 @@ func (s *SimulatorDevice) WebViewContent(wvID string) (string, error) { // WebViewWaitForLoadState blocks until the webview reaches the given load state. // timeoutMs of 0 uses the agent's default. func (s *SimulatorDevice) WebViewWaitForLoadState(wvID, state string, timeoutMs int) error { + if safari, ok := s.safariInForeground(); ok { + return safari.WebViewWaitForLoadState(wvID, state, timeoutMs) + } port, err := s.ensureIOSAgentReady() if err != nil { return err @@ -270,6 +306,9 @@ func (s *SimulatorDevice) WebViewWaitForLoadState(wvID, state string, timeoutMs // WebViewGoto navigates the webview identified by wvID to url. func (s *SimulatorDevice) WebViewGoto(wvID, url string) error { + if safari, ok := s.safariInForeground(); ok { + return safari.WebViewGoto(wvID, url) + } port, err := s.ensureIOSAgentReady() if err != nil { return err @@ -280,6 +319,9 @@ func (s *SimulatorDevice) WebViewGoto(wvID, url string) error { // WebViewEvaluate runs expression in the webview and returns the JS result value. func (s *SimulatorDevice) WebViewEvaluate(wvID, expression string, args []any) (any, error) { + if safari, ok := s.safariInForeground(); ok { + return safari.WebViewEvaluate(wvID, expression, args) + } port, err := s.ensureIOSAgentReady() if err != nil { return nil, err @@ -289,6 +331,9 @@ func (s *SimulatorDevice) WebViewEvaluate(wvID, expression string, args []any) ( // ListWebViews returns all embedded WKWebViews found in the foreground simulator app. func (s *SimulatorDevice) ListWebViews() ([]WebViewInfo, error) { + if safari, ok := s.safariInForeground(); ok { + return safari.ListWebViews() + } port, err := s.ensureIOSAgentReady() if err != nil { return nil, err diff --git a/docs/openrpc.json b/docs/openrpc.json index b7ab7c04..a9353dda 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -1514,7 +1514,7 @@ { "name": "device.webview.list", "summary": "List webviews", - "description": "Returns all embedded webviews currently visible in the foreground app on the device. When the foreground app is Safari on a real iOS device, returns its tabs instead. Chrome is not included.", + "description": "Returns all embedded webviews currently visible in the foreground app on the device. When the foreground app is Safari on iOS, returns its tabs instead. Chrome is not included.", "params": [ { "name": "deviceId", diff --git a/docs/openrpc.md b/docs/openrpc.md index dad18fa4..80d7939c 100644 --- a/docs/openrpc.md +++ b/docs/openrpc.md @@ -1664,7 +1664,7 @@ Operation result **List webviews** -Returns all embedded webviews currently visible in the foreground app on the device. When the foreground app is Safari on a real iOS device, returns its tabs instead. Chrome is not included. +Returns all embedded webviews currently visible in the foreground app on the device. When the foreground app is Safari on iOS, returns its tabs instead. Chrome is not included. #### Parameters diff --git a/test/simulator.spec.ts b/test/simulator.spec.ts index b76615e6..5f600dda 100644 --- a/test/simulator.spec.ts +++ b/test/simulator.spec.ts @@ -57,6 +57,7 @@ const TEST_SERVER_URL = 'http://localhost:12001'; // ships on every simulator image and is never a debug build const IOS_SETTINGS_BUNDLE_ID = 'com.apple.Preferences'; +const SAFARI_BUNDLE_ID = 'com.apple.mobilesafari'; // webkit hands each of these back as a value a json writer refuses. the expected // result is what JSON.stringify makes of it, which is what the android agent answers. @@ -381,6 +382,95 @@ test.describe('iOS Simulator Tests', () => { }); }); + // safari cannot take the injected agent, so its tabs are driven through the + // web inspector of the simulator instead. its own describe: `webview list` + // reads the foreground app, and the playground group below needs its own. + test.describe.serial('webview on safari', () => { + let tabId: string; + + test.beforeAll(async () => { + if (!simulatorId) return; + await eventually(() => foregroundAppAfterLaunching(simulatorId, SAFARI_BUNDLE_ID), 'safari never came to the front') + .toBe(SAFARI_BUNDLE_ID); + await eventually(() => visibleSafariTabs(simulatorId).length, 'safari shows no tab').toBe(1); + tabId = visibleSafariTabs(simulatorId)[0].id; + + webViewGoto(simulatorId, tabId, WEBVIEW_SAMPLE_URL); + webViewWait(simulatorId, tabId, 'load'); + await expectWebViewUrlToBecome(() => webViewUrl(simulatorId, tabId), WEBVIEW_SAMPLE_URL); + }); + + test('should list the tab safari is showing', () => { + test.skip(!simulatorId, 'simulator not found'); + + const tab = visibleSafariTabs(simulatorId)[0]; + expect(tab.id).toBe(tabId); + expect(tab.url).toBe(WEBVIEW_SAMPLE_URL); + expect(tab.title).toBe(WEBVIEW_SAMPLE_TITLE); + expect(tab.bundleId).toBe(SAFARI_BUNDLE_ID); + }); + + test('should report the url and title of the tab', () => { + test.skip(!simulatorId, 'simulator not found'); + + expect(webViewUrl(simulatorId, tabId)).toBe(WEBVIEW_SAMPLE_URL); + expect(webViewTitle(simulatorId, tabId)).toBe(WEBVIEW_SAMPLE_TITLE); + }); + + test('should evaluate javascript in the tab and await the promise it returns', () => { + test.skip(!simulatorId, 'simulator not found'); + + expect(webViewEval(simulatorId, tabId, '1 + 2')).toBe(3); + expect(webViewEval(simulatorId, tabId, 'new Promise(resolve => setTimeout(() => resolve(document.title), 100))')) + .toBe(WEBVIEW_SAMPLE_TITLE); + }); + + test('should report the error javascript throws in the tab', () => { + test.skip(!simulatorId, 'simulator not found'); + + const message = webViewCommandError(simulatorId, ['eval', tabId, '(() => { throw new Error("boom") })()']); + expect(message).toContain('Error: boom'); + }); + + test('should dump the html content and query the dom of the tab', () => { + test.skip(!simulatorId, 'simulator not found'); + + expect(webViewContent(simulatorId, tabId)).toContain(`${WEBVIEW_SAMPLE_TITLE}`); + expect(webViewQuery(simulatorId, tabId, 'input').length).toBeGreaterThan(0); + }); + + test('should navigate the tab to another url, then back and forward', async () => { + test.skip(!simulatorId, 'simulator not found'); + + webViewGoto(simulatorId, tabId, WEBVIEW_DONE_URL); + webViewWait(simulatorId, tabId, 'load'); + await expectWebViewUrlToBecome(() => webViewUrl(simulatorId, tabId), WEBVIEW_DONE_URL); + + webViewGoBack(simulatorId, tabId); + await expectWebViewUrlToBecome(() => webViewUrl(simulatorId, tabId), WEBVIEW_SAMPLE_URL); + + webViewGoForward(simulatorId, tabId); + await expectWebViewUrlToBecome(() => webViewUrl(simulatorId, tabId), WEBVIEW_DONE_URL); + }); + + test('should reload the tab and stay on the same url', async () => { + test.skip(!simulatorId, 'simulator not found'); + + const urlBeforeReload = webViewUrl(simulatorId, tabId); + webViewReload(simulatorId, tabId); + webViewWait(simulatorId, tabId, 'load'); + await expectWebViewUrlToBecome(() => webViewUrl(simulatorId, tabId), urlBeforeReload); + }); + + test('should report an error for a tab safari does not have', () => { + test.skip(!simulatorId, 'simulator not found'); + + const message = webViewCommandError(simulatorId, ['url', WEBVIEW_MISSING_ID]); + expect(message).toContain('webview not found'); + expect(message).toContain(WEBVIEW_MISSING_ID); + }); + }); + test.describe.serial('webview', () => { // the playground webview screen is the one embedded webview we control on // both platforms. a real handset would be left on an arbitrary screen. @@ -1036,6 +1126,17 @@ function webViewCommandError(simulatorId: string, args: string[]): string { throw new Error(`webview ${args.join(' ')} unexpectedly succeeded`); } +// the devicekit agent comes to the front itself when it starts, which can land +// after a launch, so the launch is repeated until the app is the one in front +function foregroundAppAfterLaunching(simulatorId: string, bundleId: string): string { + launchApp(simulatorId, bundleId); + return getForegroundApp(simulatorId).data.packageName; +} + +function visibleSafariTabs(simulatorId: string): WebViewInfo[] { + return (listWebViews(simulatorId) as WebViewInfo[]).filter(tab => tab.isVisible); +} + function listWebViews(simulatorId: string): unknown[] { return mobilecli(['webview', 'list', '--device', simulatorId]).data as unknown[]; } From e1c826bb16513fdd920394c366b8e217a3c2ce5e Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Sun, 4 Oct 2026 14:40:29 +0200 Subject: [PATCH 3/4] fix(ios): end a load-state wait on a safari tab that does not answer at its timeout --- devices/ios_webinspector.go | 15 ++++++++++----- devices/ios_webinspector_test.go | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/devices/ios_webinspector.go b/devices/ios_webinspector.go index 8f1012f8..68d2330e 100644 --- a/devices/ios_webinspector.go +++ b/devices/ios_webinspector.go @@ -646,7 +646,7 @@ func (session *webInspectorSession) attach(app inspectorApp, webviewID string, p session.tabs[senderID] = tab session.mu.Unlock() - notRunning := fmt.Errorf("webview %s did not answer within %s; it is probably a background tab, which safari does not keep running", webviewID, livenessTimeout) + notRunning := fmt.Errorf("webview %s did not answer within %s; it is probably a background tab, which safari does not keep running", webviewID, livenessTimeout.Round(time.Millisecond)) if err := session.send("_rpc_forwardSocketSetup:", tab.addressed(map[string]any{"WIRAutomaticallyPause": false})); err != nil { tab.detach() @@ -813,9 +813,14 @@ func (s safariWebViews) WebViewWaitForLoadState(webviewID, state string, timeout deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) for { + // a poll on a tab that does not answer must not outlive the wait + pollTimeout := min(time.Until(deadline), s.liveness()) + if pollTimeout <= 0 { + return fmt.Errorf("waitForLoadState timed out waiting for '%s'", state) + } // the tab does not answer while it swaps pages mid-navigation, so only // an unknown tab ends the wait early - loaded, err := s.hasLoaded(webviewID, hasLoaded) + loaded, err := s.hasLoaded(webviewID, hasLoaded, pollTimeout) if errors.Is(err, errSafariTabNotFound) { return err } @@ -832,13 +837,13 @@ func (s safariWebViews) WebViewWaitForLoadState(webviewID, state string, timeout } } -func (s safariWebViews) hasLoaded(webviewID, hasLoaded string) (bool, error) { - tab, err := s.attach(webviewID, s.liveness()) +func (s safariWebViews) hasLoaded(webviewID, hasLoaded string, timeout time.Duration) (bool, error) { + tab, err := s.attach(webviewID, timeout) if err != nil { return false, err } defer tab.detach() - loaded, err := tab.evaluate(hasLoaded, nil, s.liveness()) + loaded, err := tab.evaluate(hasLoaded, nil, timeout) return loaded == true, err } diff --git a/devices/ios_webinspector_test.go b/devices/ios_webinspector_test.go index 78f40480..6672c860 100644 --- a/devices/ios_webinspector_test.go +++ b/devices/ios_webinspector_test.go @@ -701,3 +701,20 @@ func TestSafariWaitOnAnUnknownWebViewFailsWithoutWaitingForTheTimeout(t *testing t.Fatalf("expected a not found error, got %v", err) } } + +// A tab that never answers must not make a wait outlive its timeout. +func TestSafariWaitOnATabThatDoesNotAnswerEndsAtTheTimeout(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.frozenPages[examplePage.ID] = true + + start := time.Now() + err := inspector.safari().WebViewWaitForLoadState("2", "load", 300) + + if elapsed := time.Since(start); elapsed > 1500*time.Millisecond { + t.Fatalf("the wait took %s for a 300ms timeout", elapsed) + } + expected := "waitForLoadState timed out waiting for 'load': webview 2 did not answer within 300ms; it is probably a background tab, which safari does not keep running" + if err == nil || err.Error() != expected { + t.Fatalf("expected the timeout to say why, got %v", err) + } +} From 1b944bee2286d9524bd8201b3c248cba09400f7f Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Sun, 4 Oct 2026 16:05:49 +0200 Subject: [PATCH 4/4] fix(ios): keep every step of a load-state poll within the time left --- devices/ios_webinspector.go | 8 ++++++-- devices/ios_webinspector_test.go | 30 +++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/devices/ios_webinspector.go b/devices/ios_webinspector.go index 68d2330e..34f1cb72 100644 --- a/devices/ios_webinspector.go +++ b/devices/ios_webinspector.go @@ -779,6 +779,8 @@ func (tab *inspectorTab) evaluate(expression string, args []any, timeout time.Du if err != nil { return nil, err } + // the two commands share the timeout, so that together they keep to it + deadline := time.Now().Add(timeout) // WebKit cannot await while evaluating. Whatever the function returns is // made a promise, which is handed back by reference and awaited separately. promise, err := tab.evaluateOnly("Promise.resolve("+call+")", timeout) @@ -788,7 +790,7 @@ func (tab *inspectorTab) evaluate(expression string, args []any, timeout time.Du raw, err := tab.command("Runtime.awaitPromise", map[string]any{ "promiseObjectId": promise.Result.ObjectID, "returnByValue": true, - }, timeout) + }, time.Until(deadline)) if err != nil { return nil, err } @@ -837,13 +839,15 @@ func (s safariWebViews) WebViewWaitForLoadState(webviewID, state string, timeout } } +// hasLoaded attaches to the tab and evaluates hasLoaded in it, within timeout in all. func (s safariWebViews) hasLoaded(webviewID, hasLoaded string, timeout time.Duration) (bool, error) { + deadline := time.Now().Add(timeout) tab, err := s.attach(webviewID, timeout) if err != nil { return false, err } defer tab.detach() - loaded, err := tab.evaluate(hasLoaded, nil, timeout) + loaded, err := tab.evaluate(hasLoaded, nil, time.Until(deadline)) return loaded == true, err } diff --git a/devices/ios_webinspector_test.go b/devices/ios_webinspector_test.go index 6672c860..59511efb 100644 --- a/devices/ios_webinspector_test.go +++ b/devices/ios_webinspector_test.go @@ -48,6 +48,9 @@ type fakeWebInspector struct { // frozenPages accept commands without ever answering them, as the tabs // safari is not showing do frozenPages map[uint64]bool + // slowDown delays the answer to each command of a method, or withholds it + // when the delay is negative, as a tab busy with its page does + slowDown map[string]time.Duration mu sync.Mutex calls []fakeInspectorCall @@ -63,7 +66,7 @@ var ( func startFakeWebInspector(t *testing.T, apps ...fakeInspectorApp) *fakeWebInspector { t.Helper() - inspector := &fakeWebInspector{t: t, apps: apps, pages: map[string][]fakeInspectorPage{}, frozenPages: map[uint64]bool{}} + inspector := &fakeWebInspector{t: t, apps: apps, pages: map[string][]fakeInspectorPage{}, frozenPages: map[uint64]bool{}, slowDown: map[string]time.Duration{}} inspector.answer = func(fakeInspectorCall) (any, string) { return map[string]any{}, "" } inspector.client = &webInspectorClient{dial: inspector.dial} t.Cleanup(inspector.client.close) @@ -201,6 +204,12 @@ func (f *fakeWebInspector) answerCommand(send func(string, map[string]any), send if f.frozenPages[pageID] { return } + if delay, slow := f.slowDown[command.Method]; slow { + if delay < 0 { + return + } + time.Sleep(delay) + } answerToTarget := func(reply map[string]any) { inner, _ := json.Marshal(reply) f.sendToInspector(send, sender, map[string]any{ @@ -718,3 +727,22 @@ func TestSafariWaitOnATabThatDoesNotAnswerEndsAtTheTimeout(t *testing.T) { t.Fatalf("expected the timeout to say why, got %v", err) } } + +// The steps of a poll each take their time, and together they must still end +// at the deadline of the wait. +func TestSafariWaitEndsAtTheTimeoutWhenEveryStepOfAPollIsSlow(t *testing.T) { + inspector := safariShowing(t, examplePage) + inspector.evaluatesTo(false) + inspector.slowDown["Runtime.evaluate"] = 250 * time.Millisecond + inspector.slowDown["Runtime.awaitPromise"] = -1 + + start := time.Now() + err := inspector.safari().WebViewWaitForLoadState("2", "load", 300) + + if elapsed := time.Since(start); elapsed > 700*time.Millisecond { + t.Fatalf("the wait took %s for a 300ms timeout", elapsed) + } + if err == nil || !strings.HasPrefix(err.Error(), "waitForLoadState timed out waiting for 'load'") { + t.Fatalf("expected a timeout, got %v", err) + } +}