diff options
| author | AlexanderCurl <alexc@alexc.hu> | 2026-04-18 17:46:06 +0100 |
|---|---|---|
| committer | AlexanderCurl <alexc@alexc.hu> | 2026-04-18 17:46:06 +0100 |
| commit | 70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch) | |
| tree | ab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez | |
| parent | 5d94c0a7d44a2255b81815a52a7056a94a39842d (diff) | |
| download | RaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.tar.gz RaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.zip | |
Replaced file structures for themes in the correct format
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/bluez')
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, ¤tState) { + 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)) + } +} |