From 7f75c7b6c06195009f6d5683a70830400a51cf2d Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:00:59 +0200 Subject: [PATCH] Add bluetooth agent Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- go.mod | 1 + go.sum | 2 + internal/bluetooth/agent.go | 83 ++++++++++++++++++++++++++++++++++++ internal/bluetooth/device.go | 9 ++-- nix/gomod2nix.toml | 78 +++------------------------------ 5 files changed, 99 insertions(+), 74 deletions(-) create mode 100644 internal/bluetooth/agent.go diff --git a/go.mod b/go.mod index 3406cce..644c6b6 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( charm.land/lipgloss/v2 v2.0.3 github.com/anotherhadi/ilovetui v0.1.6 github.com/charmbracelet/x/ansi v0.11.7 + github.com/godbus/dbus/v5 v5.2.2 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 diff --git a/go.sum b/go.sum index c4a40d1..a401c5e 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= diff --git a/internal/bluetooth/agent.go b/internal/bluetooth/agent.go new file mode 100644 index 0000000..338182a --- /dev/null +++ b/internal/bluetooth/agent.go @@ -0,0 +1,83 @@ +package bluetooth + +import ( + "fmt" + "sync" + + "github.com/godbus/dbus/v5" +) + +const ( + agentPath = dbus.ObjectPath("/org/anotherhadi/settuings/agent") + agentCapability = "DisplayYesNo" +) + +// agent implements the BlueZ org.bluez.Agent1 interface, auto-accepting +// every pairing prompt. `bluetoothctl pair` run non-interactively never +// registers an agent, so BlueZ has nobody to ask for PIN/passkey +// confirmation and any device that isn't "Just Works" fails with +// AuthenticationFailed. Registering this agent as the system default +// fixes that even though pairing itself still happens in a separate +// bluetoothctl process: BlueZ routes auth requests to whichever agent is +// registered as default, not to whoever initiated the pairing. +type agent struct{} + +func (agent) Release() *dbus.Error { return nil } + +func (agent) RequestPinCode(_ dbus.ObjectPath) (string, *dbus.Error) { + return "", dbus.NewError("org.bluez.Error.Rejected", nil) +} + +func (agent) DisplayPinCode(_ dbus.ObjectPath, _ string) *dbus.Error { return nil } + +func (agent) RequestPasskey(_ dbus.ObjectPath) (uint32, *dbus.Error) { + return 0, dbus.NewError("org.bluez.Error.Rejected", nil) +} + +func (agent) DisplayPasskey(_ dbus.ObjectPath, _ uint32, _ uint16) *dbus.Error { return nil } + +func (agent) RequestConfirmation(_ dbus.ObjectPath, _ uint32) *dbus.Error { return nil } + +func (agent) RequestAuthorization(_ dbus.ObjectPath) *dbus.Error { return nil } + +func (agent) AuthorizeService(_ dbus.ObjectPath, _ string) *dbus.Error { return nil } + +func (agent) Cancel() *dbus.Error { return nil } + +var ( + agentOnce sync.Once + agentErr error + // agentConn is kept alive for the process lifetime: BlueZ calls back + // into our agent over this connection while pairing is in progress. + agentConn *dbus.Conn +) + +// ensureAgent registers this process as BlueZ's default pairing agent, once +// per run. Call before any Pair. +func ensureAgent() error { + agentOnce.Do(func() { + conn, err := dbus.ConnectSystemBus() + if err != nil { + agentErr = fmt.Errorf("connect to system bus: %w", err) + return + } + + if err := conn.Export(agent{}, agentPath, "org.bluez.Agent1"); err != nil { + agentErr = fmt.Errorf("export agent: %w", err) + return + } + + manager := conn.Object("org.bluez", dbus.ObjectPath("/org/bluez")) + if call := manager.Call("org.bluez.AgentManager1.RegisterAgent", 0, agentPath, agentCapability); call.Err != nil { + agentErr = fmt.Errorf("register agent: %w", call.Err) + return + } + if call := manager.Call("org.bluez.AgentManager1.RequestDefaultAgent", 0, agentPath); call.Err != nil { + agentErr = fmt.Errorf("request default agent: %w", call.Err) + return + } + + agentConn = conn + }) + return agentErr +} diff --git a/internal/bluetooth/device.go b/internal/bluetooth/device.go index cd1bd28..49b2fc4 100644 --- a/internal/bluetooth/device.go +++ b/internal/bluetooth/device.go @@ -185,10 +185,13 @@ func Scan(seconds int) error { return err } -// Pair initiates pairing with a device. Because non-interactive -// bluetoothctl has no agent to answer PIN/confirmation prompts, this only -// succeeds for "just works" devices (most mice, keyboards, headsets). +// Pair initiates pairing with a device, first registering settuings as +// BlueZ's default agent so devices that need PIN/passkey confirmation +// (not just "just works" ones) can complete pairing. func Pair(address string) error { + if err := ensureAgent(); err != nil { + return err + } _, err := run(actionTimeout, "pair", address) return err } diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml index 4b331a5..632f0fa 100644 --- a/nix/gomod2nix.toml +++ b/nix/gomod2nix.toml @@ -21,10 +21,6 @@ schema = 3 version = 'v2.24.1' hash = 'sha256-DufsljWRKireFuLFcnPozuF0N3UoRYGlEfNFMD+z0ng=' - [mod.'github.com/andybalholm/brotli'] - version = 'v1.0.4' - hash = 'sha256-gAnPRdGP4yna4hiRIEDyBtDOVJqd7RU27wlPu96Rdf8=' - [mod.'github.com/anotherhadi/ilovetui'] version = 'v0.1.6' hash = 'sha256-7E+7UFks5vM3XWCvX2joFmHGcW7qqnoox6ZPFglaLO4=' @@ -77,10 +73,6 @@ schema = 3 version = 'v1.12.0' hash = 'sha256-PVX2rDCkiG0vyA1CbDi3bzLeZ2T8hcqJv3pZ5YwGzMI=' - [mod.'github.com/dustin/go-humanize'] - version = 'v1.0.1' - hash = 'sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc=' - [mod.'github.com/fsnotify/fsnotify'] version = 'v1.10.1' hash = 'sha256-6LBLgsh4nKkMpgRKVsYFEaGDSU1fncBcWVSjKBdfgjU=' @@ -89,42 +81,22 @@ schema = 3 version = 'v2.5.0' hash = 'sha256-LbrCBANBprVI84M0CWrXc7rriJL5ac5VKbh58LBTw7U=' - [mod.'github.com/golang/groupcache'] - version = 'v0.0.0-20210331224755-41bb18bfe9da' - hash = 'sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0=' - - [mod.'github.com/google/uuid'] - version = 'v1.6.0' - hash = 'sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=' + [mod.'github.com/godbus/dbus/v5'] + version = 'v5.2.2' + hash = 'sha256-j+LicPlXcB1R3oVFPa8cd4yVJLpBx+ca5gXmHqOJSN8=' [mod.'github.com/gorilla/css'] version = 'v1.0.1' hash = 'sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=' - [mod.'github.com/gorilla/websocket'] - version = 'v1.5.0' - hash = 'sha256-EYVgkSEMo4HaVrsWKqnsYRp8SSS8gNf7t+Elva02Ofc=' - [mod.'github.com/inconshreveable/mousetrap'] version = 'v1.1.0' hash = 'sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE=' - [mod.'github.com/klauspost/compress'] - version = 'v1.17.8' - hash = 'sha256-8rgCCfHX29le8m6fyVn6gwFde5TPUHjwQqZqv9JIubs=' - - [mod.'github.com/lqqyt2423/go-mitmproxy'] - version = 'v1.8.11' - hash = 'sha256-PoCL6TOt99JsenH4XFx+7MqIzzYnnLHYwUpEQ7kxxyg=' - [mod.'github.com/lucasb-eyer/go-colorful'] version = 'v1.4.0' hash = 'sha256-i/3GDHKEMLCy0kc3mtyk58UWYOPmKoUVaq6QCAWXKP0=' - [mod.'github.com/mattn/go-isatty'] - version = 'v0.0.22' - hash = 'sha256-6O/0jc33pKUzlzUGpH8Ekk54XgJvx6Qe7kJtbcNJAV4=' - [mod.'github.com/mattn/go-runewidth'] version = 'v0.0.23' hash = 'sha256-SmChZ2U1aR8pW3LPhdM7KcVF5TO6VcHgRzBtUXbBWJA=' @@ -137,18 +109,10 @@ schema = 3 version = 'v0.2.2' hash = 'sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ=' - [mod.'github.com/ncruces/go-strftime'] - version = 'v1.0.0' - hash = 'sha256-GYIwYDONuv/yTE0AEugCHQbtV3oiBaco93xUNYFcVBQ=' - [mod.'github.com/pelletier/go-toml/v2'] version = 'v2.3.1' hash = 'sha256-5H8+UOtPOs+Yc+8oVT/3bugCCdbq3jFMH6eOW8dadyg=' - [mod.'github.com/remyoudompheng/bigfft'] - version = 'v0.0.0-20230129092748-24d4a6f8daec' - hash = 'sha256-vYmpyCE37eBYP/navhaLV4oX4/nu0Z/StAocLIFqrmM=' - [mod.'github.com/rivo/uniseg'] version = 'v0.4.7' hash = 'sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=' @@ -157,14 +121,6 @@ schema = 3 version = 'v0.12.0' hash = 'sha256-EXk9S5Z5sYyApAzCgHIugsGMbt/pHWRfHYFZH5D+5Ws=' - [mod.'github.com/sahilm/fuzzy'] - version = 'v0.1.1' - hash = 'sha256-f2VsDI6G+V2w31tSDzbZPi9EI2E7jRV6Aq8yeOorSZY=' - - [mod.'github.com/satori/go.uuid'] - version = 'v1.2.0' - hash = 'sha256-y/lSGbnZa7mYJCs30a3LTyjfCFQSaYp8GbVR8dwtmsg=' - [mod.'github.com/sirupsen/logrus'] version = 'v1.9.4' hash = 'sha256-ltRvmtM3XTCAFwY0IesfRqYIivyXPPuvkFjL4ARh1wg=' @@ -205,14 +161,6 @@ schema = 3 version = 'v1.0.6' hash = 'sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY=' - [mod.'github.com/yuin/gopher-lua'] - version = 'v1.1.2' - hash = 'sha256-2YMCxv7RO3uqq6OTjvE0kR9nTt+n2cF+MrLtGEW68po=' - - [mod.'go.uber.org/atomic'] - version = 'v1.11.0' - hash = 'sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY=' - [mod.'go.yaml.in/yaml/v3'] version = 'v3.0.4' hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4=' @@ -233,22 +181,10 @@ schema = 3 version = 'v0.37.0' hash = 'sha256-8XDOnlPIybcDRy89fkjG5VqtIt5Ku+LmaqYhgKl7i1E=' + [mod.'gopkg.in/check.v1'] + version = 'v1.0.0-20201130134442-10cb98267c6c' + hash = 'sha256-VlIpM2r/OD+kkyItn6vW35dyc0rtkJufA93rjFyzncs=' + [mod.'gopkg.in/yaml.v3'] version = 'v3.0.1' hash = 'sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU=' - - [mod.'modernc.org/libc'] - version = 'v1.72.3' - hash = 'sha256-eOCoqSnX/VzpG63nh1j3JpXRndnZZcn2cDTeutexXAI=' - - [mod.'modernc.org/mathutil'] - version = 'v1.7.1' - hash = 'sha256-COZ5rF2GhQVR1r6a0DanJ8qwQ94JSKdQxTMWrDzE0Cc=' - - [mod.'modernc.org/memory'] - version = 'v1.11.0' - hash = 'sha256-MkybF8vvrxXS5j7O8w3skwTo0aMo1yjWS0K440rYcHM=' - - [mod.'modernc.org/sqlite'] - version = 'v1.50.1' - hash = 'sha256-JbGP3eerH/6mgzI0aq9SpLLwe6ld6PV/zJReG8mLQN0='