|
| 1 | +//go:build darwin |
| 2 | + |
| 3 | +package network |
| 4 | + |
| 5 | +import ( |
| 6 | + "encoding/json" |
| 7 | + "os/exec" |
| 8 | + "strconv" |
| 9 | + "strings" |
| 10 | +) |
| 11 | + |
| 12 | +// ConnectionInfo describes the active network connection regardless of type. |
| 13 | +type ConnectionInfo struct { |
| 14 | + Type string // "wifi", "ethernet", or "" |
| 15 | + // WiFi-specific |
| 16 | + RSSI int // signal strength dBm (negative; 0 = unavailable) |
| 17 | + Noise int // noise floor dBm (negative; 0 = unavailable) |
| 18 | + SNR int // signal-to-noise ratio dB (0 = unavailable) |
| 19 | + Channel int // WiFi channel number (0 = unavailable) |
| 20 | + Band string // "2GHz", "5GHz", "6GHz", or "" |
| 21 | + // Shared |
| 22 | + LinkRate int // link rate / speed Mbps (0 = unavailable) |
| 23 | + // Ethernet-specific |
| 24 | + Duplex string // "full", "half", or "" |
| 25 | +} |
| 26 | + |
| 27 | +// GetConnectionInfo detects the active interface and returns its connection details. |
| 28 | +// It reuses darwinRoute() (defined in network.go) to avoid running route twice. |
| 29 | +func GetConnectionInfo() (ConnectionInfo, bool) { |
| 30 | + _, activeIface := darwinRoute() |
| 31 | + |
| 32 | + // Try WiFi via system_profiler |
| 33 | + if spOut, err := exec.Command("system_profiler", "SPAirPortDataType", "-json").Output(); err == nil { |
| 34 | + if info, ok := parseMacWifi(spOut, activeIface); ok { |
| 35 | + return info, true |
| 36 | + } |
| 37 | + } |
| 38 | + |
| 39 | + // Fall back to Ethernet |
| 40 | + if activeIface != "" { |
| 41 | + return macEthernetInfo(activeIface) |
| 42 | + } |
| 43 | + return ConnectionInfo{}, false |
| 44 | +} |
| 45 | + |
| 46 | +// spAirPortData is the relevant subset of system_profiler SPAirPortDataType JSON. |
| 47 | +type spAirPortData struct { |
| 48 | + SPAirPortDataType []struct { |
| 49 | + Interfaces []struct { |
| 50 | + Name string `json:"_name"` |
| 51 | + Current *struct { |
| 52 | + Channel string `json:"spairport_network_channel"` |
| 53 | + Rate int `json:"spairport_network_rate"` |
| 54 | + SignalNoise string `json:"spairport_signal_noise"` |
| 55 | + } `json:"spairport_current_network_information"` |
| 56 | + } `json:"spairport_airport_interfaces"` |
| 57 | + } `json:"SPAirPortDataType"` |
| 58 | +} |
| 59 | + |
| 60 | +// parseMacWifi extracts WiFi stats from system_profiler JSON. |
| 61 | +// If activeIface is non-empty, only the matching interface is considered. |
| 62 | +func parseMacWifi(data []byte, activeIface string) (ConnectionInfo, bool) { |
| 63 | + var sp spAirPortData |
| 64 | + if err := json.Unmarshal(data, &sp); err != nil { |
| 65 | + return ConnectionInfo{}, false |
| 66 | + } |
| 67 | + for _, section := range sp.SPAirPortDataType { |
| 68 | + for _, iface := range section.Interfaces { |
| 69 | + if activeIface != "" && iface.Name != activeIface { |
| 70 | + continue |
| 71 | + } |
| 72 | + cur := iface.Current |
| 73 | + if cur == nil || cur.SignalNoise == "" { |
| 74 | + continue |
| 75 | + } |
| 76 | + var info ConnectionInfo |
| 77 | + info.Type = "wifi" |
| 78 | + |
| 79 | + // Parse "-24 dBm / -97 dBm" |
| 80 | + parts := strings.SplitN(cur.SignalNoise, " / ", 2) |
| 81 | + if len(parts) == 2 { |
| 82 | + rssi, e1 := strconv.Atoi(strings.TrimSuffix(parts[0], " dBm")) |
| 83 | + noise, e2 := strconv.Atoi(strings.TrimSuffix(parts[1], " dBm")) |
| 84 | + if e1 == nil && e2 == nil { |
| 85 | + info.RSSI = rssi |
| 86 | + info.Noise = noise |
| 87 | + info.SNR = rssi - noise |
| 88 | + } |
| 89 | + } |
| 90 | + |
| 91 | + // Parse "1 (2GHz, 20MHz)" |
| 92 | + if chParts := strings.SplitN(cur.Channel, " ", 2); len(chParts) >= 1 { |
| 93 | + info.Channel, _ = strconv.Atoi(chParts[0]) |
| 94 | + } |
| 95 | + switch { |
| 96 | + case strings.Contains(cur.Channel, "6GHz"): |
| 97 | + info.Band = "6GHz" |
| 98 | + case strings.Contains(cur.Channel, "5GHz"): |
| 99 | + info.Band = "5GHz" |
| 100 | + case strings.Contains(cur.Channel, "2GHz"): |
| 101 | + info.Band = "2GHz" |
| 102 | + } |
| 103 | + info.LinkRate = cur.Rate |
| 104 | + return info, info.RSSI != 0 |
| 105 | + } |
| 106 | + } |
| 107 | + return ConnectionInfo{}, false |
| 108 | +} |
| 109 | + |
| 110 | +// macEthernetInfo reads the link speed and duplex from ifconfig for a wired interface. |
| 111 | +func macEthernetInfo(iface string) (ConnectionInfo, bool) { |
| 112 | + out, err := exec.Command("ifconfig", iface).Output() |
| 113 | + if err != nil { |
| 114 | + return ConnectionInfo{}, false |
| 115 | + } |
| 116 | + info := parseIfconfigMedia(string(out)) |
| 117 | + if info.LinkRate == 0 { |
| 118 | + return ConnectionInfo{}, false |
| 119 | + } |
| 120 | + info.Type = "ethernet" |
| 121 | + return info, true |
| 122 | +} |
| 123 | + |
| 124 | +// parseIfconfigMedia parses "media: autoselect (1000baseT <full-duplex>)" lines. |
| 125 | +func parseIfconfigMedia(output string) ConnectionInfo { |
| 126 | + var info ConnectionInfo |
| 127 | + for _, line := range strings.Split(output, "\n") { |
| 128 | + line = strings.TrimSpace(line) |
| 129 | + if !strings.HasPrefix(line, "media:") { |
| 130 | + continue |
| 131 | + } |
| 132 | + start := strings.Index(line, "(") |
| 133 | + end := strings.LastIndex(line, ")") |
| 134 | + if start < 0 || end <= start { |
| 135 | + break |
| 136 | + } |
| 137 | + inner := line[start+1 : end] // e.g. "1000baseT <full-duplex>" |
| 138 | + fields := strings.Fields(inner) |
| 139 | + if len(fields) == 0 { |
| 140 | + break |
| 141 | + } |
| 142 | + // Extract leading digits from "1000baseT" → 1000 |
| 143 | + speedStr := fields[0] |
| 144 | + nonDigit := strings.IndexFunc(speedStr, func(r rune) bool { return r < '0' || r > '9' }) |
| 145 | + if nonDigit > 0 { |
| 146 | + info.LinkRate, _ = strconv.Atoi(speedStr[:nonDigit]) |
| 147 | + } |
| 148 | + for _, f := range fields[1:] { |
| 149 | + switch f { |
| 150 | + case "<full-duplex>": |
| 151 | + info.Duplex = "full" |
| 152 | + case "<half-duplex>": |
| 153 | + info.Duplex = "half" |
| 154 | + } |
| 155 | + } |
| 156 | + break |
| 157 | + } |
| 158 | + return info |
| 159 | +} |
0 commit comments