Skip to content

Commit 495fc0b

Browse files
vijaysharmclaudegmegidish
authored
feat: device voiceover to drive VoiceOver and read what it speaks (#552)
* feat: device voiceover to drive VoiceOver and read what it speaks mobilecli device voiceover get|set on/off|speech|move next/previous/in/out and the matching device.voiceover.* JSON-RPC methods, on iOS 27+ simulators and real devices, through devicekit's new device.voiceover.*. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: forward voiceover commands for remote devices RemoteDevice didn't implement the VoiceOver methods, so a fleet iOS 27 device was refused before any request was sent. Forward them like clipboard and orientation, and assert every iOS device type implements the interface. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the voiceover set timeout under the fleet RPC deadline A remote caller's read deadline is rpc.RPCTimeout (30s); with the agent call also allowed 30s, a timed-out set reached it as a generic read timeout. 25s still covers VoiceOver's own ~15s start failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: gmegidish <gil@megidish.net>
1 parent b32e4a8 commit 495fc0b

15 files changed

Lines changed: 828 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,27 @@ Caveats per platform:
228228
| Android Emulator | Uses the emulator console. It has no way to undo a fix, so `clear` sets the location back to the coordinates an emulator boots with (the Googleplex), not to a real one |
229229
| Android Real Device | Runs an on-device agent as a mock location provider, granting the `mock_location` appop to `com.android.shell`. Some OEM ROMs ignore mock providers, and apps checking `Location.isFromMockProvider()` or Play Integrity can tell |
230230

231+
### VoiceOver ♿
232+
233+
Drive VoiceOver and read what it speaks, to check an app's accessibility labels and focus order. Needs iOS 27 or later, on a simulator or a real device.
234+
235+
```bash
236+
# Turn VoiceOver on or off, or read its state
237+
mobilecli device voiceover set --device <device-id> on
238+
mobilecli device voiceover get --device <device-id>
239+
240+
# What VoiceOver speaks for the focused element, e.g. "Add Favorites, button"
241+
mobilecli device voiceover speech --device <device-id>
242+
243+
# Move to the next or previous element and print what VoiceOver speaks there
244+
mobilecli device voiceover move --device <device-id> next
245+
mobilecli device voiceover move --device <device-id> previous
246+
247+
mobilecli device voiceover set --device <device-id> off
248+
```
249+
250+
VoiceOver stays on after mobilecli exits, so turn it off when done. While it is on, `io tap` focuses an element instead of activating it. `move in` and `move out` need VoiceOver's Navigation Style set to Groups.
251+
231252
### Supported Hardware Buttons
232253

233254
- `HOME` - Home button

‎cli/device.go‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,47 @@ var deviceFoldCmd = &cobra.Command{
8282
},
8383
}
8484

85+
var voiceOverCmd = &cobra.Command{
86+
Use: "voiceover",
87+
Short: "VoiceOver commands",
88+
Long: `Commands for turning VoiceOver on and off, reading what it speaks, and moving its cursor. Supported on iOS 27+ simulators and real devices. VoiceOver stays on after mobilecli exits, and while it is on a tap focuses an element instead of activating it.`,
89+
}
90+
91+
var voiceOverGetCmd = &cobra.Command{
92+
Use: "get",
93+
Short: "Get whether VoiceOver is on",
94+
RunE: func(cmd *cobra.Command, args []string) error {
95+
return runViaDaemon("cli.device.voiceover.get", commands.VoiceOverGetRequest{DeviceID: deviceId})
96+
},
97+
}
98+
99+
var voiceOverSetCmd = &cobra.Command{
100+
Use: "set [on|off]",
101+
Short: "Turn VoiceOver on or off",
102+
Args: cobra.ExactArgs(1),
103+
RunE: func(cmd *cobra.Command, args []string) error {
104+
return runViaDaemon("cli.device.voiceover.set", commands.VoiceOverSetRequest{DeviceID: deviceId, State: args[0]})
105+
},
106+
}
107+
108+
var voiceOverSpeechCmd = &cobra.Command{
109+
Use: "speech",
110+
Short: "Read what VoiceOver speaks for the focused element",
111+
RunE: func(cmd *cobra.Command, args []string) error {
112+
return runViaDaemon("cli.device.voiceover.speech", commands.VoiceOverSpeechRequest{DeviceID: deviceId})
113+
},
114+
}
115+
116+
var voiceOverMoveCmd = &cobra.Command{
117+
Use: "move [next|previous|in|out]",
118+
Short: "Move the VoiceOver cursor and read what it speaks",
119+
Long: `Moves the VoiceOver cursor to the next or previous element, or into or out of a container, and prints what VoiceOver speaks there. "in" and "out" need VoiceOver's Navigation Style set to Groups.`,
120+
Args: cobra.ExactArgs(1),
121+
RunE: func(cmd *cobra.Command, args []string) error {
122+
return runViaDaemon("cli.device.voiceover.move", commands.VoiceOverMoveRequest{DeviceID: deviceId, Direction: args[0]})
123+
},
124+
}
125+
85126
var bootHeaded bool
86127

87128
var deviceBootCmd = &cobra.Command{
@@ -151,12 +192,18 @@ func init() {
151192
deviceCmd.AddCommand(deviceShutdownCmd)
152193
deviceCmd.AddCommand(orientationCmd)
153194
deviceCmd.AddCommand(deviceFoldCmd)
195+
deviceCmd.AddCommand(voiceOverCmd)
154196
deviceCmd.AddCommand(settingsCmd)
155197

156198
// add orientation subcommands
157199
orientationCmd.AddCommand(orientationGetCmd)
158200
orientationCmd.AddCommand(orientationSetCmd)
159201

202+
voiceOverCmd.AddCommand(voiceOverGetCmd)
203+
voiceOverCmd.AddCommand(voiceOverSetCmd)
204+
voiceOverCmd.AddCommand(voiceOverSpeechCmd)
205+
voiceOverCmd.AddCommand(voiceOverMoveCmd)
206+
160207
// add settings subcommands
161208
settingsCmd.AddCommand(settingsApplyCmd)
162209

@@ -169,6 +216,10 @@ func init() {
169216
orientationGetCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to get orientation from")
170217
orientationSetCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to set orientation on")
171218
deviceFoldCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to fold or unfold")
219+
voiceOverGetCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to get the VoiceOver state from")
220+
voiceOverSetCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to turn VoiceOver on or off")
221+
voiceOverSpeechCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to read VoiceOver speech from")
222+
voiceOverMoveCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to move the VoiceOver cursor on")
172223
settingsApplyCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to apply settings to")
173224
settingsApplyCmd.Flags().StringVar(&settingsAnimations, "animations", "", "Toggle system animations: 'on' or 'off'")
174225
settingsApplyCmd.Flags().StringVar(&settingsAppearance, "appearance", "", "System appearance: 'light' or 'dark'")

‎commands/registry.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,5 +94,10 @@ func CLIRegistry() map[string]CLIHandler {
9494
"cli.agent.status": adapt(AgentStatusCommand),
9595
"cli.agent.install": adapt(AgentInstallCommand),
9696
"cli.agent.uninstall": adapt(AgentUninstallCommand),
97+
98+
"cli.device.voiceover.get": adapt(VoiceOverGetCommand),
99+
"cli.device.voiceover.set": adapt(VoiceOverSetCommand),
100+
"cli.device.voiceover.speech": adapt(VoiceOverSpeechCommand),
101+
"cli.device.voiceover.move": adapt(VoiceOverMoveCommand),
97102
}
98103
}

‎commands/voiceover.go‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
type VoiceOverGetRequest struct {
9+
DeviceID string `json:"deviceId"`
10+
}
11+
12+
type VoiceOverSetRequest struct {
13+
DeviceID string `json:"deviceId"`
14+
State string `json:"state"`
15+
}
16+
17+
type VoiceOverSpeechRequest struct {
18+
DeviceID string `json:"deviceId"`
19+
}
20+
21+
type VoiceOverMoveRequest struct {
22+
DeviceID string `json:"deviceId"`
23+
Direction string `json:"direction"`
24+
}
25+
26+
type VoiceOverStateResult struct {
27+
Enabled bool `json:"enabled"`
28+
}
29+
30+
type VoiceOverSpeechResult struct {
31+
Utterance string `json:"utterance"`
32+
}
33+
34+
// voiceOverController is implemented by devices whose agent drives VoiceOver
35+
// through XCUIDevice.voiceOverService: iOS 27+ simulators and real devices
36+
type voiceOverController interface {
37+
GetVoiceOver() (bool, error)
38+
SetVoiceOver(enabled bool) error
39+
VoiceOverSpeech() (string, error)
40+
MoveVoiceOver(direction string) (string, error)
41+
}
42+
43+
var voiceOverDirections = []string{"next", "previous", "in", "out"}
44+
45+
// parseVoiceOverState accepts "on" or "off"
46+
func parseVoiceOverState(state string) (bool, error) {
47+
switch strings.ToLower(state) {
48+
case "on":
49+
return true, nil
50+
case "off":
51+
return false, nil
52+
}
53+
return false, fmt.Errorf("invalid voiceover state '%s', must be 'on' or 'off'", state)
54+
}
55+
56+
func parseVoiceOverDirection(direction string) (string, error) {
57+
lower := strings.ToLower(direction)
58+
for _, d := range voiceOverDirections {
59+
if lower == d {
60+
return d, nil
61+
}
62+
}
63+
return "", fmt.Errorf("invalid voiceover direction '%s', must be 'next', 'previous', 'in' or 'out'", direction)
64+
}
65+
66+
func findVoiceOverDevice(deviceID string) (voiceOverController, string, error) {
67+
targetDevice, err := FindDeviceWithAgent(deviceID)
68+
if err != nil {
69+
return nil, "", err
70+
}
71+
72+
controller, ok := targetDevice.(voiceOverController)
73+
if !ok {
74+
return nil, "", fmt.Errorf("device %s does not support voiceover, only iOS 27+ devices and simulators do", targetDevice.ID())
75+
}
76+
77+
return controller, targetDevice.ID(), nil
78+
}
79+
80+
// VoiceOverGetCommand reports whether VoiceOver is running
81+
func VoiceOverGetCommand(req VoiceOverGetRequest) *CommandResponse {
82+
controller, id, err := findVoiceOverDevice(req.DeviceID)
83+
if err != nil {
84+
return NewErrorResponse(err)
85+
}
86+
87+
enabled, err := controller.GetVoiceOver()
88+
if err != nil {
89+
return NewErrorResponse(fmt.Errorf("failed to get voiceover state on device %s: %v", id, err))
90+
}
91+
92+
return NewSuccessResponse(VoiceOverStateResult{Enabled: enabled})
93+
}
94+
95+
// VoiceOverSetCommand turns VoiceOver on or off. VoiceOver stays on after
96+
// mobilecli exits, and while it is on a tap focuses an element instead of
97+
// activating it.
98+
func VoiceOverSetCommand(req VoiceOverSetRequest) *CommandResponse {
99+
enabled, err := parseVoiceOverState(req.State)
100+
if err != nil {
101+
return NewErrorResponse(err)
102+
}
103+
104+
controller, id, err := findVoiceOverDevice(req.DeviceID)
105+
if err != nil {
106+
return NewErrorResponse(err)
107+
}
108+
109+
if err := controller.SetVoiceOver(enabled); err != nil {
110+
return NewErrorResponse(fmt.Errorf("failed to set voiceover on device %s: %v", id, err))
111+
}
112+
113+
return NewSuccessResponse(VoiceOverStateResult{Enabled: enabled})
114+
}
115+
116+
// VoiceOverSpeechCommand returns what VoiceOver speaks for the focused element
117+
func VoiceOverSpeechCommand(req VoiceOverSpeechRequest) *CommandResponse {
118+
controller, id, err := findVoiceOverDevice(req.DeviceID)
119+
if err != nil {
120+
return NewErrorResponse(err)
121+
}
122+
123+
utterance, err := controller.VoiceOverSpeech()
124+
if err != nil {
125+
return NewErrorResponse(fmt.Errorf("failed to read voiceover speech on device %s: %v", id, err))
126+
}
127+
128+
return NewSuccessResponse(VoiceOverSpeechResult{Utterance: utterance})
129+
}
130+
131+
// VoiceOverMoveCommand moves the VoiceOver cursor and returns what it speaks
132+
func VoiceOverMoveCommand(req VoiceOverMoveRequest) *CommandResponse {
133+
direction, err := parseVoiceOverDirection(req.Direction)
134+
if err != nil {
135+
return NewErrorResponse(err)
136+
}
137+
138+
controller, id, err := findVoiceOverDevice(req.DeviceID)
139+
if err != nil {
140+
return NewErrorResponse(err)
141+
}
142+
143+
utterance, err := controller.MoveVoiceOver(direction)
144+
if err != nil {
145+
return NewErrorResponse(fmt.Errorf("failed to move voiceover %s on device %s: %v", direction, id, err))
146+
}
147+
148+
return NewSuccessResponse(VoiceOverSpeechResult{Utterance: utterance})
149+
}

‎commands/voiceover_test.go‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package commands
2+
3+
import (
4+
"testing"
5+
6+
"github.com/mobile-next/mobilecli/devices"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// every iOS device type, local or on a fleet, has to reach the agent's VoiceOver
13+
var (
14+
_ voiceOverController = devices.SimulatorDevice{}
15+
_ voiceOverController = (*devices.IOSDevice)(nil)
16+
_ voiceOverController = (*devices.RemoteDevice)(nil)
17+
)
18+
19+
func TestVoiceOverStatesParseToEnabled(t *testing.T) {
20+
for state, expected := range map[string]bool{"on": true, "off": false, "ON": true, "Off": false} {
21+
enabled, err := parseVoiceOverState(state)
22+
23+
require.NoError(t, err)
24+
assert.Equal(t, expected, enabled, "state %q", state)
25+
}
26+
}
27+
28+
func TestVoiceOverStatesOtherThanOnOrOffAreRejected(t *testing.T) {
29+
for _, state := range []string{"true", "enabled", "1", ""} {
30+
_, err := parseVoiceOverState(state)
31+
32+
assert.Error(t, err, "expected %q to be rejected", state)
33+
}
34+
}
35+
36+
func TestVoiceOverDirectionsAreNormalized(t *testing.T) {
37+
for input, expected := range map[string]string{"next": "next", "PREVIOUS": "previous", "In": "in", "out": "out"} {
38+
direction, err := parseVoiceOverDirection(input)
39+
40+
require.NoError(t, err)
41+
assert.Equal(t, expected, direction, "direction %q", input)
42+
}
43+
}
44+
45+
func TestUnknownVoiceOverDirectionsAreRejected(t *testing.T) {
46+
for _, direction := range []string{"forward", "up", ""} {
47+
_, err := parseVoiceOverDirection(direction)
48+
49+
assert.Error(t, err, "expected %q to be rejected", direction)
50+
}
51+
}
52+
53+
func TestVoiceOverSetRejectsInvalidStateBeforeLookingUpDevice(t *testing.T) {
54+
response := VoiceOverSetCommand(VoiceOverSetRequest{DeviceID: "__nope__", State: "maybe"})
55+
56+
assert.Equal(t, "error", response.Status)
57+
assert.Contains(t, response.Error, "invalid voiceover state")
58+
}
59+
60+
func TestVoiceOverMoveRejectsInvalidDirectionBeforeLookingUpDevice(t *testing.T) {
61+
response := VoiceOverMoveCommand(VoiceOverMoveRequest{DeviceID: "__nope__", Direction: "up"})
62+
63+
assert.Equal(t, "error", response.Status)
64+
assert.Contains(t, response.Error, "invalid voiceover direction")
65+
}

0 commit comments

Comments
 (0)