diff --git a/Makefile b/Makefile index c3354786..9e7b926d 100644 --- a/Makefile +++ b/Makefile @@ -40,5 +40,10 @@ setup: @git config core.hooksPath .githooks @echo "Done! Pre-commit hooks are now active." +readme-help: + @out=$$(mktemp /tmp/go-ios.XXXXXX); trap 'rm -f "$$out"' EXIT INT TERM; \ + perl -pe'BEGIN{$$/=q()} if($$/=~s/begin/end/){<>;$$_.="\n\n```text\n".`go run . --help`."```\n\n$$/"}' README.md > "$$out" && \ + mv "$$out" README.md + # Phony targets -.PHONY: build run up lint setup +.PHONY: build run up lint setup readme-help diff --git a/README.md b/README.md index 1737c8e8..a8b67ca5 100644 --- a/README.md +++ b/README.md @@ -8,168 +8,153 @@ Welcome 👋 -`npm install -g go-ios` can be used to get going. Run `ios --help` after the installation for details. -For iOS 17+ devices you need to run `sudo ios tunnel start` for go ios to work. This will start a tunnel daemon. +`npm install -g go-ios` can be used to get going. Run `ios --help` after the installation for details. +For iOS 17+ devices you need to run `sudo ios tunnel start` for go ios to work. This will start a tunnel daemon. To make this work on Windows, download the latest wintun.dll from here `https://git.zx2c4.com/wintun` and copy it to `C:/Windows/system32` -The goal of this project is to provide a stable and production ready opensource solution to automate iOS device on Linux, Windows and Mac OS X. I am delighted to announce that a few companies including [headspin.io](https://www.headspin.io/) and [Sauce Labs](https://saucelabs.com/) will use or are using go-iOS. +The goal of this project is to provide a stable and production ready opensource solution to automate iOS device on Linux, Windows and Mac OS X. I am delighted to announce that a few companies including [headspin.io](https://www.headspin.io/) and [Sauce Labs](https://saucelabs.com/) will use or are using go-iOS. Follow my twitter for updates or check out my medium blog: https://daniel-paulus.medium.com/ -If you are interested in using go-iOS please get in touch on LinkedIn, Twitter or the Github discussions above, I always love to hear what people are doing with it. +If you are interested in using go-iOS please get in touch on LinkedIn, Twitter or the Github discussions above, I always love to hear what people are doing with it. If you miss something your Mac can do but go-iOS can't, just request a feature in the issues tab. + # New REST-API -Go-iOS is getting an experimental REST-API check it out [https://github.com/danielpaulus/go-ios/tree/main/restapi](https://github.com/danielpaulus/go-ios/tree/main/restapi) + +Go-iOS is getting an experimental REST-API check it out [https://github.com/danielpaulus/go-ios/tree/main/restapi](https://github.com/danielpaulus/go-ios/tree/main/restapi) # Design principles: -1. Using golang to compile static, small and fast binaries for all platforms very easily. - - *Build Manual*: Install golang and run `go build` + +1. Using golang to compile static, small and fast binaries for all platforms very easily. + + _Build Manual_: Install golang and run `go build` + 2. All output as JSON so you can easily use go-iOS from any other programming language 3. Everything is a module, you can use go-iOS in golang projects as a module dependency easily # Features: - Most notable: - - Install apps zipped as ipa or unzipped from their .app folder `ios install --path=/path/to/app` - - Run XCTests including WebdriverAgent on Linux, Windows and Mac - - Start and Stop apps - - Use a debug proxy to reverse engineer every tool Mac OSX has, so you can contrib to go-ios or build your own - - Pair devices without manual tapping on a popup - - Install developer images automatically by running `ios image auto` - - Set thermal states and network emulation on the device with the `ios devicestate` command -All features: +Most notable: + +- Install apps zipped as ipa or unzipped from their .app folder `ios install --path=/path/to/app` +- Run XCTests including WebdriverAgent on Linux, Windows and Mac +- Start and Stop apps +- Use a debug proxy to reverse engineer every tool Mac OSX has, so you can contrib to go-ios or build your own +- Pair devices without manual tapping on a popup +- Install developer images automatically by running `ios image auto` +- Set thermal states and network emulation on the device with the `ios devicestate` command + +Help: +```bash +ios --help +ios help +ios --help ``` -Options: - -v --verbose Enable Debug Logging. - -t --trace Enable Trace Logging (dump every message). - --nojson Disable JSON output - --pretty Pretty-print JSON command output - -h --help Show this screen. - --udid= UDID of the device. - --tunnel-info-port= When go-ios is used to manage tunnels for iOS 17+ it exposes them on an HTTP-API for localhost (default port: 28100) - --address= Address of the device on the interface. This parameter is optional and can be set if a tunnel created by MacOS needs to be used. - > To get this value run "log stream --debug --info --predicate 'eventMessage LIKE "*Tunnel established*" OR eventMessage LIKE "*for server port*"'", - > connect a device and open Xcode - --rsd-port= Port of remote service discovery on the device through the tunnel - > This parameter is similar to '--address' and can be obtained by the same log filter - --proxyurl= Set this if you want go-ios to use a http proxy for outgoing requests, like for downloading images or contacting Apple during device activation. - > A simple format like: "http://PROXY_LOGIN:PROXY_PASS@proxyIp:proxyPort" works. Otherwise use the HTTP_PROXY system env var. - --userspace-port= Optional. Set this if you run a command supplying rsd-port and address and your device is using userspace tunnel - -The commands work as following: - The default output of all commands is JSON. Should you prefer human readable outout, specify the --nojson option with your command. - By default, the first device found will be used for a command unless you specify a --udid=some_udid switch. - Specify -v for debug logging and -t for dumping every message. - - ios --version | version [options] Prints the version - ios -h | --help Prints this screen. - ios activate [options] Activate a device - ios apps [--system] [--all] [--list] [--filesharing] Retrieves a list of installed applications. --system prints out preinstalled system apps. --all prints all apps, including system, user, and hidden apps. --list only prints bundle ID, bundle name and version number. --filesharing only prints apps which enable documents sharing. - ios assistivetouch (enable | disable | toggle | get) [--force] [options] Enables, disables, toggles, or returns the state of the "AssistiveTouch" software home-screen button. iOS 11+ only (Use --force to try on older versions). - ios ax [--font=] [options] Access accessibility inspector features. - ios batterycheck [options] Prints battery info. - ios batteryregistry [options] Prints battery registry stats like Temperature, Voltage. - ios crash cp [options] copy "file pattern" to the target dir. Ex.: 'ios crash cp "*" "./crashes"' - ios crash ls [] [options] run "ios crash ls" to get all crashreports in a list, - > or use a pattern like 'ios crash ls "*ips*"' to filter - ios crash rm [options] remove file pattern from dir. Ex.: 'ios crash rm "." "*"' to delete everything - ios date [options] Prints the device date - ios debug [--stop-at-entry] Start debug with lldb - ios devicename [options] Prints the devicename - ios devicestate enable [options] Enables a profile with ids (use the list command to see options). It will only stay active until the process is terminated. - > Ex. "ios devicestate enable SlowNetworkCondition SlowNetwork3GGood" - ios devicestate list [options] Prints a list of all supported device conditions, like slow network, gpu etc. - ios devmode (enable | get | reveal) [--enable-post-restart] [options] Enable developer mode on the device, check if it is enabled, or reveal the Developer Mode toggle in Settings. Can also completely finalize developer mode setup after device is restarted. - ios diagnostics list [options] List diagnostic infos - ios diskspace [options] Prints disk space info. - ios dproxy [--binary] [--mode=] [--iface=] [options] Starts the reverse engineering proxy server. - > It dumps every communication in plain text so it can be implemented easily. - > Use "sudo launchctl unload -w /Library/Apple/System/Library/LaunchDaemons/com.apple.usbmuxd.plist" - > to stop usbmuxd and load to start it again should the proxy mess up things. - > The --binary flag will dump everything in raw binary without any decoding. - ios erase [--force] [options] Erase the device. It will prompt you to input y+Enter unless --force is specified. - ios forward [options] Similar to iproxy, forward a TCP connection to the device. - ios fsync [--app=bundleId] [options] (pull | push) --srcPath= --dstPath= Pull or Push file from srcPath to dstPath. - ios fsync [--app=bundleId] [options] (rm [--r] | tree | mkdir) --path= Remove | treeview | mkdir in target path. --r used alongside rm will recursively remove all files and directories from target path. - ios httpproxy [] [] --p12file= [--password=] set global http proxy on supervised device. Use the password argument or set the environment variable 'P12_PASSWORD' - > Specify proxy password either as argument or using the environment var: PROXY_PASSWORD - > Use p12 file and password for silent installation on supervised devices. - ios httpproxy remove [options] Removes the global http proxy config. Only works with http proxies set by go-ios! - ios image auto [--basedir=] [options] Automatically download correct dev image from the internets and mount it. - > You can specify a dir where images should be cached. - > The default is the current dir. - ios image list [options] List currently mounted developers images' signatures - ios image mount [--path=] [options] Mount a image from - > For iOS 17+ (personalized developer disk images) must point to the "Restore" directory inside the developer disk - ios image unmount [options] Unmount developer disk image - ios info [display | lockdown] [options] Prints a dump of device information from the given source. - ios install --path= [options] Specify a .app folder or an installable ipa file that will be installed. - ios instruments notifications [options] Listen to application state notifications - ios ip [options] Uses the live pcap iOS packet capture to wait until it finds one that contains the IP address of the device. - > It relies on the MAC address of the WiFi adapter to know which is the right IP. - > You have to disable the "automatic wifi address"-privacy feature of the device for this to work. - > If you wanna speed it up, open apple maps or similar to force network traffic. - > f.ex. "ios launch com.apple.Maps" - ios kill ( | --pid= | --process=) [options] Kill app with the specified bundleID, process id, or process name on the device. - ios lang [--setlocale=] [--setlang=] [options] Sets or gets the Device language. ios lang will print the current language and locale, as well as a list of all supported langs and locales. - ios launch [--wait] [--kill-existing] [--arg=]... [--env=]... [options] Launch app with the bundleID on the device. Get your bundle ID from the apps command. --wait keeps the connection open if you want logs. - ios list [options] [--details] Prints a list of all connected device's udids. If --details is specified, it includes version, name and model of each device. - ios listen [options] Keeps a persistent connection open and notifies about newly connected or disconnected devices. - ios lockdown get [] [--domain=] [options] Query lockdown values. Without arguments returns all values. Specify a key to get a specific value. - > Use --domain to query from a specific domain (e.g., com.apple.disk_usage, com.apple.PurpleBuddy). - > Examples: "ios lockdown get DeviceName", "ios lockdown get --domain=com.apple.PurpleBuddy" - ios memlimitoff (--process=) [options] Waives memory limit set by iOS (For instance a Broadcast Extension limit is 50 MB). - ios mobilegestalt ... [--plist] [options] Lets you query mobilegestalt keys. Standard output is json but if desired you can get - > it in plist format by adding the --plist param. - > Ex.: "ios mobilegestalt MainScreenCanvasSizes ArtworkTraits --plist" - ios pair [--p12file=] [--password=] [options] Pairs the device. If the device is supervised, specify the path to the p12 file - > to pair without a trust dialog. Specify the password either with the argument or - > by setting the environment variable 'P12_PASSWORD' - ios pcap [options] [--pid=] [--process=] Starts a pcap dump of network traffic, use --pid or --process to filter specific processes. - ios prepare [--skip-all] [--skip=]... [--env=]...[options] runs WebDriverAgents - > specify runtime args and env vars like --env ENV_1=something --env ENV_2=else and --arg ARG1 --arg ARG2 - ios runxctest [--xctestrun-file-path=] [--log-output=] [options] Run a XCTest. The --xctestrun-file-path specifies the path to the .xctestrun file to configure the test execution. - > If you provide '-' as log output, it prints resuts to stdout. - ios screenshot [options] [--output=] [--stream] [--port=] Takes a screenshot and writes it to the current dir or to If --stream is supplied it - > starts an mjpeg server at 0.0.0.0:3333. Use --port to set another port. - ios setlocation [options] [--lat=] [--lon=] Updates the location of the device to the provided by latitude and longitude coordinates. Example: setlocation --lat=40.730610 --lon=-73.935242 - ios setlocationgpx [options] [--gpxfilepath=] Updates the location of the device based on the data in a GPX file. Example: setlocationgpx --gpxfilepath=/home/username/location.gpx - ios syslog [--parse] [options] Prints a device's log output, Use --parse to parse the fields from the log - ios sysmontap Get system stats like MEM, CPU - ios timeformat (24h | 12h | toggle | get) [--force] [options] Sets, or returns the state of the "time format". iOS 11+ only (Use --force to try on older versions). - ios tunnel ls List currently started tunnels. Use --enabletun to activate using TUN devices rather than user space network. Requires sudo/admin shells. - ios tunnel start [options] [--pair-record-path=] [--enabletun] Creates a tunnel connection to the device. If the device was not paired with the host yet, device pairing will also be executed. - > On systems with System Integrity Protection enabled the argument '--pair-record-path=default' can be used to point to /var/db/lockdown/RemotePairing/user_501. - > If nothing is specified, the current dir is used for the pair record. - > This command needs to be executed with admin privileges. - > (On MacOS the process 'remoted' must be paused before starting a tunnel is possible 'sudo pkill -SIGSTOP remoted', and 'sudo pkill -SIGCONT remoted' to resume) - ios voiceover (enable | disable | toggle | get) [--force] [options] Enables, disables, toggles, or returns the state of the "VoiceOver" software home-screen button. iOS 11+ only (Use --force to try on older versions). - ios zoom (enable | disable | toggle | get) [--force] [options] Enables, disables, toggles, or returns the state of the "ZoomTouch" software home-screen button. iOS 11+ only (Use --force to try on older versions). + + +```text +go-ios local-build + +Cross-platform iOS automation CLI. + +Usage: + ios [--help] + ios help [...] + ios [...] + +Global options: + -h, --help Show help. + -v, --verbose Enable debug logging. + -t, --trace Enable trace logging. + --nojson Disable JSON output. + --pretty Pretty-print JSON output. + --udid= Target a specific device. + --tunnel-info-port= Tunnel info API port (default 28100). + --address= Device tunnel address. + --rsd-port= Device tunnel RSD port. + --proxyurl= Outbound HTTP proxy URL. + --userspace-port= Userspace tunnel port. + +Commands: + activate Activate a device. + apps List installed applications. + assistivetouch Manage AssistiveTouch state. + ax Accessibility inspector features. + batterycheck Battery information. + batteryregistry Battery registry metrics. + crash cp Copy crash reports. + crash ls List crash reports. + crash rm Remove crash reports. + date Print device date. + debug Start LLDB debug session. + devicename Print device name. + devicestate enable Enable device condition profile. + devicestate list List device condition profiles. + devmode Manage developer mode. + diagnostics list List diagnostics. + diskspace Print disk usage. + dproxy Start debug proxy. + erase Erase device. + file ls List files in app/group/temp/crash container. + file pull Pull file from device. + file push Push file to device. + forward Forward host port to device. + fsync App container file sync operations. + httpproxy Install global HTTP proxy profile. + httpproxy remove Remove go-ios HTTP proxy profile. + image auto Auto-download and mount developer image. + image list List mounted developer images. + image mount Mount developer image. + image unmount Unmount developer image. + info Dump device info. + install Install app bundle or IPA. + instruments notifications Stream app state notifications. + ip Detect device IP from packet capture. + kill Kill app by bundle ID, PID, or process. + lang Read or set device language and locale. + launch Launch app by bundle ID. + list List connected devices. + listen Listen for device connect/disconnect. + lockdown get Query lockdown values. + memlimitoff Disable process memory limit. + mobilegestalt Query mobilegestalt keys. + ostrace Stream os_trace_relay logs. + pair Pair host with device. + pcap Capture network packets. + prepare Prepare device for automation. + prepare cloudconfig Print cloud configuration. + prepare create-cert Create supervision certificate. + prepare printskip Print prepare skip options. + profile add Install profile on device. + profile list List installed profiles. + profile remove Remove installed profile. + ps List running processes. + readpair Dump pair record. + reboot Reboot device. + resetax Reset accessibility settings. + resetlocation Reset simulated location. + rsd ls List RSD services. + runtest Run XCUITest bundles. + runwda Run WebDriverAgent. + runxctest Run XCTest from .xctestrun file. + screenshot Capture screenshot or stream MJPEG. + setlocation Set simulated location coordinates. + setlocationgpx Set simulated location from GPX. + syslog Stream device syslog. + sysmontap Stream CPU and memory metrics. + timeformat Manage time format setting. + tunnel ls List running tunnels. + tunnel start Start iOS 17+ tunnel and pair if needed. + tunnel stopagent Stop tunnel agent. + uninstall Uninstall app by bundle ID. + version Print version. + voiceover Manage VoiceOver state. + zoom Manage Zoom state. + +Run 'ios help ' or 'ios --help' for command details. ``` + + diff --git a/go.mod b/go.mod index 15f0a9f0..d0399363 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/danielpaulus/go-ios -go 1.22.0 +go 1.22 toolchain go1.22.5 @@ -51,5 +51,5 @@ require ( golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 ) diff --git a/internal/clihelp/help.go b/internal/clihelp/help.go new file mode 100644 index 00000000..5c5c3427 --- /dev/null +++ b/internal/clihelp/help.go @@ -0,0 +1,382 @@ +package clihelp + +import ( + _ "embed" + "fmt" + "io" + "slices" + "sort" + "strings" + "unicode/utf8" + + "gopkg.in/yaml.v3" +) + +//go:embed help.yaml +var embeddedHelp []byte + +type Option struct { + Flag string `yaml:"flag"` + Description string `yaml:"description"` +} + +type Command struct { + Path string `yaml:"path"` + Usage string `yaml:"usage"` + Summary string `yaml:"summary"` + Aliases []string `yaml:"aliases"` +} + +type Catalog struct { + Program string `yaml:"program"` + Intro string `yaml:"intro"` + Usage []string `yaml:"usage"` + GlobalOptions []Option `yaml:"global_options"` + Commands []Command `yaml:"commands"` + + commandsByPath map[string]Command + aliasesToPath map[string]string + commandPathList []string + globalBoolFlags map[string]struct{} + globalValFlags map[string]struct{} + helpFlags map[string]struct{} +} + +func Load() (*Catalog, error) { + return parseCatalogYAML(embeddedHelp) +} + +func parseCatalogYAML(data []byte) (*Catalog, error) { + var c Catalog + if err := yaml.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("invalid help yaml: %w", err) + } + if c.Program == "" { + return nil, fmt.Errorf("invalid help yaml: missing program") + } + if len(c.Usage) == 0 { + return nil, fmt.Errorf("invalid help yaml: missing usage") + } + if len(c.Commands) == 0 { + return nil, fmt.Errorf("invalid help yaml: missing commands") + } + + c.commandsByPath = make(map[string]Command, len(c.Commands)) + c.aliasesToPath = make(map[string]string) + c.commandPathList = make([]string, 0, len(c.Commands)) + c.globalBoolFlags = map[string]struct{}{} + c.globalValFlags = map[string]struct{}{} + c.helpFlags = map[string]struct{}{} + for _, option := range c.GlobalOptions { + tokens := splitFlagTokens(option.Flag) + isHelpOption := false + for _, token := range tokens { + if strings.Contains(token, "help") { + isHelpOption = true + break + } + } + for _, token := range tokens { + isValueFlag := strings.Contains(token, "=") + base := token + if isValueFlag { + base = token[:strings.Index(token, "=")] + c.globalValFlags[base] = struct{}{} + } else { + c.globalBoolFlags[token] = struct{}{} + } + if isHelpOption { + c.helpFlags[base] = struct{}{} + } + } + } + for _, cmd := range c.Commands { + path := normalizePath(cmd.Path) + if path == "" { + return nil, fmt.Errorf("invalid help yaml: command path is empty") + } + if cmd.Usage == "" { + return nil, fmt.Errorf("invalid help yaml: missing usage for %q", path) + } + if _, ok := c.commandsByPath[path]; ok { + return nil, fmt.Errorf("invalid help yaml: duplicate command path %q", path) + } + cmd.Path = path + c.commandsByPath[path] = cmd + c.commandPathList = append(c.commandPathList, path) + for _, alias := range cmd.Aliases { + normalizedAlias := normalizePath(alias) + if normalizedAlias == "" { + return nil, fmt.Errorf("invalid help yaml: empty alias for %q", path) + } + if existing, ok := c.aliasesToPath[normalizedAlias]; ok && existing != path { + return nil, fmt.Errorf("invalid help yaml: duplicate alias %q", normalizedAlias) + } + c.aliasesToPath[normalizedAlias] = path + } + } + sort.Slice(c.commandPathList, func(i, j int) bool { + left := strings.Count(c.commandPathList[i], " ") + right := strings.Count(c.commandPathList[j], " ") + if left == right { + return c.commandPathList[i] < c.commandPathList[j] + } + return left > right + }) + return &c, nil +} + +func (c *Catalog) ResolveHelp(args []string) (topic string, handled bool) { + if len(args) == 0 { + return "", true + } + tokens := c.stripGlobalArgs(args) + if len(tokens) == 0 { + return "", true + } + + if tokens[0] == "help" { + rest := tokens[1:] + if len(rest) > 0 && rest[0] == "--" { + rest = rest[1:] + } + path, hasTopic, _ := c.resolvePathFromTopic(rest) + if !hasTopic { + return "", true + } + return path, true + } + + if !slices.ContainsFunc(tokens, c.isHelpFlag) { + return "", false + } + pathTokens := make([]string, 0, len(tokens)) + for _, token := range tokens { + if c.isHelpFlag(token) { + continue + } + pathTokens = append(pathTokens, token) + } + path, hasTopic, _ := c.resolvePathFromArgs(pathTokens) + if !hasTopic { + return "", true + } + return path, true +} + +func (c *Catalog) Render(version, topic string) (string, error) { + if topic == "" { + return c.renderGlobal(version), nil + } + command, ok := c.findCommand(topic) + if !ok { + return "", fmt.Errorf("unknown help topic %q\nRun 'ios --help' to list available commands.", topic) + } + + var b strings.Builder + fmt.Fprintf(&b, "%s %s\n\n", c.Program, version) + fmt.Fprintf(&b, "Command: %s\n", command.Path) + if command.Summary != "" { + fmt.Fprintf(&b, "%s\n\n", command.Summary) + } else { + b.WriteString("\n") + } + b.WriteString("Usage:\n") + fmt.Fprintf(&b, " %s\n\n", command.Usage) + b.WriteString("Global options:\n") + writeAlignedOptions(&b, c.GlobalOptions) + return b.String(), nil +} + +func (c *Catalog) WriteHelp(args []string, version string, stdout, stderr io.Writer) (handled bool, exitCode int) { + topic, handled := c.ResolveHelp(args) + if !handled { + return false, 0 + } + out, err := c.Render(version, topic) + if err != nil { + _, _ = fmt.Fprintln(stderr, err.Error()) + return true, 1 + } + _, _ = io.WriteString(stdout, out) + return true, 0 +} + +func (c *Catalog) renderGlobal(version string) string { + var b strings.Builder + fmt.Fprintf(&b, "%s %s\n\n", c.Program, version) + if c.Intro != "" { + b.WriteString(c.Intro) + b.WriteString("\n\n") + } + b.WriteString("Usage:\n") + for _, line := range c.Usage { + fmt.Fprintf(&b, " %s\n", line) + } + b.WriteString("\nGlobal options:\n") + writeAlignedOptions(&b, c.GlobalOptions) + b.WriteString("\nCommands:\n") + maxWidth := c.maxCommandPathWidth() + for _, command := range c.sortedCommandsForDisplay() { + summary := command.Summary + if summary == "" { + summary = "See command help." + } + writeAlignedPair(&b, command.Path, summary, maxWidth) + } + b.WriteString("\nRun 'ios help ' or 'ios --help' for command details.\n") + return b.String() +} + +func (c *Catalog) sortedCommandsForDisplay() []Command { + commands := make([]Command, len(c.Commands)) + copy(commands, c.Commands) + sort.Slice(commands, func(i, j int) bool { + return commands[i].Path < commands[j].Path + }) + return commands +} + +func (c *Catalog) resolvePathFromTopic(tokens []string) (string, bool, bool) { + if len(tokens) == 0 { + return "", false, true + } + filtered := make([]string, 0, len(tokens)) + for _, token := range tokens { + if strings.HasPrefix(token, "-") { + continue + } + filtered = append(filtered, token) + } + if len(filtered) == 0 { + return "", false, true + } + return c.resolvePathFromArgs(filtered) +} + +func (c *Catalog) resolvePathFromArgs(tokens []string) (string, bool, bool) { + candidateTokens := make([]string, 0, len(tokens)) + for _, token := range tokens { + if strings.HasPrefix(token, "-") { + continue + } + candidateTokens = append(candidateTokens, token) + } + if len(candidateTokens) == 0 { + return "", false, true + } + for _, path := range c.commandPathList { + commandTokens := strings.Fields(path) + if len(commandTokens) > len(candidateTokens) { + continue + } + matched := true + for i := range commandTokens { + if commandTokens[i] != candidateTokens[i] { + matched = false + break + } + } + if matched { + return path, true, true + } + } + return normalizePath(strings.Join(candidateTokens, " ")), true, false +} + +func (c *Catalog) findCommand(path string) (Command, bool) { + normalized := normalizePath(path) + if command, ok := c.commandsByPath[normalized]; ok { + return command, true + } + if target, ok := c.aliasesToPath[normalized]; ok { + command, ok := c.commandsByPath[target] + return command, ok + } + return Command{}, false +} + +func normalizePath(path string) string { + return strings.Join(strings.Fields(strings.TrimSpace(path)), " ") +} + +func splitFlagTokens(flag string) []string { + parts := strings.Split(flag, ",") + tokens := make([]string, 0, len(parts)) + for _, part := range parts { + token := strings.TrimSpace(part) + if token != "" { + tokens = append(tokens, token) + } + } + return tokens +} + +func (c *Catalog) isHelpFlag(token string) bool { + _, ok := c.helpFlags[token] + return ok +} + +func (c *Catalog) stripGlobalArgs(args []string) []string { + result := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + if _, ok := c.globalBoolFlags[arg]; ok { + if c.isHelpFlag(arg) { + result = append(result, arg) + } + continue + } + if _, ok := c.globalValFlags[arg]; ok { + if i+1 < len(args) { + i++ + } + continue + } + skipped := false + for flag := range c.globalValFlags { + if strings.HasPrefix(arg, flag+"=") { + skipped = true + break + } + } + if skipped { + continue + } + result = append(result, arg) + } + return result +} + +func writeAlignedOptions(b *strings.Builder, options []Option) { + maxWidth := 0 + for _, option := range options { + width := utf8.RuneCountInString(option.Flag) + if width > maxWidth { + maxWidth = width + } + } + for _, option := range options { + writeAlignedPair(b, option.Flag, option.Description, maxWidth) + } +} + +func (c *Catalog) maxCommandPathWidth() int { + maxWidth := 0 + for _, command := range c.Commands { + width := utf8.RuneCountInString(command.Path) + if width > maxWidth { + maxWidth = width + } + } + return maxWidth +} + +func writeAlignedPair(b *strings.Builder, left, right string, maxWidth int) { + padding := max(maxWidth-utf8.RuneCountInString(left), 0) + b.WriteString(" ") + b.WriteString(left) + b.WriteString(strings.Repeat(" ", padding+2)) + b.WriteString(right) + b.WriteString("\n") +} diff --git a/internal/clihelp/help.yaml b/internal/clihelp/help.yaml new file mode 100644 index 00000000..73e41c21 --- /dev/null +++ b/internal/clihelp/help.yaml @@ -0,0 +1,252 @@ +program: go-ios +intro: Cross-platform iOS automation CLI. +usage: + - ios [--help] + - ios help [...] + - ios [...] +global_options: + - flag: -h, --help + description: Show help. + - flag: -v, --verbose + description: Enable debug logging. + - flag: -t, --trace + description: Enable trace logging. + - flag: --nojson + description: Disable JSON output. + - flag: --pretty + description: Pretty-print JSON output. + - flag: --udid= + description: Target a specific device. + - flag: --tunnel-info-port= + description: Tunnel info API port (default 28100). + - flag: --address= + description: Device tunnel address. + - flag: --rsd-port= + description: Device tunnel RSD port. + - flag: --proxyurl= + description: Outbound HTTP proxy URL. + - flag: --userspace-port= + description: Userspace tunnel port. +commands: + - path: activate + usage: ios activate [options] + summary: Activate a device. + - path: apps + usage: ios apps [--system] [--all] [--list] [--filesharing] [options] + summary: List installed applications. + - path: assistivetouch + usage: ios assistivetouch (enable | disable | toggle | get) [--force] [options] + summary: Manage AssistiveTouch state. + - path: ax + usage: ios ax [--font=] [options] + summary: Accessibility inspector features. + - path: batterycheck + usage: ios batterycheck [options] + summary: Battery information. + - path: batteryregistry + usage: ios batteryregistry [options] + summary: Battery registry metrics. + - path: crash cp + usage: ios crash cp [options] + summary: Copy crash reports. + - path: crash ls + usage: ios crash ls [] [options] + summary: List crash reports. + - path: crash rm + usage: ios crash rm [options] + summary: Remove crash reports. + - path: date + usage: ios date [options] + summary: Print device date. + - path: debug + usage: ios debug [options] [--stop-at-entry] + summary: Start LLDB debug session. + - path: devicename + usage: ios devicename [options] + summary: Print device name. + - path: devicestate enable + usage: ios devicestate enable [options] + summary: Enable device condition profile. + - path: devicestate list + usage: ios devicestate list [options] + summary: List device condition profiles. + - path: devmode + usage: ios devmode (enable | get | reveal) [--enable-post-restart] [options] + summary: Manage developer mode. + - path: diagnostics list + usage: ios diagnostics list [options] + summary: List diagnostics. + - path: diskspace + usage: ios diskspace [options] + summary: Print disk usage. + - path: dproxy + usage: ios dproxy [--binary] [--mode=] [--iface=] [options] + summary: Start debug proxy. + - path: erase + usage: ios erase [--force] [options] + summary: Erase device. + - path: file ls + usage: ios file ls [--app= | --app-group= | --crash | --temp] [--path=] [options] + summary: List files in app/group/temp/crash container. + - path: file pull + usage: ios file pull [--app= | --app-group= | --crash | --temp] --remote= --local= [options] + summary: Pull file from device. + - path: file push + usage: ios file push [--app= | --app-group= | --crash | --temp] --local= --remote= [options] + summary: Push file to device. + - path: forward + usage: ios forward [options] [ ] [--port=]... + summary: Forward host port to device. + - path: fsync + usage: ios fsync [--app=bundleId] [options] (pull | push | rm [--r] | tree | mkdir) --srcPath= --dstPath= --path= + summary: App container file sync operations. + - path: httpproxy + usage: ios httpproxy [] [] --p12file= --password= [options] + summary: Install global HTTP proxy profile. + - path: httpproxy remove + usage: ios httpproxy remove [options] + summary: Remove go-ios HTTP proxy profile. + - path: image auto + usage: ios image auto [--basedir=] [options] + summary: Auto-download and mount developer image. + - path: image list + usage: ios image list [options] + summary: List mounted developer images. + - path: image mount + usage: ios image mount [--path=] [options] + summary: Mount developer image. + - path: image unmount + usage: ios image unmount [options] + summary: Unmount developer image. + - path: info + usage: ios info [display | lockdown] [options] + summary: Dump device info. + - path: install + usage: ios install --path= [options] + summary: Install app bundle or IPA. + - path: instruments notifications + usage: ios instruments notifications [options] + summary: Stream app state notifications. + - path: ip + usage: ios ip [options] + summary: Detect device IP from packet capture. + - path: kill + usage: ios kill ( | --pid= | --process=) [options] + summary: Kill app by bundle ID, PID, or process. + - path: lang + usage: ios lang [--setlocale=] [--setlang=] [options] + summary: Read or set device language and locale. + - path: launch + usage: ios launch [--wait] [--kill-existing] [--arg=]... [--env=]... [options] + summary: Launch app by bundle ID. + - path: list + usage: ios list [options] [--details] + summary: List connected devices. + - path: listen + usage: ios listen [options] + summary: Listen for device connect/disconnect. + - path: lockdown get + usage: ios lockdown get [] [--domain=] [options] + summary: Query lockdown values. + - path: memlimitoff + usage: ios memlimitoff (--process=) [options] + summary: Disable process memory limit. + - path: mobilegestalt + usage: ios mobilegestalt ... [--plist] [options] + summary: Query mobilegestalt keys. + - path: pair + usage: ios pair [--p12file=] [--password=] [options] + summary: Pair host with device. + - path: pcap + usage: ios pcap [options] [--pid=] [--process=] + summary: Capture network packets. + - path: prepare + usage: ios prepare [--skip-all] [--skip=]... [--env=]... [options] + summary: Run WebDriverAgent. + - path: runxctest + usage: ios runxctest [--xctestrun-file-path=] [--log-output=] [options] + summary: Run XCTest from .xctestrun file. + - path: screenshot + usage: ios screenshot [options] [--output=] [--stream] [--port=] + summary: Capture screenshot or stream MJPEG. + - path: setlocation + usage: ios setlocation [options] [--lat=] [--lon=] + summary: Set simulated location coordinates. + - path: setlocationgpx + usage: ios setlocationgpx [options] [--gpxfilepath=] + summary: Set simulated location from GPX. + - path: syslog + usage: ios syslog [--parse] [options] + summary: Stream device syslog. + - path: ostrace + usage: ios ostrace [--pid=] [--process=] [--level=] [--subsystem=] [--match=] [--exclude=] [options] + summary: Stream os_trace_relay logs. + - path: sysmontap + usage: ios sysmontap [options] + summary: Stream CPU and memory metrics. + - path: timeformat + usage: ios timeformat (24h | 12h | toggle | get) [--force] [options] + summary: Manage time format setting. + - path: tunnel ls + usage: ios tunnel ls [options] + summary: List running tunnels. + - path: tunnel start + usage: ios tunnel start [options] [--pair-record-path=] [--userspace] + summary: Start iOS 17+ tunnel and pair if needed. + - path: tunnel stopagent + usage: ios tunnel stopagent + summary: Stop tunnel agent. + - path: uninstall + usage: ios uninstall [options] + summary: Uninstall app by bundle ID. + - path: version + usage: ios --version | version [options] + summary: Print version. + - path: voiceover + usage: ios voiceover (enable | disable | toggle | get) [--force] [options] + summary: Manage VoiceOver state. + - path: zoom + usage: ios zoom (enable | disable | toggle | get) [--force] [options] + summary: Manage Zoom state. diff --git a/internal/clihelp/loader_test.go b/internal/clihelp/loader_test.go new file mode 100644 index 00000000..d4f41406 --- /dev/null +++ b/internal/clihelp/loader_test.go @@ -0,0 +1,35 @@ +package clihelp + +import "testing" + +func TestParseCatalogYAML_RejectsDuplicatePath(t *testing.T) { + data := []byte(` +program: go-ios +usage: ["ios [--help]"] +commands: + - path: apps + usage: ios apps [options] + summary: one + - path: apps + usage: ios apps [options] + summary: two +`) + _, err := parseCatalogYAML(data) + if err == nil { + t.Fatal("expected duplicate command path error") + } +} + +func TestParseCatalogYAML_RejectsMissingUsage(t *testing.T) { + data := []byte(` +program: go-ios +usage: ["ios [--help]"] +commands: + - path: apps + summary: app list +`) + _, err := parseCatalogYAML(data) + if err == nil { + t.Fatal("expected missing usage error") + } +} diff --git a/internal/clihelp/render_test.go b/internal/clihelp/render_test.go new file mode 100644 index 00000000..fe795478 --- /dev/null +++ b/internal/clihelp/render_test.go @@ -0,0 +1,40 @@ +package clihelp + +import ( + "strings" + "testing" +) + +func TestRender_Global(t *testing.T) { + c, err := Load() + if err != nil { + t.Fatalf("load help catalog: %v", err) + } + out, err := c.Render("test-version", "") + if err != nil { + t.Fatalf("render global: %v", err) + } + if !strings.Contains(out, "go-ios test-version") { + t.Fatalf("missing header: %q", out) + } + if !strings.Contains(out, "Commands:") { + t.Fatalf("missing commands section: %q", out) + } +} + +func TestRender_Command(t *testing.T) { + c, err := Load() + if err != nil { + t.Fatalf("load help catalog: %v", err) + } + out, err := c.Render("test-version", "apps") + if err != nil { + t.Fatalf("render command: %v", err) + } + if !strings.Contains(out, "Command: apps") { + t.Fatalf("missing command title: %q", out) + } + if !strings.Contains(out, "ios apps") { + t.Fatalf("missing command usage: %q", out) + } +} diff --git a/internal/clihelp/resolve_test.go b/internal/clihelp/resolve_test.go new file mode 100644 index 00000000..abdd9afe --- /dev/null +++ b/internal/clihelp/resolve_test.go @@ -0,0 +1,46 @@ +package clihelp + +import "testing" + +func TestResolveHelp_ExplicitAndImplicitParity(t *testing.T) { + c, err := Load() + if err != nil { + t.Fatalf("load help catalog: %v", err) + } + topicA, handledA := c.ResolveHelp([]string{"help", "tunnel", "start"}) + topicB, handledB := c.ResolveHelp([]string{"tunnel", "start", "--help"}) + if !handledA || !handledB { + t.Fatal("expected help to be handled for both forms") + } + if topicA != topicB { + t.Fatalf("topic mismatch: explicit=%q implicit=%q", topicA, topicB) + } +} + +func TestResolveHelp_StripsGlobalFlags(t *testing.T) { + c, err := Load() + if err != nil { + t.Fatalf("load help catalog: %v", err) + } + topic, handled := c.ResolveHelp([]string{"--udid=abc", "help", "apps"}) + if !handled { + t.Fatal("expected help to be handled") + } + if topic != "apps" { + t.Fatalf("topic = %q, want apps", topic) + } +} + +func TestResolveHelp_UnknownTopic(t *testing.T) { + c, err := Load() + if err != nil { + t.Fatalf("load help catalog: %v", err) + } + topic, handled := c.ResolveHelp([]string{"help", "nope"}) + if !handled { + t.Fatal("expected help to be handled") + } + if topic != "nope" { + t.Fatalf("topic = %q, want nope", topic) + } +} diff --git a/main.go b/main.go index e065f5c2..3b879d1c 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "golang.org/x/crypto/pkcs12" + "github.com/danielpaulus/go-ios/internal/clihelp" "github.com/danielpaulus/go-ios/ios/debugproxy" "github.com/danielpaulus/go-ios/ios/deviceinfo" "github.com/danielpaulus/go-ios/ios/house_arrest" @@ -71,6 +72,14 @@ const version = "local-build" // Main Exports main for testing func Main() { + helpCatalog, err := clihelp.Load() + exitIfError("failed loading help definitions", err) + if handled, exitCode := helpCatalog.WriteHelp(os.Args[1:], version, os.Stdout, os.Stderr); handled { + if exitCode != 0 { + os.Exit(exitCode) + } + return + } usage := fmt.Sprintf(`go-ios %s diff --git a/main_test.go b/main_test.go index 1b45a06b..ae51e344 100644 --- a/main_test.go +++ b/main_test.go @@ -1,9 +1,13 @@ package main_test import ( + "bytes" "flag" "fmt" + "os" "os/exec" + "path/filepath" + "strings" "testing" ) @@ -22,3 +26,129 @@ func TestDeviceList(t *testing.T) { } fmt.Println(string(output)) } + +func TestHelp_GlobalNoArgs(t *testing.T) { + stdout, stderr, code := runCLI(t) + if code != 0 { + t.Fatalf("exit code = %d, stderr = %q", code, stderr) + } + if stderr != "" { + t.Fatalf("stderr = %q, want empty", stderr) + } + assertGolden(t, "global.golden", stdout) +} + +func TestHelp_GlobalFlagsEquivalent(t *testing.T) { + stdoutLong, stderrLong, codeLong := runCLI(t, "--help") + if codeLong != 0 || stderrLong != "" { + t.Fatalf("--help failed: code=%d stderr=%q", codeLong, stderrLong) + } + stdoutShort, stderrShort, codeShort := runCLI(t, "-h") + if codeShort != 0 || stderrShort != "" { + t.Fatalf("-h failed: code=%d stderr=%q", codeShort, stderrShort) + } + if eolFormat(stdoutLong) != eolFormat(stdoutShort) { + t.Fatalf("help outputs differ\n--help:\n%s\n-h:\n%s", stdoutLong, stdoutShort) + } +} + +func TestHelp_SubcommandExplicitImplicitAndGlobalFlagOrdering(t *testing.T) { + explicit, explicitErr, explicitCode := runCLI(t, "help", "apps") + if explicitCode != 0 || explicitErr != "" { + t.Fatalf("help apps failed: code=%d stderr=%q", explicitCode, explicitErr) + } + implicit, implicitErr, implicitCode := runCLI(t, "apps", "--help") + if implicitCode != 0 || implicitErr != "" { + t.Fatalf("apps --help failed: code=%d stderr=%q", implicitCode, implicitErr) + } + implicitShort, implicitShortErr, implicitShortCode := runCLI(t, "apps", "-h") + if implicitShortCode != 0 || implicitShortErr != "" { + t.Fatalf("apps -h failed: code=%d stderr=%q", implicitShortCode, implicitShortErr) + } + withGlobal, withGlobalErr, withGlobalCode := runCLI(t, "--udid=abc", "help", "apps") + if withGlobalCode != 0 || withGlobalErr != "" { + t.Fatalf("--udid=abc help apps failed: code=%d stderr=%q", withGlobalCode, withGlobalErr) + } + normalizedExplicit := eolFormat(explicit) + if normalizedExplicit != eolFormat(implicit) { + t.Fatalf("explicit and implicit help differ") + } + if normalizedExplicit != eolFormat(implicitShort) { + t.Fatalf("explicit and short implicit help differ") + } + if normalizedExplicit != eolFormat(withGlobal) { + t.Fatalf("explicit and global-flag help differ") + } + assertGolden(t, "apps.golden", explicit) +} + +func TestHelp_NestedSubcommandAndUnknown(t *testing.T) { + explicit, explicitErr, explicitCode := runCLI(t, "help", "tunnel", "start") + if explicitCode != 0 || explicitErr != "" { + t.Fatalf("help tunnel start failed: code=%d stderr=%q", explicitCode, explicitErr) + } + implicit, implicitErr, implicitCode := runCLI(t, "tunnel", "start", "--help") + if implicitCode != 0 || implicitErr != "" { + t.Fatalf("tunnel start --help failed: code=%d stderr=%q", implicitCode, implicitErr) + } + if eolFormat(explicit) != eolFormat(implicit) { + t.Fatalf("nested explicit and implicit help differ") + } + + stdout, stderr, code := runCLI(t, "help", "nope") + if code == 0 { + t.Fatalf("expected non-zero exit for unknown topic") + } + if stdout != "" { + t.Fatalf("stdout should be empty for unknown topic, got %q", stdout) + } + if !strings.Contains(stderr, "unknown help topic") { + t.Fatalf("missing unknown topic message: %q", stderr) + } +} + +func runCLI(t *testing.T, args ...string) (string, string, int) { + t.Helper() + cmd := exec.Command("go", append([]string{"run", "."}, args...)...) + cmd.Dir = "." + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + code := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + code = exitErr.ExitCode() + } else { + t.Fatalf("run cli: %v", err) + } + } + return stdout.String(), stderr.String(), code +} + +func assertGolden(t *testing.T, goldenName string, got string) { + t.Helper() + path := filepath.Join("testdata", "help", goldenName) + got = eolFormat(got) + if *update { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir golden dir: %v", err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + } + wantBytes, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden %s: %v", goldenName, err) + } + want := eolFormat(string(wantBytes)) + if got != want { + t.Fatalf("golden mismatch for %s\n--- got ---\n%s\n--- want ---\n%s", goldenName, got, want) + } +} + +func eolFormat(s string) string { + return strings.ReplaceAll(s, "\r\n", "\n") +} diff --git a/testdata/help/apps.golden b/testdata/help/apps.golden new file mode 100644 index 00000000..01ad897f --- /dev/null +++ b/testdata/help/apps.golden @@ -0,0 +1,20 @@ +go-ios local-build + +Command: apps +List installed applications. + +Usage: + ios apps [--system] [--all] [--list] [--filesharing] [options] + +Global options: + -h, --help Show help. + -v, --verbose Enable debug logging. + -t, --trace Enable trace logging. + --nojson Disable JSON output. + --pretty Pretty-print JSON output. + --udid= Target a specific device. + --tunnel-info-port= Tunnel info API port (default 28100). + --address= Device tunnel address. + --rsd-port= Device tunnel RSD port. + --proxyurl= Outbound HTTP proxy URL. + --userspace-port= Userspace tunnel port. diff --git a/testdata/help/global.golden b/testdata/help/global.golden new file mode 100644 index 00000000..e22ba7a3 --- /dev/null +++ b/testdata/help/global.golden @@ -0,0 +1,99 @@ +go-ios local-build + +Cross-platform iOS automation CLI. + +Usage: + ios [--help] + ios help [...] + ios [...] + +Global options: + -h, --help Show help. + -v, --verbose Enable debug logging. + -t, --trace Enable trace logging. + --nojson Disable JSON output. + --pretty Pretty-print JSON output. + --udid= Target a specific device. + --tunnel-info-port= Tunnel info API port (default 28100). + --address= Device tunnel address. + --rsd-port= Device tunnel RSD port. + --proxyurl= Outbound HTTP proxy URL. + --userspace-port= Userspace tunnel port. + +Commands: + activate Activate a device. + apps List installed applications. + assistivetouch Manage AssistiveTouch state. + ax Accessibility inspector features. + batterycheck Battery information. + batteryregistry Battery registry metrics. + crash cp Copy crash reports. + crash ls List crash reports. + crash rm Remove crash reports. + date Print device date. + debug Start LLDB debug session. + devicename Print device name. + devicestate enable Enable device condition profile. + devicestate list List device condition profiles. + devmode Manage developer mode. + diagnostics list List diagnostics. + diskspace Print disk usage. + dproxy Start debug proxy. + erase Erase device. + file ls List files in app/group/temp/crash container. + file pull Pull file from device. + file push Push file to device. + forward Forward host port to device. + fsync App container file sync operations. + httpproxy Install global HTTP proxy profile. + httpproxy remove Remove go-ios HTTP proxy profile. + image auto Auto-download and mount developer image. + image list List mounted developer images. + image mount Mount developer image. + image unmount Unmount developer image. + info Dump device info. + install Install app bundle or IPA. + instruments notifications Stream app state notifications. + ip Detect device IP from packet capture. + kill Kill app by bundle ID, PID, or process. + lang Read or set device language and locale. + launch Launch app by bundle ID. + list List connected devices. + listen Listen for device connect/disconnect. + lockdown get Query lockdown values. + memlimitoff Disable process memory limit. + mobilegestalt Query mobilegestalt keys. + ostrace Stream os_trace_relay logs. + pair Pair host with device. + pcap Capture network packets. + prepare Prepare device for automation. + prepare cloudconfig Print cloud configuration. + prepare create-cert Create supervision certificate. + prepare printskip Print prepare skip options. + profile add Install profile on device. + profile list List installed profiles. + profile remove Remove installed profile. + ps List running processes. + readpair Dump pair record. + reboot Reboot device. + resetax Reset accessibility settings. + resetlocation Reset simulated location. + rsd ls List RSD services. + runtest Run XCUITest bundles. + runwda Run WebDriverAgent. + runxctest Run XCTest from .xctestrun file. + screenshot Capture screenshot or stream MJPEG. + setlocation Set simulated location coordinates. + setlocationgpx Set simulated location from GPX. + syslog Stream device syslog. + sysmontap Stream CPU and memory metrics. + timeformat Manage time format setting. + tunnel ls List running tunnels. + tunnel start Start iOS 17+ tunnel and pair if needed. + tunnel stopagent Stop tunnel agent. + uninstall Uninstall app by bundle ID. + version Print version. + voiceover Manage VoiceOver state. + zoom Manage Zoom state. + +Run 'ios help ' or 'ios --help' for command details.