// Command geolocate estimates the machine's physical location by scanning // nearby Wi-Fi access points and submitting their BSSIDs to a public // Wi-Fi-to-location service, then records the result under ~/.data/location. // // This is a macOS port of the original Linux script. Two things changed since // the original was written: // // - The original scanned access points with nmcli (NetworkManager). macOS // has no equivalent, and Apple removed the `airport` CLI in macOS 14.4. // On macOS 26, CoreWLAN only returns real BSSIDs to a Developer-ID-signed, // notarized binary that the user has granted Location Services access. We // get that via the macwifi package, which ships such a signed helper. // // - The original queried Mozilla Location Services, which shut down in 2024. // We use BeaconDB instead: a community-run successor that speaks the same // Ichnaea API (identical request/response shape). package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "time" "github.com/jaisonerick/macwifi" ) // geolocateURL is BeaconDB's Ichnaea-compatible geolocate endpoint, the // drop-in replacement for the defunct location.services.mozilla.com. const geolocateURL = "https://api.beacondb.net/v1/geolocate" // accessPoint is one observed Wi-Fi access point in the Ichnaea request // schema. Only macAddress is required; signal strength and channel improve // the position estimate when available. type accessPoint struct { MacAddress string `json:"macAddress"` SignalStrength int `json:"signalStrength,omitempty"` Channel int `json:"channel,omitempty"` } type geolocateRequest struct { WifiAccessPoints []accessPoint `json:"wifiAccessPoints,omitempty"` } func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "geolocate:", err) os.Exit(1) } } func run() error { ts := time.Now().Unix() // The first scan triggers macOS's Location Services prompt for the // bundled helper; allow generous time for a user to approve it. ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() // Wi-Fi scanning requires Location Services. If it is disabled (or the // scan otherwise fails), fall back to IP-based geolocation rather than // aborting — matching the original, which posted an empty request when // it couldn't find enough access points. aps, err := listAccessPoints(ctx) if err != nil { fmt.Fprintf(os.Stderr, "geolocate: wifi scan unavailable (%v); falling back to IP geolocation\n", err) aps = nil } loc, err := geolocate(aps) if err != nil { return err } loc["timestamp"] = ts out, err := json.Marshal(loc) if err != nil { return err } home, err := os.UserHomeDir() if err != nil { return err } dir := filepath.Join(home, ".data", "location") if err := os.MkdirAll(dir, 0o755); err != nil { return err } if err := writeFile(filepath.Join(dir, "latest.json"), out); err != nil { return err } return writeFile(filepath.Join(dir, fmt.Sprintf("%d.json", ts)), out) } func writeFile(path string, content []byte) error { return os.WriteFile(path, content, 0o644) } // listAccessPoints scans nearby Wi-Fi networks via CoreWLAN (through the // macwifi helper) and returns their BSSIDs. It returns nil if fewer than two // usable access points are found, mirroring the original script: the // geolocation service needs at least two observations to triangulate. func listAccessPoints(ctx context.Context) ([]accessPoint, error) { networks, err := macwifi.Scan(ctx) if err != nil { return nil, fmt.Errorf("scanning wifi: %w", err) } seen := make(map[string]bool) var aps []accessPoint for _, n := range networks { // BSSID is empty when Location Services access has not been // granted, or for saved-but-not-visible networks. if n.BSSID == "" || seen[n.BSSID] { continue } seen[n.BSSID] = true aps = append(aps, accessPoint{ MacAddress: n.BSSID, SignalStrength: n.RSSI, Channel: n.Channel, }) } if len(aps) < 2 { return nil, nil } return aps, nil } // geolocate posts the observed access points to BeaconDB and returns the // decoded response (e.g. {"location":{"lat":..,"lng":..},"accuracy":..}). // With no access points it degrades to a GeoIP estimate, matching the // original behaviour of posting an empty request. func geolocate(aps []accessPoint) (map[string]any, error) { body, err := json.Marshal(geolocateRequest{WifiAccessPoints: aps}) if err != nil { return nil, err } resp, err := http.Post(geolocateURL, "application/json", bytes.NewReader(body)) if err != nil { return nil, err } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("geolocation service returned %s: %s", resp.Status, bytes.TrimSpace(data)) } var result map[string]any if err := json.Unmarshal(data, &result); err != nil { return nil, err } return result, nil }