Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions internal/reporter/reporter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package reporter

import (
"fmt"
"io"
)

func PrintBanner(w io.Writer) {
fmt.Println(w, "Debugger")
}

func PrintDeviceList(w io.Writer, platform string, devices []string) {
if len(devices) == 0 {
fmt.Fprintf(w, " No %s devices found (booted / connected)\n\n", platform)
return
}

fmt.Fprintf(w, "%s\n", platform)

for _, d := range devices {
fmt.Fprintf(w, "%s %s\n", "*", d)
}
fmt.Fprintln(w)
}
72 changes: 72 additions & 0 deletions internal/simulator/simulator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package simulator

import (
"encoding/json"
"fmt"
"os/exec"
"strings"
)

type simctlDevice struct {
UDID string `json:"udid"`
Name string `json:"name"`
State string `json:"state"`
IsAvailable bool `json:"isAvailable"`
}

type simctlOutput struct {
Devices map[string][]simctlDevice `json:"devices"`
}

func CheckToolsAvailable() map[string]bool {
available := make(map[string]bool)
for _, tool := range []string{"xcrun"} {
_, err := exec.LookPath(tool)
available[tool] = err == nil
}
return available
}

func ListIOSDevices() ([]string, error) {
out, err := exec.Command("xcrun", "simctl", "list", "devices", "booted", "--json").Output()
if err != nil {
return nil, fmt.Errorf("xcrun simctl failed: %w", err)
}

var payload simctlOutput
if err := json.Unmarshal(out, &payload); err != nil {
return nil, fmt.Errorf("failed to parse simctl JSON: %w", err)
}

var names []string
for _, devices := range payload.Devices {
for _, d := range devices {
if strings.EqualFold(d.State, "Booted") && d.IsAvailable {
names = append(names, fmt.Sprintf("%s (%s)", d.Name, d.UDID))
}
}
}
return names, nil
}

func GetBootedIOSDevices() ([]simctlDevice, error) {
out, err := exec.Command("xcrun", "simctl", "list", "devices", "booted", "--json").Output()
if err != nil {
return nil, fmt.Errorf("xcrun simctl failed: %w", err)
}

var payload simctlOutput
if err := json.Unmarshal(out, &payload); err != nil {
return nil, fmt.Errorf("failed to parse simctl JSON: %w", err)
}

var devices []simctlDevice
for _, devices := range payload.Devices {
for _, d := range devices {
if strings.EqualFold(d.State, "Booted") && d.IsAvailable {
devices = append(devices, d)
}
}
}
return devices, nil
}
59 changes: 59 additions & 0 deletions pkg/cmd/devices/devices.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package devices

import (
"encoding/json"

"github.com/space-code/linkctl/internal/reporter"
"github.com/space-code/linkctl/internal/simulator"
"github.com/space-code/linkctl/pkg/cmdutil"
"github.com/spf13/cobra"
)

type options struct {
asJSON bool
}

func NewCmdDevices(f *cmdutil.Factory) *cobra.Command {
opts := options{}

cmd := &cobra.Command{
Use: "devices",
Short: "List connected iOS simulators",
Long: "List all currently booted iOS simulators.",
Example: `linkctl devices`,
RunE: func(cmd *cobra.Command, args []string) error {
return run(f, &opts)
},
}

cmd.Flags().BoolVar(&opts.asJSON, "json", false, "Output results as JSON")

return cmd
}

type devicesJSON struct {
IOS []string `json:"ios"`
Tools map[string]bool `json:"tools"`
}

func run(f *cmdutil.Factory, opts *options) error {
tools := simulator.CheckToolsAvailable()
var iosDevices []string

if tools["xcrun"] {
iosDevices, _ = simulator.ListIOSDevices()
}

if opts.asJSON {
enc := json.NewEncoder(f.IOStreams.Out)
enc.SetIndent("", " ")
return enc.Encode(devicesJSON{
IOS: iosDevices,
Tools: tools,
})
}

w := f.IOStreams.Out
reporter.PrintDeviceList(w, "iOS", iosDevices)
return nil
}
83 changes: 83 additions & 0 deletions pkg/cmd/devices/devices_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package devices_test

import (
"bytes"
"encoding/json"
"strings"
"testing"

"github.com/space-code/linkctl/pkg/cmd/devices"
"github.com/space-code/linkctl/pkg/cmdutil"
"github.com/space-code/linkctl/pkg/iostreams"
)

func newFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) {
t.Helper()

ios, _, stdout, _ := iostreams.Test()

f := &cmdutil.Factory{
AppVersion: "1.0.0",
ExecutableName: "linkctl",
IOStreams: ios,
}

return f, stdout
}

func TestDevicesCmd_NoError(t *testing.T) {
f, _ := newFactory(t)
cmd := devices.NewCmdDevices(f)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

func TestDevicesCmd_JSONOutput_HasAllKeys(t *testing.T) {
f, stdout := newFactory(t)
cmd := devices.NewCmdDevices(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}

raw := stdout.String()
for _, key := range []string{"ios", "tools"} {
if !strings.Contains(raw, key) {
t.Errorf("JSON output missing key %s\ngot: %s", key, raw)
}
}
}

func TestDevicesCmd_UnknownFlag(t *testing.T) {
f, _ := newFactory(t)
cmd := devices.NewCmdDevices(f)
cmd.SetArgs([]string{"--unknown"})
if err := cmd.Execute(); err == nil {
t.Fatalf("unexpected error for unknown flag")
}
}

func TestDevicesCmd_JSONOutput_Shape(t *testing.T) {
f, stdout := newFactory(t)
cmd := devices.NewCmdDevices(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}

var payload struct {
IOS []string `json:"ios"`
Tools map[string]bool `json:"tools"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\ngot: %s", err, stdout.String())
}

for _, key := range []string{"xcrun"} {
if _, ok := payload.Tools[key]; !ok {
t.Errorf("uexpected tools map to contain key %q", key)
}
}
}
2 changes: 2 additions & 0 deletions pkg/cmd/root/root.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package root

import (
devicesCmd "github.com/space-code/linkctl/pkg/cmd/devices"
versionCmd "github.com/space-code/linkctl/pkg/cmd/version"
"github.com/space-code/linkctl/pkg/cmdutil"
"github.com/spf13/cobra"
Expand All @@ -17,6 +18,7 @@ func NewCmdRoot(f *cmdutil.Factory, appVersion string) (*cobra.Command, error) {
}

cmd.AddCommand(versionCmd.NewCmdVersion(f))
cmd.AddCommand(devicesCmd.NewCmdDevices(f))

return cmd, nil
}
52 changes: 49 additions & 3 deletions pkg/iostreams/iostreams.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
package iostreams

import (
"bytes"
"io"
"os"
)

type fileWriter interface {
io.Writer
Fd() uintptr
}

type fileReader interface {
io.ReadCloser
Fd() uintptr
}

type IOStreams struct {
In io.Reader
Out io.Writer
ErrOut io.Writer
In fileReader
Out fileWriter
ErrOut fileWriter
}

func System() *IOStreams {
Expand All @@ -20,3 +31,38 @@ func System() *IOStreams {

return ios
}

func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) {
in := &bytes.Buffer{}
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}

io := &IOStreams{
In: &fdReader{
fd: 0,
ReadCloser: io.NopCloser(in),
},
Out: &fdWriter{fd: 1, Writer: out},
ErrOut: &fdWriter{fd: 2, Writer: errOut},
}

return io, in, out, errOut
}

type fdWriter struct {
io.Writer
fd uintptr
}

func (w *fdWriter) Fd() uintptr {
return w.fd
}

type fdReader struct {
io.ReadCloser
fd uintptr
}

func (r *fdReader) Fd() uintptr {
return r.fd
}