From 2fe5db60c2bc5790f51662675b51cc59fc937455 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 6 Jul 2026 19:12:44 +0200 Subject: [PATCH] latest --- flappy/go.mod | 28 +++ flappy/go.sum | 43 +++++ flappy/main.go | 382 +++++++++++++++++++++++++++++++++++++++++ geolocate/geolocate.go | 168 ++++++++++++++++++ geolocate/go.mod | 5 + geolocate/go.sum | 2 + 6 files changed, 628 insertions(+) create mode 100644 flappy/go.mod create mode 100644 flappy/go.sum create mode 100644 flappy/main.go create mode 100644 geolocate/geolocate.go create mode 100644 geolocate/go.mod create mode 100644 geolocate/go.sum diff --git a/flappy/go.mod b/flappy/go.mod new file mode 100644 index 0000000..a15d084 --- /dev/null +++ b/flappy/go.mod @@ -0,0 +1,28 @@ +module flappy + +go 1.25 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/flappy/go.sum b/flappy/go.sum new file mode 100644 index 0000000..4789639 --- /dev/null +++ b/flappy/go.sum @@ -0,0 +1,43 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/flappy/main.go b/flappy/main.go new file mode 100644 index 0000000..7e6910e --- /dev/null +++ b/flappy/main.go @@ -0,0 +1,382 @@ +package main + +import ( + "fmt" + "math/rand" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Physics tuned so per-frame displacement stays around one row, which keeps +// motion smooth and the 7-row gap threadable at 30fps. +const ( + fps = 30 + gravity = 0.09 // rows/frame^2 + jumpVel = -0.95 // rows/frame; peak rise ≈ v²/2g ≈ 5 rows over ~0.35s + maxFall = 1.15 // terminal velocity, rows/frame — never skip >1 row + pipeSpeed = 0.5 // cells/frame → 15 cells/s + pipeGap = 7 + pipeSpacing = 28 // cells between pipes → one pipe ≈ every 1.9s + pipeWidth = 3 + groundH = 2 +) + +// tickMsg advances the simulation. gen guards against stale tick chains: +// without it, restarting can leave an old chain in flight and the game +// silently runs at double speed. +type tickMsg struct{ gen int } + +func tick(gen int) tea.Cmd { + return tea.Tick(time.Second/fps, func(time.Time) tea.Msg { + return tickMsg{gen: gen} + }) +} + +type pipe struct { + x float64 + gapY int + scored bool +} + +type star struct{ x, y int } + +type model struct { + termW, termH int + width int // field width in cells + height int // field height in cells (terminal minus header+footer) + birdX int + birdY float64 + birdVel float64 + pipes []pipe + stars []star + score int + best int + gen int // tick generation + gameOver bool + started bool + resized bool +} + +func newGame(best, gen int, termW, termH int) model { + m := model{best: best, gen: gen} + if termW > 0 && termH > 0 { + m.applySize(termW, termH) + } + return m +} + +// applySize records the terminal size, sizes the field, and rebuilds the +// static starfield. The starfield must be stable between frames — rolling +// new random speckles every render is what caused full-screen flicker. +func (m *model) applySize(w, h int) { + m.termW, m.termH = w, h + m.width = w + fieldH := h - 2 // one row header, one row footer + if fieldH < 8 { + fieldH = 8 + } + m.height = fieldH + m.birdX = w / 4 + if m.birdX < 4 { + m.birdX = 4 + } + if !m.resized { + m.birdY = float64(m.height-groundH) / 2 + m.resized = true + } + // keep the bird inside the new bounds after a resize + floor := float64(m.height - groundH - 1) + if m.birdY > floor { + m.birdY = floor + } + // static starfield, ~1 speckle per 40 sky cells + skyH := m.height - groundH + m.stars = m.stars[:0] + n := m.width * skyH / 40 + for i := 0; i < n; i++ { + m.stars = append(m.stars, star{x: rand.Intn(m.width), y: rand.Intn(skyH)}) + } +} + +func (m model) Init() tea.Cmd { return nil } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.applySize(msg.Width, msg.Height) + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case " ", "space", "enter", "up", "k": + if m.gameOver { + // restart: bump generation so any in-flight tick is ignored + nm := newGame(m.best, m.gen+1, m.termW, m.termH) + nm.started = true + nm.birdVel = jumpVel // start with a flap, like the original + return nm, tick(nm.gen) + } + if !m.started { + m.started = true + m.birdVel = jumpVel + return m, tick(m.gen) + } + m.birdVel = jumpVel + case "q", "esc", "ctrl+c": + return m, tea.Quit + } + + case tickMsg: + if msg.gen != m.gen { + return m, nil // stale chain — drop it + } + if !m.started || m.gameOver { + return m, nil // stop the loop; nothing animates on end screens + } + if m.resized { + m.step() + } + return m, tick(m.gen) + } + return m, nil +} + +func (m *model) step() { + m.birdVel += gravity + if m.birdVel > maxFall { + m.birdVel = maxFall + } + m.birdY += m.birdVel + + // ceiling clamp + if m.birdY < 0 { + m.birdY = 0 + m.birdVel = 0 + } + // ground collision + floor := float64(m.height - groundH - 1) + if m.birdY >= floor { + m.birdY = floor + m.gameOver = true + } + + // advance pipes + for i := range m.pipes { + m.pipes[i].x -= pipeSpeed + } + for len(m.pipes) > 0 && m.pipes[0].x+pipeWidth < 0 { + m.pipes = m.pipes[1:] + } + // spawn pipes + usableH := m.height - groundH - pipeGap - 4 + if usableH < 1 { + usableH = 1 + } + if len(m.pipes) == 0 || m.pipes[len(m.pipes)-1].x < float64(m.width-pipeSpacing) { + gapY := rand.Intn(usableH) + 2 + m.pipes = append(m.pipes, pipe{x: float64(m.width), gapY: gapY}) + } + + by := int(m.birdY) + for i := range m.pipes { + p := &m.pipes[i] + px := int(p.x) + // score once the pipe's trailing edge passes the bird + if !p.scored && px+pipeWidth <= m.birdX { + p.scored = true + m.score++ + if m.score > m.best { + m.best = m.score + } + } + // collision with pipe body + if m.birdX >= px && m.birdX < px+pipeWidth { + if by < p.gapY || by > p.gapY+pipeGap { + m.gameOver = true + } + } + } +} + +// styles ---------------------------------------------------------------------- + +var ( + birdStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true) + birdUpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true) + birdDnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + pipeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("34")) + pipeHi = lipgloss.NewStyle().Foreground(lipgloss.Color("46")) + capStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("36")) + skyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("33")) + groundStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("142")) + dirtStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130")) + scoreStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) + bestStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + overStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) + hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + titleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("220")). + Background(lipgloss.Color("23")). + Bold(true) +) + +func birdGlyph(vel float64) (string, lipgloss.Style) { + switch { + case vel < -0.3: + return "➚", birdUpStyle + case vel > 0.5: + return "➘", birdDnStyle + default: + return "➙", birdStyle + } +} + +func (m model) View() string { + if !m.resized { + return hintStyle.Render(" sizing terminal… ") + } + width, height := m.width, m.height + grid := make([][]string, height) + for i := range grid { + grid[i] = make([]string, width) + for j := range grid[i] { + grid[i][j] = " " + } + } + + // static sky speckles (precomputed — stable between frames) + for _, s := range m.stars { + if s.y < height-groundH && s.x < width { + grid[s.y][s.x] = skyStyle.Render("·") + } + } + + // pipes + for _, p := range m.pipes { + px := int(p.x) + topCap := p.gapY - 1 + botCap := p.gapY + pipeGap + 1 + for x := px; x < px+pipeWidth && x < width; x++ { + if x < 0 { + continue + } + for y := 0; y < height-groundH; y++ { + if y < p.gapY || y > p.gapY+pipeGap { + if y == topCap || y == botCap { + grid[y][x] = capStyle.Render("▓") + } else if x == px { + grid[y][x] = pipeHi.Render("█") + } else { + grid[y][x] = pipeStyle.Render("█") + } + } + } + } + } + + // ground + for y := height - groundH; y < height; y++ { + for x := 0; x < width; x++ { + if y == height-groundH { + grid[y][x] = groundStyle.Render("▔") + } else { + grid[y][x] = dirtStyle.Render("▓") + } + } + } + + // bird + by := int(m.birdY) + if by >= 0 && by < height && m.birdX >= 0 && m.birdX < width { + glyph, style := birdGlyph(m.birdVel) + grid[by][m.birdX] = style.Render(glyph) + } + + // overlays are written directly into the grid, cell by cell, so styled + // field content underneath can never be corrupted by string splicing. + if !m.started && !m.gameOver { + m.overlayGrid(grid, []overline{ + {" FLAPPY BIRD ", titleStyle}, + {}, + {"press SPACE to flap", hintStyle}, + }) + } else if m.gameOver { + m.overlayGrid(grid, []overline{ + {"✖ GAME OVER ✖", overStyle}, + {}, + {fmt.Sprintf("score %d • SPACE to retry", m.score), hintStyle}, + }) + } + + // compose field + var b strings.Builder + for _, row := range grid { + for _, c := range row { + b.WriteString(c) + } + b.WriteString("\n") + } + field := b.String() + + header := scoreStyle.Render(fmt.Sprintf(" SCORE %d ", m.score)) + + " " + + bestStyle.Render(fmt.Sprintf("BEST %d ", m.best)) + footer := hintStyle.Render(" SPACE: flap • Q: quit ") + + return header + "\n" + field + footer +} + +// overline is one centred line of overlay text with its style. +type overline struct { + text string + style lipgloss.Style +} + +// overlayGrid writes overlay lines into the middle of the grid, one styled +// cell per rune. Empty lines clear a centred band for readability. +func (m model) overlayGrid(grid [][]string, lines []overline) { + startY := (len(grid) - len(lines)) / 2 + if startY < 0 { + startY = 0 + } + // widest line determines the cleared band + maxW := 0 + for _, ol := range lines { + if n := len([]rune(ol.text)); n > maxW { + maxW = n + } + } + for i, ol := range lines { + y := startY + i + if y < 0 || y >= len(grid) { + continue + } + // clear a band as wide as the widest line so text sits on a clean row + bandX := (m.width - maxW - 2) / 2 + for j := 0; j < maxW+2; j++ { + x := bandX + j + if x >= 0 && x < m.width { + grid[y][x] = " " + } + } + runes := []rune(ol.text) + startX := (m.width - len(runes)) / 2 + for j, r := range runes { + x := startX + j + if x < 0 || x >= m.width { + continue + } + grid[y][x] = ol.style.Render(string(r)) + } + } +} + +func main() { + p := tea.NewProgram(newGame(0, 0, 0, 0), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Println("error running game:", err) + } +} diff --git a/geolocate/geolocate.go b/geolocate/geolocate.go new file mode 100644 index 0000000..e2d89df --- /dev/null +++ b/geolocate/geolocate.go @@ -0,0 +1,168 @@ +// 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 +} diff --git a/geolocate/go.mod b/geolocate/go.mod new file mode 100644 index 0000000..0491017 --- /dev/null +++ b/geolocate/go.mod @@ -0,0 +1,5 @@ +module sneak.berlin/go/geolocate + +go 1.26.1 + +require github.com/jaisonerick/macwifi v1.0.0 diff --git a/geolocate/go.sum b/geolocate/go.sum new file mode 100644 index 0000000..93bfe0c --- /dev/null +++ b/geolocate/go.sum @@ -0,0 +1,2 @@ +github.com/jaisonerick/macwifi v1.0.0 h1:Jy86C7BBR/J7u1cxFWOWi+YlwYjiMMjYLUt6RGFD1Mc= +github.com/jaisonerick/macwifi v1.0.0/go.mod h1:AtCf/s2Y4izU7IYcBSPoGe2lp2Oj53T7rz9D08uqSRM=