diff --git a/README.md b/README.md index c2de0cd4..bc599bfe 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 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 mobilecli webview list --device diff --git a/cli/webview.go b/cli/webview.go index 1ed0938e..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. 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 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 c9112258..eb427d1d 100644 --- a/devices/ios_device_webview.go +++ b/devices/ios_device_webview.go @@ -3,14 +3,43 @@ 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 + } + inspector := webInspectorOf(d.Udid, func() (io.ReadWriteCloser, error) { return dialDeviceWebInspector(d.Udid) }) + return safariWebViews{inspector: inspector}, 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 +62,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 +106,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 +130,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..34f1cb72 --- /dev/null +++ b/devices/ios_webinspector.go @@ -0,0 +1,853 @@ +package devices + +import ( + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "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. +// +// 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" + safariBundleID = "com.apple.mobilesafari" + + webInspectorWebPageType = "WIRTypeWebPage" + + safariFindTimeout = 5 * time.Second + safariCommandTimeout = 30 * 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") + errInspectorTimedOut = errors.New("web inspector did not answer in time") + errInspectorClosed = errors.New("web inspector connection closed") +) + +type webInspectorDialer func() (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) + } + conn, err := goios.ConnectToService(device, webInspectorService) + if err != nil { + return nil, fmt.Errorf("connect to %s: %w", webInspectorService, err) + } + 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 +} + +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() } + +// webInspectorClient keeps the one connection to the web inspector of a device, +// and connects again when it was lost. +type webInspectorClient struct { + dial webInspectorDialer + + mu sync.Mutex + session *webInspectorSession +} + +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 client +} + +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 := 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, + apps: map[string]inspectorApp{}, + listings: map[string]inspectorListing{}, + tabs: map[string]*inspectorTab{}, + changed: make(chan struct{}), + } + go session.readMessages() + return session +} + +func (session *webInspectorSession) isClosed() bool { + session.mu.Lock() + defer session.mu.Unlock() + return session.closed +} + +func (session *webInspectorSession) close() { + _ = 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 +} + +// 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() + for { + session.mu.Lock() + if reached() { + session.mu.Unlock() + return nil + } + if session.closed { + session.mu.Unlock() + return errInspectorClosed + } + changed := session.changed + session.mu.Unlock() + + select { + case <-changed: + case <-timer.C: + return errInspectorTimedOut + } + } +} + +func (session *webInspectorSession) readMessages() { + for { + 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"])) + } +} + +func asStringMap(value any) map[string]any { + asMap, _ := value.(map[string]any) + return asMap +} + +func (session *webInspectorSession) handle(selector string, argument map[string]any) { + session.mu.Lock() + defer session.mu.Unlock() + + switch selector { + case "_rpc_reportConnectedApplicationList:": + session.apps = map[string]inspectorApp{} + for _, description := range asStringMap(argument["WIRApplicationDictionaryKey"]) { + session.rememberApp(asStringMap(description)) + } + 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"])), + } + case "_rpc_applicationSentData:": + destination, _ := argument["WIRDestinationKey"].(string) + data, _ := argument["WIRMessageDataKey"].([]byte) + if tab, attached := session.tabs[destination]; attached { + tab.handle(data) + } + default: + return + } + session.notifyChange() +} + +func (session *webInspectorSession) rememberApp(description map[string]any) { + id, _ := description["WIRApplicationIdentifierKey"].(string) + if id == "" { + return + } + bundleID, _ := description["WIRApplicationBundleIdentifierKey"].(string) + name, _ := description["WIRApplicationNameKey"].(string) + session.apps[id] = inspectorApp{id: id, bundleID: bundleID, name: name} +} + +// 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 + } + 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 +} + +// 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 safari, err +} + +// 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 { + 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"` + 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"` +} + +// 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 + } + case "Target.didCommitProvisionalTarget": + if message.Params.OldTargetID == tab.targetID { + tab.targetID = message.Params.NewTargetID + } + 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 + } + 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 + } + } +} + +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, 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) + } + envelope, err := json.Marshal(map[string]any{ + "id": envelopeID, + "method": "Target.sendMessageToTarget", + "params": map[string]any{"targetId": 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 + } + + 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) + } + 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 +// 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"` +} + +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 +} + +// 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 { + 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 +} + +// 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 + } + return safariFindTimeout +} + +func (s safariWebViews) liveness() time.Duration { + if s.livenessTimeout > 0 { + return s.livenessTimeout + } + return safariLivenessTimeout +} + +// 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 + } + 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) +} + +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 + } + 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.Round(time.Millisecond)) + + 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), + URL: page.url, + Title: page.title, + BundleID: safariBundleID, + ProcessName: safari.name, + } + wg.Add(1) + go func(webview *WebViewInfo, pageID uint64) { + defer wg.Done() + webview.IsVisible = session.isVisible(safari, webview.ID, pageID) + }(&webviews[i], page.id) + } + wg.Wait() + + return webviews, nil +} + +// 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.liveness()) + if err != nil { + return err + } + defer tab.detach() + + expression, err := functionCall(body, args) + if err != nil { + return err + } + _, err = tab.evaluateOnly(expression, safariCommandTimeout) + 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.liveness()) + if err != nil { + return err + } + defer tab.detach() + + _, err = tab.command("Page.reload", map[string]any{}, safariCommandTimeout) + 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) { + tab, err := s.attach(webviewID, s.liveness()) + if err != nil { + return nil, err + } + 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 + } + // 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) + if err != nil { + return nil, err + } + raw, err := tab.command("Runtime.awaitPromise", map[string]any{ + "promiseObjectId": promise.Result.ObjectID, + "returnByValue": true, + }, time.Until(deadline)) + 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 { + // 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, pollTimeout) + if errors.Is(err, errSafariTabNotFound) { + return err + } + 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) + } +} + +// 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, time.Until(deadline)) + return loaded == true, err +} diff --git a/devices/ios_webinspector_test.go b/devices/ios_webinspector_test.go new file mode 100644 index 00000000..59511efb --- /dev/null +++ b/devices/ios_webinspector_test.go @@ -0,0 +1,748 @@ +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) + // 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 + connections []net.Conn + client *webInspectorClient +} + +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{}, 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) + 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{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 +} + +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) + + f.sendToInspector(send, sender, map[string]any{"id": outer.ID, "result": map[string]any{}}) + 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{ + "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() + + 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}} + } + answerToTarget(reply) +} + +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) + } +} + +// 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) + inspector.frozenPages[examplePage.ID] = true + safari := inspector.safari() + safari.livenessTimeout = 200 * time.Millisecond + + _, err := safari.WebViewEvaluate("2", "return 1", nil) + + 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) { + 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) + } +} + +// 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) + } +} + +// 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) + } +} 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 f2e9dbb2..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. 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 iOS, returns its tabs instead. Chrome is not included.", "params": [ { "name": "deviceId", diff --git a/docs/openrpc.md b/docs/openrpc.md index 9fc4ed27..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. 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 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[]; }