summaryrefslogtreecommitdiff
path: root/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez
diff options
context:
space:
mode:
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez')
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/agent.go341
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/broker.go21
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/broker_test.go220
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/handlers.go237
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/handlers_test.go41
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/manager.go588
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/subscription_broker.go86
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/types.go78
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/types_test.go210
9 files changed, 1822 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/agent.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/agent.go
new file mode 100644
index 0000000..b630a19
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/agent.go
@@ -0,0 +1,341 @@
+package bluez
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ bluezService = "org.bluez"
+ agentManagerPath = "/org/bluez"
+ agentManagerIface = "org.bluez.AgentManager1"
+ agent1Iface = "org.bluez.Agent1"
+ device1Iface = "org.bluez.Device1"
+ agentPath = "/com/danklinux/bluez/agent"
+ agentCapability = "KeyboardDisplay"
+)
+
+const introspectXML = `
+<node>
+ <interface name="org.bluez.Agent1">
+ <method name="Release"/>
+ <method name="RequestPinCode">
+ <arg direction="in" type="o" name="device"/>
+ <arg direction="out" type="s" name="pincode"/>
+ </method>
+ <method name="RequestPasskey">
+ <arg direction="in" type="o" name="device"/>
+ <arg direction="out" type="u" name="passkey"/>
+ </method>
+ <method name="DisplayPinCode">
+ <arg direction="in" type="o" name="device"/>
+ <arg direction="in" type="s" name="pincode"/>
+ </method>
+ <method name="DisplayPasskey">
+ <arg direction="in" type="o" name="device"/>
+ <arg direction="in" type="u" name="passkey"/>
+ <arg direction="in" type="q" name="entered"/>
+ </method>
+ <method name="RequestConfirmation">
+ <arg direction="in" type="o" name="device"/>
+ <arg direction="in" type="u" name="passkey"/>
+ </method>
+ <method name="RequestAuthorization">
+ <arg direction="in" type="o" name="device"/>
+ </method>
+ <method name="AuthorizeService">
+ <arg direction="in" type="o" name="device"/>
+ <arg direction="in" type="s" name="uuid"/>
+ </method>
+ <method name="Cancel"/>
+ </interface>
+ <interface name="org.freedesktop.DBus.Introspectable">
+ <method name="Introspect">
+ <arg direction="out" type="s" name="data"/>
+ </method>
+ </interface>
+</node>`
+
+type BluezAgent struct {
+ conn *dbus.Conn
+ broker PromptBroker
+}
+
+func NewBluezAgent(broker PromptBroker) (*BluezAgent, error) {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("system bus connection failed: %w", err)
+ }
+
+ agent := &BluezAgent{
+ conn: conn,
+ broker: broker,
+ }
+
+ if err := conn.Export(agent, dbus.ObjectPath(agentPath), agent1Iface); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("agent export failed: %w", err)
+ }
+
+ if err := conn.Export(agent, dbus.ObjectPath(agentPath), "org.freedesktop.DBus.Introspectable"); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("introspection export failed: %w", err)
+ }
+
+ mgr := conn.Object(bluezService, dbus.ObjectPath(agentManagerPath))
+ if err := mgr.Call(agentManagerIface+".RegisterAgent", 0, dbus.ObjectPath(agentPath), agentCapability).Err; err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("agent registration failed: %w", err)
+ }
+
+ if err := mgr.Call(agentManagerIface+".RequestDefaultAgent", 0, dbus.ObjectPath(agentPath)).Err; err != nil {
+ log.Debugf("[BluezAgent] not default agent: %v", err)
+ }
+
+ log.Infof("[BluezAgent] registered at %s with capability %s", agentPath, agentCapability)
+ return agent, nil
+}
+
+func (a *BluezAgent) Close() {
+ if a.conn == nil {
+ return
+ }
+ mgr := a.conn.Object(bluezService, dbus.ObjectPath(agentManagerPath))
+ mgr.Call(agentManagerIface+".UnregisterAgent", 0, dbus.ObjectPath(agentPath))
+ a.conn.Close()
+}
+
+func (a *BluezAgent) Release() *dbus.Error {
+ log.Infof("[BluezAgent] Release called")
+ return nil
+}
+
+func (a *BluezAgent) RequestPinCode(device dbus.ObjectPath) (string, *dbus.Error) {
+ log.Infof("[BluezAgent] RequestPinCode: device=%s", device)
+
+ secrets, err := a.promptFor(device, "pin", []string{"pin"}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] RequestPinCode failed: %v", err)
+ return "", a.errorFrom(err)
+ }
+
+ pin := secrets["pin"]
+ log.Infof("[BluezAgent] RequestPinCode returning PIN (len=%d)", len(pin))
+ return pin, nil
+}
+
+func (a *BluezAgent) RequestPasskey(device dbus.ObjectPath) (uint32, *dbus.Error) {
+ log.Infof("[BluezAgent] RequestPasskey: device=%s", device)
+
+ secrets, err := a.promptFor(device, "passkey", []string{"passkey"}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] RequestPasskey failed: %v", err)
+ return 0, a.errorFrom(err)
+ }
+
+ passkey, err := strconv.ParseUint(secrets["passkey"], 10, 32)
+ if err != nil {
+ log.Warnf("[BluezAgent] invalid passkey format: %v", err)
+ return 0, dbus.MakeFailedError(fmt.Errorf("invalid passkey: %w", err))
+ }
+
+ log.Infof("[BluezAgent] RequestPasskey returning: %d", passkey)
+ return uint32(passkey), nil
+}
+
+func (a *BluezAgent) DisplayPinCode(device dbus.ObjectPath, pincode string) *dbus.Error {
+ log.Infof("[BluezAgent] DisplayPinCode: device=%s, pin=%s", device, pincode)
+
+ _, err := a.promptFor(device, "display-pin", []string{}, &pincode)
+ if err != nil {
+ log.Warnf("[BluezAgent] DisplayPinCode acknowledgment failed: %v", err)
+ }
+
+ return nil
+}
+
+func (a *BluezAgent) DisplayPasskey(device dbus.ObjectPath, passkey uint32, entered uint16) *dbus.Error {
+ log.Infof("[BluezAgent] DisplayPasskey: device=%s, passkey=%06d, entered=%d", device, passkey, entered)
+
+ if entered == 0 {
+ passkeyStr := strconv.FormatUint(uint64(passkey), 10)
+ _, err := a.promptFor(device, "display-passkey", []string{}, &passkeyStr)
+ if err != nil {
+ log.Warnf("[BluezAgent] DisplayPasskey acknowledgment failed: %v", err)
+ }
+ }
+
+ return nil
+}
+
+func (a *BluezAgent) RequestConfirmation(device dbus.ObjectPath, passkey uint32) *dbus.Error {
+ log.Infof("[BluezAgent] RequestConfirmation: device=%s, passkey=%06d", device, passkey)
+
+ passkeyStr := strconv.FormatUint(uint64(passkey), 10)
+ secrets, err := a.promptFor(device, "confirm", []string{"decision"}, &passkeyStr)
+ if err != nil {
+ log.Warnf("[BluezAgent] RequestConfirmation failed: %v", err)
+ return a.errorFrom(err)
+ }
+
+ if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
+ log.Debugf("[BluezAgent] RequestConfirmation rejected by user")
+ return dbus.NewError("org.bluez.Error.Rejected", nil)
+ }
+
+ log.Infof("[BluezAgent] RequestConfirmation accepted")
+ return nil
+}
+
+func (a *BluezAgent) RequestAuthorization(device dbus.ObjectPath) *dbus.Error {
+ log.Infof("[BluezAgent] RequestAuthorization: device=%s", device)
+
+ secrets, err := a.promptFor(device, "authorize", []string{"decision"}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] RequestAuthorization failed: %v", err)
+ return a.errorFrom(err)
+ }
+
+ if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
+ log.Debugf("[BluezAgent] RequestAuthorization rejected by user")
+ return dbus.NewError("org.bluez.Error.Rejected", nil)
+ }
+
+ log.Infof("[BluezAgent] RequestAuthorization accepted")
+ return nil
+}
+
+func (a *BluezAgent) AuthorizeService(device dbus.ObjectPath, uuid string) *dbus.Error {
+ log.Infof("[BluezAgent] AuthorizeService: device=%s, uuid=%s", device, uuid)
+
+ secrets, err := a.promptFor(device, "authorize-service:"+uuid, []string{"decision"}, nil)
+ if err != nil {
+ log.Warnf("[BluezAgent] AuthorizeService failed: %v", err)
+ return a.errorFrom(err)
+ }
+
+ if secrets["decision"] != "yes" && secrets["decision"] != "accept" {
+ log.Debugf("[BluezAgent] AuthorizeService rejected by user")
+ return dbus.NewError("org.bluez.Error.Rejected", nil)
+ }
+
+ log.Infof("[BluezAgent] AuthorizeService accepted")
+ return nil
+}
+
+func (a *BluezAgent) Cancel() *dbus.Error {
+ log.Infof("[BluezAgent] Cancel called")
+ return nil
+}
+
+func (a *BluezAgent) Introspect() (string, *dbus.Error) {
+ return introspectXML, nil
+}
+
+func (a *BluezAgent) promptFor(device dbus.ObjectPath, requestType string, fields []string, displayValue *string) (map[string]string, error) {
+ if a.broker == nil {
+ return nil, fmt.Errorf("broker not initialized")
+ }
+
+ deviceName, deviceAddr := a.getDeviceInfo(device)
+ hints := []string{}
+ if displayValue != nil {
+ hints = append(hints, *displayValue)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ var passkey *uint32
+ if requestType == "confirm" || requestType == "display-passkey" {
+ if displayValue != nil {
+ if pk, err := strconv.ParseUint(*displayValue, 10, 32); err == nil {
+ pk32 := uint32(pk)
+ passkey = &pk32
+ }
+ }
+ }
+
+ token, err := a.broker.Ask(ctx, PromptRequest{
+ DevicePath: string(device),
+ DeviceName: deviceName,
+ DeviceAddr: deviceAddr,
+ RequestType: requestType,
+ Fields: fields,
+ Hints: hints,
+ Passkey: passkey,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("prompt creation failed: %w", err)
+ }
+
+ log.Infof("[BluezAgent] waiting for user response (token=%s)", token)
+ reply, err := a.broker.Wait(ctx, token)
+ if err != nil {
+ if errors.Is(err, errdefs.ErrSecretPromptTimeout) {
+ return nil, err
+ }
+ if reply.Cancel || errors.Is(err, errdefs.ErrSecretPromptCancelled) {
+ return nil, errdefs.ErrSecretPromptCancelled
+ }
+ return nil, err
+ }
+
+ if !reply.Accept && len(fields) > 0 {
+ return nil, errdefs.ErrSecretPromptCancelled
+ }
+
+ return reply.Secrets, nil
+}
+
+func (a *BluezAgent) getDeviceInfo(device dbus.ObjectPath) (string, string) {
+ obj := a.conn.Object(bluezService, device)
+
+ var name, alias, addr string
+
+ nameVar, err := obj.GetProperty(device1Iface + ".Name")
+ if err == nil {
+ if n, ok := nameVar.Value().(string); ok {
+ name = n
+ }
+ }
+
+ aliasVar, err := obj.GetProperty(device1Iface + ".Alias")
+ if err == nil {
+ if a, ok := aliasVar.Value().(string); ok {
+ alias = a
+ }
+ }
+
+ addrVar, err := obj.GetProperty(device1Iface + ".Address")
+ if err == nil {
+ if a, ok := addrVar.Value().(string); ok {
+ addr = a
+ }
+ }
+
+ if alias != "" {
+ return alias, addr
+ }
+ if name != "" {
+ return name, addr
+ }
+ return addr, addr
+}
+
+func (a *BluezAgent) errorFrom(err error) *dbus.Error {
+ if errors.Is(err, errdefs.ErrSecretPromptTimeout) {
+ return dbus.NewError("org.bluez.Error.Canceled", nil)
+ }
+ if errors.Is(err, errdefs.ErrSecretPromptCancelled) {
+ return dbus.NewError("org.bluez.Error.Canceled", nil)
+ }
+ return dbus.MakeFailedError(err)
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/broker.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/broker.go
new file mode 100644
index 0000000..f817707
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/broker.go
@@ -0,0 +1,21 @@
+package bluez
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+)
+
+type PromptBroker interface {
+ Ask(ctx context.Context, req PromptRequest) (token string, err error)
+ Wait(ctx context.Context, token string) (PromptReply, error)
+ Resolve(token string, reply PromptReply) error
+}
+
+func generateToken() (string, error) {
+ bytes := make([]byte, 16)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/broker_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/broker_test.go
new file mode 100644
index 0000000..9eebe61
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/broker_test.go
@@ -0,0 +1,220 @@
+package bluez
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestSubscriptionBrokerAskWait(t *testing.T) {
+ promptReceived := false
+ broker := NewSubscriptionBroker(func(p PairingPrompt) {
+ promptReceived = true
+ if p.Token == "" {
+ t.Error("expected token to be non-empty")
+ }
+ if p.DeviceName != "TestDevice" {
+ t.Errorf("expected DeviceName=TestDevice, got %s", p.DeviceName)
+ }
+ })
+
+ ctx := context.Background()
+ req := PromptRequest{
+ DevicePath: "/org/bluez/test",
+ DeviceName: "TestDevice",
+ DeviceAddr: "AA:BB:CC:DD:EE:FF",
+ RequestType: "pin",
+ Fields: []string{"pin"},
+ }
+
+ token, err := broker.Ask(ctx, req)
+ if err != nil {
+ t.Fatalf("Ask failed: %v", err)
+ }
+
+ if token == "" {
+ t.Fatal("expected non-empty token")
+ }
+
+ if !promptReceived {
+ t.Fatal("expected prompt broadcast to be called")
+ }
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ broker.Resolve(token, PromptReply{
+ Secrets: map[string]string{"pin": "1234"},
+ Accept: true,
+ })
+ }()
+
+ reply, err := broker.Wait(ctx, token)
+ if err != nil {
+ t.Fatalf("Wait failed: %v", err)
+ }
+
+ if reply.Secrets["pin"] != "1234" {
+ t.Errorf("expected pin=1234, got %s", reply.Secrets["pin"])
+ }
+
+ if !reply.Accept {
+ t.Error("expected Accept=true")
+ }
+}
+
+func TestSubscriptionBrokerTimeout(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ req := PromptRequest{
+ DevicePath: "/org/bluez/test",
+ DeviceName: "TestDevice",
+ RequestType: "passkey",
+ Fields: []string{"passkey"},
+ }
+
+ token, err := broker.Ask(ctx, req)
+ if err != nil {
+ t.Fatalf("Ask failed: %v", err)
+ }
+
+ _, err = broker.Wait(ctx, token)
+ if err == nil {
+ t.Fatal("expected timeout error")
+ }
+}
+
+func TestSubscriptionBrokerCancel(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+
+ ctx := context.Background()
+ req := PromptRequest{
+ DevicePath: "/org/bluez/test",
+ DeviceName: "TestDevice",
+ RequestType: "confirm",
+ Fields: []string{"decision"},
+ }
+
+ token, err := broker.Ask(ctx, req)
+ if err != nil {
+ t.Fatalf("Ask failed: %v", err)
+ }
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ broker.Resolve(token, PromptReply{
+ Cancel: true,
+ })
+ }()
+
+ _, err = broker.Wait(ctx, token)
+ if err == nil {
+ t.Fatal("expected cancelled error")
+ }
+}
+
+func TestSubscriptionBrokerUnknownToken(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+
+ ctx := context.Background()
+ _, err := broker.Wait(ctx, "invalid-token")
+ if err == nil {
+ t.Fatal("expected error for unknown token")
+ }
+}
+
+func TestGenerateToken(t *testing.T) {
+ token1, err := generateToken()
+ if err != nil {
+ t.Fatalf("generateToken failed: %v", err)
+ }
+
+ token2, err := generateToken()
+ if err != nil {
+ t.Fatalf("generateToken failed: %v", err)
+ }
+
+ if token1 == token2 {
+ t.Error("expected unique tokens")
+ }
+
+ if len(token1) != 32 {
+ t.Errorf("expected token length 32, got %d", len(token1))
+ }
+}
+
+func TestSubscriptionBrokerResolveUnknownToken(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+
+ err := broker.Resolve("unknown-token", PromptReply{
+ Secrets: map[string]string{"test": "value"},
+ })
+ if err == nil {
+ t.Fatal("expected error for unknown token")
+ }
+}
+
+func TestSubscriptionBrokerMultipleRequests(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+ ctx := context.Background()
+
+ req1 := PromptRequest{
+ DevicePath: "/org/bluez/test1",
+ DeviceName: "Device1",
+ RequestType: "pin",
+ Fields: []string{"pin"},
+ }
+
+ req2 := PromptRequest{
+ DevicePath: "/org/bluez/test2",
+ DeviceName: "Device2",
+ RequestType: "passkey",
+ Fields: []string{"passkey"},
+ }
+
+ token1, err := broker.Ask(ctx, req1)
+ if err != nil {
+ t.Fatalf("Ask1 failed: %v", err)
+ }
+
+ token2, err := broker.Ask(ctx, req2)
+ if err != nil {
+ t.Fatalf("Ask2 failed: %v", err)
+ }
+
+ if token1 == token2 {
+ t.Error("expected different tokens")
+ }
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ broker.Resolve(token1, PromptReply{
+ Secrets: map[string]string{"pin": "1234"},
+ Accept: true,
+ })
+ broker.Resolve(token2, PromptReply{
+ Secrets: map[string]string{"passkey": "567890"},
+ Accept: true,
+ })
+ }()
+
+ reply1, err := broker.Wait(ctx, token1)
+ if err != nil {
+ t.Fatalf("Wait1 failed: %v", err)
+ }
+
+ reply2, err := broker.Wait(ctx, token2)
+ if err != nil {
+ t.Fatalf("Wait2 failed: %v", err)
+ }
+
+ if reply1.Secrets["pin"] != "1234" {
+ t.Errorf("expected pin=1234, got %s", reply1.Secrets["pin"])
+ }
+
+ if reply2.Secrets["passkey"] != "567890" {
+ t.Errorf("expected passkey=567890, got %s", reply2.Secrets["passkey"])
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/handlers.go
new file mode 100644
index 0000000..be5a1c4
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/handlers.go
@@ -0,0 +1,237 @@
+package bluez
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+type BluetoothEvent struct {
+ Type string `json:"type"`
+ Data BluetoothState `json:"data"`
+}
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ switch req.Method {
+ case "bluetooth.getState":
+ handleGetState(conn, req, manager)
+ case "bluetooth.startDiscovery":
+ handleStartDiscovery(conn, req, manager)
+ case "bluetooth.stopDiscovery":
+ handleStopDiscovery(conn, req, manager)
+ case "bluetooth.setPowered":
+ handleSetPowered(conn, req, manager)
+ case "bluetooth.pair":
+ handlePairDevice(conn, req, manager)
+ case "bluetooth.connect":
+ handleConnectDevice(conn, req, manager)
+ case "bluetooth.disconnect":
+ handleDisconnectDevice(conn, req, manager)
+ case "bluetooth.remove":
+ handleRemoveDevice(conn, req, manager)
+ case "bluetooth.trust":
+ handleTrustDevice(conn, req, manager)
+ case "bluetooth.untrust":
+ handleUntrustDevice(conn, req, manager)
+ case "bluetooth.subscribe":
+ handleSubscribe(conn, req, manager)
+ case "bluetooth.pairing.submit":
+ handlePairingSubmit(conn, req, manager)
+ case "bluetooth.pairing.cancel":
+ handlePairingCancel(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ models.Respond(conn, req.ID, manager.GetState())
+}
+
+func handleStartDiscovery(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.StartDiscovery(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "discovery started"})
+}
+
+func handleStopDiscovery(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.StopDiscovery(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "discovery stopped"})
+}
+
+func handleSetPowered(conn net.Conn, req models.Request, manager *Manager) {
+ powered, err := params.Bool(req.Params, "powered")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetPowered(powered); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "powered state updated"})
+}
+
+func handlePairDevice(conn net.Conn, req models.Request, manager *Manager) {
+ devicePath, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.PairDevice(devicePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "pairing initiated"})
+}
+
+func handleConnectDevice(conn net.Conn, req models.Request, manager *Manager) {
+ devicePath, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.ConnectDevice(devicePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connecting"})
+}
+
+func handleDisconnectDevice(conn net.Conn, req models.Request, manager *Manager) {
+ devicePath, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.DisconnectDevice(devicePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"})
+}
+
+func handleRemoveDevice(conn net.Conn, req models.Request, manager *Manager) {
+ devicePath, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.RemoveDevice(devicePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "device removed"})
+}
+
+func handleTrustDevice(conn net.Conn, req models.Request, manager *Manager) {
+ devicePath, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.TrustDevice(devicePath, true); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "device trusted"})
+}
+
+func handleUntrustDevice(conn net.Conn, req models.Request, manager *Manager) {
+ devicePath, err := params.String(req.Params, "device")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.TrustDevice(devicePath, false); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "device untrusted"})
+}
+
+func handlePairingSubmit(conn net.Conn, req models.Request, manager *Manager) {
+ token, err := params.String(req.Params, "token")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ secrets := params.StringMapOpt(req.Params, "secrets")
+ accept := params.BoolOpt(req.Params, "accept", false)
+
+ if err := manager.SubmitPairing(token, secrets, accept); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "pairing response submitted"})
+}
+
+func handlePairingCancel(conn net.Conn, req models.Request, manager *Manager) {
+ token, err := params.String(req.Params, "token")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.CancelPairing(token); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "pairing cancelled"})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ event := BluetoothEvent{
+ Type: "state_changed",
+ Data: initialState,
+ }
+
+ if err := json.NewEncoder(conn).Encode(models.Response[BluetoothEvent]{
+ ID: req.ID,
+ Result: &event,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ event := BluetoothEvent{
+ Type: "state_changed",
+ Data: state,
+ }
+ if err := json.NewEncoder(conn).Encode(models.Response[BluetoothEvent]{
+ Result: &event,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/handlers_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/handlers_test.go
new file mode 100644
index 0000000..1846eb0
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/handlers_test.go
@@ -0,0 +1,41 @@
+package bluez
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestBrokerIntegration(t *testing.T) {
+ broker := NewSubscriptionBroker(nil)
+ ctx := context.Background()
+
+ req := PromptRequest{
+ DevicePath: "/org/bluez/test",
+ DeviceName: "TestDevice",
+ RequestType: "pin",
+ Fields: []string{"pin"},
+ }
+
+ token, err := broker.Ask(ctx, req)
+ if err != nil {
+ t.Fatalf("Ask failed: %v", err)
+ }
+
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ broker.Resolve(token, PromptReply{
+ Secrets: map[string]string{"pin": "1234"},
+ Accept: true,
+ })
+ }()
+
+ reply, err := broker.Wait(ctx, token)
+ if err != nil {
+ t.Fatalf("Wait failed: %v", err)
+ }
+
+ if reply.Secrets["pin"] != "1234" {
+ t.Errorf("expected pin=1234, got %s", reply.Secrets["pin"])
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/manager.go
new file mode 100644
index 0000000..4408d25
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/manager.go
@@ -0,0 +1,588 @@
+package bluez
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ adapter1Iface = "org.bluez.Adapter1"
+ objectMgrIface = "org.freedesktop.DBus.ObjectManager"
+ propertiesIface = "org.freedesktop.DBus.Properties"
+)
+
+func NewManager() (*Manager, error) {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("system bus connection failed: %w", err)
+ }
+
+ m := &Manager{
+ state: &BluetoothState{
+ Powered: false,
+ Discovering: false,
+ Devices: []Device{},
+ PairedDevices: []Device{},
+ ConnectedDevices: []Device{},
+ },
+ stateMutex: sync.RWMutex{},
+
+ stopChan: make(chan struct{}),
+ dbusConn: conn,
+ signals: make(chan *dbus.Signal, 256),
+ dirty: make(chan struct{}, 1),
+ eventQueue: make(chan func(), 32),
+ }
+
+ broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
+ m.promptBroker = broker
+
+ adapter, err := m.findAdapter()
+ if err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
+ }
+ m.adapterPath = adapter
+
+ if err := m.initialize(); err != nil {
+ conn.Close()
+ return nil, err
+ }
+
+ if err := m.startAgent(); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("agent start failed: %w", err)
+ }
+
+ if err := m.startSignalPump(); err != nil {
+ m.Close()
+ return nil, err
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ m.eventWg.Add(1)
+ go m.eventWorker()
+
+ return m, nil
+}
+
+func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+
+ if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
+ return "", err
+ }
+
+ for path, interfaces := range objects {
+ if _, ok := interfaces[adapter1Iface]; ok {
+ log.Infof("[BluezManager] found adapter: %s", path)
+ return path, nil
+ }
+ }
+
+ return "", fmt.Errorf("no adapter found")
+}
+
+func (m *Manager) initialize() error {
+ if err := m.updateAdapterState(); err != nil {
+ return err
+ }
+
+ if err := m.updateDevices(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (m *Manager) updateAdapterState() error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+
+ poweredVar, err := obj.GetProperty(adapter1Iface + ".Powered")
+ if err != nil {
+ return err
+ }
+
+ discoveringVar, err := obj.GetProperty(adapter1Iface + ".Discovering")
+ if err != nil {
+ return err
+ }
+
+ m.stateMutex.Lock()
+ m.state.Powered = dbusutil.AsOr(poweredVar, false)
+ m.state.Discovering = dbusutil.AsOr(discoveringVar, false)
+ m.stateMutex.Unlock()
+
+ return nil
+}
+
+func (m *Manager) updateDevices() error {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+
+ if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
+ return err
+ }
+
+ devices := []Device{}
+ paired := []Device{}
+ connected := []Device{}
+
+ for path, interfaces := range objects {
+ devProps, ok := interfaces[device1Iface]
+ if !ok {
+ continue
+ }
+
+ if !strings.HasPrefix(string(path), string(m.adapterPath)+"/") {
+ continue
+ }
+
+ dev := m.deviceFromProps(string(path), devProps)
+ devices = append(devices, dev)
+
+ if dev.Paired {
+ paired = append(paired, dev)
+ }
+ if dev.Connected {
+ connected = append(connected, dev)
+ }
+ }
+
+ m.stateMutex.Lock()
+ m.state.Devices = devices
+ m.state.PairedDevices = paired
+ m.state.ConnectedDevices = connected
+ m.stateMutex.Unlock()
+
+ return nil
+}
+
+func (m *Manager) deviceFromProps(path string, props map[string]dbus.Variant) Device {
+ return Device{
+ Path: path,
+ Address: dbusutil.GetOr(props, "Address", ""),
+ Name: dbusutil.GetOr(props, "Name", ""),
+ Alias: dbusutil.GetOr(props, "Alias", ""),
+ Paired: dbusutil.GetOr(props, "Paired", false),
+ Trusted: dbusutil.GetOr(props, "Trusted", false),
+ Blocked: dbusutil.GetOr(props, "Blocked", false),
+ Connected: dbusutil.GetOr(props, "Connected", false),
+ Class: dbusutil.GetOr(props, "Class", uint32(0)),
+ Icon: dbusutil.GetOr(props, "Icon", ""),
+ RSSI: dbusutil.GetOr(props, "RSSI", int16(0)),
+ LegacyPairing: dbusutil.GetOr(props, "LegacyPairing", false),
+ }
+}
+
+func (m *Manager) startAgent() error {
+ if m.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ agent, err := NewBluezAgent(m.promptBroker)
+ if err != nil {
+ return err
+ }
+
+ m.agent = agent
+ return nil
+}
+
+func (m *Manager) startSignalPump() error {
+ m.dbusConn.Signal(m.signals)
+
+ if err := m.dbusConn.AddMatchSignal(
+ dbus.WithMatchInterface(propertiesIface),
+ dbus.WithMatchMember("PropertiesChanged"),
+ ); err != nil {
+ return err
+ }
+
+ if err := m.dbusConn.AddMatchSignal(
+ dbus.WithMatchInterface(objectMgrIface),
+ dbus.WithMatchMember("InterfacesAdded"),
+ ); err != nil {
+ return err
+ }
+
+ if err := m.dbusConn.AddMatchSignal(
+ dbus.WithMatchInterface(objectMgrIface),
+ dbus.WithMatchMember("InterfacesRemoved"),
+ ); err != nil {
+ return err
+ }
+
+ m.sigWG.Add(1)
+ go func() {
+ defer m.sigWG.Done()
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case sig, ok := <-m.signals:
+ if !ok {
+ return
+ }
+ if sig == nil {
+ continue
+ }
+ m.handleSignal(sig)
+ }
+ }
+ }()
+
+ return nil
+}
+
+func (m *Manager) handleSignal(sig *dbus.Signal) {
+ switch sig.Name {
+ case propertiesIface + ".PropertiesChanged":
+ if len(sig.Body) < 2 {
+ return
+ }
+
+ iface, ok := sig.Body[0].(string)
+ if !ok {
+ return
+ }
+
+ changed, ok := sig.Body[1].(map[string]dbus.Variant)
+ if !ok {
+ return
+ }
+
+ switch iface {
+ case adapter1Iface:
+ if strings.HasPrefix(string(sig.Path), string(m.adapterPath)) {
+ m.handleAdapterPropertiesChanged(changed)
+ }
+ case device1Iface:
+ m.handleDevicePropertiesChanged(sig.Path, changed)
+ }
+
+ case objectMgrIface + ".InterfacesAdded":
+ m.notifySubscribers()
+
+ case objectMgrIface + ".InterfacesRemoved":
+ m.notifySubscribers()
+ }
+}
+
+func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant) {
+ m.stateMutex.Lock()
+ dirty := false
+
+ if powered, ok := dbusutil.Get[bool](changed, "Powered"); ok {
+ m.state.Powered = powered
+ dirty = true
+ }
+ if discovering, ok := dbusutil.Get[bool](changed, "Discovering"); ok {
+ m.state.Discovering = discovering
+ dirty = true
+ }
+
+ m.stateMutex.Unlock()
+
+ if dirty {
+ m.notifySubscribers()
+ }
+}
+
+func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed map[string]dbus.Variant) {
+ paired, hasPaired := dbusutil.Get[bool](changed, "Paired")
+ _, hasConnected := changed["Connected"]
+ _, hasTrusted := changed["Trusted"]
+
+ if hasPaired {
+ devicePath := string(path)
+ if paired {
+ _, wasPending := m.pendingPairings.LoadAndDelete(devicePath)
+ if wasPending {
+ select {
+ case m.eventQueue <- func() {
+ time.Sleep(300 * time.Millisecond)
+ log.Infof("[Bluetooth] Auto-trusting newly paired device: %s", devicePath)
+ if err := m.TrustDevice(devicePath, true); err != nil {
+ log.Warnf("[Bluetooth] Auto-trust failed: %v", err)
+ }
+ log.Infof("[Bluetooth] Auto-connecting newly paired device: %s", devicePath)
+ if err := m.ConnectDevice(devicePath); err != nil {
+ log.Warnf("[Bluetooth] Auto-connect failed: %v", err)
+ }
+ }:
+ default:
+ }
+ }
+ } else {
+ m.pendingPairings.Delete(devicePath)
+ }
+ }
+
+ if hasPaired || hasConnected || hasTrusted {
+ select {
+ case m.eventQueue <- func() {
+ time.Sleep(100 * time.Millisecond)
+ m.updateDevices()
+ m.notifySubscribers()
+ }:
+ default:
+ }
+ }
+}
+
+func (m *Manager) eventWorker() {
+ defer m.eventWg.Done()
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case event := <-m.eventQueue:
+ event()
+ }
+ }
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 200 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+ m.updateDevices()
+
+ currentState := m.snapshotState()
+
+ if m.lastNotifiedState != nil && !stateChanged(m.lastNotifiedState, &currentState) {
+ pending = false
+ continue
+ }
+
+ m.subscribers.Range(func(key string, ch chan BluetoothState) bool {
+ select {
+ case ch <- currentState:
+ default:
+ }
+ return true
+ })
+
+ stateCopy := currentState
+ m.lastNotifiedState = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) GetState() BluetoothState {
+ return m.snapshotState()
+}
+
+func (m *Manager) snapshotState() BluetoothState {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+
+ s := *m.state
+ s.Devices = append([]Device(nil), m.state.Devices...)
+ s.PairedDevices = append([]Device(nil), m.state.PairedDevices...)
+ s.ConnectedDevices = append([]Device(nil), m.state.ConnectedDevices...)
+ return s
+}
+
+func (m *Manager) Subscribe(id string) chan BluetoothState {
+ ch := make(chan BluetoothState, 64)
+ m.subscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if ch, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(ch)
+ }
+}
+
+func (m *Manager) SubscribePairing(id string) chan PairingPrompt {
+ ch := make(chan PairingPrompt, 16)
+ m.pairingSubscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) UnsubscribePairing(id string) {
+ if ch, ok := m.pairingSubscribers.LoadAndDelete(id); ok {
+ close(ch)
+ }
+}
+
+func (m *Manager) broadcastPairingPrompt(prompt PairingPrompt) {
+ m.pairingSubscribers.Range(func(key string, ch chan PairingPrompt) bool {
+ select {
+ case ch <- prompt:
+ default:
+ }
+ return true
+ })
+}
+
+func (m *Manager) SubmitPairing(token string, secrets map[string]string, accept bool) error {
+ if m.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return m.promptBroker.Resolve(token, PromptReply{
+ Secrets: secrets,
+ Accept: accept,
+ Cancel: false,
+ })
+}
+
+func (m *Manager) CancelPairing(token string) error {
+ if m.promptBroker == nil {
+ return fmt.Errorf("prompt broker not initialized")
+ }
+
+ return m.promptBroker.Resolve(token, PromptReply{
+ Cancel: true,
+ })
+}
+
+func (m *Manager) StartDiscovery() error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+ return obj.Call(adapter1Iface+".StartDiscovery", 0).Err
+}
+
+func (m *Manager) StopDiscovery() error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+ return obj.Call(adapter1Iface+".StopDiscovery", 0).Err
+}
+
+func (m *Manager) SetPowered(powered bool) error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+ return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
+}
+
+func (m *Manager) PairDevice(devicePath string) error {
+ m.pendingPairings.Store(devicePath, true)
+
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
+ err := obj.Call(device1Iface+".Pair", 0).Err
+
+ if err != nil {
+ m.pendingPairings.Delete(devicePath)
+ }
+
+ return err
+}
+
+func (m *Manager) ConnectDevice(devicePath string) error {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
+ return obj.Call(device1Iface+".Connect", 0).Err
+}
+
+func (m *Manager) DisconnectDevice(devicePath string) error {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
+ return obj.Call(device1Iface+".Disconnect", 0).Err
+}
+
+func (m *Manager) RemoveDevice(devicePath string) error {
+ obj := m.dbusConn.Object(bluezService, m.adapterPath)
+ return obj.Call(adapter1Iface+".RemoveDevice", 0, dbus.ObjectPath(devicePath)).Err
+}
+
+func (m *Manager) TrustDevice(devicePath string, trusted bool) error {
+ obj := m.dbusConn.Object(bluezService, dbus.ObjectPath(devicePath))
+ return obj.Call(propertiesIface+".Set", 0, device1Iface, "Trusted", dbus.MakeVariant(trusted)).Err
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.notifierWg.Wait()
+ m.eventWg.Wait()
+
+ m.sigWG.Wait()
+
+ if m.signals != nil {
+ m.dbusConn.RemoveSignal(m.signals)
+ close(m.signals)
+ }
+
+ if m.agent != nil {
+ m.agent.Close()
+ }
+
+ m.subscribers.Range(func(key string, ch chan BluetoothState) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+
+ m.pairingSubscribers.Range(func(key string, ch chan PairingPrompt) bool {
+ close(ch)
+ m.pairingSubscribers.Delete(key)
+ return true
+ })
+
+ if m.dbusConn != nil {
+ m.dbusConn.Close()
+ }
+}
+
+func stateChanged(old, new *BluetoothState) bool {
+ if old.Powered != new.Powered {
+ return true
+ }
+ if old.Discovering != new.Discovering {
+ return true
+ }
+ if len(old.Devices) != len(new.Devices) {
+ return true
+ }
+ if len(old.PairedDevices) != len(new.PairedDevices) {
+ return true
+ }
+ if len(old.ConnectedDevices) != len(new.ConnectedDevices) {
+ return true
+ }
+ for i := range old.Devices {
+ if old.Devices[i].Path != new.Devices[i].Path {
+ return true
+ }
+ if old.Devices[i].Paired != new.Devices[i].Paired {
+ return true
+ }
+ if old.Devices[i].Connected != new.Devices[i].Connected {
+ return true
+ }
+ }
+ return false
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/subscription_broker.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/subscription_broker.go
new file mode 100644
index 0000000..ea04a19
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/subscription_broker.go
@@ -0,0 +1,86 @@
+package bluez
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+)
+
+type SubscriptionBroker struct {
+ pending syncmap.Map[string, chan PromptReply]
+ requests syncmap.Map[string, PromptRequest]
+ broadcastPrompt func(PairingPrompt)
+}
+
+func NewSubscriptionBroker(broadcastPrompt func(PairingPrompt)) PromptBroker {
+ return &SubscriptionBroker{
+ broadcastPrompt: broadcastPrompt,
+ }
+}
+
+func (b *SubscriptionBroker) Ask(ctx context.Context, req PromptRequest) (string, error) {
+ token, err := generateToken()
+ if err != nil {
+ return "", err
+ }
+
+ replyChan := make(chan PromptReply, 1)
+ b.pending.Store(token, replyChan)
+ b.requests.Store(token, req)
+
+ if b.broadcastPrompt != nil {
+ prompt := PairingPrompt{
+ Token: token,
+ DevicePath: req.DevicePath,
+ DeviceName: req.DeviceName,
+ DeviceAddr: req.DeviceAddr,
+ RequestType: req.RequestType,
+ Fields: req.Fields,
+ Hints: req.Hints,
+ Passkey: req.Passkey,
+ }
+ b.broadcastPrompt(prompt)
+ }
+
+ return token, nil
+}
+
+func (b *SubscriptionBroker) Wait(ctx context.Context, token string) (PromptReply, error) {
+ replyChan, exists := b.pending.Load(token)
+ if !exists {
+ return PromptReply{}, fmt.Errorf("unknown token: %s", token)
+ }
+
+ select {
+ case <-ctx.Done():
+ b.cleanup(token)
+ return PromptReply{}, errdefs.ErrSecretPromptTimeout
+ case reply := <-replyChan:
+ b.cleanup(token)
+ if reply.Cancel {
+ return reply, errdefs.ErrSecretPromptCancelled
+ }
+ return reply, nil
+ }
+}
+
+func (b *SubscriptionBroker) Resolve(token string, reply PromptReply) error {
+ replyChan, exists := b.pending.Load(token)
+ if !exists {
+ return fmt.Errorf("unknown or expired token: %s", token)
+ }
+
+ select {
+ case replyChan <- reply:
+ return nil
+ default:
+ return fmt.Errorf("failed to deliver reply for token: %s", token)
+ }
+}
+
+func (b *SubscriptionBroker) cleanup(token string) {
+ b.pending.Delete(token)
+ b.requests.Delete(token)
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/types.go
new file mode 100644
index 0000000..663acca
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/types.go
@@ -0,0 +1,78 @@
+package bluez
+
+import (
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+ "github.com/godbus/dbus/v5"
+)
+
+type BluetoothState struct {
+ Powered bool `json:"powered"`
+ Discovering bool `json:"discovering"`
+ Devices []Device `json:"devices"`
+ PairedDevices []Device `json:"pairedDevices"`
+ ConnectedDevices []Device `json:"connectedDevices"`
+}
+
+type Device struct {
+ Path string `json:"path"`
+ Address string `json:"address"`
+ Name string `json:"name"`
+ Alias string `json:"alias"`
+ Paired bool `json:"paired"`
+ Trusted bool `json:"trusted"`
+ Blocked bool `json:"blocked"`
+ Connected bool `json:"connected"`
+ Class uint32 `json:"class"`
+ Icon string `json:"icon"`
+ RSSI int16 `json:"rssi"`
+ LegacyPairing bool `json:"legacyPairing"`
+}
+
+type PromptRequest struct {
+ DevicePath string `json:"devicePath"`
+ DeviceName string `json:"deviceName"`
+ DeviceAddr string `json:"deviceAddr"`
+ RequestType string `json:"requestType"`
+ Fields []string `json:"fields"`
+ Hints []string `json:"hints"`
+ Passkey *uint32 `json:"passkey,omitempty"`
+}
+
+type PromptReply struct {
+ Secrets map[string]string `json:"secrets"`
+ Accept bool `json:"accept"`
+ Cancel bool `json:"cancel"`
+}
+
+type PairingPrompt struct {
+ Token string `json:"token"`
+ DevicePath string `json:"devicePath"`
+ DeviceName string `json:"deviceName"`
+ DeviceAddr string `json:"deviceAddr"`
+ RequestType string `json:"requestType"`
+ Fields []string `json:"fields"`
+ Hints []string `json:"hints"`
+ Passkey *uint32 `json:"passkey,omitempty"`
+}
+
+type Manager struct {
+ state *BluetoothState
+ stateMutex sync.RWMutex
+ subscribers syncmap.Map[string, chan BluetoothState]
+ stopChan chan struct{}
+ dbusConn *dbus.Conn
+ signals chan *dbus.Signal
+ sigWG sync.WaitGroup
+ agent *BluezAgent
+ promptBroker PromptBroker
+ pairingSubscribers syncmap.Map[string, chan PairingPrompt]
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotifiedState *BluetoothState
+ adapterPath dbus.ObjectPath
+ pendingPairings syncmap.Map[string, bool]
+ eventQueue chan func()
+ eventWg sync.WaitGroup
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/types_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/types_test.go
new file mode 100644
index 0000000..ab5b857
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez/types_test.go
@@ -0,0 +1,210 @@
+package bluez
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestBluetoothStateJSON(t *testing.T) {
+ state := BluetoothState{
+ Powered: true,
+ Discovering: false,
+ Devices: []Device{
+ {
+ Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ Address: "AA:BB:CC:DD:EE:FF",
+ Name: "TestDevice",
+ Alias: "My Device",
+ Paired: true,
+ Trusted: false,
+ Connected: true,
+ Class: 0x240418,
+ Icon: "audio-headset",
+ RSSI: -50,
+ },
+ },
+ PairedDevices: []Device{
+ {
+ Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ Address: "AA:BB:CC:DD:EE:FF",
+ Paired: true,
+ },
+ },
+ ConnectedDevices: []Device{
+ {
+ Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ Address: "AA:BB:CC:DD:EE:FF",
+ Connected: true,
+ },
+ },
+ }
+
+ data, err := json.Marshal(state)
+ if err != nil {
+ t.Fatalf("failed to marshal state: %v", err)
+ }
+
+ var decoded BluetoothState
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal state: %v", err)
+ }
+
+ if decoded.Powered != state.Powered {
+ t.Errorf("expected Powered=%v, got %v", state.Powered, decoded.Powered)
+ }
+
+ if len(decoded.Devices) != 1 {
+ t.Fatalf("expected 1 device, got %d", len(decoded.Devices))
+ }
+
+ if decoded.Devices[0].Address != "AA:BB:CC:DD:EE:FF" {
+ t.Errorf("expected address AA:BB:CC:DD:EE:FF, got %s", decoded.Devices[0].Address)
+ }
+}
+
+func TestDeviceJSON(t *testing.T) {
+ device := Device{
+ Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ Address: "AA:BB:CC:DD:EE:FF",
+ Name: "TestDevice",
+ Alias: "My Device",
+ Paired: true,
+ Trusted: true,
+ Blocked: false,
+ Connected: true,
+ Class: 0x240418,
+ Icon: "audio-headset",
+ RSSI: -50,
+ LegacyPairing: false,
+ }
+
+ data, err := json.Marshal(device)
+ if err != nil {
+ t.Fatalf("failed to marshal device: %v", err)
+ }
+
+ var decoded Device
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal device: %v", err)
+ }
+
+ if decoded.Address != device.Address {
+ t.Errorf("expected Address=%s, got %s", device.Address, decoded.Address)
+ }
+
+ if decoded.Name != device.Name {
+ t.Errorf("expected Name=%s, got %s", device.Name, decoded.Name)
+ }
+
+ if decoded.Paired != device.Paired {
+ t.Errorf("expected Paired=%v, got %v", device.Paired, decoded.Paired)
+ }
+
+ if decoded.RSSI != device.RSSI {
+ t.Errorf("expected RSSI=%d, got %d", device.RSSI, decoded.RSSI)
+ }
+}
+
+func TestPairingPromptJSON(t *testing.T) {
+ passkey := uint32(123456)
+ prompt := PairingPrompt{
+ Token: "test-token",
+ DevicePath: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ DeviceName: "TestDevice",
+ DeviceAddr: "AA:BB:CC:DD:EE:FF",
+ RequestType: "confirm",
+ Fields: []string{"decision"},
+ Hints: []string{},
+ Passkey: &passkey,
+ }
+
+ data, err := json.Marshal(prompt)
+ if err != nil {
+ t.Fatalf("failed to marshal prompt: %v", err)
+ }
+
+ var decoded PairingPrompt
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal prompt: %v", err)
+ }
+
+ if decoded.Token != prompt.Token {
+ t.Errorf("expected Token=%s, got %s", prompt.Token, decoded.Token)
+ }
+
+ if decoded.DeviceName != prompt.DeviceName {
+ t.Errorf("expected DeviceName=%s, got %s", prompt.DeviceName, decoded.DeviceName)
+ }
+
+ if decoded.Passkey == nil {
+ t.Fatal("expected non-nil Passkey")
+ }
+
+ if *decoded.Passkey != *prompt.Passkey {
+ t.Errorf("expected Passkey=%d, got %d", *prompt.Passkey, *decoded.Passkey)
+ }
+}
+
+func TestPromptReplyJSON(t *testing.T) {
+ reply := PromptReply{
+ Secrets: map[string]string{
+ "pin": "1234",
+ "passkey": "567890",
+ },
+ Accept: true,
+ Cancel: false,
+ }
+
+ data, err := json.Marshal(reply)
+ if err != nil {
+ t.Fatalf("failed to marshal reply: %v", err)
+ }
+
+ var decoded PromptReply
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal reply: %v", err)
+ }
+
+ if decoded.Secrets["pin"] != reply.Secrets["pin"] {
+ t.Errorf("expected pin=%s, got %s", reply.Secrets["pin"], decoded.Secrets["pin"])
+ }
+
+ if decoded.Accept != reply.Accept {
+ t.Errorf("expected Accept=%v, got %v", reply.Accept, decoded.Accept)
+ }
+}
+
+func TestPromptRequestJSON(t *testing.T) {
+ passkey := uint32(123456)
+ req := PromptRequest{
+ DevicePath: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
+ DeviceName: "TestDevice",
+ DeviceAddr: "AA:BB:CC:DD:EE:FF",
+ RequestType: "confirm",
+ Fields: []string{"decision"},
+ Hints: []string{"hint1", "hint2"},
+ Passkey: &passkey,
+ }
+
+ data, err := json.Marshal(req)
+ if err != nil {
+ t.Fatalf("failed to marshal request: %v", err)
+ }
+
+ var decoded PromptRequest
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal request: %v", err)
+ }
+
+ if decoded.DevicePath != req.DevicePath {
+ t.Errorf("expected DevicePath=%s, got %s", req.DevicePath, decoded.DevicePath)
+ }
+
+ if decoded.RequestType != req.RequestType {
+ t.Errorf("expected RequestType=%s, got %s", req.RequestType, decoded.RequestType)
+ }
+
+ if len(decoded.Fields) != len(req.Fields) {
+ t.Errorf("expected %d fields, got %d", len(req.Fields), len(decoded.Fields))
+ }
+}