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 | |
| 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')
186 files changed, 44551 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/apppicker/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/apppicker/handlers.go new file mode 100644 index 0000000..007ab3e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/apppicker/handlers.go @@ -0,0 +1,51 @@ +package apppicker + +import ( + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + switch req.Method { + case "apppicker.open", "browser.open": + handleOpen(conn, req, manager) + default: + models.RespondError(conn, req.ID, "unknown method") + } +} + +func handleOpen(conn net.Conn, req models.Request, manager *Manager) { + log.Infof("AppPicker: Received %s request with params: %+v", req.Method, req.Params) + + target, ok := models.Get[string](req, "target") + if !ok { + target, ok = models.Get[string](req, "url") + if !ok { + log.Warnf("AppPicker: Invalid target parameter in request") + models.RespondError(conn, req.ID, "invalid target parameter") + return + } + } + + event := OpenEvent{ + Target: target, + RequestType: models.GetOr(req, "requestType", "url"), + MimeType: models.GetOr(req, "mimeType", ""), + } + + if categories, ok := models.Get[[]any](req, "categories"); ok { + event.Categories = make([]string, 0, len(categories)) + for _, cat := range categories { + if catStr, ok := cat.(string); ok { + event.Categories = append(event.Categories, catStr) + } + } + } + + log.Infof("AppPicker: Broadcasting event: %+v", event) + manager.RequestOpen(event) + models.Respond(conn, req.ID, "ok") + log.Infof("AppPicker: Request handled successfully") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/apppicker/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/apppicker/manager.go new file mode 100644 index 0000000..bde0846 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/apppicker/manager.go @@ -0,0 +1,48 @@ +package apppicker + +import ( + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type Manager struct { + subscribers syncmap.Map[string, chan OpenEvent] + closeOnce sync.Once +} + +func NewManager() *Manager { + return &Manager{} +} + +func (m *Manager) Subscribe(id string) chan OpenEvent { + ch := make(chan OpenEvent, 16) + m.subscribers.Store(id, ch) + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) RequestOpen(event OpenEvent) { + m.subscribers.Range(func(key string, ch chan OpenEvent) bool { + select { + case ch <- event: + default: + } + return true + }) +} + +func (m *Manager) Close() { + m.closeOnce.Do(func() { + m.subscribers.Range(func(key string, ch chan OpenEvent) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/apppicker/models.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/apppicker/models.go new file mode 100644 index 0000000..8861461 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/apppicker/models.go @@ -0,0 +1,8 @@ +package apppicker + +type OpenEvent struct { + Target string `json:"target"` + MimeType string `json:"mimeType,omitempty"` + Categories []string `json:"categories,omitempty"` + RequestType string `json:"requestType"` +} 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)) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc.go new file mode 100644 index 0000000..00cf2d9 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc.go @@ -0,0 +1,510 @@ +package brightness + +import ( + "encoding/binary" + "fmt" + "math" + "os" + "strings" + "syscall" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "golang.org/x/sys/unix" +) + +const ( + I2C_SLAVE = 0x0703 + DDCCI_ADDR = 0x37 + DDCCI_VCP_GET = 0x01 + DDCCI_VCP_SET = 0x03 + VCP_BRIGHTNESS = 0x10 + DDC_SOURCE_ADDR = 0x51 +) + +func NewDDCBackend() (*DDCBackend, error) { + b := &DDCBackend{ + scanInterval: 30 * time.Second, + debounceTimers: make(map[string]*time.Timer), + debouncePending: make(map[string]ddcPendingSet), + } + + if err := b.scanI2CDevices(); err != nil { + return nil, err + } + + return b, nil +} + +func (b *DDCBackend) scanI2CDevices() error { + return b.scanI2CDevicesInternal(false) +} + +func (b *DDCBackend) ForceRescan() error { + return b.scanI2CDevicesInternal(true) +} + +func (b *DDCBackend) scanI2CDevicesInternal(force bool) error { + b.scanMutex.Lock() + defer b.scanMutex.Unlock() + + if !force && time.Since(b.lastScan) < b.scanInterval { + return nil + } + + activeBuses := make(map[int]bool) + + for i := 0; i < 32; i++ { + busPath := fmt.Sprintf("/dev/i2c-%d", i) + if _, err := os.Stat(busPath); os.IsNotExist(err) { + continue + } + + if isIgnorableI2CBus(i) { + log.Debugf("Skipping ignorable i2c-%d", i) + continue + } + + activeBuses[i] = true + id := fmt.Sprintf("ddc:i2c-%d", i) + + dev, err := b.probeDDCDevice(i) + if err != nil || dev == nil { + continue + } + + dev.id = id + b.devices.Store(id, dev) + log.Debugf("found DDC device on i2c-%d", i) + } + + b.devices.Range(func(id string, dev *ddcDevice) bool { + if !activeBuses[dev.bus] { + b.devices.Delete(id) + log.Debugf("removed DDC device %s (bus no longer exists)", id) + } + return true + }) + + b.lastScan = time.Now() + + return nil +} + +func (b *DDCBackend) probeDDCDevice(bus int) (*ddcDevice, error) { + busPath := fmt.Sprintf("/dev/i2c-%d", bus) + + fd, err := syscall.Open(busPath, syscall.O_RDWR, 0) + if err != nil { + return nil, err + } + defer syscall.Close(fd) + + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(DDCCI_ADDR)); errno != 0 { + return nil, errno + } + + dummy := make([]byte, 32) + syscall.Read(fd, dummy) //nolint:errcheck + + writebuf := []byte{0x00} + n, err := syscall.Write(fd, writebuf) + if err == nil && n == len(writebuf) { + name := b.getDDCName(bus) + dev := &ddcDevice{ + bus: bus, + addr: DDCCI_ADDR, + name: name, + } + b.readInitialBrightness(fd, dev) + return dev, nil + } + + readbuf := make([]byte, 4) + n, err = syscall.Read(fd, readbuf) + if err != nil || n == 0 { + return nil, fmt.Errorf("x37 unresponsive") + } + + name := b.getDDCName(bus) + + dev := &ddcDevice{ + bus: bus, + addr: DDCCI_ADDR, + name: name, + } + b.readInitialBrightness(fd, dev) + return dev, nil +} + +func (b *DDCBackend) getDDCName(bus int) string { + sysfsPath := fmt.Sprintf("/sys/class/i2c-adapter/i2c-%d/name", bus) + data, err := os.ReadFile(sysfsPath) + if err != nil { + return fmt.Sprintf("I2C-%d", bus) + } + + name := strings.TrimSpace(string(data)) + if name == "" { + name = fmt.Sprintf("I2C-%d", bus) + } + + return name +} + +func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) { + cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS) + if err != nil { + log.Debugf("failed to read initial brightness for %s: %v", dev.name, err) + return + } + + dev.max = cap.max + dev.lastBrightness = cap.current + log.Debugf("initialized %s with brightness %d/%d", dev.name, cap.current, cap.max) +} + +func (b *DDCBackend) GetDevices() ([]Device, error) { + if err := b.scanI2CDevices(); err != nil { + log.Debugf("DDC scan error: %v", err) + } + + devices := make([]Device, 0) + + b.devices.Range(func(id string, dev *ddcDevice) bool { + devices = append(devices, Device{ + Class: ClassDDC, + ID: id, + Name: dev.name, + Current: dev.lastBrightness, + Max: dev.max, + CurrentPercent: dev.lastBrightness, + Backend: "ddc", + }) + return true + }) + + return devices, nil +} + +func (b *DDCBackend) SetBrightness(id string, value int, exponential bool, callback func()) error { + return b.SetBrightnessWithExponent(id, value, exponential, 1.2, callback) +} + +func (b *DDCBackend) SetBrightnessWithExponent(id string, value int, exponential bool, exponent float64, callback func()) error { + _, ok := b.devices.Load(id) + + if !ok { + if err := b.scanI2CDevicesInternal(true); err != nil { + log.Debugf("rescan failed for %s: %v", id, err) + } + _, ok = b.devices.Load(id) + } + + if !ok { + return fmt.Errorf("device not found: %s", id) + } + + if value < 0 { + return fmt.Errorf("value out of range: %d", value) + } + + b.debounceMutex.Lock() + b.debouncePending[id] = ddcPendingSet{ + percent: value, + callback: callback, + } + + if timer, exists := b.debounceTimers[id]; exists { + timer.Reset(200 * time.Millisecond) + } else { + b.debounceWg.Add(1) + b.debounceTimers[id] = time.AfterFunc(200*time.Millisecond, func() { + defer b.debounceWg.Done() + b.debounceMutex.Lock() + pending, exists := b.debouncePending[id] + if exists { + delete(b.debouncePending, id) + } + b.debounceMutex.Unlock() + + if !exists { + return + } + + err := b.setBrightnessImmediateWithExponent(id, pending.percent) + if err != nil { + log.Debugf("Failed to set brightness for %s: %v", id, err) + } + + if pending.callback != nil { + pending.callback() + } + }) + } + b.debounceMutex.Unlock() + + return nil +} + +func (b *DDCBackend) setBrightnessImmediateWithExponent(id string, value int) error { + dev, ok := b.devices.Load(id) + + if !ok { + if err := b.scanI2CDevicesInternal(true); err != nil { + log.Debugf("rescan failed for %s: %v", id, err) + } + dev, ok = b.devices.Load(id) + } + + if !ok { + return fmt.Errorf("device not found: %s", id) + } + + busPath := fmt.Sprintf("/dev/i2c-%d", dev.bus) + + if _, err := os.Stat(busPath); os.IsNotExist(err) { + b.devices.Delete(id) + log.Debugf("removed stale DDC device %s (bus no longer exists)", id) + return fmt.Errorf("device disconnected: %s", id) + } + + fd, err := syscall.Open(busPath, syscall.O_RDWR, 0) + if err != nil { + b.devices.Delete(id) + log.Debugf("removed DDC device %s (open failed: %v)", id, err) + return fmt.Errorf("open i2c device: %w", err) + } + defer syscall.Close(fd) + + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(dev.addr)); errno != 0 { + return fmt.Errorf("set i2c slave addr: %w", errno) + } + + max := dev.max + if max == 0 { + cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS) + if err != nil { + return fmt.Errorf("get current capability: %w", err) + } + max = cap.max + dev.max = max + b.devices.Store(id, dev) + } + + if err := b.setVCPFeature(fd, VCP_BRIGHTNESS, value); err != nil { + return fmt.Errorf("set vcp feature: %w", err) + } + + log.Debugf("set %s to %d/%d", id, value, max) + + dev.max = max + dev.lastBrightness = value + b.devices.Store(id, dev) + + return nil +} + +func (b *DDCBackend) getVCPFeature(fd int, vcp byte) (*ddcCapability, error) { + for flushTry := 0; flushTry < 3; flushTry++ { + dummy := make([]byte, 32) + n, _ := syscall.Read(fd, dummy) + if n == 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + + data := []byte{ + DDCCI_VCP_GET, + vcp, + } + + payload := []byte{ + DDC_SOURCE_ADDR, + byte(len(data)) | 0x80, + } + payload = append(payload, data...) + payload = append(payload, ddcciChecksum(payload)) + + n, err := syscall.Write(fd, payload) + if err != nil || n != len(payload) { + return nil, fmt.Errorf("write i2c: %w", err) + } + + time.Sleep(50 * time.Millisecond) + + pollFds := []unix.PollFd{ + { + Fd: int32(fd), + Events: unix.POLLIN, + }, + } + + pollTimeout := 200 + pollResult, err := unix.Poll(pollFds, pollTimeout) + if err != nil { + return nil, fmt.Errorf("poll i2c: %w", err) + } + if pollResult == 0 { + return nil, fmt.Errorf("poll timeout after %dms", pollTimeout) + } + if pollFds[0].Revents&unix.POLLIN == 0 { + return nil, fmt.Errorf("poll returned but POLLIN not set") + } + + response := make([]byte, 12) + n, err = syscall.Read(fd, response) + if err != nil || n < 8 { + return nil, fmt.Errorf("read i2c: %w", err) + } + + if response[0] != 0x6E || response[2] != 0x02 { + return nil, fmt.Errorf("invalid ddc response") + } + + resultCode := response[3] + if resultCode != 0x00 { + return nil, fmt.Errorf("vcp feature not supported") + } + + responseVCP := response[4] + if responseVCP != vcp { + return nil, fmt.Errorf("vcp mismatch: wanted 0x%02x, got 0x%02x", vcp, responseVCP) + } + + maxHigh := response[6] + maxLow := response[7] + currentHigh := response[8] + currentLow := response[9] + + max := int(binary.BigEndian.Uint16([]byte{maxHigh, maxLow})) + current := int(binary.BigEndian.Uint16([]byte{currentHigh, currentLow})) + + return &ddcCapability{ + vcp: vcp, + max: max, + current: current, + }, nil +} + +func ddcciChecksum(payload []byte) byte { + sum := byte(0x6E) + for _, b := range payload { + sum ^= b + } + return sum +} + +func (b *DDCBackend) setVCPFeature(fd int, vcp byte, value int) error { + data := []byte{ + DDCCI_VCP_SET, + vcp, + byte(value >> 8), + byte(value & 0xFF), + } + + payload := []byte{ + DDC_SOURCE_ADDR, + byte(len(data)) | 0x80, + } + payload = append(payload, data...) + payload = append(payload, ddcciChecksum(payload)) + + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), I2C_SLAVE, uintptr(DDCCI_ADDR)); errno != 0 { + return fmt.Errorf("set i2c slave for write: %w", errno) + } + + n, err := syscall.Write(fd, payload) + if err != nil || n != len(payload) { + return fmt.Errorf("write i2c: wrote %d/%d: %w", n, len(payload), err) + } + + time.Sleep(50 * time.Millisecond) + + return nil +} + +func (b *DDCBackend) percentToValue(percent int, max int, exponential bool) int { + const minValue = 1 + + if percent == 0 { + return minValue + } + + usableRange := max - minValue + var value int + + if exponential { + const exponent = 2.0 + normalizedPercent := float64(percent) / 100.0 + hardwarePercent := math.Pow(normalizedPercent, 1.0/exponent) + value = minValue + int(math.Round(hardwarePercent*float64(usableRange))) + } else { + value = minValue + ((percent - 1) * usableRange / 99) + } + + if value < minValue { + value = minValue + } + if value > max { + value = max + } + + return value +} + +func (b *DDCBackend) valueToPercent(value int, max int, exponential bool) int { + const minValue = 1 + + if max == 0 { + return 0 + } + + if value <= minValue { + return 1 + } + + usableRange := max - minValue + if usableRange == 0 { + return 100 + } + + var percent int + + if exponential { + const exponent = 2.0 + linearPercent := 1 + ((value - minValue) * 99 / usableRange) + normalizedLinear := float64(linearPercent) / 100.0 + expPercent := math.Pow(normalizedLinear, exponent) + percent = int(math.Round(expPercent * 100.0)) + } else { + percent = 1 + ((value - minValue) * 99 / usableRange) + } + + if percent > 100 { + percent = 100 + } + if percent < 1 { + percent = 1 + } + + return percent +} + +func (b *DDCBackend) WaitPending() { + done := make(chan struct{}) + go func() { + b.debounceWg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + log.Debug("WaitPending timed out waiting for DDC writes") + } +} + +func (b *DDCBackend) Close() { +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter.go new file mode 100644 index 0000000..12fc535 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter.go @@ -0,0 +1,135 @@ +package brightness + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +// isIgnorableI2CBus checks if an I2C bus should be skipped during DDC probing. +// Based on ddcutil's sysfs_is_ignorable_i2c_device() (sysfs_base.c:1441) +func isIgnorableI2CBus(busno int) bool { + name := getI2CDeviceSysfsName(busno) + driver := getI2CSysfsDriver(busno) + + if name != "" && isIgnorableI2CDeviceName(name, driver) { + log.Debugf("i2c-%d: ignoring '%s' (driver: %s)", busno, name, driver) + return true + } + + // Only probe display adapters (0x03xxxx) and docking stations (0x0axxxx) + class := getI2CDeviceSysfsClass(busno) + if class != 0 { + classHigh := class & 0xFFFF0000 + ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000) + if ignorable { + log.Debugf("i2c-%d: ignoring class 0x%08x", busno, class) + } + return ignorable + } + + return false +} + +// Based on ddcutil's ignorable_i2c_device_sysfs_name() (sysfs_base.c:1408) +func isIgnorableI2CDeviceName(name, driver string) bool { + ignorablePrefixes := []string{ + "SMBus", + "Synopsys DesignWare", + "soc:i2cdsi", + "smu", + "mac-io", + "u4", + "AMDGPU SMU", // AMD Navi2+ - probing hangs GPU + } + + for _, prefix := range ignorablePrefixes { + if strings.HasPrefix(name, prefix) { + return true + } + } + + // nouveau driver: only nvkm-* buses are valid + if driver == "nouveau" && !strings.HasPrefix(name, "nvkm-") { + return true + } + + return false +} + +// Based on ddcutil's get_i2c_device_sysfs_name() (sysfs_base.c:1175) +func getI2CDeviceSysfsName(busno int) string { + path := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/name", busno) + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +// Based on ddcutil's get_i2c_device_sysfs_class() (sysfs_base.c:1380) +func getI2CDeviceSysfsClass(busno int) uint32 { + classPath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/class", busno) + data, err := os.ReadFile(classPath) + if err != nil { + classPath = fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d/device/device/device/class", busno) + data, err = os.ReadFile(classPath) + if err != nil { + return 0 + } + } + + classStr := strings.TrimSpace(string(data)) + classStr = strings.TrimPrefix(classStr, "0x") + + class, err := strconv.ParseUint(classStr, 16, 32) + if err != nil { + return 0 + } + + return uint32(class) +} + +// Based on ddcutil's get_i2c_sysfs_driver_by_busno() (sysfs_base.c:1284) +func getI2CSysfsDriver(busno int) string { + devicePath := fmt.Sprintf("/sys/bus/i2c/devices/i2c-%d", busno) + adapterPath, err := findI2CAdapter(devicePath) + if err != nil { + return "" + } + + driverLink := filepath.Join(adapterPath, "driver") + target, err := os.Readlink(driverLink) + if err != nil { + return "" + } + + return filepath.Base(target) +} + +func findI2CAdapter(devicePath string) (string, error) { + currentPath := devicePath + + for depth := 0; depth < 10; depth++ { + if _, err := os.Stat(filepath.Join(currentPath, "name")); err == nil { + return currentPath, nil + } + + deviceLink := filepath.Join(currentPath, "device") + target, err := os.Readlink(deviceLink) + if err != nil { + break + } + + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(currentPath), target) + } + currentPath = filepath.Clean(target) + } + + return "", fmt.Errorf("could not find adapter for %s", devicePath) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter_test.go new file mode 100644 index 0000000..dc3751f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter_test.go @@ -0,0 +1,122 @@ +package brightness + +import ( + "testing" +) + +func TestIsIgnorableI2CDeviceName(t *testing.T) { + tests := []struct { + name string + deviceName string + driver string + want bool + }{ + { + name: "AMDGPU SMU should be ignored", + deviceName: "AMDGPU SMU", + driver: "amdgpu", + want: true, + }, + { + name: "SMBus should be ignored", + deviceName: "SMBus I801 adapter", + driver: "", + want: true, + }, + { + name: "Synopsys DesignWare should be ignored", + deviceName: "Synopsys DesignWare I2C adapter", + driver: "", + want: true, + }, + { + name: "smu prefix should be ignored (Mac G5)", + deviceName: "smu-i2c-controller", + driver: "", + want: true, + }, + { + name: "Regular NVIDIA DDC should not be ignored", + deviceName: "NVIDIA i2c adapter 1", + driver: "nvidia", + want: false, + }, + { + name: "nouveau nvkm bus should not be ignored", + deviceName: "nvkm-0000:01:00.0-bus-0000", + driver: "nouveau", + want: false, + }, + { + name: "nouveau non-nvkm bus should be ignored", + deviceName: "nouveau-other-bus", + driver: "nouveau", + want: true, + }, + { + name: "Regular AMD display adapter should not be ignored", + deviceName: "AMDGPU DM i2c hw bus 0", + driver: "amdgpu", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isIgnorableI2CDeviceName(tt.deviceName, tt.driver) + if got != tt.want { + t.Errorf("isIgnorableI2CDeviceName(%q, %q) = %v, want %v", + tt.deviceName, tt.driver, got, tt.want) + } + }) + } +} + +func TestClassFiltering(t *testing.T) { + tests := []struct { + name string + class uint32 + want bool + }{ + { + name: "Display adapter class should not be ignored", + class: 0x030000, + want: false, + }, + { + name: "Docking station class should not be ignored", + class: 0x0a0000, + want: false, + }, + { + name: "Display adapter with subclass should not be ignored", + class: 0x030001, + want: false, + }, + { + name: "SMBus class should be ignored", + class: 0x0c0500, + want: true, + }, + { + name: "Bridge class should be ignored", + class: 0x060400, + want: true, + }, + { + name: "Generic system peripheral should be ignored", + class: 0x088000, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + classHigh := tt.class & 0xFFFF0000 + ignorable := (classHigh != 0x030000 && classHigh != 0x0A0000) + if ignorable != tt.want { + t.Errorf("class 0x%08x: ignorable = %v, want %v", tt.class, ignorable, tt.want) + } + }) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_test.go new file mode 100644 index 0000000..7f85510 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_test.go @@ -0,0 +1,135 @@ +package brightness + +import ( + "testing" +) + +func TestDDCBackend_PercentConversions(t *testing.T) { + tests := []struct { + name string + max int + percent int + wantValue int + }{ + { + name: "0% should map to minValue=1", + max: 100, + percent: 0, + wantValue: 1, + }, + { + name: "1% should be 1", + max: 100, + percent: 1, + wantValue: 1, + }, + { + name: "50% should be ~50", + max: 100, + percent: 50, + wantValue: 50, + }, + { + name: "100% should be max", + max: 100, + percent: 100, + wantValue: 100, + }, + } + + b := &DDCBackend{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := b.percentToValue(tt.percent, tt.max, false) + diff := got - tt.wantValue + if diff < 0 { + diff = -diff + } + if diff > 1 { + t.Errorf("percentToValue() = %v, want %v (±1)", got, tt.wantValue) + } + }) + } +} + +func TestDDCBackend_ValueToPercent(t *testing.T) { + tests := []struct { + name string + max int + value int + wantPercent int + tolerance int + }{ + { + name: "zero value should be 1%", + max: 100, + value: 0, + wantPercent: 1, + tolerance: 0, + }, + { + name: "min value should be 1%", + max: 100, + value: 1, + wantPercent: 1, + tolerance: 0, + }, + { + name: "mid value should be ~50%", + max: 100, + value: 50, + wantPercent: 50, + tolerance: 2, + }, + { + name: "max value should be 100%", + max: 100, + value: 100, + wantPercent: 100, + tolerance: 0, + }, + } + + b := &DDCBackend{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := b.valueToPercent(tt.value, tt.max, false) + diff := got - tt.wantPercent + if diff < 0 { + diff = -diff + } + if diff > tt.tolerance { + t.Errorf("valueToPercent() = %v, want %v (±%d)", got, tt.wantPercent, tt.tolerance) + } + }) + } +} + +func TestDDCBackend_RoundTrip(t *testing.T) { + b := &DDCBackend{} + + tests := []struct { + name string + max int + percent int + }{ + {"1%", 100, 1}, + {"25%", 100, 25}, + {"50%", 100, 50}, + {"75%", 100, 75}, + {"100%", 100, 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := b.percentToValue(tt.percent, tt.max, false) + gotPercent := b.valueToPercent(value, tt.max, false) + + if diff := tt.percent - gotPercent; diff < -1 || diff > 1 { + t.Errorf("round trip failed: wanted %d%%, got %d%% (value=%d)", tt.percent, gotPercent, value) + } + }) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/handlers.go new file mode 100644 index 0000000..c71163e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/handlers.go @@ -0,0 +1,124 @@ +package brightness + +import ( + "encoding/json" + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +func HandleRequest(conn net.Conn, req models.Request, m *Manager) { + switch req.Method { + case "brightness.getState": + handleGetState(conn, req, m) + case "brightness.setBrightness": + handleSetBrightness(conn, req, m) + case "brightness.increment": + handleIncrement(conn, req, m) + case "brightness.decrement": + handleDecrement(conn, req, m) + case "brightness.rescan": + handleRescan(conn, req, m) + case "brightness.subscribe": + handleSubscribe(conn, req, m) + default: + models.RespondError(conn, req.ID, "unknown method: "+req.Method) + } +} + +func handleGetState(conn net.Conn, req models.Request, m *Manager) { + models.Respond(conn, req.ID, m.GetState()) +} + +func handleSetBrightness(conn net.Conn, req models.Request, m *Manager) { + device, err := params.String(req.Params, "device") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + percent, err := params.Int(req.Params, "percent") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + exponential := params.BoolOpt(req.Params, "exponential", false) + exponent := params.FloatOpt(req.Params, "exponent", 1.2) + + if err := m.SetBrightnessWithExponent(device, percent, exponential, exponent); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, m.GetState()) +} + +func handleIncrement(conn net.Conn, req models.Request, m *Manager) { + device, err := params.String(req.Params, "device") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + step := params.IntOpt(req.Params, "step", 10) + exponential := params.BoolOpt(req.Params, "exponential", false) + exponent := params.FloatOpt(req.Params, "exponent", 1.2) + + if err := m.IncrementBrightnessWithExponent(device, step, exponential, exponent); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, m.GetState()) +} + +func handleDecrement(conn net.Conn, req models.Request, m *Manager) { + device, err := params.String(req.Params, "device") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + step := params.IntOpt(req.Params, "step", 10) + exponential := params.BoolOpt(req.Params, "exponential", false) + exponent := params.FloatOpt(req.Params, "exponent", 1.2) + + if err := m.IncrementBrightnessWithExponent(device, -step, exponential, exponent); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, m.GetState()) +} + +func handleRescan(conn net.Conn, req models.Request, m *Manager) { + m.Rescan() + models.Respond(conn, req.ID, m.GetState()) +} + +func handleSubscribe(conn net.Conn, req models.Request, m *Manager) { + clientID := fmt.Sprintf("brightness-%d", req.ID) + + ch := m.Subscribe(clientID) + defer m.Unsubscribe(clientID) + + initialState := m.GetState() + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + ID: req.ID, + Result: &initialState, + }); err != nil { + return + } + + for state := range ch { + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + ID: req.ID, + Result: &state, + }); err != nil { + return + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind.go new file mode 100644 index 0000000..8449ab5 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind.go @@ -0,0 +1,66 @@ +package brightness + +import ( + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/godbus/dbus/v5" +) + +type DBusConn interface { + Object(dest string, path dbus.ObjectPath) dbus.BusObject + Close() error +} + +type LogindBackend struct { + conn DBusConn +} + +func NewLogindBackend() (*LogindBackend, error) { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, fmt.Errorf("connect to system bus: %w", err) + } + + obj := conn.Object("org.freedesktop.login1", "/org/freedesktop/login1/session/auto") + call := obj.Call("org.freedesktop.DBus.Peer.Ping", 0) + if call.Err != nil { + conn.Close() + return nil, fmt.Errorf("logind not available: %w", call.Err) + } + + conn.Close() + + return &LogindBackend{}, nil +} + +func NewLogindBackendWithConn(conn DBusConn) *LogindBackend { + return &LogindBackend{ + conn: conn, + } +} + +func (b *LogindBackend) SetBrightness(subsystem, name string, brightness uint32) error { + if b.conn == nil { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return fmt.Errorf("connect to system bus: %w", err) + } + b.conn = conn + } + + obj := b.conn.Object("org.freedesktop.login1", "/org/freedesktop/login1/session/auto") + call := obj.Call("org.freedesktop.login1.Session.SetBrightness", 0, subsystem, name, brightness) + if call.Err != nil { + return fmt.Errorf("dbus call failed: %w", call.Err) + } + + log.Debugf("logind: set %s/%s to %d", subsystem, name, brightness) + return nil +} + +func (b *LogindBackend) Close() { + if b.conn != nil { + b.conn.Close() + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind_test.go new file mode 100644 index 0000000..3d4c2ee --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/logind_test.go @@ -0,0 +1,95 @@ +package brightness + +import ( + "errors" + "testing" + + mocks_brightness "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/brightness" + mock_dbus "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/mock" +) + +func TestLogindBackend_SetBrightness_Success(t *testing.T) { + mockConn := mocks_brightness.NewMockDBusConn(t) + mockObj := mock_dbus.NewMockBusObject(t) + + backend := NewLogindBackendWithConn(mockConn) + + mockConn.EXPECT(). + Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")). + Return(mockObj). + Once() + + mockObj.EXPECT(). + Call("org.freedesktop.login1.Session.SetBrightness", dbus.Flags(0), "backlight", "nvidia_0", uint32(75)). + Return(&dbus.Call{Err: nil}). + Once() + + err := backend.SetBrightness("backlight", "nvidia_0", 75) + if err != nil { + t.Errorf("SetBrightness() error = %v, want nil", err) + } +} + +func TestLogindBackend_SetBrightness_DBusError(t *testing.T) { + mockConn := mocks_brightness.NewMockDBusConn(t) + mockObj := mock_dbus.NewMockBusObject(t) + + backend := NewLogindBackendWithConn(mockConn) + + mockConn.EXPECT(). + Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")). + Return(mockObj). + Once() + + dbusErr := errors.New("permission denied") + mockObj.EXPECT(). + Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&dbus.Call{Err: dbusErr}). + Once() + + err := backend.SetBrightness("backlight", "test_device", 50) + if err == nil { + t.Error("SetBrightness() error = nil, want error") + } +} + +func TestLogindBackend_SetBrightness_LEDDevice(t *testing.T) { + mockConn := mocks_brightness.NewMockDBusConn(t) + mockObj := mock_dbus.NewMockBusObject(t) + + backend := NewLogindBackendWithConn(mockConn) + + mockConn.EXPECT(). + Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")). + Return(mockObj). + Once() + + mockObj.EXPECT(). + Call("org.freedesktop.login1.Session.SetBrightness", dbus.Flags(0), "leds", "test_led", uint32(128)). + Return(&dbus.Call{Err: nil}). + Once() + + err := backend.SetBrightness("leds", "test_led", 128) + if err != nil { + t.Errorf("SetBrightness() error = %v, want nil", err) + } +} + +func TestLogindBackend_Close(t *testing.T) { + mockConn := mocks_brightness.NewMockDBusConn(t) + backend := NewLogindBackendWithConn(mockConn) + + mockConn.EXPECT(). + Close(). + Return(nil). + Once() + + backend.Close() +} + +func TestLogindBackend_Close_NilConn(t *testing.T) { + backend := &LogindBackend{conn: nil} + backend.Close() +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager.go new file mode 100644 index 0000000..4cf7dd4 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager.go @@ -0,0 +1,389 @@ +package brightness + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +func NewManager() (*Manager, error) { + return NewManagerWithOptions(false) +} + +func NewManagerWithOptions(exponential bool) (*Manager, error) { + m := &Manager{ + stopChan: make(chan struct{}), + exponential: exponential, + } + + go m.initLogind() + go m.initSysfs() + go m.initDDC() + + return m, nil +} + +func (m *Manager) initLogind() { + log.Debug("Initializing logind backend...") + logind, err := NewLogindBackend() + if err != nil { + log.Infof("Logind backend not available: %v", err) + log.Info("Will use direct sysfs access for brightness control") + return + } + + m.logindBackend = logind + m.logindReady = true + log.Info("Logind backend initialized - will use for brightness control") +} + +func (m *Manager) initSysfs() { + log.Debug("Initializing sysfs backend...") + sysfs, err := NewSysfsBackend() + if err != nil { + log.Warnf("Failed to initialize sysfs backend: %v", err) + return + } + + devices, err := sysfs.GetDevices() + if err != nil { + log.Warnf("Failed to get initial sysfs devices: %v", err) + m.sysfsBackend = sysfs + m.sysfsReady = true + m.updateState() + m.initUdev() + return + } + + log.Infof("Sysfs backend initialized with %d devices", len(devices)) + for _, d := range devices { + log.Debugf(" - %s: %s (%d%%)", d.ID, d.Name, d.CurrentPercent) + } + + m.sysfsBackend = sysfs + m.sysfsReady = true + m.updateState() + m.initUdev() +} + +func (m *Manager) initUdev() { + m.udevMonitor = NewUdevMonitor(m) +} + +func (m *Manager) initDDC() { + ddc, err := NewDDCBackend() + if err != nil { + log.Debugf("Failed to initialize DDC backend: %v", err) + return + } + + m.ddcBackend = ddc + m.ddcReady = true + log.Info("DDC backend initialized") + + m.updateState() +} + +func (m *Manager) Rescan() { + log.Debug("Rescanning brightness devices...") + + if m.ddcReady && m.ddcBackend != nil { + if err := m.ddcBackend.ForceRescan(); err != nil { + log.Debugf("DDC force rescan failed: %v", err) + } + } + + if m.sysfsReady && m.sysfsBackend != nil { + if err := m.sysfsBackend.Rescan(); err != nil { + log.Debugf("Sysfs rescan failed: %v", err) + } + } + + m.updateState() +} + +func sortDevices(devices []Device) { + sort.Slice(devices, func(i, j int) bool { + classOrder := map[DeviceClass]int{ + ClassBacklight: 0, + ClassDDC: 1, + ClassLED: 2, + } + + orderI := classOrder[devices[i].Class] + orderJ := classOrder[devices[j].Class] + + if orderI != orderJ { + return orderI < orderJ + } + + return devices[i].Name < devices[j].Name + }) +} + +func stateChanged(old, new State) bool { + if len(old.Devices) != len(new.Devices) { + return true + } + + oldMap := make(map[string]Device) + for _, d := range old.Devices { + oldMap[d.ID] = d + } + + for _, newDev := range new.Devices { + oldDev, exists := oldMap[newDev.ID] + if !exists { + return true + } + if oldDev.Current != newDev.Current || oldDev.Max != newDev.Max { + return true + } + } + + return false +} + +func (m *Manager) updateState() { + allDevices := make([]Device, 0) + + if m.sysfsReady && m.sysfsBackend != nil { + devices, err := m.sysfsBackend.GetDevices() + if err != nil { + log.Debugf("Failed to get sysfs devices: %v", err) + } + if err == nil { + allDevices = append(allDevices, devices...) + } + } + + if m.ddcReady && m.ddcBackend != nil { + devices, err := m.ddcBackend.GetDevices() + if err != nil { + log.Debugf("Failed to get DDC devices: %v", err) + } + if err == nil { + allDevices = append(allDevices, devices...) + } + } + + sortDevices(allDevices) + + m.stateMutex.Lock() + oldState := m.state + newState := State{Devices: allDevices} + + if !stateChanged(oldState, newState) { + m.stateMutex.Unlock() + return + } + + m.state = newState + m.stateMutex.Unlock() + log.Debugf("State changed, notifying subscribers") + m.NotifySubscribers() +} + +func (m *Manager) SetBrightness(deviceID string, percent int) error { + return m.SetBrightnessWithMode(deviceID, percent, m.exponential) +} + +func (m *Manager) SetBrightnessWithMode(deviceID string, percent int, exponential bool) error { + return m.SetBrightnessWithExponent(deviceID, percent, exponential, 1.2) +} + +func (m *Manager) SetBrightnessWithExponent(deviceID string, percent int, exponential bool, exponent float64) error { + if percent < 0 { + return fmt.Errorf("percent out of range: %d", percent) + } + + log.Debugf("SetBrightness: %s to %d%%", deviceID, percent) + + m.stateMutex.Lock() + currentState := m.state + var found bool + var deviceClass DeviceClass + var deviceIndex int + + log.Debugf("Current state has %d devices", len(currentState.Devices)) + + for i, dev := range currentState.Devices { + if dev.ID == deviceID { + found = true + deviceClass = dev.Class + deviceIndex = i + break + } + } + + if !found { + m.stateMutex.Unlock() + log.Debugf("Device not found in state: %s", deviceID) + return fmt.Errorf("device not found: %s", deviceID) + } + + newDevices := make([]Device, len(currentState.Devices)) + copy(newDevices, currentState.Devices) + newDevices[deviceIndex].CurrentPercent = percent + m.state = State{Devices: newDevices} + m.stateMutex.Unlock() + + var err error + if deviceClass == ClassDDC { + log.Debugf("Calling DDC backend for %s", deviceID) + err = m.ddcBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, func() { + m.updateState() + m.debouncedBroadcast(deviceID) + }) + } else if m.logindReady && m.logindBackend != nil { + log.Debugf("Calling logind backend for %s", deviceID) + err = m.setViaSysfsWithLogindWithExponent(deviceID, percent, exponential, exponent) + } else { + log.Debugf("Calling sysfs backend for %s", deviceID) + err = m.sysfsBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent) + } + + if err != nil { + m.updateState() + return fmt.Errorf("failed to set brightness: %w", err) + } + + if deviceClass != ClassDDC { + log.Debugf("Queueing broadcast for %s", deviceID) + m.debouncedBroadcast(deviceID) + } + return nil +} + +func (m *Manager) IncrementBrightness(deviceID string, step int) error { + return m.IncrementBrightnessWithMode(deviceID, step, m.exponential) +} + +func (m *Manager) IncrementBrightnessWithMode(deviceID string, step int, exponential bool) error { + return m.IncrementBrightnessWithExponent(deviceID, step, exponential, 1.2) +} + +func (m *Manager) IncrementBrightnessWithExponent(deviceID string, step int, exponential bool, exponent float64) error { + m.stateMutex.RLock() + currentState := m.state + m.stateMutex.RUnlock() + + var currentPercent int + var found bool + + for _, dev := range currentState.Devices { + if dev.ID == deviceID { + currentPercent = dev.CurrentPercent + found = true + break + } + } + + if !found { + return fmt.Errorf("device not found: %s", deviceID) + } + + newPercent := currentPercent + step + if newPercent > 100 { + newPercent = 100 + } + if newPercent < 0 { + newPercent = 0 + } + + return m.SetBrightnessWithExponent(deviceID, newPercent, exponential, exponent) +} + +func (m *Manager) DecrementBrightness(deviceID string, step int) error { + return m.IncrementBrightness(deviceID, -step) +} + +func (m *Manager) setViaSysfsWithLogindWithExponent(deviceID string, percent int, exponential bool, exponent float64) error { + parts := strings.SplitN(deviceID, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid device id: %s", deviceID) + } + + subsystem := parts[0] + name := parts[1] + + dev, err := m.sysfsBackend.GetDevice(deviceID) + if err != nil { + return err + } + + value := m.sysfsBackend.PercentToValueWithExponent(percent, dev, exponential, exponent) + + if m.logindBackend == nil { + return m.sysfsBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent) + } + + err = m.logindBackend.SetBrightness(subsystem, name, uint32(value)) + if err != nil { + log.Debugf("logind SetBrightness failed, falling back to direct sysfs: %v", err) + return m.sysfsBackend.SetBrightnessWithExponent(deviceID, percent, exponential, exponent) + } + + log.Debugf("set %s to %d%% (%d/%d) via logind", deviceID, percent, value, dev.maxBrightness) + return nil +} + +func (m *Manager) debouncedBroadcast(deviceID string) { + m.broadcastMutex.Lock() + defer m.broadcastMutex.Unlock() + + m.broadcastPending = true + m.pendingDeviceID = deviceID + + if m.broadcastTimer == nil { + m.broadcastTimer = time.AfterFunc(150*time.Millisecond, func() { + m.broadcastMutex.Lock() + pending := m.broadcastPending + deviceID := m.pendingDeviceID + m.broadcastPending = false + m.pendingDeviceID = "" + m.broadcastMutex.Unlock() + + if !pending || deviceID == "" { + return + } + + m.broadcastDeviceUpdate(deviceID) + }) + } else { + m.broadcastTimer.Reset(150 * time.Millisecond) + } +} + +func (m *Manager) broadcastDeviceUpdate(deviceID string) { + m.stateMutex.RLock() + var targetDevice *Device + for _, dev := range m.state.Devices { + if dev.ID == deviceID { + devCopy := dev + targetDevice = &devCopy + break + } + } + m.stateMutex.RUnlock() + + if targetDevice == nil { + log.Debugf("Device not found for broadcast: %s", deviceID) + return + } + + update := DeviceUpdate{Device: *targetDevice} + + log.Debugf("Broadcasting device update: %s at %d%%", deviceID, targetDevice.CurrentPercent) + + m.updateSubscribers.Range(func(key string, ch chan DeviceUpdate) bool { + select { + case ch <- update: + default: + } + return true + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager_test.go new file mode 100644 index 0000000..a8ae0f8 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/manager_test.go @@ -0,0 +1,11 @@ +package brightness + +import ( + "testing" +) + +// Manager tests can be added here as needed +func TestManager_Placeholder(t *testing.T) { + // Placeholder test to keep the test file valid + t.Skip("No tests implemented yet") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs.go new file mode 100644 index 0000000..1ff38f9 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs.go @@ -0,0 +1,267 @@ +package brightness + +import ( + "fmt" + "math" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +func NewSysfsBackend() (*SysfsBackend, error) { + b := &SysfsBackend{ + basePath: "/sys/class", + classes: []string{"backlight", "leds"}, + } + + if err := b.scanDevices(); err != nil { + return nil, err + } + + return b, nil +} + +func (b *SysfsBackend) scanDevices() error { + for _, class := range b.classes { + classPath := filepath.Join(b.basePath, class) + entries, err := os.ReadDir(classPath) + if err != nil { + if os.IsNotExist(err) { + continue + } + return fmt.Errorf("read %s: %w", classPath, err) + } + + for _, entry := range entries { + devicePath := filepath.Join(classPath, entry.Name()) + + stat, err := os.Stat(devicePath) + if err != nil || !stat.IsDir() { + continue + } + maxPath := filepath.Join(devicePath, "max_brightness") + + maxData, err := os.ReadFile(maxPath) + if err != nil { + log.Debugf("skip %s/%s: no max_brightness", class, entry.Name()) + continue + } + + maxBrightness, err := strconv.Atoi(strings.TrimSpace(string(maxData))) + if err != nil || maxBrightness <= 0 { + log.Debugf("skip %s/%s: invalid max_brightness", class, entry.Name()) + continue + } + + deviceClass := ClassBacklight + minValue := 1 + if class == "leds" { + deviceClass = ClassLED + minValue = 0 + } + + deviceID := fmt.Sprintf("%s:%s", class, entry.Name()) + b.deviceCache.Store(deviceID, &sysfsDevice{ + class: deviceClass, + id: deviceID, + name: entry.Name(), + maxBrightness: maxBrightness, + minValue: minValue, + }) + + log.Debugf("found %s device: %s (max=%d)", class, entry.Name(), maxBrightness) + } + } + + return nil +} + +func shouldSuppressDevice(name string) bool { + if strings.HasSuffix(name, "::lan") { + return true + } + + keyboardLEDs := []string{ + "::scrolllock", + "::capslock", + "::numlock", + "::kana", + "::compose", + } + + for _, suffix := range keyboardLEDs { + if strings.HasSuffix(name, suffix) { + return true + } + } + + return false +} + +func (b *SysfsBackend) Rescan() error { + return b.scanDevices() +} + +func (b *SysfsBackend) GetDevices() ([]Device, error) { + devices := make([]Device, 0) + + b.deviceCache.Range(func(key string, dev *sysfsDevice) bool { + if shouldSuppressDevice(dev.name) { + return true + } + + parts := strings.SplitN(dev.id, ":", 2) + if len(parts) != 2 { + return true + } + + class := parts[0] + name := parts[1] + + devicePath := filepath.Join(b.basePath, class, name) + brightnessPath := filepath.Join(devicePath, "brightness") + + brightnessData, err := os.ReadFile(brightnessPath) + if err != nil { + log.Debugf("failed to read brightness for %s: %v", dev.id, err) + return true + } + + current, err := strconv.Atoi(strings.TrimSpace(string(brightnessData))) + if err != nil { + log.Debugf("failed to parse brightness for %s: %v", dev.id, err) + return true + } + + percent := b.ValueToPercent(current, dev, false) + + devices = append(devices, Device{ + Class: dev.class, + ID: dev.id, + Name: dev.name, + Current: current, + Max: dev.maxBrightness, + CurrentPercent: percent, + Backend: "sysfs", + }) + return true + }) + + return devices, nil +} + +func (b *SysfsBackend) GetDevice(id string) (*sysfsDevice, error) { + dev, ok := b.deviceCache.Load(id) + if !ok { + return nil, fmt.Errorf("device not found: %s", id) + } + + return dev, nil +} + +func (b *SysfsBackend) SetBrightness(id string, percent int, exponential bool) error { + return b.SetBrightnessWithExponent(id, percent, exponential, 1.2) +} + +func (b *SysfsBackend) SetBrightnessWithExponent(id string, percent int, exponential bool, exponent float64) error { + dev, err := b.GetDevice(id) + if err != nil { + return err + } + + if percent < 0 { + return fmt.Errorf("percent out of range: %d", percent) + } + + value := b.PercentToValueWithExponent(percent, dev, exponential, exponent) + + parts := strings.SplitN(id, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid device id: %s", id) + } + + class := parts[0] + name := parts[1] + + devicePath := filepath.Join(b.basePath, class, name) + brightnessPath := filepath.Join(devicePath, "brightness") + + data := []byte(fmt.Sprintf("%d", value)) + if err := os.WriteFile(brightnessPath, data, 0o644); err != nil { + return fmt.Errorf("write brightness: %w", err) + } + + log.Debugf("set %s to %d%% (%d/%d) via direct sysfs", id, percent, value, dev.maxBrightness) + + return nil +} + +func (b *SysfsBackend) PercentToValue(percent int, dev *sysfsDevice, exponential bool) int { + return b.PercentToValueWithExponent(percent, dev, exponential, 1.2) +} + +func (b *SysfsBackend) PercentToValueWithExponent(percent int, dev *sysfsDevice, exponential bool, exponent float64) int { + if percent == 0 { + return dev.minValue + } + + usableRange := dev.maxBrightness - dev.minValue + var value int + + if exponential { + normalizedPercent := float64(percent-1) / 99.0 + hardwarePercent := math.Pow(normalizedPercent, exponent) + value = dev.minValue + int(math.Round(hardwarePercent*float64(usableRange))) + } else { + value = dev.minValue + ((percent - 1) * usableRange / 99) + } + + if value < dev.minValue { + value = dev.minValue + } + if value > dev.maxBrightness { + value = dev.maxBrightness + } + + return value +} + +func (b *SysfsBackend) ValueToPercent(value int, dev *sysfsDevice, exponential bool) int { + return b.ValueToPercentWithExponent(value, dev, exponential, 1.2) +} + +func (b *SysfsBackend) ValueToPercentWithExponent(value int, dev *sysfsDevice, exponential bool, exponent float64) int { + if value <= dev.minValue { + if dev.minValue == 0 && value == 0 { + return 0 + } + return 1 + } + + usableRange := dev.maxBrightness - dev.minValue + if usableRange == 0 { + return 100 + } + + var percent int + + if exponential { + hardwarePercent := float64(value-dev.minValue) / float64(usableRange) + normalizedPercent := math.Pow(hardwarePercent, 1.0/exponent) + percent = 1 + int(math.Round(normalizedPercent*99.0)) + } else { + percent = 1 + int(math.Round(float64(value-dev.minValue)*99.0/float64(usableRange))) + } + + if percent > 100 { + percent = 100 + } + if percent < 1 { + percent = 1 + } + + return percent +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_logind_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_logind_test.go new file mode 100644 index 0000000..6ba84b8 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_logind_test.go @@ -0,0 +1,278 @@ +package brightness + +import ( + "os" + "path/filepath" + "testing" + + mocks_brightness "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/brightness" + mock_dbus "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/mock" +) + +func TestManager_SetBrightness_LogindSuccess(t *testing.T) { + tmpDir := t.TempDir() + + backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight") + if err := os.MkdirAll(backlightDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil { + t.Fatal(err) + } + + mockConn := mocks_brightness.NewMockDBusConn(t) + mockObj := mock_dbus.NewMockBusObject(t) + + mockLogind := NewLogindBackendWithConn(mockConn) + + sysfs := &SysfsBackend{ + basePath: tmpDir, + classes: []string{"backlight"}, + } + + if err := sysfs.scanDevices(); err != nil { + t.Fatal(err) + } + + m := &Manager{ + logindBackend: mockLogind, + sysfsBackend: sysfs, + logindReady: true, + sysfsReady: true, + stopChan: make(chan struct{}), + } + + m.state = State{ + Devices: []Device{ + { + Class: ClassBacklight, + ID: "backlight:test_backlight", + Name: "test_backlight", + Current: 50, + Max: 100, + CurrentPercent: 50, + Backend: "sysfs", + }, + }, + } + + mockConn.EXPECT(). + Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")). + Return(mockObj). + Once() + + mockObj.EXPECT(). + Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, "backlight", "test_backlight", uint32(75)). + Return(&dbus.Call{Err: nil}). + Once() + + err := m.SetBrightness("backlight:test_backlight", 75) + if err != nil { + t.Errorf("SetBrightness() with logind error = %v, want nil", err) + } + + data, _ := os.ReadFile(filepath.Join(backlightDir, "brightness")) + if string(data) == "75\n" { + t.Error("Direct sysfs write occurred when logind should have been used") + } +} + +func TestManager_SetBrightness_LogindFailsFallbackToSysfs(t *testing.T) { + tmpDir := t.TempDir() + + backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight") + if err := os.MkdirAll(backlightDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil { + t.Fatal(err) + } + + mockConn := mocks_brightness.NewMockDBusConn(t) + mockObj := mock_dbus.NewMockBusObject(t) + + mockLogind := NewLogindBackendWithConn(mockConn) + + sysfs := &SysfsBackend{ + basePath: tmpDir, + classes: []string{"backlight"}, + } + + if err := sysfs.scanDevices(); err != nil { + t.Fatal(err) + } + + m := &Manager{ + logindBackend: mockLogind, + sysfsBackend: sysfs, + logindReady: true, + sysfsReady: true, + stopChan: make(chan struct{}), + } + + m.state = State{ + Devices: []Device{ + { + Class: ClassBacklight, + ID: "backlight:test_backlight", + Name: "test_backlight", + Current: 50, + Max: 100, + CurrentPercent: 50, + Backend: "sysfs", + }, + }, + } + + mockConn.EXPECT(). + Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")). + Return(mockObj). + Once() + + mockObj.EXPECT(). + Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, "backlight", "test_backlight", mock.Anything). + Return(&dbus.Call{Err: dbus.ErrMsgNoObject}). + Once() + + err := m.SetBrightness("backlight:test_backlight", 75) + if err != nil { + t.Errorf("SetBrightness() with fallback error = %v, want nil", err) + } + + data, _ := os.ReadFile(filepath.Join(backlightDir, "brightness")) + brightness := string(data) + if brightness != "75" { + t.Errorf("Fallback sysfs write did not occur, got brightness = %q, want %q", brightness, "75") + } +} + +func TestManager_SetBrightness_NoLogind(t *testing.T) { + tmpDir := t.TempDir() + + backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight") + if err := os.MkdirAll(backlightDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil { + t.Fatal(err) + } + + sysfs := &SysfsBackend{ + basePath: tmpDir, + classes: []string{"backlight"}, + } + + if err := sysfs.scanDevices(); err != nil { + t.Fatal(err) + } + + m := &Manager{ + logindBackend: nil, + sysfsBackend: sysfs, + logindReady: false, + sysfsReady: true, + stopChan: make(chan struct{}), + } + + m.state = State{ + Devices: []Device{ + { + Class: ClassBacklight, + ID: "backlight:test_backlight", + Name: "test_backlight", + Current: 50, + Max: 100, + CurrentPercent: 50, + Backend: "sysfs", + }, + }, + } + + err := m.SetBrightness("backlight:test_backlight", 75) + if err != nil { + t.Errorf("SetBrightness() without logind error = %v, want nil", err) + } + + data, _ := os.ReadFile(filepath.Join(backlightDir, "brightness")) + brightness := string(data) + if brightness != "75" { + t.Errorf("Direct sysfs write = %q, want %q", brightness, "75") + } +} + +func TestManager_SetBrightness_LEDWithLogind(t *testing.T) { + tmpDir := t.TempDir() + + ledsDir := filepath.Join(tmpDir, "leds", "test_led") + if err := os.MkdirAll(ledsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ledsDir, "max_brightness"), []byte("255\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ledsDir, "brightness"), []byte("128\n"), 0o644); err != nil { + t.Fatal(err) + } + + mockConn := mocks_brightness.NewMockDBusConn(t) + mockObj := mock_dbus.NewMockBusObject(t) + + mockLogind := NewLogindBackendWithConn(mockConn) + + sysfs := &SysfsBackend{ + basePath: tmpDir, + classes: []string{"leds"}, + } + + if err := sysfs.scanDevices(); err != nil { + t.Fatal(err) + } + + m := &Manager{ + logindBackend: mockLogind, + sysfsBackend: sysfs, + logindReady: true, + sysfsReady: true, + stopChan: make(chan struct{}), + } + + m.state = State{ + Devices: []Device{ + { + Class: ClassLED, + ID: "leds:test_led", + Name: "test_led", + Current: 128, + Max: 255, + CurrentPercent: 50, + Backend: "sysfs", + }, + }, + } + + mockConn.EXPECT(). + Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1/session/auto")). + Return(mockObj). + Once() + + mockObj.EXPECT(). + Call("org.freedesktop.login1.Session.SetBrightness", mock.Anything, "leds", "test_led", uint32(0)). + Return(&dbus.Call{Err: nil}). + Once() + + err := m.SetBrightness("leds:test_led", 0) + if err != nil { + t.Errorf("SetBrightness() LED with logind error = %v, want nil", err) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_test.go new file mode 100644 index 0000000..8061513 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_test.go @@ -0,0 +1,180 @@ +package brightness + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSysfsBackend_PercentConversions(t *testing.T) { + tests := []struct { + name string + device *sysfsDevice + percent int + wantValue int + tolerance int + }{ + { + name: "backlight 0% should be minValue=1", + device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight}, + percent: 0, + wantValue: 1, + tolerance: 0, + }, + { + name: "backlight 1% should be minValue=1", + device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight}, + percent: 1, + wantValue: 1, + tolerance: 0, + }, + { + name: "backlight 50% should be ~50", + device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight}, + percent: 50, + wantValue: 50, + tolerance: 1, + }, + { + name: "backlight 100% should be max", + device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight}, + percent: 100, + wantValue: 100, + tolerance: 0, + }, + { + name: "led 0% should be 0", + device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED}, + percent: 0, + wantValue: 0, + tolerance: 0, + }, + { + name: "led 1% should be ~2-3", + device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED}, + percent: 1, + wantValue: 2, + tolerance: 3, + }, + { + name: "led 50% should be ~127", + device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED}, + percent: 50, + wantValue: 127, + tolerance: 2, + }, + { + name: "led 100% should be max", + device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED}, + percent: 100, + wantValue: 255, + tolerance: 0, + }, + } + + b := &SysfsBackend{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := b.PercentToValue(tt.percent, tt.device, false) + diff := got - tt.wantValue + if diff < 0 { + diff = -diff + } + if diff > tt.tolerance { + t.Errorf("percentToValue() = %v, want %v (±%d)", got, tt.wantValue, tt.tolerance) + } + + gotPercent := b.ValueToPercent(got, tt.device, false) + if tt.percent > 1 && gotPercent == 0 { + t.Errorf("valueToPercent() returned 0 for non-zero input (percent=%d, got value=%d)", tt.percent, got) + } + }) + } +} + +func TestSysfsBackend_ValueToPercent(t *testing.T) { + tests := []struct { + name string + device *sysfsDevice + value int + wantPercent int + }{ + { + name: "backlight min value", + device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight}, + value: 1, + wantPercent: 1, + }, + { + name: "backlight max value", + device: &sysfsDevice{maxBrightness: 100, minValue: 1, class: ClassBacklight}, + value: 100, + wantPercent: 100, + }, + { + name: "led zero", + device: &sysfsDevice{maxBrightness: 255, minValue: 0, class: ClassLED}, + value: 0, + wantPercent: 0, + }, + } + + b := &SysfsBackend{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := b.ValueToPercent(tt.value, tt.device, false) + if got != tt.wantPercent { + t.Errorf("valueToPercent() = %v, want %v", got, tt.wantPercent) + } + }) + } +} + +func TestSysfsBackend_ScanDevices(t *testing.T) { + tmpDir := t.TempDir() + + backlightDir := filepath.Join(tmpDir, "backlight", "test_backlight") + if err := os.MkdirAll(backlightDir, 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("100\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("50\n"), 0o644); err != nil { + t.Fatal(err) + } + + ledsDir := filepath.Join(tmpDir, "leds", "test_led") + if err := os.MkdirAll(ledsDir, 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(ledsDir, "max_brightness"), []byte("255\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ledsDir, "brightness"), []byte("128\n"), 0o644); err != nil { + t.Fatal(err) + } + + b := &SysfsBackend{ + basePath: tmpDir, + classes: []string{"backlight", "leds"}, + } + + if err := b.scanDevices(); err != nil { + t.Fatalf("scanDevices() error = %v", err) + } + + backlightID := "backlight:test_backlight" + if _, ok := b.deviceCache.Load(backlightID); !ok { + t.Errorf("backlight device not found") + } + + ledID := "leds:test_led" + if _, ok := b.deviceCache.Load(ledID); !ok { + t.Errorf("LED device not found") + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/types.go new file mode 100644 index 0000000..81e00cf --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/types.go @@ -0,0 +1,184 @@ +package brightness + +import ( + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type DeviceClass string + +const ( + ClassBacklight DeviceClass = "backlight" + ClassLED DeviceClass = "leds" + ClassDDC DeviceClass = "ddc" +) + +type Device struct { + Class DeviceClass `json:"class"` + ID string `json:"id"` + Name string `json:"name"` + Current int `json:"current"` + Max int `json:"max"` + CurrentPercent int `json:"currentPercent"` + Backend string `json:"backend"` +} + +type State struct { + Devices []Device `json:"devices"` +} + +type DeviceUpdate struct { + Device Device `json:"device"` +} + +type Manager struct { + logindBackend *LogindBackend + sysfsBackend *SysfsBackend + ddcBackend *DDCBackend + udevMonitor *UdevMonitor + + logindReady bool + sysfsReady bool + ddcReady bool + + exponential bool + + stateMutex sync.RWMutex + state State + + subscribers syncmap.Map[string, chan State] + updateSubscribers syncmap.Map[string, chan DeviceUpdate] + + broadcastMutex sync.Mutex + broadcastTimer *time.Timer + broadcastPending bool + pendingDeviceID string + + stopChan chan struct{} +} + +type SysfsBackend struct { + basePath string + classes []string + + deviceCache syncmap.Map[string, *sysfsDevice] +} + +type sysfsDevice struct { + class DeviceClass + id string + name string + maxBrightness int + minValue int +} + +type DDCBackend struct { + devices syncmap.Map[string, *ddcDevice] + + scanMutex sync.Mutex + lastScan time.Time + scanInterval time.Duration + + debounceMutex sync.Mutex + debounceTimers map[string]*time.Timer + debouncePending map[string]ddcPendingSet + debounceWg sync.WaitGroup +} + +type ddcPendingSet struct { + percent int + callback func() +} + +type ddcDevice struct { + bus int + addr int + id string + name string + max int + lastBrightness int +} + +type ddcCapability struct { + vcp byte + max int + current int +} + +func (m *Manager) Subscribe(id string) chan State { + ch := make(chan State, 16) + + m.subscribers.Store(id, ch) + + return ch +} + +func (m *Manager) Unsubscribe(id string) { + + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + + } + +} + +func (m *Manager) SubscribeUpdates(id string) chan DeviceUpdate { + ch := make(chan DeviceUpdate, 16) + m.updateSubscribers.Store(id, ch) + return ch +} + +func (m *Manager) UnsubscribeUpdates(id string) { + if val, ok := m.updateSubscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) NotifySubscribers() { + m.stateMutex.RLock() + state := m.state + m.stateMutex.RUnlock() + + m.subscribers.Range(func(key string, ch chan State) bool { + select { + case ch <- state: + default: + } + return true + }) +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + return m.state +} + +func (m *Manager) Close() { + close(m.stopChan) + + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + m.updateSubscribers.Range(func(key string, ch chan DeviceUpdate) bool { + close(ch) + m.updateSubscribers.Delete(key) + return true + }) + + if m.udevMonitor != nil { + m.udevMonitor.Close() + } + + if m.logindBackend != nil { + m.logindBackend.Close() + } + + if m.ddcBackend != nil { + m.ddcBackend.Close() + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev.go new file mode 100644 index 0000000..b7f02a8 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev.go @@ -0,0 +1,237 @@ +package brightness + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/pilebones/go-udev/netlink" +) + +const ( + udevRecvBufSize = 8 * 1024 * 1024 + udevMaxRetries = 5 + udevBaseDelay = 2 * time.Second + udevMaxDelay = 60 * time.Second +) + +type UdevMonitor struct { + stop chan struct{} + rescanMutex sync.Mutex + rescanTimer *time.Timer + rescanPending bool +} + +func NewUdevMonitor(manager *Manager) *UdevMonitor { + m := &UdevMonitor{ + stop: make(chan struct{}), + } + + go m.run(manager) + return m +} + +func (m *UdevMonitor) run(manager *Manager) { + matcher := &netlink.RuleDefinitions{ + Rules: []netlink.RuleDefinition{ + {Env: map[string]string{"SUBSYSTEM": "backlight"}}, + {Env: map[string]string{"SUBSYSTEM": "drm"}}, + {Env: map[string]string{"SUBSYSTEM": "i2c"}}, + }, + } + if err := matcher.Compile(); err != nil { + log.Errorf("Failed to compile udev matcher: %v", err) + return + } + + failures := 0 + for { + if err := m.monitorLoop(manager, matcher); err != nil { + log.Errorf("Udev monitor error: %v", err) + } + + select { + case <-m.stop: + return + default: + } + + failures++ + if failures > udevMaxRetries { + log.Errorf("Udev monitor exceeded %d retries, giving up", udevMaxRetries) + return + } + + delay := min(udevBaseDelay*time.Duration(1<<(failures-1)), udevMaxDelay) + log.Infof("Udev monitor reconnecting in %v (attempt %d/%d)", delay, failures, udevMaxRetries) + + select { + case <-m.stop: + return + case <-time.After(delay): + } + } +} + +func (m *UdevMonitor) monitorLoop(manager *Manager, matcher *netlink.RuleDefinitions) error { + conn := &netlink.UEventConn{} + if err := conn.Connect(netlink.UdevEvent); err != nil { + return err + } + defer conn.Close() + + if err := syscall.SetsockoptInt(conn.Fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, udevRecvBufSize); err != nil { + log.Warnf("Failed to set udev socket receive buffer: %v", err) + } + + events := make(chan netlink.UEvent) + errs := make(chan error) + conn.Monitor(events, errs, matcher) + + log.Info("Udev monitor started for backlight/drm/i2c events") + + for { + select { + case <-m.stop: + return nil + case err := <-errs: + return err + case event := <-events: + m.handleEvent(manager, event) + } + } +} + +func (m *UdevMonitor) handleEvent(manager *Manager, event netlink.UEvent) { + subsystem := event.Env["SUBSYSTEM"] + devpath := event.Env["DEVPATH"] + + if subsystem == "" || devpath == "" { + return + } + + sysname := filepath.Base(devpath) + action := string(event.Action) + + switch subsystem { + case "drm", "i2c": + m.handleDisplayEvent(manager, action, subsystem, sysname) + case "backlight": + m.handleBacklightEvent(manager, action, sysname) + } +} + +func (m *UdevMonitor) handleDisplayEvent(manager *Manager, action, subsystem, sysname string) { + switch action { + case "add", "remove", "change": + log.Debugf("Udev %s event: %s:%s - queueing DDC rescan", action, subsystem, sysname) + m.debouncedRescan(manager) + } +} + +func (m *UdevMonitor) debouncedRescan(manager *Manager) { + m.rescanMutex.Lock() + defer m.rescanMutex.Unlock() + + m.rescanPending = true + + if m.rescanTimer != nil { + m.rescanTimer.Reset(2 * time.Second) + return + } + + m.rescanTimer = time.AfterFunc(2*time.Second, func() { + m.rescanMutex.Lock() + pending := m.rescanPending + m.rescanPending = false + m.rescanMutex.Unlock() + + if !pending { + return + } + + log.Debug("Executing debounced DDC rescan") + manager.Rescan() + }) +} + +func (m *UdevMonitor) handleBacklightEvent(manager *Manager, action, sysname string) { + switch action { + case "change": + m.handleChange(manager, "backlight", sysname) + case "add", "remove": + log.Debugf("Udev %s event: backlight:%s - triggering rescan", action, sysname) + manager.Rescan() + } +} + +func (m *UdevMonitor) handleChange(manager *Manager, subsystem, sysname string) { + deviceID := subsystem + ":" + sysname + + if manager.sysfsBackend == nil { + return + } + + brightnessPath := filepath.Join(manager.sysfsBackend.basePath, subsystem, sysname, "brightness") + data, err := os.ReadFile(brightnessPath) + if err != nil { + log.Debugf("Udev change event for %s but failed to read brightness: %v", deviceID, err) + return + } + + brightness, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + log.Debugf("Failed to parse brightness for %s: %v", deviceID, err) + return + } + + manager.handleUdevBrightnessChange(deviceID, brightness) +} + +func (m *UdevMonitor) Close() { + close(m.stop) +} + +func (m *Manager) handleUdevBrightnessChange(deviceID string, rawBrightness int) { + if m.sysfsBackend == nil { + return + } + + dev, err := m.sysfsBackend.GetDevice(deviceID) + if err != nil { + log.Debugf("Udev event for unknown device %s: %v", deviceID, err) + return + } + + percent := m.sysfsBackend.ValueToPercent(rawBrightness, dev, false) + + m.stateMutex.Lock() + var found bool + for i, d := range m.state.Devices { + if d.ID != deviceID { + continue + } + found = true + if d.Current == rawBrightness { + m.stateMutex.Unlock() + return + } + m.state.Devices[i].Current = rawBrightness + m.state.Devices[i].CurrentPercent = percent + break + } + m.stateMutex.Unlock() + + if !found { + log.Debugf("Udev event for device not in state: %s", deviceID) + return + } + + log.Debugf("Udev brightness change: %s -> %d (%d%%)", deviceID, rawBrightness, percent) + m.broadcastDeviceUpdate(deviceID) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev_test.go new file mode 100644 index 0000000..a5c406b --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/brightness/udev_test.go @@ -0,0 +1,260 @@ +package brightness + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/pilebones/go-udev/netlink" +) + +func setupTestManager(t *testing.T) (*Manager, string) { + tmpDir := t.TempDir() + + backlightDir := filepath.Join(tmpDir, "backlight", "intel_backlight") + if err := os.MkdirAll(backlightDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("1000\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("500\n"), 0o644); err != nil { + t.Fatal(err) + } + + sysfs := &SysfsBackend{ + basePath: tmpDir, + classes: []string{"backlight"}, + } + if err := sysfs.scanDevices(); err != nil { + t.Fatal(err) + } + + m := &Manager{ + sysfsBackend: sysfs, + sysfsReady: true, + stopChan: make(chan struct{}), + } + + m.state = State{ + Devices: []Device{ + { + Class: ClassBacklight, + ID: "backlight:intel_backlight", + Name: "intel_backlight", + Current: 500, + Max: 1000, + CurrentPercent: 50, + Backend: "sysfs", + }, + }, + } + + return m, tmpDir +} + +func TestHandleUdevBrightnessChange_UpdatesState(t *testing.T) { + m, _ := setupTestManager(t) + + m.handleUdevBrightnessChange("backlight:intel_backlight", 750) + + state := m.GetState() + if len(state.Devices) != 1 { + t.Fatalf("expected 1 device, got %d", len(state.Devices)) + } + + dev := state.Devices[0] + if dev.Current != 750 { + t.Errorf("expected Current=750, got %d", dev.Current) + } + if dev.CurrentPercent != 75 { + t.Errorf("expected CurrentPercent=75, got %d", dev.CurrentPercent) + } +} + +func TestHandleUdevBrightnessChange_NoChangeWhenSameValue(t *testing.T) { + m, _ := setupTestManager(t) + + updateCh := m.SubscribeUpdates("test") + defer m.UnsubscribeUpdates("test") + + m.handleUdevBrightnessChange("backlight:intel_backlight", 500) + + select { + case <-updateCh: + t.Error("should not broadcast when brightness unchanged") + case <-time.After(50 * time.Millisecond): + } +} + +func TestHandleUdevBrightnessChange_BroadcastsOnChange(t *testing.T) { + m, _ := setupTestManager(t) + + updateCh := m.SubscribeUpdates("test") + defer m.UnsubscribeUpdates("test") + + m.handleUdevBrightnessChange("backlight:intel_backlight", 750) + + select { + case update := <-updateCh: + if update.Device.Current != 750 { + t.Errorf("broadcast had wrong Current: got %d, want 750", update.Device.Current) + } + case <-time.After(100 * time.Millisecond): + t.Error("expected broadcast on brightness change") + } +} + +func TestHandleUdevBrightnessChange_UnknownDevice(t *testing.T) { + m, _ := setupTestManager(t) + + m.handleUdevBrightnessChange("backlight:unknown_device", 500) + + state := m.GetState() + if len(state.Devices) != 1 { + t.Errorf("state should be unchanged, got %d devices", len(state.Devices)) + } +} + +func TestHandleUdevBrightnessChange_NilSysfsBackend(t *testing.T) { + m := &Manager{ + sysfsBackend: nil, + stopChan: make(chan struct{}), + } + + m.handleUdevBrightnessChange("backlight:test", 500) +} + +func TestHandleUdevBrightnessChange_DeviceNotInState(t *testing.T) { + m, _ := setupTestManager(t) + + m.sysfsBackend.deviceCache.Store("backlight:other_device", &sysfsDevice{ + class: ClassBacklight, + id: "backlight:other_device", + name: "other_device", + maxBrightness: 100, + minValue: 1, + }) + + m.handleUdevBrightnessChange("backlight:other_device", 50) + + state := m.GetState() + for _, d := range state.Devices { + if d.ID == "backlight:other_device" { + t.Error("device should not be added to state via udev change event") + } + } +} + +func TestHandleEvent_ChangeAction(t *testing.T) { + m, tmpDir := setupTestManager(t) + um := &UdevMonitor{stop: make(chan struct{})} + + brightnessPath := filepath.Join(tmpDir, "backlight", "intel_backlight", "brightness") + if err := os.WriteFile(brightnessPath, []byte("800\n"), 0o644); err != nil { + t.Fatal(err) + } + + event := netlink.UEvent{ + Action: netlink.CHANGE, + Env: map[string]string{ + "SUBSYSTEM": "backlight", + "DEVPATH": "/devices/pci0000:00/0000:00:02.0/drm/card0/card0-eDP-1/intel_backlight", + }, + } + + um.handleEvent(m, event) + + state := m.GetState() + if state.Devices[0].Current != 800 { + t.Errorf("expected Current=800 after change event, got %d", state.Devices[0].Current) + } +} + +func TestHandleEvent_MissingEnvVars(t *testing.T) { + m, _ := setupTestManager(t) + um := &UdevMonitor{stop: make(chan struct{})} + + event := netlink.UEvent{ + Action: netlink.CHANGE, + Env: map[string]string{}, + } + + um.handleEvent(m, event) + + state := m.GetState() + if state.Devices[0].Current != 500 { + t.Error("state should be unchanged with missing env vars") + } +} + +func TestHandleEvent_MissingSubsystem(t *testing.T) { + m, _ := setupTestManager(t) + um := &UdevMonitor{stop: make(chan struct{})} + + event := netlink.UEvent{ + Action: netlink.CHANGE, + Env: map[string]string{ + "DEVPATH": "/devices/foo/bar", + }, + } + + um.handleEvent(m, event) + + state := m.GetState() + if state.Devices[0].Current != 500 { + t.Error("state should be unchanged with missing SUBSYSTEM") + } +} + +func TestHandleChange_BrightnessFileNotFound(t *testing.T) { + m, _ := setupTestManager(t) + um := &UdevMonitor{stop: make(chan struct{})} + + um.handleChange(m, "backlight", "nonexistent_device") + + state := m.GetState() + if state.Devices[0].Current != 500 { + t.Error("state should be unchanged when brightness file not found") + } +} + +func TestHandleChange_InvalidBrightnessValue(t *testing.T) { + m, tmpDir := setupTestManager(t) + um := &UdevMonitor{stop: make(chan struct{})} + + brightnessPath := filepath.Join(tmpDir, "backlight", "intel_backlight", "brightness") + if err := os.WriteFile(brightnessPath, []byte("not_a_number\n"), 0o644); err != nil { + t.Fatal(err) + } + + um.handleChange(m, "backlight", "intel_backlight") + + state := m.GetState() + if state.Devices[0].Current != 500 { + t.Error("state should be unchanged with invalid brightness value") + } +} + +func TestUdevMonitor_Close(t *testing.T) { + um := &UdevMonitor{stop: make(chan struct{})} + + um.Close() + + select { + case <-um.stop: + default: + t.Error("stop channel should be closed") + } +} + +func TestHandleChange_NilSysfsBackend(t *testing.T) { + m := &Manager{ + sysfsBackend: nil, + stopChan: make(chan struct{}), + } + um := &UdevMonitor{stop: make(chan struct{})} + + um.handleChange(m, "backlight", "test_device") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/browser/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/browser/handlers.go new file mode 100644 index 0000000..0b4159a --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/browser/handlers.go @@ -0,0 +1,22 @@ +package browser + +import ( + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + switch req.Method { + case "browser.open": + url, ok := models.Get[string](req, "url") + if !ok { + models.RespondError(conn, req.ID, "invalid url parameter") + return + } + manager.RequestOpen(url) + models.Respond(conn, req.ID, "ok") + default: + models.RespondError(conn, req.ID, "unknown method") + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/browser/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/browser/manager.go new file mode 100644 index 0000000..5306759 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/browser/manager.go @@ -0,0 +1,49 @@ +package browser + +import ( + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type Manager struct { + subscribers syncmap.Map[string, chan OpenEvent] + closeOnce sync.Once +} + +func NewManager() *Manager { + return &Manager{} +} + +func (m *Manager) Subscribe(id string) chan OpenEvent { + ch := make(chan OpenEvent, 16) + m.subscribers.Store(id, ch) + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) RequestOpen(url string) { + event := OpenEvent{URL: url} + m.subscribers.Range(func(key string, ch chan OpenEvent) bool { + select { + case ch <- event: + default: + } + return true + }) +} + +func (m *Manager) Close() { + m.closeOnce.Do(func() { + m.subscribers.Range(func(key string, ch chan OpenEvent) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/browser/models.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/browser/models.go new file mode 100644 index 0000000..fc63519 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/browser/models.go @@ -0,0 +1,5 @@ +package browser + +type OpenEvent struct { + URL string `json:"url"` +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/handlers.go new file mode 100644 index 0000000..1ccc710 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/handlers.go @@ -0,0 +1,325 @@ +package clipboard + +import ( + "encoding/json" + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +func HandleRequest(conn net.Conn, req models.Request, m *Manager) { + switch req.Method { + case "clipboard.getState": + handleGetState(conn, req, m) + case "clipboard.getHistory": + handleGetHistory(conn, req, m) + case "clipboard.getEntry": + handleGetEntry(conn, req, m) + case "clipboard.deleteEntry": + handleDeleteEntry(conn, req, m) + case "clipboard.clearHistory": + handleClearHistory(conn, req, m) + case "clipboard.copy": + handleCopy(conn, req, m) + case "clipboard.copyEntry": + handleCopyEntry(conn, req, m) + case "clipboard.paste": + handlePaste(conn, req, m) + case "clipboard.subscribe": + handleSubscribe(conn, req, m) + case "clipboard.search": + handleSearch(conn, req, m) + case "clipboard.getConfig": + handleGetConfig(conn, req, m) + case "clipboard.setConfig": + handleSetConfig(conn, req, m) + case "clipboard.store": + handleStore(conn, req, m) + case "clipboard.pinEntry": + handlePinEntry(conn, req, m) + case "clipboard.unpinEntry": + handleUnpinEntry(conn, req, m) + case "clipboard.getPinnedEntries": + handleGetPinnedEntries(conn, req, m) + case "clipboard.getPinnedCount": + handleGetPinnedCount(conn, req, m) + case "clipboard.copyFile": + handleCopyFile(conn, req, m) + default: + models.RespondError(conn, req.ID, "unknown method: "+req.Method) + } +} + +func handleGetState(conn net.Conn, req models.Request, m *Manager) { + models.Respond(conn, req.ID, m.GetState()) +} + +func handleGetHistory(conn net.Conn, req models.Request, m *Manager) { + history := m.GetHistory() + for i := range history { + history[i].Data = nil + } + models.Respond(conn, req.ID, history) +} + +func handleGetEntry(conn net.Conn, req models.Request, m *Manager) { + id, err := params.Int(req.Params, "id") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + entry, err := m.GetEntry(uint64(id)) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, entry) +} + +func handleDeleteEntry(conn net.Conn, req models.Request, m *Manager) { + id, err := params.Int(req.Params, "id") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := m.DeleteEntry(uint64(id)); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry deleted"}) +} + +func handleClearHistory(conn net.Conn, req models.Request, m *Manager) { + m.ClearHistory() + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "history cleared"}) +} + +func handleCopy(conn net.Conn, req models.Request, m *Manager) { + text, err := params.String(req.Params, "text") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := m.CopyText(text); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"}) +} + +func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) { + id, err := params.Int(req.Params, "id") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + entry, err := m.GetEntry(uint64(id)) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + filePath := m.EntryToFile(entry) + if filePath != "" { + if err := m.CopyFile(filePath); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, map[string]any{ + "success": true, + "filePath": filePath, + }) + return + } + + if err := m.SetClipboard(entry.Data, entry.MimeType); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if entry.Pinned { + if err := m.CreateHistoryEntryFromPinned(entry); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + } else { + if err := m.TouchEntry(uint64(id)); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"}) +} + +func handlePaste(conn net.Conn, req models.Request, m *Manager) { + text, err := m.PasteText() + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, map[string]string{"text": text}) +} + +func handleSubscribe(conn net.Conn, req models.Request, m *Manager) { + clientID := fmt.Sprintf("clipboard-%d", req.ID) + + ch := m.Subscribe(clientID) + defer m.Unsubscribe(clientID) + + initialState := m.GetState() + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + ID: req.ID, + Result: &initialState, + }); err != nil { + return + } + + for state := range ch { + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + ID: req.ID, + Result: &state, + }); err != nil { + return + } + } +} + +func handleSearch(conn net.Conn, req models.Request, m *Manager) { + p := SearchParams{ + Query: params.StringOpt(req.Params, "query", ""), + MimeType: params.StringOpt(req.Params, "mimeType", ""), + Limit: params.IntOpt(req.Params, "limit", 50), + Offset: params.IntOpt(req.Params, "offset", 0), + } + + if img, ok := models.Get[bool](req, "isImage"); ok { + p.IsImage = &img + } + if b, ok := models.Get[float64](req, "before"); ok { + v := int64(b) + p.Before = &v + } + if a, ok := models.Get[float64](req, "after"); ok { + v := int64(a) + p.After = &v + } + + models.Respond(conn, req.ID, m.Search(p)) +} + +func handleGetConfig(conn net.Conn, req models.Request, m *Manager) { + models.Respond(conn, req.ID, m.GetConfig()) +} + +func handleSetConfig(conn net.Conn, req models.Request, m *Manager) { + cfg := m.GetConfig() + + if v, ok := models.Get[float64](req, "maxHistory"); ok { + cfg.MaxHistory = int(v) + } + if v, ok := models.Get[float64](req, "maxEntrySize"); ok { + cfg.MaxEntrySize = int64(v) + } + if v, ok := models.Get[float64](req, "autoClearDays"); ok { + cfg.AutoClearDays = int(v) + } + if v, ok := models.Get[bool](req, "clearAtStartup"); ok { + cfg.ClearAtStartup = v + } + if v, ok := models.Get[bool](req, "disabled"); ok { + cfg.Disabled = v + } + if v, ok := models.Get[float64](req, "maxPinned"); ok { + cfg.MaxPinned = int(v) + } + + if err := m.SetConfig(cfg); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "config updated"}) +} + +func handleStore(conn net.Conn, req models.Request, m *Manager) { + data, err := params.String(req.Params, "data") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + mimeType := params.StringOpt(req.Params, "mimeType", "text/plain;charset=utf-8") + + if err := m.StoreData([]byte(data), mimeType); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "stored"}) +} + +func handlePinEntry(conn net.Conn, req models.Request, m *Manager) { + id, err := params.Int(req.Params, "id") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := m.PinEntry(uint64(id)); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry pinned"}) +} + +func handleUnpinEntry(conn net.Conn, req models.Request, m *Manager) { + id, err := params.Int(req.Params, "id") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := m.UnpinEntry(uint64(id)); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry unpinned"}) +} + +func handleGetPinnedEntries(conn net.Conn, req models.Request, m *Manager) { + pinned := m.GetPinnedEntries() + models.Respond(conn, req.ID, pinned) +} + +func handleGetPinnedCount(conn net.Conn, req models.Request, m *Manager) { + count := m.GetPinnedCount() + models.Respond(conn, req.ID, map[string]int{"count": count}) +} + +func handleCopyFile(conn net.Conn, req models.Request, m *Manager) { + filePath, err := params.String(req.Params, "filePath") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := m.CopyFile(filePath); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied"}) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager.go new file mode 100644 index 0000000..f5f528b --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager.go @@ -0,0 +1,1911 @@ +package clipboard + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "syscall" + "time" + + "hash/fnv" + + "github.com/fsnotify/fsnotify" + "github.com/godbus/dbus/v5" + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" + + bolt "go.etcd.io/bbolt" + + clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +// These mime types won't be stored in history +var sensitiveMimeTypes = []string{ + "x-kde-passwordManagerHint", +} + +func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) { + display := wlCtx.Display() + dbPath, err := clipboardstore.GetDBPath() + if err != nil { + return nil, fmt.Errorf("failed to get db path: %w", err) + } + + configPath, _ := getConfigPath() + + m := &Manager{ + config: config, + configPath: configPath, + display: display, + wlCtx: wlCtx, + stopChan: make(chan struct{}), + subscribers: make(map[string]chan State), + dirty: make(chan struct{}, 1), + offerMimeTypes: make(map[any][]string), + offerRegistry: make(map[uint32]any), + dbPath: dbPath, + } + + if !config.Disabled { + if err := m.setupRegistry(); err != nil { + return nil, err + } + } + + m.notifierWg.Add(1) + go m.notifier() + + go m.watchConfig() + + db, err := openDB(dbPath) + if err != nil { + return nil, fmt.Errorf("failed to open db: %w", err) + } + m.db = db + + if err := m.migrateHashes(); err != nil { + log.Errorf("Failed to migrate hashes: %v", err) + } + + if !config.Disabled { + if config.ClearAtStartup { + if err := m.clearHistoryInternal(); err != nil { + log.Errorf("Failed to clear history at startup: %v", err) + } + } + + if config.AutoClearDays > 0 { + if err := m.clearOldEntries(config.AutoClearDays); err != nil { + log.Errorf("Failed to clear old entries: %v", err) + } + } + } + + m.alive = true + m.updateState() + + if !config.Disabled && m.dataControlMgr != nil && m.seat != nil { + m.setupDataDeviceSync() + } + + return m, nil +} + +func openDB(path string) (*bolt.DB, error) { + db, err := bolt.Open(path, 0o644, &bolt.Options{ + Timeout: 1 * time.Second, + }) + if err != nil { + return nil, err + } + + err = db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte("clipboard")) + return err + }) + if err != nil { + db.Close() + return nil, err + } + + return db, nil +} + +func (m *Manager) post(fn func()) { + m.wlCtx.Post(fn) +} + +func (m *Manager) setupRegistry() error { + ctx := m.display.Context() + + registry, err := m.display.GetRegistry() + if err != nil { + return fmt.Errorf("failed to get registry: %w", err) + } + m.registry = registry + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + if e.Version < 1 { + return + } + dataControlMgr := ext_data_control.NewExtDataControlManagerV1(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + log.Errorf("Failed to bind ext_data_control_manager_v1: %v", err) + return + } + m.dataControlMgr = dataControlMgr + log.Info("Bound ext_data_control_manager_v1") + case "wl_seat": + seat := wlclient.NewSeat(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + log.Errorf("Failed to bind wl_seat: %v", err) + return + } + m.seat = seat + m.seatName = e.Name + log.Info("Bound wl_seat") + } + }) + + m.display.Roundtrip() + m.display.Roundtrip() + + if m.dataControlMgr == nil { + return fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + + if m.seat == nil { + return fmt.Errorf("no seat available") + } + + return nil +} + +func (m *Manager) setupDataDeviceSync() { + if m.dataControlMgr == nil || m.seat == nil { + return + } + + ctx := m.display.Context() + dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) + + dataDevice := ext_data_control.NewExtDataControlDeviceV1(ctx) + + dataDevice.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { + if e.Id == nil { + return + } + + m.offerMutex.Lock() + m.offerRegistry[e.Id.ID()] = e.Id + m.offerMimeTypes[e.Id] = make([]string, 0) + m.offerMutex.Unlock() + + e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) { + m.offerMutex.Lock() + m.offerMimeTypes[e.Id] = append(m.offerMimeTypes[e.Id], me.MimeType) + m.offerMutex.Unlock() + }) + }) + + dataDevice.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { + if !m.initialized { + m.initialized = true + return + } + + var offer any + if e.Id != nil { + offer = e.Id + } else if e.OfferId != 0 { + m.offerMutex.RLock() + offer = m.offerRegistry[e.OfferId] + m.offerMutex.RUnlock() + } + + m.ownerLock.Lock() + wasOwner := m.isOwner + m.ownerLock.Unlock() + + if offer == nil { + return + } + + if wasOwner { + return + } + + prevOffer := m.currentOffer + m.currentOffer = offer + + if prevOffer != nil && prevOffer != offer { + m.offerMutex.Lock() + delete(m.offerMimeTypes, prevOffer) + m.offerMutex.Unlock() + } + + m.offerMutex.RLock() + mimes := m.offerMimeTypes[offer] + m.offerMutex.RUnlock() + + m.mimeTypes = mimes + + if len(mimes) == 0 { + return + } + + if m.hasSensitiveMimeType(mimes) { + return + } + + preferredMime := m.selectMimeType(mimes) + if preferredMime == "" { + return + } + + typedOffer := offer.(*ext_data_control.ExtDataControlOfferV1) + + r, w, err := os.Pipe() + if err != nil { + return + } + + if err := typedOffer.Receive(preferredMime, int(w.Fd())); err != nil { + r.Close() + w.Close() + return + } + w.Close() + + go m.readAndStore(r, preferredMime) + }) + + if err := dataMgr.GetDataDeviceWithProxy(dataDevice, m.seat); err != nil { + log.Errorf("Failed to send get_data_device request: %v", err) + return + } + + m.dataDevice = dataDevice + + if err := ctx.Dispatch(); err != nil { + log.Errorf("Failed to dispatch initial events: %v", err) + return + } + + log.Info("Data device setup complete") +} + +func (m *Manager) readAndStore(r *os.File, mimeType string) { + defer r.Close() + + cfg := m.getConfig() + + done := make(chan []byte, 1) + go func() { + data, _ := io.ReadAll(r) + done <- data + }() + + var data []byte + select { + case data = <-done: + case <-time.After(500 * time.Millisecond): + return + } + + if len(data) == 0 || int64(len(data)) > cfg.MaxEntrySize { + return + } + if len(bytes.TrimSpace(data)) == 0 { + return + } + + if !cfg.Disabled && m.db != nil { + m.storeClipboardEntry(data, mimeType) + } + + m.updateState() + m.notifySubscribers() +} + +func (m *Manager) storeClipboardEntry(data []byte, mimeType string) { + if mimeType == "text/uri-list" { + if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok { + data = imgData + mimeType = imgMime + } + } + + entry := Entry{ + Data: data, + MimeType: mimeType, + Size: len(data), + Timestamp: time.Now(), + IsImage: m.isImageMimeType(mimeType), + } + + switch { + case entry.IsImage: + entry.Preview = m.imagePreview(data, mimeType) + case mimeType == "text/uri-list": + entry.Preview, entry.IsImage = m.uriListPreview(data) + default: + entry.Preview = m.textPreview(data) + } + + if err := m.storeEntry(entry); err != nil { + log.Errorf("Failed to store clipboard entry: %v", err) + } +} + +func (m *Manager) storeEntry(entry Entry) error { + if m.db == nil { + return fmt.Errorf("database not available") + } + + entry.Hash = computeHash(entry.Data) + + return m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + + if err := m.deduplicateInTx(b, entry.Hash); err != nil { + return err + } + + id, err := b.NextSequence() + if err != nil { + return err + } + + entry.ID = id + + encoded, err := encodeEntry(entry) + if err != nil { + return err + } + + if err := b.Put(itob(id), encoded); err != nil { + return err + } + + return m.trimLengthInTx(b) + }) +} + +func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error { + c := b.Cursor() + for k, v := c.Last(); k != nil; k, v = c.Prev() { + if extractHash(v) != hash { + continue + } + entry, err := decodeEntry(v) + if err == nil && entry.Pinned { + continue + } + if err := b.Delete(k); err != nil { + return err + } + } + return nil +} + +func (m *Manager) trimLengthInTx(b *bolt.Bucket) error { + if m.config.MaxHistory < 0 { + return nil + } + c := b.Cursor() + var count int + for k, v := c.Last(); k != nil; k, v = c.Prev() { + entry, err := decodeEntry(v) + if err == nil && entry.Pinned { + continue + } + if count < m.config.MaxHistory { + count++ + continue + } + if err := b.Delete(k); err != nil { + return err + } + } + return nil +} + +func encodeEntry(e Entry) ([]byte, error) { + buf := new(bytes.Buffer) + + binary.Write(buf, binary.BigEndian, e.ID) + binary.Write(buf, binary.BigEndian, uint32(len(e.Data))) + buf.Write(e.Data) + binary.Write(buf, binary.BigEndian, uint32(len(e.MimeType))) + buf.WriteString(e.MimeType) + binary.Write(buf, binary.BigEndian, uint32(len(e.Preview))) + buf.WriteString(e.Preview) + binary.Write(buf, binary.BigEndian, int32(e.Size)) + binary.Write(buf, binary.BigEndian, e.Timestamp.Unix()) + if e.IsImage { + buf.WriteByte(1) + } else { + buf.WriteByte(0) + } + binary.Write(buf, binary.BigEndian, e.Hash) + if e.Pinned { + buf.WriteByte(1) + } else { + buf.WriteByte(0) + } + + return buf.Bytes(), nil +} + +func decodeEntry(data []byte) (Entry, error) { + buf := bytes.NewReader(data) + var e Entry + + binary.Read(buf, binary.BigEndian, &e.ID) + + var dataLen uint32 + binary.Read(buf, binary.BigEndian, &dataLen) + e.Data = make([]byte, dataLen) + buf.Read(e.Data) + + var mimeLen uint32 + binary.Read(buf, binary.BigEndian, &mimeLen) + mimeBytes := make([]byte, mimeLen) + buf.Read(mimeBytes) + e.MimeType = string(mimeBytes) + + var prevLen uint32 + binary.Read(buf, binary.BigEndian, &prevLen) + prevBytes := make([]byte, prevLen) + buf.Read(prevBytes) + e.Preview = string(prevBytes) + + var size int32 + binary.Read(buf, binary.BigEndian, &size) + e.Size = int(size) + + var timestamp int64 + binary.Read(buf, binary.BigEndian, ×tamp) + e.Timestamp = time.Unix(timestamp, 0) + + var isImage byte + binary.Read(buf, binary.BigEndian, &isImage) + e.IsImage = isImage == 1 + + if buf.Len() >= 8 { + binary.Read(buf, binary.BigEndian, &e.Hash) + } + + if buf.Len() >= 1 { + var pinnedByte byte + binary.Read(buf, binary.BigEndian, &pinnedByte) + e.Pinned = pinnedByte == 1 + } + + return e, nil +} + +func itob(v uint64) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, v) + return b +} + +func computeHash(data []byte) uint64 { + h := fnv.New64a() + h.Write(data) + return h.Sum64() +} + +func extractHash(data []byte) uint64 { + if len(data) < 9 { + return 0 + } + return binary.BigEndian.Uint64(data[len(data)-9 : len(data)-1]) +} + +func (m *Manager) hasSensitiveMimeType(mimes []string) bool { + return slices.ContainsFunc(mimes, func(mime string) bool { + return slices.Contains(sensitiveMimeTypes, mime) + }) +} + +func (m *Manager) selectMimeType(mimes []string) string { + preferredTypes := []string{ + "text/uri-list", + "text/plain;charset=utf-8", + "text/plain", + "UTF8_STRING", + "STRING", + "TEXT", + "image/png", + "image/jpeg", + "image/gif", + "image/bmp", + "image/tiff", + } + + for _, pref := range preferredTypes { + for _, mime := range mimes { + if mime == pref { + return mime + } + } + } + + // Skip useless MIME types when falling back + for _, mime := range mimes { + switch mime { + case "application/vnd.portal.filetransfer": + continue + default: + return mime + } + } + + return "" +} + +func (m *Manager) isImageMimeType(mime string) bool { + return strings.HasPrefix(mime, "image/") +} + +func (m *Manager) textPreview(data []byte) string { + text := string(data) + text = strings.TrimSpace(text) + text = strings.Join(strings.Fields(text), " ") + + if len(text) > 100 { + return text[:100] + "…" + } + return text +} + +func (m *Manager) imagePreview(data []byte, format string) string { + config, imgFmt, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return fmt.Sprintf("[[ image %s %s ]]", sizeStr(len(data)), format) + } + return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height) +} + +func (m *Manager) uriListPreview(data []byte) (string, bool) { + text := strings.TrimSpace(string(data)) + uris := strings.Split(text, "\r\n") + if len(uris) == 0 { + uris = strings.Split(text, "\n") + } + + if len(uris) > 1 { + return fmt.Sprintf("[[ %d files ]]", len(uris)), false + } + + if len(uris) == 1 && strings.HasPrefix(uris[0], "file://") { + filePath := strings.TrimPrefix(uris[0], "file://") + info, err := os.Stat(filePath) + if err != nil || info.IsDir() { + return m.textPreview(data), false + } + + cfg := m.getConfig() + if info.Size() <= cfg.MaxEntrySize { + if imgData, err := os.ReadFile(filePath); err == nil { + if config, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData)); err == nil { + return fmt.Sprintf("[[ file %s %s %dx%d ]]", filepath.Base(filePath), imgFmt, config.Width, config.Height), true + } + } + } + return fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)), false + } + + return m.textPreview(data), false +} + +func (m *Manager) tryReadImageFromURI(data []byte) ([]byte, string, bool) { + text := strings.TrimSpace(string(data)) + uris := strings.Split(text, "\r\n") + if len(uris) == 0 { + uris = strings.Split(text, "\n") + } + + if len(uris) != 1 || !strings.HasPrefix(uris[0], "file://") { + return nil, "", false + } + + filePath := strings.TrimPrefix(uris[0], "file://") + info, err := os.Stat(filePath) + if err != nil || info.IsDir() { + return nil, "", false + } + + cfg := m.getConfig() + if info.Size() > cfg.MaxEntrySize { + return nil, "", false + } + + imgData, err := os.ReadFile(filePath) + if err != nil { + return nil, "", false + } + + _, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData)) + if err != nil { + return nil, "", false + } + + return imgData, "image/" + imgFmt, true +} + +func sizeStr(size int) string { + units := []string{"B", "KiB", "MiB"} + var i int + fsize := float64(size) + for fsize >= 1024 && i < len(units)-1 { + fsize /= 1024 + i++ + } + return fmt.Sprintf("%.0f %s", fsize, units[i]) +} + +func (m *Manager) updateState() { + history := m.GetHistory() + + for i := range history { + history[i].Data = nil + } + + var current *Entry + if len(history) > 0 { + c := history[0] + c.Data = nil + current = &c + } + + newState := &State{ + Enabled: m.alive, + History: history, + Current: current, + } + + m.stateMutex.Lock() + m.state = newState + m.stateMutex.Unlock() +} + +func (m *Manager) notifier() { + defer m.notifierWg.Done() + + for range m.dirty { + state := m.GetState() + + if m.lastState != nil && stateEqual(m.lastState, &state) { + continue + } + + m.lastState = &state + + m.subMutex.RLock() + subs := make([]chan State, 0, len(m.subscribers)) + for _, ch := range m.subscribers { + subs = append(subs, ch) + } + m.subMutex.RUnlock() + + for _, ch := range subs { + select { + case ch <- state: + default: + } + } + } +} + +func stateEqual(a, b *State) bool { + if a == nil || b == nil { + return false + } + if a.Enabled != b.Enabled { + return false + } + if len(a.History) != len(b.History) { + return false + } + return true +} + +func (m *Manager) GetHistory() []Entry { + if m.db == nil { + return nil + } + + cfg := m.getConfig() + var cutoff time.Time + if cfg.AutoClearDays > 0 { + cutoff = time.Now().AddDate(0, 0, -cfg.AutoClearDays) + } + + var history []Entry + var stale []uint64 + + if err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + c := b.Cursor() + + for k, v := c.Last(); k != nil; k, v = c.Prev() { + entry, err := decodeEntry(v) + if err != nil { + continue + } + if !cutoff.IsZero() && entry.Timestamp.Before(cutoff) { + stale = append(stale, entry.ID) + continue + } + history = append(history, entry) + } + return nil + }); err != nil { + log.Errorf("Failed to read clipboard history: %v", err) + } + + if len(stale) > 0 { + go m.deleteStaleEntries(stale) + } + + return history +} + +func (m *Manager) deleteStaleEntries(ids []uint64) { + if m.db == nil { + return + } + + if err := m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + for _, id := range ids { + if err := b.Delete(itob(id)); err != nil { + log.Errorf("Failed to delete stale entry %d: %v", id, err) + } + } + return nil + }); err != nil { + log.Errorf("Failed to delete stale entries: %v", err) + } +} + +func (m *Manager) GetEntry(id uint64) (*Entry, error) { + if m.db == nil { + return nil, fmt.Errorf("database not available") + } + + var entry Entry + var found bool + + err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + v := b.Get(itob(id)) + if v == nil { + return nil + } + + var err error + entry, err = decodeEntry(v) + if err != nil { + return err + } + found = true + return nil + }) + + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("entry not found") + } + + return &entry, nil +} + +func (m *Manager) DeleteEntry(id uint64) error { + if m.db == nil { + return fmt.Errorf("database not available") + } + + err := m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + return b.Delete(itob(id)) + }) + + if err == nil { + m.updateState() + m.notifySubscribers() + } + + return err +} + +func (m *Manager) TouchEntry(id uint64) error { + if m.db == nil { + return fmt.Errorf("database not available") + } + + entry, err := m.GetEntry(id) + if err != nil { + return err + } + + entry.Timestamp = time.Now() + + if err := m.storeEntry(*entry); err != nil { + return err + } + + m.updateState() + m.notifySubscribers() + + return nil +} + +func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error { + if m.db == nil { + return fmt.Errorf("database not available") + } + + // Create a new unpinned entry with the same data + newEntry := Entry{ + Data: pinnedEntry.Data, + MimeType: pinnedEntry.MimeType, + Size: pinnedEntry.Size, + Timestamp: time.Now(), + IsImage: pinnedEntry.IsImage, + Preview: pinnedEntry.Preview, + Pinned: false, + } + + if err := m.storeEntryWithoutDedup(newEntry); err != nil { + return err + } + + m.updateState() + m.notifySubscribers() + + return nil +} + +func (m *Manager) storeEntryWithoutDedup(entry Entry) error { + if m.db == nil { + return fmt.Errorf("database not available") + } + + entry.Hash = computeHash(entry.Data) + + return m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + + id, err := b.NextSequence() + if err != nil { + return err + } + + entry.ID = id + + encoded, err := encodeEntry(entry) + if err != nil { + return err + } + + if err := b.Put(itob(id), encoded); err != nil { + return err + } + + return m.trimLengthInTx(b) + }) +} + +func (m *Manager) ClearHistory() { + if m.db == nil { + return + } + + // Delete only non-pinned entries + if err := m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + + var toDelete [][]byte + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + entry, err := decodeEntry(v) + if err != nil || !entry.Pinned { + toDelete = append(toDelete, k) + } + } + + for _, k := range toDelete { + if err := b.Delete(k); err != nil { + return err + } + } + return nil + }); err != nil { + log.Errorf("Failed to clear clipboard history: %v", err) + return + } + + pinnedCount := 0 + if err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b != nil { + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + entry, _ := decodeEntry(v) + if entry.Pinned { + pinnedCount++ + } + } + } + return nil + }); err != nil { + log.Errorf("Failed to count pinned entries: %v", err) + } + + if pinnedCount == 0 { + if err := m.compactDB(); err != nil { + log.Errorf("Failed to compact database: %v", err) + } + } + + m.updateState() + m.notifySubscribers() +} + +func (m *Manager) compactDB() error { + m.db.Close() + + tmpPath := m.dbPath + ".compact" + defer os.Remove(tmpPath) + + srcDB, err := bolt.Open(m.dbPath, 0o644, &bolt.Options{ReadOnly: true, Timeout: time.Second}) + if err != nil { + m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) + return fmt.Errorf("open source: %w", err) + } + + dstDB, err := bolt.Open(tmpPath, 0o644, &bolt.Options{Timeout: time.Second}) + if err != nil { + srcDB.Close() + m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) + return fmt.Errorf("open destination: %w", err) + } + + if err := bolt.Compact(dstDB, srcDB, 0); err != nil { + srcDB.Close() + dstDB.Close() + m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) + return fmt.Errorf("compact: %w", err) + } + + srcDB.Close() + dstDB.Close() + + if err := os.Rename(tmpPath, m.dbPath); err != nil { + m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) + return fmt.Errorf("rename: %w", err) + } + + m.db, err = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second}) + if err != nil { + return fmt.Errorf("reopen: %w", err) + } + + return nil +} + +func (m *Manager) SetClipboard(data []byte, mimeType string) error { + if int64(len(data)) > m.config.MaxEntrySize { + return fmt.Errorf("data too large") + } + + dataCopy := make([]byte, len(data)) + copy(dataCopy, data) + + m.post(func() { + if m.dataControlMgr == nil || m.dataDevice == nil { + log.Error("Data control manager or device not initialized") + return + } + + dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) + + source, err := dataMgr.CreateDataSource() + if err != nil { + log.Errorf("Failed to create data source: %v", err) + return + } + + if err := source.Offer(mimeType); err != nil { + log.Errorf("Failed to offer mime type: %v", err) + return + } + + source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { + fd := e.Fd + defer syscall.Close(fd) + + file := os.NewFile(uintptr(fd), "clipboard-pipe") + defer file.Close() + + if _, err := file.Write(dataCopy); err != nil { + log.Errorf("Failed to write clipboard data: %v", err) + } + }) + + source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) { + m.ownerLock.Lock() + m.isOwner = false + m.ownerLock.Unlock() + }) + + m.currentSource = source + m.sourceMutex.Lock() + m.sourceMimeTypes = []string{mimeType} + m.sourceMutex.Unlock() + + m.ownerLock.Lock() + m.isOwner = true + m.ownerLock.Unlock() + + device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1) + if err := device.SetSelection(source); err != nil { + log.Errorf("Failed to set selection: %v", err) + } + }) + + return nil +} + +func (m *Manager) CopyText(text string) error { + if err := m.SetClipboard([]byte(text), "text/plain;charset=utf-8"); err != nil { + return err + } + + entry := Entry{ + Data: []byte(text), + MimeType: "text/plain;charset=utf-8", + Size: len(text), + Timestamp: time.Now(), + IsImage: false, + Preview: m.textPreview([]byte(text)), + } + + if err := m.storeEntry(entry); err != nil { + log.Errorf("Failed to store clipboard entry: %v", err) + } + + m.updateState() + m.notifySubscribers() + + return nil +} + +func (m *Manager) PasteText() (string, error) { + history := m.GetHistory() + if len(history) == 0 { + return "", fmt.Errorf("no clipboard data available") + } + + entry := history[0] + if entry.IsImage { + return "", fmt.Errorf("clipboard contains image, not text") + } + + fullEntry, err := m.GetEntry(entry.ID) + if err != nil { + return "", err + } + + return string(fullEntry.Data), nil +} + +func (m *Manager) Close() { + if !m.alive { + return + } + + m.alive = false + close(m.stopChan) + + close(m.dirty) + m.notifierWg.Wait() + + m.subMutex.Lock() + for _, ch := range m.subscribers { + close(ch) + } + m.subscribers = make(map[string]chan State) + m.subMutex.Unlock() + + if m.currentSource != nil { + source := m.currentSource.(*ext_data_control.ExtDataControlSourceV1) + source.Destroy() + } + + if m.dataDevice != nil { + device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1) + device.Destroy() + } + + if m.dataControlMgr != nil { + mgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) + mgr.Destroy() + } + + if m.registry != nil { + m.registry.Destroy() + } + + if m.db != nil { + m.db.Close() + } +} + +func (m *Manager) clearHistoryInternal() error { + return m.db.Update(func(tx *bolt.Tx) error { + if err := tx.DeleteBucket([]byte("clipboard")); err != nil { + return err + } + _, err := tx.CreateBucket([]byte("clipboard")) + return err + }) +} + +func (m *Manager) clearOldEntries(days int) error { + cutoff := time.Now().AddDate(0, 0, -days) + + return m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + + var toDelete [][]byte + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + entry, err := decodeEntry(v) + if err != nil { + continue + } + // Skip pinned entries + if entry.Pinned { + continue + } + if entry.Timestamp.Before(cutoff) { + toDelete = append(toDelete, k) + } + } + + for _, k := range toDelete { + if err := b.Delete(k); err != nil { + return err + } + } + return nil + }) +} + +func (m *Manager) migrateHashes() error { + if m.db == nil { + return nil + } + + var needsMigration bool + if err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + if extractHash(v) == 0 { + needsMigration = true + return nil + } + } + return nil + }); err != nil { + return err + } + + if !needsMigration { + return nil + } + + log.Info("Migrating clipboard entries to add hashes...") + + return m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + + var updates []struct { + key []byte + entry Entry + } + + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + entry, err := decodeEntry(v) + if err != nil { + continue + } + if entry.Hash != 0 { + continue + } + entry.Hash = computeHash(entry.Data) + keyCopy := make([]byte, len(k)) + copy(keyCopy, k) + updates = append(updates, struct { + key []byte + entry Entry + }{keyCopy, entry}) + } + + for _, u := range updates { + encoded, err := encodeEntry(u.entry) + if err != nil { + continue + } + if err := b.Put(u.key, encoded); err != nil { + return err + } + } + + log.Infof("Migrated %d clipboard entries", len(updates)) + return nil + }) +} + +func (m *Manager) Search(params SearchParams) SearchResult { + if m.db == nil { + return SearchResult{} + } + + if params.Limit <= 0 { + params.Limit = 50 + } + if params.Limit > 500 { + params.Limit = 500 + } + + query := strings.ToLower(params.Query) + mimeFilter := strings.ToLower(params.MimeType) + + var all []Entry + if err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + + c := b.Cursor() + for k, v := c.Last(); k != nil; k, v = c.Prev() { + entry, err := decodeEntry(v) + if err != nil { + continue + } + + if params.IsImage != nil && entry.IsImage != *params.IsImage { + continue + } + + if mimeFilter != "" && !strings.Contains(strings.ToLower(entry.MimeType), mimeFilter) { + continue + } + + if params.Before != nil && entry.Timestamp.Unix() >= *params.Before { + continue + } + + if params.After != nil && entry.Timestamp.Unix() <= *params.After { + continue + } + + if query != "" && !strings.Contains(strings.ToLower(entry.Preview), query) { + continue + } + + entry.Data = nil + all = append(all, entry) + } + return nil + }); err != nil { + log.Errorf("Search failed: %v", err) + } + + total := len(all) + + start := params.Offset + if start > total { + start = total + } + end := start + params.Limit + if end > total { + end = total + } + + return SearchResult{ + Entries: all[start:end], + Total: total, + HasMore: end < total, + } +} + +func (m *Manager) GetConfig() Config { + return m.config +} + +func (m *Manager) SetConfig(cfg Config) error { + m.configMutex.Lock() + m.config = cfg + m.configMutex.Unlock() + + m.updateState() + m.notifySubscribers() + + return SaveConfig(cfg) +} + +func (m *Manager) getConfig() Config { + m.configMutex.RLock() + defer m.configMutex.RUnlock() + return m.config +} + +func (m *Manager) watchConfig() { + if m.configPath == "" { + return + } + + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Warnf("Failed to create config watcher: %v", err) + return + } + defer watcher.Close() + + configDir := filepath.Dir(m.configPath) + if err := watcher.Add(configDir); err != nil { + log.Warnf("Failed to watch config directory: %v", err) + return + } + + for { + select { + case <-m.stopChan: + return + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Name != m.configPath { + continue + } + if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { + continue + } + newCfg := LoadConfig() + m.applyConfigChange(newCfg) + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Warnf("Config watcher error: %v", err) + } + } +} + +func (m *Manager) applyConfigChange(newCfg Config) { + m.configMutex.Lock() + oldCfg := m.config + m.config = newCfg + m.configMutex.Unlock() + + switch { + case newCfg.Disabled && !oldCfg.Disabled: + log.Info("Clipboard tracking disabled") + case !newCfg.Disabled && oldCfg.Disabled: + log.Info("Clipboard tracking enabled") + } + + m.updateState() + m.notifySubscribers() +} + +func (m *Manager) StoreData(data []byte, mimeType string) error { + cfg := m.getConfig() + + if cfg.Disabled { + return fmt.Errorf("clipboard tracking disabled") + } + + if m.db == nil { + return fmt.Errorf("database not available") + } + + if len(data) == 0 { + return nil + } + + if int64(len(data)) > cfg.MaxEntrySize { + return fmt.Errorf("data too large") + } + + if len(bytes.TrimSpace(data)) == 0 { + return nil + } + + entry := Entry{ + Data: data, + MimeType: mimeType, + Size: len(data), + Timestamp: time.Now(), + IsImage: m.isImageMimeType(mimeType), + } + + switch { + case entry.IsImage: + entry.Preview = m.imagePreview(data, mimeType) + case mimeType == "text/uri-list": + entry.Preview, entry.IsImage = m.uriListPreview(data) + default: + entry.Preview = m.textPreview(data) + } + + if err := m.storeEntry(entry); err != nil { + return err + } + + m.updateState() + m.notifySubscribers() + + return nil +} + +func (m *Manager) PinEntry(id uint64) error { + if m.db == nil { + return fmt.Errorf("database not available") + } + + entryToPin, err := m.GetEntry(id) + if err != nil { + return err + } + + var hashExists bool + if err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + entry, err := decodeEntry(v) + if err != nil || !entry.Pinned { + continue + } + if entry.Hash == entryToPin.Hash { + hashExists = true + return nil + } + } + return nil + }); err != nil { + return err + } + + if hashExists { + return nil + } + + // Check pinned count + cfg := m.getConfig() + pinnedCount := 0 + if err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + entry, err := decodeEntry(v) + if err == nil && entry.Pinned { + pinnedCount++ + } + } + return nil + }); err != nil { + log.Errorf("Failed to count pinned entries: %v", err) + } + + if pinnedCount >= cfg.MaxPinned { + return fmt.Errorf("maximum pinned entries reached (%d)", cfg.MaxPinned) + } + + err = m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + v := b.Get(itob(id)) + if v == nil { + return fmt.Errorf("entry not found") + } + + entry, err := decodeEntry(v) + if err != nil { + return err + } + + entry.Pinned = true + encoded, err := encodeEntry(entry) + if err != nil { + return err + } + + return b.Put(itob(id), encoded) + }) + + if err == nil { + m.updateState() + m.notifySubscribers() + } + + return err +} + +func (m *Manager) UnpinEntry(id uint64) error { + if m.db == nil { + return fmt.Errorf("database not available") + } + + err := m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + v := b.Get(itob(id)) + if v == nil { + return fmt.Errorf("entry not found") + } + + entry, err := decodeEntry(v) + if err != nil { + return err + } + + entry.Pinned = false + encoded, err := encodeEntry(entry) + if err != nil { + return err + } + + return b.Put(itob(id), encoded) + }) + + if err == nil { + m.updateState() + m.notifySubscribers() + } + + return err +} + +func (m *Manager) GetPinnedEntries() []Entry { + if m.db == nil { + return nil + } + + var pinned []Entry + if err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + + c := b.Cursor() + for k, v := c.Last(); k != nil; k, v = c.Prev() { + entry, err := decodeEntry(v) + if err != nil { + continue + } + if entry.Pinned { + entry.Data = nil + pinned = append(pinned, entry) + } + } + return nil + }); err != nil { + log.Errorf("Failed to get pinned entries: %v", err) + } + + return pinned +} + +func (m *Manager) GetPinnedCount() int { + if m.db == nil { + return 0 + } + + count := 0 + if err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + entry, err := decodeEntry(v) + if err == nil && entry.Pinned { + count++ + } + } + return nil + }); err != nil { + log.Errorf("Failed to count pinned entries: %v", err) + } + + return count +} + +func (m *Manager) CopyFile(filePath string) error { + fileInfo, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("file not found: %w", err) + } + + cfg := m.getConfig() + if fileInfo.Size() > cfg.MaxEntrySize { + return fmt.Errorf("file too large: %d > %d", fileInfo.Size(), cfg.MaxEntrySize) + } + + fileData, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("read file: %w", err) + } + + exportedPath, err := m.ExportFileForFlatpak(filePath) + if err != nil { + exportedPath = filePath + } + fileURI := "file://" + exportedPath + + if imgData, imgMime, ok := m.tryReadImageFromURI([]byte("file://" + filePath)); ok { + entry := Entry{ + Data: imgData, + MimeType: imgMime, + Size: len(imgData), + Timestamp: time.Now(), + IsImage: true, + Preview: m.imagePreview(imgData, imgMime), + } + if err := m.storeEntry(entry); err != nil { + log.Errorf("Failed to store file entry: %v", err) + } + } else { + entry := Entry{ + Data: fileData, + MimeType: "text/uri-list", + Size: len(fileData), + Timestamp: time.Now(), + IsImage: false, + Preview: fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)), + } + if err := m.storeEntry(entry); err != nil { + log.Errorf("Failed to store file entry: %v", err) + } + } + + m.updateState() + m.notifySubscribers() + + _, imgMime, imgErr := image.DecodeConfig(bytes.NewReader(fileData)) + + m.post(func() { + if m.dataControlMgr == nil || m.dataDevice == nil { + log.Error("Data control manager or device not initialized") + return + } + + dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) + source, err := dataMgr.CreateDataSource() + if err != nil { + log.Errorf("Failed to create data source: %v", err) + return + } + + type offer struct { + mime string + data []byte + } + offers := []offer{ + {"x-special/gnome-copied-files", []byte("copy\n" + fileURI)}, + {"text/uri-list", []byte(fileURI + "\r\n")}, + {"text/plain", []byte(filePath)}, + } + + if imgErr == nil { + imgMimeType := "image/" + imgMime + offers = append(offers, offer{imgMimeType, fileData}) + } + + offerData := make(map[string][]byte) + for _, o := range offers { + if err := source.Offer(o.mime); err != nil { + log.Errorf("Failed to offer %s: %v", o.mime, err) + return + } + offerData[o.mime] = o.data + } + + source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { + fd := e.Fd + defer syscall.Close(fd) + file := os.NewFile(uintptr(fd), "clipboard-pipe") + defer file.Close() + if data, ok := offerData[e.MimeType]; ok { + file.Write(data) + } + }) + + source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) { + m.ownerLock.Lock() + m.isOwner = false + m.ownerLock.Unlock() + }) + + m.currentSource = source + + m.ownerLock.Lock() + m.isOwner = true + m.ownerLock.Unlock() + + device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1) + if err := device.SetSelection(source); err != nil { + log.Errorf("Failed to set selection: %v", err) + } + }) + + return nil +} + +func (m *Manager) EntryToFile(entry *Entry) string { + switch { + case entry.MimeType == "text/uri-list": + data := strings.TrimSpace(string(entry.Data)) + lines := strings.Split(data, "\n") + if len(lines) == 0 { + return "" + } + uri := strings.TrimSuffix(strings.TrimSpace(lines[0]), "\r") + if path, ok := strings.CutPrefix(uri, "file://"); ok { + return path + } + case entry.IsImage: + ext := ".png" + if suffix, ok := strings.CutPrefix(entry.MimeType, "image/"); ok { + ext = "." + suffix + } + cacheDir, err := os.UserCacheDir() + if err != nil { + return "" + } + clipDir := filepath.Join(cacheDir, "dms", "clipboard") + if err := os.MkdirAll(clipDir, 0o755); err != nil { + return "" + } + filePath := filepath.Join(clipDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)) + if os.WriteFile(filePath, entry.Data, 0o644) != nil { + return "" + } + return filePath + } + return "" +} + +func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) { + if _, err := os.Stat(filePath); err != nil { + return "", fmt.Errorf("file not found: %w", err) + } + + if m.dbusConn == nil { + conn, err := dbus.ConnectSessionBus() + if err != nil { + return "", fmt.Errorf("connect session bus: %w", err) + } + if !conn.SupportsUnixFDs() { + conn.Close() + return "", fmt.Errorf("D-Bus connection does not support Unix FD passing") + } + m.dbusConn = conn + } + + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("open file: %w", err) + } + fd := int(file.Fd()) + + portal := m.dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents") + + var docIds []string + var extra map[string]dbus.Variant + flags := uint32(0) + + err = portal.Call( + "org.freedesktop.portal.Documents.AddFull", + 0, + []dbus.UnixFD{dbus.UnixFD(fd)}, + flags, + "", + []string{}, + ).Store(&docIds, &extra) + + file.Close() + + if err != nil { + return "", fmt.Errorf("AddFull: %w", err) + } + + if len(docIds) == 0 { + return "", fmt.Errorf("no doc IDs returned") + } + + docId := docIds[0] + + for _, app := range getInstalledFlatpaks() { + _ = portal.Call( + "org.freedesktop.portal.Documents.GrantPermissions", + 0, + docId, + app, + []string{"read"}, + ).Err + } + + uid := os.Getuid() + basename := filepath.Base(filePath) + exportedPath := fmt.Sprintf("/run/user/%d/doc/%s/%s", uid, docId, basename) + + return exportedPath, nil +} + +func getInstalledFlatpaks() []string { + out, err := exec.Command("flatpak", "list", "--app", "--columns=application").Output() + if err != nil { + return nil + } + + var apps []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if app := strings.TrimSpace(line); app != "" { + apps = append(apps, app) + } + } + return apps +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager_test.go new file mode 100644 index 0000000..db45bca --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager_test.go @@ -0,0 +1,531 @@ +package clipboard + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext" +) + +func TestEncodeDecodeEntry_Roundtrip(t *testing.T) { + original := Entry{ + ID: 12345, + Data: []byte("hello world"), + MimeType: "text/plain;charset=utf-8", + Preview: "hello world", + Size: 11, + Timestamp: time.Now().Truncate(time.Second), + IsImage: false, + } + + encoded, err := encodeEntry(original) + assert.NoError(t, err) + + decoded, err := decodeEntry(encoded) + assert.NoError(t, err) + + assert.Equal(t, original.ID, decoded.ID) + assert.Equal(t, original.Data, decoded.Data) + assert.Equal(t, original.MimeType, decoded.MimeType) + assert.Equal(t, original.Preview, decoded.Preview) + assert.Equal(t, original.Size, decoded.Size) + assert.Equal(t, original.Timestamp.Unix(), decoded.Timestamp.Unix()) + assert.Equal(t, original.IsImage, decoded.IsImage) +} + +func TestEncodeDecodeEntry_Image(t *testing.T) { + original := Entry{ + ID: 99999, + Data: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, + MimeType: "image/png", + Preview: "[[ image 8 B png 100x100 ]]", + Size: 8, + Timestamp: time.Now().Truncate(time.Second), + IsImage: true, + } + + encoded, err := encodeEntry(original) + assert.NoError(t, err) + + decoded, err := decodeEntry(encoded) + assert.NoError(t, err) + + assert.Equal(t, original.ID, decoded.ID) + assert.Equal(t, original.Data, decoded.Data) + assert.True(t, decoded.IsImage) + assert.Equal(t, original.Preview, decoded.Preview) +} + +func TestEncodeDecodeEntry_EmptyData(t *testing.T) { + original := Entry{ + ID: 1, + Data: []byte{}, + MimeType: "text/plain", + Preview: "", + Size: 0, + Timestamp: time.Now().Truncate(time.Second), + IsImage: false, + } + + encoded, err := encodeEntry(original) + assert.NoError(t, err) + + decoded, err := decodeEntry(encoded) + assert.NoError(t, err) + + assert.Equal(t, original.ID, decoded.ID) + assert.Empty(t, decoded.Data) +} + +func TestEncodeDecodeEntry_LargeData(t *testing.T) { + largeData := make([]byte, 100000) + for i := range largeData { + largeData[i] = byte(i % 256) + } + + original := Entry{ + ID: 777, + Data: largeData, + MimeType: "application/octet-stream", + Preview: "binary data...", + Size: len(largeData), + Timestamp: time.Now().Truncate(time.Second), + IsImage: false, + } + + encoded, err := encodeEntry(original) + assert.NoError(t, err) + + decoded, err := decodeEntry(encoded) + assert.NoError(t, err) + + assert.Equal(t, original.Data, decoded.Data) + assert.Equal(t, original.Size, decoded.Size) +} + +func TestStateEqual_BothNil(t *testing.T) { + assert.False(t, stateEqual(nil, nil)) +} + +func TestStateEqual_OneNil(t *testing.T) { + s := &State{Enabled: true} + assert.False(t, stateEqual(s, nil)) + assert.False(t, stateEqual(nil, s)) +} + +func TestStateEqual_EnabledDiffers(t *testing.T) { + a := &State{Enabled: true, History: []Entry{}} + b := &State{Enabled: false, History: []Entry{}} + assert.False(t, stateEqual(a, b)) +} + +func TestStateEqual_HistoryLengthDiffers(t *testing.T) { + a := &State{Enabled: true, History: []Entry{{ID: 1}}} + b := &State{Enabled: true, History: []Entry{}} + assert.False(t, stateEqual(a, b)) +} + +func TestStateEqual_BothEqual(t *testing.T) { + a := &State{Enabled: true, History: []Entry{{ID: 1}, {ID: 2}}} + b := &State{Enabled: true, History: []Entry{{ID: 3}, {ID: 4}}} + assert.True(t, stateEqual(a, b)) +} + +func TestManager_ConcurrentSubscriberAccess(t *testing.T) { + m := &Manager{ + subscribers: make(map[string]chan State), + dirty: make(chan struct{}, 1), + } + + var wg sync.WaitGroup + const goroutines = 20 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + subID := string(rune('a' + id)) + ch := m.Subscribe(subID) + assert.NotNil(t, ch) + time.Sleep(time.Millisecond) + m.Unsubscribe(subID) + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentGetState(t *testing.T) { + m := &Manager{ + state: &State{ + Enabled: true, + History: []Entry{{ID: 1}, {ID: 2}}, + }, + } + + var wg sync.WaitGroup + const goroutines = 50 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + s := m.GetState() + _ = s.Enabled + _ = len(s.History) + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.stateMutex.Lock() + m.state = &State{ + Enabled: j%2 == 0, + History: []Entry{{ID: uint64(j)}}, + } + m.stateMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentConfigAccess(t *testing.T) { + m := &Manager{ + config: DefaultConfig(), + } + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + cfg := m.getConfig() + _ = cfg.MaxHistory + _ = cfg.MaxEntrySize + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.configMutex.Lock() + m.config.MaxHistory = 50 + j + m.config.MaxEntrySize = int64(1024 * j) + m.configMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_NotifySubscribersNonBlocking(t *testing.T) { + m := &Manager{ + dirty: make(chan struct{}, 1), + } + + for i := 0; i < 10; i++ { + m.notifySubscribers() + } + + assert.Len(t, m.dirty, 1) +} + +func TestManager_ConcurrentOfferAccess(t *testing.T) { + m := &Manager{ + offerMimeTypes: make(map[any][]string), + offerRegistry: make(map[uint32]any), + } + + var wg sync.WaitGroup + const goroutines = 20 + const iterations = 50 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + key := uint32(id) + + for j := 0; j < iterations; j++ { + m.offerMutex.Lock() + m.offerRegistry[key] = struct{}{} + m.offerMimeTypes[key] = []string{"text/plain"} + m.offerMutex.Unlock() + + m.offerMutex.RLock() + _ = m.offerRegistry[key] + _ = m.offerMimeTypes[key] + m.offerMutex.RUnlock() + + m.offerMutex.Lock() + delete(m.offerRegistry, key) + delete(m.offerMimeTypes, key) + m.offerMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentPersistAccess(t *testing.T) { + m := &Manager{ + persistData: make(map[string][]byte), + persistMimeTypes: []string{}, + } + + var wg sync.WaitGroup + const goroutines = 20 + const iterations = 50 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.persistMutex.RLock() + _ = m.persistData + _ = m.persistMimeTypes + m.persistMutex.RUnlock() + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.persistMutex.Lock() + m.persistMimeTypes = []string{"text/plain", "text/html"} + m.persistData = map[string][]byte{ + "text/plain": []byte("test"), + } + m.persistMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentOwnerAccess(t *testing.T) { + m := &Manager{} + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.ownerLock.Lock() + _ = m.isOwner + m.ownerLock.Unlock() + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.ownerLock.Lock() + m.isOwner = j%2 == 0 + m.ownerLock.Unlock() + } + }() + } + + wg.Wait() +} + +func TestItob(t *testing.T) { + tests := []struct { + input uint64 + expected []byte + }{ + {0, []byte{0, 0, 0, 0, 0, 0, 0, 0}}, + {1, []byte{0, 0, 0, 0, 0, 0, 0, 1}}, + {256, []byte{0, 0, 0, 0, 0, 0, 1, 0}}, + {0xFFFFFFFFFFFFFFFF, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}}, + } + + for _, tt := range tests { + result := itob(tt.input) + assert.Equal(t, tt.expected, result) + } +} + +func TestSizeStr(t *testing.T) { + tests := []struct { + input int + expected string + }{ + {0, "0 B"}, + {100, "100 B"}, + {1024, "1 KiB"}, + {2048, "2 KiB"}, + {1048576, "1 MiB"}, + {5242880, "5 MiB"}, + } + + for _, tt := range tests { + result := sizeStr(tt.input) + assert.Equal(t, tt.expected, result) + } +} + +func TestSelectMimeType(t *testing.T) { + m := &Manager{} + + tests := []struct { + mimes []string + expected string + }{ + {[]string{"text/plain;charset=utf-8", "text/html"}, "text/plain;charset=utf-8"}, + {[]string{"text/html", "text/plain"}, "text/plain"}, + {[]string{"text/html", "image/png"}, "image/png"}, + {[]string{"image/png", "image/jpeg"}, "image/png"}, + {[]string{"image/png"}, "image/png"}, + {[]string{"application/octet-stream"}, "application/octet-stream"}, + {[]string{}, ""}, + } + + for _, tt := range tests { + result := m.selectMimeType(tt.mimes) + assert.Equal(t, tt.expected, result) + } +} + +func TestIsImageMimeType(t *testing.T) { + m := &Manager{} + + assert.True(t, m.isImageMimeType("image/png")) + assert.True(t, m.isImageMimeType("image/jpeg")) + assert.True(t, m.isImageMimeType("image/gif")) + assert.False(t, m.isImageMimeType("text/plain")) + assert.False(t, m.isImageMimeType("application/json")) +} + +func TestTextPreview(t *testing.T) { + m := &Manager{} + + short := m.textPreview([]byte("hello world")) + assert.Equal(t, "hello world", short) + + withWhitespace := m.textPreview([]byte(" hello world ")) + assert.Equal(t, "hello world", withWhitespace) + + longText := make([]byte, 200) + for i := range longText { + longText[i] = 'a' + } + preview := m.textPreview(longText) + assert.True(t, len(preview) > 100) + assert.Contains(t, preview, "…") +} + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + assert.Equal(t, 100, cfg.MaxHistory) + assert.Equal(t, int64(5*1024*1024), cfg.MaxEntrySize) + assert.Equal(t, 0, cfg.AutoClearDays) + assert.False(t, cfg.ClearAtStartup) + assert.False(t, cfg.Disabled) +} + +func TestManager_PostDelegatesToWlContext(t *testing.T) { + mockCtx := mocks_wlcontext.NewMockWaylandContext(t) + + var called atomic.Bool + mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) { + called.Store(true) + fn() + }).Once() + + m := &Manager{ + wlCtx: mockCtx, + } + + executed := false + m.post(func() { + executed = true + }) + + assert.True(t, called.Load()) + assert.True(t, executed) +} + +func TestManager_PostExecutesFunctionViaContext(t *testing.T) { + mockCtx := mocks_wlcontext.NewMockWaylandContext(t) + + var capturedFn func() + mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) { + capturedFn = fn + }).Times(3) + + m := &Manager{ + wlCtx: mockCtx, + } + + counter := 0 + m.post(func() { counter++ }) + m.post(func() { counter += 10 }) + m.post(func() { counter += 100 }) + + assert.NotNil(t, capturedFn) + capturedFn() + assert.Equal(t, 100, counter) +} + +func TestManager_ConcurrentPostWithMock(t *testing.T) { + mockCtx := mocks_wlcontext.NewMockWaylandContext(t) + + var postCount atomic.Int32 + mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) { + postCount.Add(1) + }).Times(100) + + m := &Manager{ + wlCtx: mockCtx, + } + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 10; j++ { + m.post(func() {}) + } + }() + } + + wg.Wait() + assert.Equal(t, int32(100), postCount.Load()) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/types.go new file mode 100644 index 0000000..c754561 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/types.go @@ -0,0 +1,196 @@ +package clipboard + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" + + "github.com/godbus/dbus/v5" + bolt "go.etcd.io/bbolt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type Config struct { + MaxHistory int `json:"maxHistory"` + MaxEntrySize int64 `json:"maxEntrySize"` + AutoClearDays int `json:"autoClearDays"` + ClearAtStartup bool `json:"clearAtStartup"` + Disabled bool `json:"disabled"` + MaxPinned int `json:"maxPinned"` +} + +func DefaultConfig() Config { + return Config{ + MaxHistory: 100, + MaxEntrySize: 5 * 1024 * 1024, + AutoClearDays: 0, + ClearAtStartup: false, + MaxPinned: 25, + } +} + +func getConfigPath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "DankMaterialShell", "clsettings.json"), nil +} + +func LoadConfig() Config { + cfg := DefaultConfig() + + path, err := getConfigPath() + if err != nil { + return cfg + } + + data, err := os.ReadFile(path) + if err != nil { + return cfg + } + + if err := json.Unmarshal(data, &cfg); err != nil { + return DefaultConfig() + } + return cfg +} + +func SaveConfig(cfg Config) error { + path, err := getConfigPath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0o644) +} + +type SearchParams struct { + Query string `json:"query"` + MimeType string `json:"mimeType"` + IsImage *bool `json:"isImage"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Before *int64 `json:"before"` + After *int64 `json:"after"` +} + +type SearchResult struct { + Entries []Entry `json:"entries"` + Total int `json:"total"` + HasMore bool `json:"hasMore"` +} + +type Entry struct { + ID uint64 `json:"id"` + Data []byte `json:"data,omitempty"` + MimeType string `json:"mimeType"` + Preview string `json:"preview"` + Size int `json:"size"` + Timestamp time.Time `json:"timestamp"` + IsImage bool `json:"isImage"` + Hash uint64 `json:"hash,omitempty"` + Pinned bool `json:"pinned"` +} + +type State struct { + Enabled bool `json:"enabled"` + History []Entry `json:"history"` + Current *Entry `json:"current,omitempty"` +} + +type Manager struct { + config Config + configMutex sync.RWMutex + configPath string + + display wlclient.WaylandDisplay + wlCtx wlcontext.WaylandContext + + registry *wlclient.Registry + dataControlMgr any + seat *wlclient.Seat + dataDevice any + currentOffer any + currentSource any + seatName uint32 + mimeTypes []string + offerMimeTypes map[any][]string + offerMutex sync.RWMutex + offerRegistry map[uint32]any + + sourceMimeTypes []string + sourceMutex sync.RWMutex + + persistData map[string][]byte + persistMimeTypes []string + persistMutex sync.RWMutex + + isOwner bool + ownerLock sync.Mutex + + initialized bool + + alive bool + stopChan chan struct{} + + db *bolt.DB + dbPath string + + state *State + stateMutex sync.RWMutex + + subscribers map[string]chan State + subMutex sync.RWMutex + dirty chan struct{} + notifierWg sync.WaitGroup + lastState *State + + dbusConn *dbus.Conn +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + if m.state == nil { + return State{} + } + return *m.state +} + +func (m *Manager) Subscribe(id string) chan State { + ch := make(chan State, 64) + m.subMutex.Lock() + m.subscribers[id] = ch + m.subMutex.Unlock() + return ch +} + +func (m *Manager) Unsubscribe(id string) { + m.subMutex.Lock() + if ch, ok := m.subscribers[id]; ok { + close(ch) + delete(m.subscribers, id) + } + m.subMutex.Unlock() +} + +func (m *Manager) notifySubscribers() { + select { + case m.dirty <- struct{}{}: + default: + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions.go new file mode 100644 index 0000000..2f9b98b --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions.go @@ -0,0 +1,479 @@ +package cups + +import ( + "errors" + "fmt" + "net" + "net/url" + "os/exec" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/config" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp" +) + +func isAuthError(err error) bool { + if err == nil { + return false + } + + var httpErr ipp.HTTPError + if errors.As(err, &httpErr) { + return httpErr.Code == 401 || httpErr.Code == 403 + } + + var ippErr ipp.IPPError + if errors.As(err, &ippErr) { + return ippErr.Status == ipp.StatusErrorForbidden || + ippErr.Status == ipp.StatusErrorNotAuthenticated || + ippErr.Status == ipp.StatusErrorNotAuthorized + } + + return false +} + +func (m *Manager) GetPrinters() ([]Printer, error) { + attributes := []string{ + ipp.AttributePrinterName, + ipp.AttributePrinterUriSupported, + ipp.AttributePrinterState, + ipp.AttributePrinterStateReasons, + ipp.AttributePrinterLocation, + ipp.AttributePrinterInfo, + ipp.AttributePrinterMakeAndModel, + ipp.AttributePrinterIsAcceptingJobs, + } + + printerAttrs, err := m.client.GetPrinters(attributes) + if err != nil { + if isNoPrintersError(err) { + return []Printer{}, nil + } + return nil, err + } + + printers := make([]Printer, 0, len(printerAttrs)) + for _, attrs := range printerAttrs { + printer := Printer{ + Name: getStringAttr(attrs, ipp.AttributePrinterName), + URI: getStringAttr(attrs, ipp.AttributePrinterUriSupported), + State: parsePrinterState(attrs), + StateReason: getStringAttr(attrs, ipp.AttributePrinterStateReasons), + Location: getStringAttr(attrs, ipp.AttributePrinterLocation), + Info: getStringAttr(attrs, ipp.AttributePrinterInfo), + MakeModel: getStringAttr(attrs, ipp.AttributePrinterMakeAndModel), + Accepting: getBoolAttr(attrs, ipp.AttributePrinterIsAcceptingJobs), + } + + if printer.Name != "" { + printers = append(printers, printer) + } + } + + return printers, nil +} + +func (m *Manager) GetJobs(printerName string, whichJobs string) ([]Job, error) { + attributes := []string{ + ipp.AttributeJobID, + ipp.AttributeJobName, + ipp.AttributeJobState, + ipp.AttributeJobPrinterURI, + ipp.AttributeJobOriginatingUserName, + ipp.AttributeJobKilobyteOctets, + "time-at-creation", + } + + jobAttrs, err := m.client.GetJobs(printerName, "", whichJobs, false, 0, 0, attributes) + if err != nil { + return nil, err + } + + jobs := make([]Job, 0, len(jobAttrs)) + for _, attrs := range jobAttrs { + job := Job{ + ID: getIntAttr(attrs, ipp.AttributeJobID), + Name: getStringAttr(attrs, ipp.AttributeJobName), + State: parseJobState(attrs), + User: getStringAttr(attrs, ipp.AttributeJobOriginatingUserName), + Size: getIntAttr(attrs, ipp.AttributeJobKilobyteOctets) * 1024, + } + + if uri := getStringAttr(attrs, ipp.AttributeJobPrinterURI); uri != "" { + parts := strings.Split(uri, "/") + if len(parts) > 0 { + job.Printer = parts[len(parts)-1] + } + } + + if ts := getIntAttr(attrs, "time-at-creation"); ts > 0 { + job.TimeCreated = time.Unix(int64(ts), 0) + } + + if job.ID != 0 { + jobs = append(jobs, job) + } + } + + return jobs, nil +} + +func (m *Manager) CancelJob(jobID int) error { + err := m.client.CancelJob(jobID, false) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.JobCancelPurge(jobID, false) + } + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) PausePrinter(printerName string) error { + err := m.client.PausePrinter(printerName) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.PrinterSetEnabled(printerName, false) + } + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) ResumePrinter(printerName string) error { + err := m.client.ResumePrinter(printerName) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.PrinterSetEnabled(printerName, true) + } + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) PurgeJobs(printerName string) error { + err := m.client.CancelAllJob(printerName, true) + if err == nil { + m.RefreshState() + } + return err +} + +func resolveIPFromURI(uri string) string { + parsed, err := url.Parse(uri) + if err != nil { + return "" + } + host := parsed.Hostname() + if host == "" { + return "" + } + if ip := net.ParseIP(host); ip != nil { + return ip.String() + } + addrs, err := net.LookupIP(host) + if err != nil || len(addrs) == 0 { + return "" + } + for _, addr := range addrs { + if v4 := addr.To4(); v4 != nil { + return v4.String() + } + } + return addrs[0].String() +} + +func (m *Manager) GetDevices() ([]Device, error) { + if m.pkHelper != nil { + devices, err := m.pkHelper.DevicesGet(10, 0, nil, nil) + if err != nil { + return nil, err + } + for i := range devices { + if devices[i].Class == "network" { + devices[i].IP = resolveIPFromURI(devices[i].URI) + } + } + return devices, nil + } + + deviceAttrs, err := m.client.GetDevices() + if err != nil { + return nil, err + } + + devices := make([]Device, 0, len(deviceAttrs)) + for uri, attrs := range deviceAttrs { + device := Device{ + URI: uri, + Class: getStringAttr(attrs, "device-class"), + Info: getStringAttr(attrs, "device-info"), + MakeModel: getStringAttr(attrs, "device-make-and-model"), + ID: getStringAttr(attrs, "device-id"), + Location: getStringAttr(attrs, "device-location"), + } + if device.Class == "network" { + device.IP = resolveIPFromURI(uri) + } + devices = append(devices, device) + } + + return devices, nil +} + +func (m *Manager) GetPPDs() ([]PPD, error) { + ppdAttrs, err := m.client.GetPPDs() + if err != nil { + return nil, err + } + + ppds := make([]PPD, 0, len(ppdAttrs)) + for name, attrs := range ppdAttrs { + ppd := PPD{ + Name: name, + NaturalLanguage: getStringAttr(attrs, "ppd-natural-language"), + MakeModel: getStringAttr(attrs, ipp.AttributePPDMakeAndModel), + DeviceID: getStringAttr(attrs, "ppd-device-id"), + Product: getStringAttr(attrs, "ppd-product"), + PSVersion: getStringAttr(attrs, "ppd-psversion"), + Type: getStringAttr(attrs, "ppd-type"), + } + ppds = append(ppds, ppd) + } + + return ppds, nil +} + +func (m *Manager) GetClasses() ([]PrinterClass, error) { + attributes := []string{ + ipp.AttributePrinterName, + ipp.AttributePrinterUriSupported, + ipp.AttributePrinterState, + ipp.AttributeMemberURIs, + ipp.AttributeMemberNames, + ipp.AttributePrinterLocation, + ipp.AttributePrinterInfo, + } + + classAttrs, err := m.client.GetClasses(attributes) + if err != nil { + return nil, err + } + + classes := make([]PrinterClass, 0, len(classAttrs)) + for _, attrs := range classAttrs { + class := PrinterClass{ + Name: getStringAttr(attrs, ipp.AttributePrinterName), + URI: getStringAttr(attrs, ipp.AttributePrinterUriSupported), + State: parsePrinterState(attrs), + Location: getStringAttr(attrs, ipp.AttributePrinterLocation), + Info: getStringAttr(attrs, ipp.AttributePrinterInfo), + Members: getStringSliceAttr(attrs, ipp.AttributeMemberNames), + } + classes = append(classes, class) + } + + return classes, nil +} + +func createPrinterViaLpadmin(name, deviceURI, ppd, information, location string) error { + args := []string{"-p", name, "-E", "-v", deviceURI, "-m", ppd} + if information != "" { + args = append(args, "-D", information) + } + if location != "" { + args = append(args, "-L", location) + } + out, err := exec.Command("lpadmin", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("lpadmin failed: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +func deletePrinterViaLpadmin(name string) error { + out, err := exec.Command("lpadmin", "-x", name).CombinedOutput() + if err != nil { + return fmt.Errorf("lpadmin failed: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +func (m *Manager) CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy, information, location string) error { + usedPkHelper := false + + err := m.client.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location) + if isAuthError(err) && m.pkHelper != nil { + if err = m.pkHelper.PrinterAdd(name, deviceURI, ppd, information, location); err != nil { + // pkHelper failed (e.g., no polkit agent), try lpadmin as last resort. + // lpadmin -E enables the printer, so no further setup needed. + if lpadminErr := createPrinterViaLpadmin(name, deviceURI, ppd, information, location); lpadminErr != nil { + return err + } + m.RefreshState() + return nil + } + usedPkHelper = true + } else if err != nil { + return err + } + + if usedPkHelper { + m.pkHelper.PrinterSetEnabled(name, true) //nolint:errcheck + m.pkHelper.PrinterSetAcceptJobs(name, true, "") //nolint:errcheck + } else { + if err := m.client.ResumePrinter(name); isAuthError(err) && m.pkHelper != nil { + m.pkHelper.PrinterSetEnabled(name, true) //nolint:errcheck + } + if err := m.client.AcceptJobs(name); isAuthError(err) && m.pkHelper != nil { + m.pkHelper.PrinterSetAcceptJobs(name, true, "") //nolint:errcheck + } + } + + m.RefreshState() + return nil +} + +func (m *Manager) DeletePrinter(printerName string) error { + err := m.client.DeletePrinter(printerName) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.PrinterDelete(printerName) + if err != nil { + // pkHelper failed, try lpadmin as last resort + if lpadminErr := deletePrinterViaLpadmin(printerName); lpadminErr == nil { + err = nil + } + } + } + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) AcceptJobs(printerName string) error { + err := m.client.AcceptJobs(printerName) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.PrinterSetAcceptJobs(printerName, true, "") + } + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) RejectJobs(printerName string) error { + err := m.client.RejectJobs(printerName) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.PrinterSetAcceptJobs(printerName, false, "") + } + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) SetPrinterShared(printerName string, shared bool) error { + err := m.client.SetPrinterIsShared(printerName, shared) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.PrinterSetShared(printerName, shared) + } + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) SetPrinterLocation(printerName, location string) error { + err := m.client.SetPrinterLocation(printerName, location) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.PrinterSetLocation(printerName, location) + } + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) SetPrinterInfo(printerName, info string) error { + err := m.client.SetPrinterInformation(printerName, info) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.PrinterSetInfo(printerName, info) + } + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) MoveJob(jobID int, destPrinter string) error { + err := m.client.MoveJob(jobID, destPrinter) + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) PrintTestPage(printerName string) (int, error) { + jobID, err := m.client.PrintTestPage(printerName, strings.NewReader(config.TestPage), len(config.TestPage)) + if err == nil { + m.RefreshState() + } + return jobID, err +} + +func (m *Manager) AddPrinterToClass(className, printerName string) error { + err := m.client.AddPrinterToClass(className, printerName) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.ClassAddPrinter(className, printerName) + } + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) RemovePrinterFromClass(className, printerName string) error { + err := m.client.DeletePrinterFromClass(className, printerName) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.ClassDeletePrinter(className, printerName) + } + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) DeleteClass(className string) error { + err := m.client.DeleteClass(className) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.ClassDelete(className) + } + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) RestartJob(jobID int) error { + err := m.client.RestartJob(jobID) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.JobRestart(jobID) + } + if err == nil { + m.RefreshState() + } + return err +} + +func (m *Manager) HoldJob(jobID int, holdUntil string) error { + err := m.client.HoldJobUntil(jobID, holdUntil) + if isAuthError(err) && m.pkHelper != nil { + err = m.pkHelper.JobSetHoldUntil(jobID, holdUntil) + } + if err == nil { + m.RefreshState() + } + return err +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_pkhelper_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_pkhelper_test.go new file mode 100644 index 0000000..ad16a8a --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_pkhelper_test.go @@ -0,0 +1,235 @@ +package cups_test + +import ( + "testing" + + mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups" + mocks_pkhelper "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups_pkhelper" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func authErr() error { + return ipp.IPPError{Status: ipp.StatusErrorForbidden} +} + +func TestManager_CancelJob_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().CancelJob(1, false).Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().JobCancelPurge(1, false).Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.CancelJob(1)) +} + +func TestManager_CancelJob_PkHelperError(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().CancelJob(1, false).Return(authErr()) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().JobCancelPurge(1, false).Return(assert.AnError) + + m := cups.NewTestManager(mockClient, mockPk) + assert.Error(t, m.CancelJob(1)) +} + +func TestManager_PausePrinter_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().PausePrinter("printer1").Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().PrinterSetEnabled("printer1", false).Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.PausePrinter("printer1")) +} + +func TestManager_ResumePrinter_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().ResumePrinter("printer1").Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().PrinterSetEnabled("printer1", true).Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.ResumePrinter("printer1")) +} + +func TestManager_GetDevices_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().DevicesGet(10, 0, []string(nil), []string(nil)).Return([]cups.Device{ + {URI: "usb://HP/LaserJet", Class: "direct"}, + }, nil) + + m := cups.NewTestManager(mockClient, mockPk) + got, err := m.GetDevices() + assert.NoError(t, err) + assert.Len(t, got, 1) + assert.Equal(t, "usb://HP/LaserJet", got[0].URI) +} + +func TestManager_GetDevices_PkHelperError(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().DevicesGet(10, 0, []string(nil), []string(nil)).Return(nil, assert.AnError) + + m := cups.NewTestManager(mockClient, mockPk) + _, err := m.GetDevices() + assert.Error(t, err) +} + +func TestManager_CreatePrinter_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().CreatePrinter("newprinter", "usb://HP", "generic.ppd", true, "stop-printer", "info", "location").Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().PrinterAdd("newprinter", "usb://HP", "generic.ppd", "info", "location").Return(nil) + mockPk.EXPECT().PrinterSetEnabled("newprinter", true).Return(nil) + mockPk.EXPECT().PrinterSetAcceptJobs("newprinter", true, "").Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.CreatePrinter("newprinter", "usb://HP", "generic.ppd", true, "stop-printer", "info", "location")) +} + +func TestManager_DeletePrinter_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().DeletePrinter("printer1").Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().PrinterDelete("printer1").Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.DeletePrinter("printer1")) +} + +func TestManager_AcceptJobs_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().AcceptJobs("printer1").Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().PrinterSetAcceptJobs("printer1", true, "").Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.AcceptJobs("printer1")) +} + +func TestManager_RejectJobs_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().RejectJobs("printer1").Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().PrinterSetAcceptJobs("printer1", false, "").Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.RejectJobs("printer1")) +} + +func TestManager_SetPrinterShared_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().SetPrinterIsShared("printer1", true).Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().PrinterSetShared("printer1", true).Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.SetPrinterShared("printer1", true)) +} + +func TestManager_SetPrinterLocation_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().SetPrinterLocation("printer1", "Office").Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().PrinterSetLocation("printer1", "Office").Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.SetPrinterLocation("printer1", "Office")) +} + +func TestManager_SetPrinterInfo_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().SetPrinterInformation("printer1", "Main Printer").Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().PrinterSetInfo("printer1", "Main Printer").Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.SetPrinterInfo("printer1", "Main Printer")) +} + +func TestManager_AddPrinterToClass_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().AddPrinterToClass("office", "printer1").Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().ClassAddPrinter("office", "printer1").Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.AddPrinterToClass("office", "printer1")) +} + +func TestManager_RemovePrinterFromClass_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().DeletePrinterFromClass("office", "printer1").Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().ClassDeletePrinter("office", "printer1").Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.RemovePrinterFromClass("office", "printer1")) +} + +func TestManager_DeleteClass_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().DeleteClass("office").Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().ClassDelete("office").Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.DeleteClass("office")) +} + +func TestManager_RestartJob_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().RestartJob(1).Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().JobRestart(1).Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.RestartJob(1)) +} + +func TestManager_HoldJob_WithPkHelper(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().HoldJobUntil(1, "indefinite").Return(authErr()) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + mockPk := mocks_pkhelper.NewMockPkHelper(t) + mockPk.EXPECT().JobSetHoldUntil(1, "indefinite").Return(nil) + + m := cups.NewTestManager(mockClient, mockPk) + assert.NoError(t, m.HoldJob(1, "indefinite")) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_test.go new file mode 100644 index 0000000..70f4f21 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/actions_test.go @@ -0,0 +1,450 @@ +package cups + +import ( + "errors" + "testing" + "time" + + mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestManager_GetPrinters(t *testing.T) { + tests := []struct { + name string + mockRet map[string]ipp.Attributes + mockErr error + want int + wantErr bool + }{ + { + name: "success", + mockRet: map[string]ipp.Attributes{ + "printer1": { + ipp.AttributePrinterName: []ipp.Attribute{{Value: "printer1"}}, + ipp.AttributePrinterUriSupported: []ipp.Attribute{{Value: "ipp://localhost/printers/printer1"}}, + ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}}, + ipp.AttributePrinterStateReasons: []ipp.Attribute{{Value: "none"}}, + ipp.AttributePrinterLocation: []ipp.Attribute{{Value: "Office"}}, + ipp.AttributePrinterInfo: []ipp.Attribute{{Value: "Test Printer"}}, + ipp.AttributePrinterMakeAndModel: []ipp.Attribute{{Value: "Generic"}}, + ipp.AttributePrinterIsAcceptingJobs: []ipp.Attribute{{Value: true}}, + }, + }, + mockErr: nil, + want: 1, + wantErr: false, + }, + { + name: "error", + mockRet: nil, + mockErr: errors.New("test error"), + want: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(tt.mockRet, tt.mockErr) + + m := &Manager{ + client: mockClient, + } + + got, err := m.GetPrinters() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, len(got)) + if len(got) > 0 { + assert.Equal(t, "printer1", got[0].Name) + assert.Equal(t, "idle", got[0].State) + assert.Equal(t, "Office", got[0].Location) + assert.True(t, got[0].Accepting) + } + } + }) + } +} + +func TestManager_GetJobs(t *testing.T) { + tests := []struct { + name string + mockRet map[int]ipp.Attributes + mockErr error + want int + wantErr bool + }{ + { + name: "success", + mockRet: map[int]ipp.Attributes{ + 1: { + ipp.AttributeJobID: []ipp.Attribute{{Value: 1}}, + ipp.AttributeJobName: []ipp.Attribute{{Value: "test-job"}}, + ipp.AttributeJobState: []ipp.Attribute{{Value: 5}}, + ipp.AttributeJobPrinterURI: []ipp.Attribute{{Value: "ipp://localhost/printers/printer1"}}, + ipp.AttributeJobOriginatingUserName: []ipp.Attribute{{Value: "testuser"}}, + ipp.AttributeJobKilobyteOctets: []ipp.Attribute{{Value: 10}}, + "time-at-creation": []ipp.Attribute{{Value: 1609459200}}, + }, + }, + mockErr: nil, + want: 1, + wantErr: false, + }, + { + name: "error", + mockRet: nil, + mockErr: errors.New("test error"), + want: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().GetJobs("printer1", "", "not-completed", false, 0, 0, mock.Anything). + Return(tt.mockRet, tt.mockErr) + + m := &Manager{ + client: mockClient, + } + + got, err := m.GetJobs("printer1", "not-completed") + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, len(got)) + if len(got) > 0 { + assert.Equal(t, 1, got[0].ID) + assert.Equal(t, "test-job", got[0].Name) + assert.Equal(t, "processing", got[0].State) + assert.Equal(t, "testuser", got[0].User) + assert.Equal(t, "printer1", got[0].Printer) + assert.Equal(t, 10240, got[0].Size) + assert.Equal(t, time.Unix(1609459200, 0), got[0].TimeCreated) + } + } + }) + } +} + +func TestManager_CancelJob(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().CancelJob(1, false).Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.CancelJob(1)) +} + +func TestManager_PausePrinter(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().PausePrinter("printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.PausePrinter("printer1")) +} + +func TestManager_ResumePrinter(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().ResumePrinter("printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.ResumePrinter("printer1")) +} + +func TestManager_PurgeJobs(t *testing.T) { + tests := []struct { + name string + mockErr error + wantErr bool + }{ + { + name: "success", + mockErr: nil, + wantErr: false, + }, + { + name: "error", + mockErr: errors.New("test error"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().CancelAllJob("printer1", true).Return(tt.mockErr) + if !tt.wantErr { + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + } + + m := NewTestManager(mockClient, nil) + + err := m.PurgeJobs("printer1") + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestManager_GetDevices(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().GetDevices().Return(map[string]ipp.Attributes{ + "usb://HP/LaserJet": { + "device-class": []ipp.Attribute{{Value: "direct"}}, + "device-info": []ipp.Attribute{{Value: "HP LaserJet"}}, + "device-make-and-model": []ipp.Attribute{{Value: "HP LaserJet 1020"}}, + }, + }, nil) + + m := &Manager{client: mockClient} + got, err := m.GetDevices() + assert.NoError(t, err) + assert.Len(t, got, 1) + assert.Equal(t, "usb://HP/LaserJet", got[0].URI) + assert.Equal(t, "direct", got[0].Class) +} + +func TestManager_GetPPDs(t *testing.T) { + tests := []struct { + name string + mockRet map[string]ipp.Attributes + mockErr error + want int + wantErr bool + }{ + { + name: "success", + mockRet: map[string]ipp.Attributes{ + "drv:///sample.drv/generic.ppd": { + "ppd-make-and-model": []ipp.Attribute{{Value: "Generic PostScript"}}, + "ppd-type": []ipp.Attribute{{Value: "ppd"}}, + }, + }, + mockErr: nil, + want: 1, + wantErr: false, + }, + { + name: "error", + mockRet: nil, + mockErr: errors.New("test error"), + want: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().GetPPDs().Return(tt.mockRet, tt.mockErr) + + m := &Manager{client: mockClient} + + got, err := m.GetPPDs() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.want, len(got)) + }) + } +} + +func TestManager_GetClasses(t *testing.T) { + tests := []struct { + name string + mockRet map[string]ipp.Attributes + mockErr error + want int + wantErr bool + }{ + { + name: "success", + mockRet: map[string]ipp.Attributes{ + "office": { + ipp.AttributePrinterName: []ipp.Attribute{{Value: "office"}}, + ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}}, + ipp.AttributeMemberNames: []ipp.Attribute{{Value: "printer1"}, {Value: "printer2"}}, + }, + }, + mockErr: nil, + want: 1, + wantErr: false, + }, + { + name: "error", + mockRet: nil, + mockErr: errors.New("test error"), + want: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().GetClasses(mock.Anything).Return(tt.mockRet, tt.mockErr) + + m := &Manager{client: mockClient} + + got, err := m.GetClasses() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.want, len(got)) + if len(got) > 0 { + assert.Equal(t, "office", got[0].Name) + assert.Equal(t, 2, len(got[0].Members)) + } + }) + } +} + +func TestManager_CreatePrinter(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().CreatePrinter("newprinter", "usb://HP", "generic.ppd", true, "stop-printer", "info", "location").Return(nil) + mockClient.EXPECT().ResumePrinter("newprinter").Return(nil) + mockClient.EXPECT().AcceptJobs("newprinter").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.CreatePrinter("newprinter", "usb://HP", "generic.ppd", true, "stop-printer", "info", "location")) +} + +func TestManager_DeletePrinter(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().DeletePrinter("printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.DeletePrinter("printer1")) +} + +func TestManager_AcceptJobs(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().AcceptJobs("printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.AcceptJobs("printer1")) +} + +func TestManager_RejectJobs(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().RejectJobs("printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.RejectJobs("printer1")) +} + +func TestManager_SetPrinterShared(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().SetPrinterIsShared("printer1", true).Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.SetPrinterShared("printer1", true)) +} + +func TestManager_SetPrinterLocation(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().SetPrinterLocation("printer1", "Office").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.SetPrinterLocation("printer1", "Office")) +} + +func TestManager_SetPrinterInfo(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().SetPrinterInformation("printer1", "Main Printer").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.SetPrinterInfo("printer1", "Main Printer")) +} + +func TestManager_MoveJob(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().MoveJob(1, "printer2").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + err := m.MoveJob(1, "printer2") + assert.NoError(t, err) +} + +func TestManager_PrintTestPage(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().PrintTestPage("printer1", mock.Anything, mock.Anything).Return(42, nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + jobID, err := m.PrintTestPage("printer1") + assert.NoError(t, err) + assert.Equal(t, 42, jobID) +} + +func TestManager_AddPrinterToClass(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().AddPrinterToClass("office", "printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.AddPrinterToClass("office", "printer1")) +} + +func TestManager_RemovePrinterFromClass(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().DeletePrinterFromClass("office", "printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.RemovePrinterFromClass("office", "printer1")) +} + +func TestManager_DeleteClass(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().DeleteClass("office").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.DeleteClass("office")) +} + +func TestManager_RestartJob(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().RestartJob(1).Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.RestartJob(1)) +} + +func TestManager_HoldJob(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().HoldJobUntil(1, "indefinite").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + assert.NoError(t, m.HoldJob(1, "indefinite")) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers.go new file mode 100644 index 0000000..57b45d5 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers.go @@ -0,0 +1,487 @@ +package cups + +import ( + "encoding/json" + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +type CUPSEvent struct { + Type string `json:"type"` + Data CUPSState `json:"data"` +} + +type TestPageResult struct { + Success bool `json:"success"` + JobID int `json:"jobId"` + Message string `json:"message"` +} + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + switch req.Method { + case "cups.subscribe": + handleSubscribe(conn, req, manager) + case "cups.getPrinters": + handleGetPrinters(conn, req, manager) + case "cups.getJobs": + handleGetJobs(conn, req, manager) + case "cups.pausePrinter": + handlePausePrinter(conn, req, manager) + case "cups.resumePrinter": + handleResumePrinter(conn, req, manager) + case "cups.cancelJob": + handleCancelJob(conn, req, manager) + case "cups.purgeJobs": + handlePurgeJobs(conn, req, manager) + case "cups.getDevices": + handleGetDevices(conn, req, manager) + case "cups.getPPDs": + handleGetPPDs(conn, req, manager) + case "cups.getClasses": + handleGetClasses(conn, req, manager) + case "cups.createPrinter": + handleCreatePrinter(conn, req, manager) + case "cups.deletePrinter": + handleDeletePrinter(conn, req, manager) + case "cups.acceptJobs": + handleAcceptJobs(conn, req, manager) + case "cups.rejectJobs": + handleRejectJobs(conn, req, manager) + case "cups.setPrinterShared": + handleSetPrinterShared(conn, req, manager) + case "cups.setPrinterLocation": + handleSetPrinterLocation(conn, req, manager) + case "cups.setPrinterInfo": + handleSetPrinterInfo(conn, req, manager) + case "cups.moveJob": + handleMoveJob(conn, req, manager) + case "cups.printTestPage": + handlePrintTestPage(conn, req, manager) + case "cups.addPrinterToClass": + handleAddPrinterToClass(conn, req, manager) + case "cups.removePrinterFromClass": + handleRemovePrinterFromClass(conn, req, manager) + case "cups.deleteClass": + handleDeleteClass(conn, req, manager) + case "cups.restartJob": + handleRestartJob(conn, req, manager) + case "cups.holdJob": + handleHoldJob(conn, req, manager) + case "cups.testConnection": + handleTestConnection(conn, req, manager) + default: + models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) + } +} + +func handleGetPrinters(conn net.Conn, req models.Request, manager *Manager) { + printers, err := manager.GetPrinters() + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, printers) +} + +func handleGetJobs(conn net.Conn, req models.Request, manager *Manager) { + printerName, err := params.String(req.Params, "printerName") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + jobs, err := manager.GetJobs(printerName, "not-completed") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, jobs) +} + +func handlePausePrinter(conn net.Conn, req models.Request, manager *Manager) { + printerName, err := params.String(req.Params, "printerName") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.PausePrinter(printerName); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "paused"}) +} + +func handleResumePrinter(conn net.Conn, req models.Request, manager *Manager) { + printerName, err := params.String(req.Params, "printerName") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.ResumePrinter(printerName); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "resumed"}) +} + +func handleCancelJob(conn net.Conn, req models.Request, manager *Manager) { + jobID, err := params.Int(req.Params, "jobID") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.CancelJob(jobID); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job canceled"}) +} + +func handlePurgeJobs(conn net.Conn, req models.Request, manager *Manager) { + printerName, err := params.String(req.Params, "printerName") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.PurgeJobs(printerName); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "jobs canceled"}) +} + +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 := CUPSEvent{ + Type: "state_changed", + Data: initialState, + } + + if err := json.NewEncoder(conn).Encode(models.Response[CUPSEvent]{ + ID: req.ID, + Result: &event, + }); err != nil { + return + } + + for state := range stateChan { + event := CUPSEvent{ + Type: "state_changed", + Data: state, + } + if err := json.NewEncoder(conn).Encode(models.Response[CUPSEvent]{ + Result: &event, + }); err != nil { + return + } + } +} + +func handleGetDevices(conn net.Conn, req models.Request, manager *Manager) { + devices, err := manager.GetDevices() + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, devices) +} + +func handleGetPPDs(conn net.Conn, req models.Request, manager *Manager) { + ppds, err := manager.GetPPDs() + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, ppds) +} + +func handleGetClasses(conn net.Conn, req models.Request, manager *Manager) { + classes, err := manager.GetClasses() + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, classes) +} + +func handleCreatePrinter(conn net.Conn, req models.Request, manager *Manager) { + name, err := params.StringNonEmpty(req.Params, "name") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + deviceURI, err := params.StringNonEmpty(req.Params, "deviceURI") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + ppd, err := params.StringNonEmpty(req.Params, "ppd") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + shared := params.BoolOpt(req.Params, "shared", false) + errorPolicy := params.StringOpt(req.Params, "errorPolicy", "") + information := params.StringOpt(req.Params, "information", "") + location := params.StringOpt(req.Params, "location", "") + + if err := manager.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "printer created"}) +} + +func handleDeletePrinter(conn net.Conn, req models.Request, manager *Manager) { + printerName, err := params.StringNonEmpty(req.Params, "printerName") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.DeletePrinter(printerName); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "printer deleted"}) +} + +func handleAcceptJobs(conn net.Conn, req models.Request, manager *Manager) { + printerName, err := params.StringNonEmpty(req.Params, "printerName") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.AcceptJobs(printerName); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "accepting jobs"}) +} + +func handleRejectJobs(conn net.Conn, req models.Request, manager *Manager) { + printerName, err := params.StringNonEmpty(req.Params, "printerName") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.RejectJobs(printerName); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "rejecting jobs"}) +} + +func handleSetPrinterShared(conn net.Conn, req models.Request, manager *Manager) { + printerName, err := params.StringNonEmpty(req.Params, "printerName") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + shared, err := params.Bool(req.Params, "shared") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetPrinterShared(printerName, shared); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "sharing updated"}) +} + +func handleSetPrinterLocation(conn net.Conn, req models.Request, manager *Manager) { + printerName, err := params.StringNonEmpty(req.Params, "printerName") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + location, err := params.String(req.Params, "location") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetPrinterLocation(printerName, location); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "location updated"}) +} + +func handleSetPrinterInfo(conn net.Conn, req models.Request, manager *Manager) { + printerName, err := params.StringNonEmpty(req.Params, "printerName") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + info, err := params.String(req.Params, "info") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetPrinterInfo(printerName, info); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "info updated"}) +} + +func handleMoveJob(conn net.Conn, req models.Request, manager *Manager) { + jobID, err := params.Int(req.Params, "jobID") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + destPrinter, err := params.StringNonEmpty(req.Params, "destPrinter") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.MoveJob(jobID, destPrinter); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job moved"}) +} + +func handlePrintTestPage(conn net.Conn, req models.Request, manager *Manager) { + printerName, err := params.StringNonEmpty(req.Params, "printerName") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + jobID, err := manager.PrintTestPage(printerName) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, TestPageResult{Success: true, JobID: jobID, Message: "test page queued"}) +} + +func handleAddPrinterToClass(conn net.Conn, req models.Request, manager *Manager) { + className, err := params.StringNonEmpty(req.Params, "className") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + printerName, err := params.StringNonEmpty(req.Params, "printerName") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.AddPrinterToClass(className, printerName); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "printer added to class"}) +} + +func handleRemovePrinterFromClass(conn net.Conn, req models.Request, manager *Manager) { + className, err := params.StringNonEmpty(req.Params, "className") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + printerName, err := params.StringNonEmpty(req.Params, "printerName") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.RemovePrinterFromClass(className, printerName); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "printer removed from class"}) +} + +func handleDeleteClass(conn net.Conn, req models.Request, manager *Manager) { + className, err := params.StringNonEmpty(req.Params, "className") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.DeleteClass(className); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "class deleted"}) +} + +func handleRestartJob(conn net.Conn, req models.Request, manager *Manager) { + jobID, err := params.Int(req.Params, "jobID") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.RestartJob(jobID); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job restarted"}) +} + +func handleHoldJob(conn net.Conn, req models.Request, manager *Manager) { + jobID, err := params.Int(req.Params, "jobID") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + holdUntil := params.StringOpt(req.Params, "holdUntil", "indefinite") + + if err := manager.HoldJob(jobID, holdUntil); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job held"}) +} + +func handleTestConnection(conn net.Conn, req models.Request, manager *Manager) { + host, err := params.StringNonEmpty(req.Params, "host") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + port := params.IntOpt(req.Params, "port", 631) + protocol := params.StringOpt(req.Params, "protocol", "ipp") + + result, err := manager.TestRemotePrinter(host, port, protocol) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, result) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers_test.go new file mode 100644 index 0000000..b73931e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/handlers_test.go @@ -0,0 +1,711 @@ +package cups + +import ( + "bytes" + "encoding/json" + "errors" + "net" + "testing" + "time" + + mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type mockConn struct { + *bytes.Buffer +} + +func (m *mockConn) Close() error { return nil } +func (m *mockConn) LocalAddr() net.Addr { return nil } +func (m *mockConn) RemoteAddr() net.Addr { return nil } +func (m *mockConn) SetDeadline(t time.Time) error { return nil } +func (m *mockConn) SetReadDeadline(t time.Time) error { return nil } +func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil } + +func TestHandleGetPrinters(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{ + "printer1": { + ipp.AttributePrinterName: []ipp.Attribute{{Value: "printer1"}}, + ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}}, + ipp.AttributePrinterUriSupported: []ipp.Attribute{{Value: "ipp://localhost/printers/printer1"}}, + }, + }, nil) + + m := &Manager{ + client: mockClient, + } + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.getPrinters", + } + + handleGetPrinters(conn, req, m) + + var resp models.Response[[]Printer] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.Equal(t, 1, len(*resp.Result)) +} + +func TestHandleGetPrinters_Error(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(nil, errors.New("test error")) + + m := &Manager{ + client: mockClient, + } + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.getPrinters", + } + + handleGetPrinters(conn, req, m) + + var resp models.Response[any] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.Nil(t, resp.Result) + assert.NotNil(t, resp.Error) +} + +func TestHandleGetJobs(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().GetJobs("printer1", "", "not-completed", false, 0, 0, mock.Anything). + Return(map[int]ipp.Attributes{ + 1: { + ipp.AttributeJobID: []ipp.Attribute{{Value: 1}}, + ipp.AttributeJobName: []ipp.Attribute{{Value: "job1"}}, + ipp.AttributeJobState: []ipp.Attribute{{Value: 5}}, + }, + }, nil) + + m := &Manager{ + client: mockClient, + } + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.getJobs", + Params: map[string]any{ + "printerName": "printer1", + }, + } + + handleGetJobs(conn, req, m) + + var resp models.Response[[]Job] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.Equal(t, 1, len(*resp.Result)) +} + +func TestHandleGetJobs_MissingParam(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + + m := &Manager{ + client: mockClient, + } + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.getJobs", + Params: map[string]any{}, + } + + handleGetJobs(conn, req, m) + + var resp models.Response[any] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.Nil(t, resp.Result) + assert.NotNil(t, resp.Error) +} + +func TestHandlePausePrinter(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().PausePrinter("printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.pausePrinter", + Params: map[string]any{ + "printerName": "printer1", + }, + } + + handlePausePrinter(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleResumePrinter(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().ResumePrinter("printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.resumePrinter", + Params: map[string]any{ + "printerName": "printer1", + }, + } + + handleResumePrinter(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleCancelJob(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().CancelJob(1, false).Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.cancelJob", + Params: map[string]any{ + "jobID": float64(1), + }, + } + + handleCancelJob(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandlePurgeJobs(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().CancelAllJob("printer1", true).Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.purgeJobs", + Params: map[string]any{ + "printerName": "printer1", + }, + } + + handlePurgeJobs(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleRequest_UnknownMethod(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + + m := &Manager{ + client: mockClient, + } + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.unknownMethod", + } + + HandleRequest(conn, req, m) + + var resp models.Response[any] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.Nil(t, resp.Result) + assert.NotNil(t, resp.Error) +} + +func TestHandleGetDevices(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().GetDevices().Return(map[string]ipp.Attributes{ + "usb://HP/LaserJet": { + "device-class": []ipp.Attribute{{Value: "direct"}}, + "device-info": []ipp.Attribute{{Value: "HP LaserJet"}}, + }, + }, nil) + + m := &Manager{client: mockClient} + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ID: 1, Method: "cups.getDevices"} + handleGetDevices(conn, req, m) + + var resp models.Response[[]Device] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.Equal(t, 1, len(*resp.Result)) +} + +func TestHandleGetPPDs(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().GetPPDs().Return(map[string]ipp.Attributes{ + "generic.ppd": { + "ppd-make-and-model": []ipp.Attribute{{Value: "Generic"}}, + }, + }, nil) + + m := &Manager{client: mockClient} + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ID: 1, Method: "cups.getPPDs"} + handleGetPPDs(conn, req, m) + + var resp models.Response[[]PPD] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.Equal(t, 1, len(*resp.Result)) +} + +func TestHandleGetClasses(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().GetClasses(mock.Anything).Return(map[string]ipp.Attributes{ + "office": { + ipp.AttributePrinterName: []ipp.Attribute{{Value: "office"}}, + ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}}, + }, + }, nil) + + m := &Manager{client: mockClient} + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ID: 1, Method: "cups.getClasses"} + handleGetClasses(conn, req, m) + + var resp models.Response[[]PrinterClass] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.Equal(t, 1, len(*resp.Result)) +} + +func TestHandleCreatePrinter(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().CreatePrinter("newprinter", "usb://HP", "generic.ppd", false, "", "", "").Return(nil) + mockClient.EXPECT().ResumePrinter("newprinter").Return(nil) + mockClient.EXPECT().AcceptJobs("newprinter").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.createPrinter", + Params: map[string]any{ + "name": "newprinter", + "deviceURI": "usb://HP", + "ppd": "generic.ppd", + }, + } + handleCreatePrinter(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleCreatePrinter_MissingParams(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + m := &Manager{client: mockClient} + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ID: 1, Method: "cups.createPrinter", Params: map[string]any{}} + handleCreatePrinter(conn, req, m) + + var resp models.Response[any] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.Nil(t, resp.Result) + assert.NotNil(t, resp.Error) +} + +func TestHandleDeletePrinter(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().DeletePrinter("printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.deletePrinter", + Params: map[string]any{"printerName": "printer1"}, + } + handleDeletePrinter(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleAcceptJobs(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().AcceptJobs("printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.acceptJobs", + Params: map[string]any{"printerName": "printer1"}, + } + handleAcceptJobs(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleRejectJobs(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().RejectJobs("printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.rejectJobs", + Params: map[string]any{"printerName": "printer1"}, + } + handleRejectJobs(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleSetPrinterShared(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().SetPrinterIsShared("printer1", true).Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.setPrinterShared", + Params: map[string]any{"printerName": "printer1", "shared": true}, + } + handleSetPrinterShared(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleSetPrinterLocation(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().SetPrinterLocation("printer1", "Office").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.setPrinterLocation", + Params: map[string]any{"printerName": "printer1", "location": "Office"}, + } + handleSetPrinterLocation(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleSetPrinterInfo(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().SetPrinterInformation("printer1", "Main Printer").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.setPrinterInfo", + Params: map[string]any{"printerName": "printer1", "info": "Main Printer"}, + } + handleSetPrinterInfo(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleMoveJob(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().MoveJob(1, "printer2").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.moveJob", + Params: map[string]any{"jobID": float64(1), "destPrinter": "printer2"}, + } + handleMoveJob(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandlePrintTestPage(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().PrintTestPage("printer1", mock.Anything, mock.Anything).Return(42, nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.printTestPage", + Params: map[string]any{"printerName": "printer1"}, + } + handlePrintTestPage(conn, req, m) + + var resp models.Response[TestPageResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.Equal(t, 42, resp.Result.JobID) +} + +func TestHandleAddPrinterToClass(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().AddPrinterToClass("office", "printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.addPrinterToClass", + Params: map[string]any{"className": "office", "printerName": "printer1"}, + } + handleAddPrinterToClass(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleRemovePrinterFromClass(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().DeletePrinterFromClass("office", "printer1").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.removePrinterFromClass", + Params: map[string]any{"className": "office", "printerName": "printer1"}, + } + handleRemovePrinterFromClass(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleDeleteClass(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().DeleteClass("office").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.deleteClass", + Params: map[string]any{"className": "office"}, + } + handleDeleteClass(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleRestartJob(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().RestartJob(1).Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.restartJob", + Params: map[string]any{"jobID": float64(1)}, + } + handleRestartJob(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleHoldJob(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().HoldJobUntil(1, "indefinite").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.holdJob", + Params: map[string]any{"jobID": float64(1)}, + } + handleHoldJob(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} + +func TestHandleHoldJob_WithHoldUntil(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + mockClient.EXPECT().HoldJobUntil(1, "no-hold").Return(nil) + mockClient.EXPECT().GetPrinters(mock.Anything).Return(map[string]ipp.Attributes{}, nil) + + m := NewTestManager(mockClient, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.holdJob", + Params: map[string]any{"jobID": float64(1), "holdUntil": "no-hold"}, + } + handleHoldJob(conn, req, m) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager.go new file mode 100644 index 0000000..e5b7e8c --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager.go @@ -0,0 +1,390 @@ +package cups + +import ( + "errors" + "fmt" + "os" + "strconv" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp" +) + +func NewManager() (*Manager, error) { + host := os.Getenv("DMS_IPP_HOST") + if host == "" { + host = "localhost" + } + + portStr := os.Getenv("DMS_IPP_PORT") + port := 631 + if portStr != "" { + if p, err := strconv.Atoi(portStr); err == nil { + port = p + } + } + + username := os.Getenv("DMS_IPP_USERNAME") + password := os.Getenv("DMS_IPP_PASSWORD") + + client := ipp.NewCUPSClient(host, port, username, password, false) + baseURL := fmt.Sprintf("http://%s:%d", host, port) + + var pkHelper PkHelper + if isLocalCUPS(host) { + var err error + pkHelper, err = NewPkHelper() + if err != nil { + log.Warnf("[CUPS] Failed to initialize pkhelper: %v", err) + } + } + + m := &Manager{ + state: &CUPSState{ + Printers: make(map[string]*Printer), + }, + client: client, + pkHelper: pkHelper, + baseURL: baseURL, + stateMutex: sync.RWMutex{}, + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + + if err := m.updateState(); err != nil { + return nil, err + } + + if isLocalCUPS(host) { + m.subscription = NewDBusSubscriptionManager(client, baseURL) + log.Infof("[CUPS] Using D-Bus notifications for local CUPS") + } else { + m.subscription = NewSubscriptionManager(client, baseURL) + log.Infof("[CUPS] Using IPPGET notifications for remote CUPS") + } + + m.notifierWg.Add(1) + go m.notifier() + + return m, nil +} + +func isLocalCUPS(host string) bool { + switch host { + case "localhost", "127.0.0.1", "::1", "": + return true + } + return false +} + +func (m *Manager) eventHandler() { + defer m.eventWG.Done() + + if m.subscription == nil { + return + } + + for { + select { + case <-m.stopChan: + return + case event, ok := <-m.subscription.Events(): + if !ok { + return + } + log.Debugf("[CUPS] Received event: %s (printer: %s, job: %d)", + event.EventName, event.PrinterName, event.JobID) + + if err := m.updateState(); err != nil { + log.Warnf("[CUPS] Failed to update state after event: %v", err) + } else { + m.notifySubscribers() + } + } + } +} + +func (m *Manager) updateState() error { + printers, err := m.GetPrinters() + if err != nil { + if isNoPrintersError(err) { + m.stateMutex.Lock() + m.state.Printers = make(map[string]*Printer) + m.stateMutex.Unlock() + return nil + } + return err + } + + printerMap := make(map[string]*Printer, len(printers)) + for _, printer := range printers { + jobs, err := m.GetJobs(printer.Name, "not-completed") + if err != nil { + return err + } + + printer.Jobs = jobs + printerMap[printer.Name] = &printer + } + + m.stateMutex.Lock() + m.state.Printers = printerMap + m.stateMutex.Unlock() + + return nil +} + +func isNoPrintersError(err error) bool { + if err == nil { + return false + } + + var ippErr ipp.IPPError + if errors.As(err, &ippErr) { + return ippErr.Status == 1030 + } + + return false +} + +func (m *Manager) notifier() { + defer m.notifierWg.Done() + const minGap = 100 * 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 + } + + currentState := m.snapshotState() + + if m.lastNotifiedState != nil && !stateChanged(m.lastNotifiedState, ¤tState) { + pending = false + continue + } + + m.subscribers.Range(func(key string, ch chan CUPSState) 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) RefreshState() { + if err := m.updateState(); err != nil { + log.Warnf("[CUPS] Failed to refresh state: %v", err) + return + } + m.notifySubscribers() +} + +func (m *Manager) GetState() CUPSState { + return m.snapshotState() +} + +func (m *Manager) snapshotState() CUPSState { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + + s := CUPSState{ + Printers: make(map[string]*Printer, len(m.state.Printers)), + } + for name, printer := range m.state.Printers { + printerCopy := *printer + s.Printers[name] = &printerCopy + } + return s +} + +func (m *Manager) Subscribe(id string) chan CUPSState { + ch := make(chan CUPSState, 64) + + wasEmpty := true + m.subscribers.Range(func(key string, ch chan CUPSState) bool { + wasEmpty = false + return false + }) + + m.subscribers.Store(id, ch) + + if wasEmpty && m.subscription != nil { + if err := m.subscription.Start(); err != nil { + log.Warnf("[CUPS] Failed to start subscription manager: %v", err) + } else { + m.eventWG.Add(1) + go m.eventHandler() + } + } + + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } + + isEmpty := true + m.subscribers.Range(func(key string, ch chan CUPSState) bool { + isEmpty = false + return false + }) + + if isEmpty && m.subscription != nil { + m.subscription.Stop() + m.eventWG.Wait() + } +} + +func (m *Manager) Close() { + close(m.stopChan) + + if m.subscription != nil { + m.subscription.Stop() + } + + m.eventWG.Wait() + m.notifierWg.Wait() + + m.subscribers.Range(func(key string, ch chan CUPSState) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) +} + +func stateChanged(old, new *CUPSState) bool { + if len(old.Printers) != len(new.Printers) { + return true + } + for name, oldPrinter := range old.Printers { + newPrinter, exists := new.Printers[name] + if !exists { + return true + } + if oldPrinter.State != newPrinter.State || + oldPrinter.StateReason != newPrinter.StateReason || + oldPrinter.Accepting != newPrinter.Accepting || + len(oldPrinter.Jobs) != len(newPrinter.Jobs) { + return true + } + } + return false +} + +func parsePrinterState(attrs ipp.Attributes) string { + if stateAttr, ok := attrs[ipp.AttributePrinterState]; ok && len(stateAttr) > 0 { + if state, ok := stateAttr[0].Value.(int); ok { + switch state { + case 3: + return "idle" + case 4: + return "processing" + case 5: + return "stopped" + default: + return fmt.Sprintf("%d", state) + } + } + } + return "unknown" +} + +func parseJobState(attrs ipp.Attributes) string { + if stateAttr, ok := attrs[ipp.AttributeJobState]; ok && len(stateAttr) > 0 { + if state, ok := stateAttr[0].Value.(int); ok { + switch state { + case 3: + return "pending" + case 4: + return "pending-held" + case 5: + return "processing" + case 6: + return "processing-stopped" + case 7: + return "canceled" + case 8: + return "aborted" + case 9: + return "completed" + default: + return fmt.Sprintf("%d", state) + } + } + } + return "unknown" +} + +func getStringAttr(attrs ipp.Attributes, key string) string { + if attr, ok := attrs[key]; ok && len(attr) > 0 { + if val, ok := attr[0].Value.(string); ok { + return val + } + return fmt.Sprintf("%v", attr[0].Value) + } + return "" +} + +func getIntAttr(attrs ipp.Attributes, key string) int { + if attr, ok := attrs[key]; ok && len(attr) > 0 { + if val, ok := attr[0].Value.(int); ok { + return val + } + } + return 0 +} + +func getBoolAttr(attrs ipp.Attributes, key string) bool { + if attr, ok := attrs[key]; ok && len(attr) > 0 { + if val, ok := attr[0].Value.(bool); ok { + return val + } + } + return false +} + +func getStringSliceAttr(attrs ipp.Attributes, key string) []string { + attr, ok := attrs[key] + if !ok { + return nil + } + + result := make([]string, 0, len(attr)) + for _, a := range attr { + if val, ok := a.Value.(string); ok { + result = append(result, val) + } + } + return result +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager_test.go new file mode 100644 index 0000000..7f09e67 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/manager_test.go @@ -0,0 +1,363 @@ +package cups + +import ( + "testing" + + mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp" + "github.com/stretchr/testify/assert" +) + +func TestNewManager(t *testing.T) { + m := &Manager{ + state: &CUPSState{ + Printers: make(map[string]*Printer), + }, + client: nil, + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + + assert.NotNil(t, m) + assert.NotNil(t, m.state) +} + +func TestManager_GetState(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + + m := &Manager{ + state: &CUPSState{ + Printers: map[string]*Printer{ + "test-printer": { + Name: "test-printer", + State: "idle", + }, + }, + }, + client: mockClient, + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + + state := m.GetState() + assert.Equal(t, 1, len(state.Printers)) + assert.Equal(t, "test-printer", state.Printers["test-printer"].Name) +} + +func TestManager_Subscribe(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + + m := &Manager{ + state: &CUPSState{ + Printers: make(map[string]*Printer), + }, + client: mockClient, + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + + ch := m.Subscribe("test-client") + assert.NotNil(t, ch) + + count := 0 + m.subscribers.Range(func(key string, ch chan CUPSState) bool { + count++ + return true + }) + assert.Equal(t, 1, count) + + m.Unsubscribe("test-client") + count = 0 + m.subscribers.Range(func(key string, ch chan CUPSState) bool { + count++ + return true + }) + assert.Equal(t, 0, count) +} + +func TestManager_Close(t *testing.T) { + mockClient := mocks_cups.NewMockCUPSClientInterface(t) + + m := &Manager{ + state: &CUPSState{ + Printers: make(map[string]*Printer), + }, + client: mockClient, + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + + m.eventWG.Add(1) + go func() { + defer m.eventWG.Done() + <-m.stopChan + }() + + m.notifierWg.Add(1) + go func() { + defer m.notifierWg.Done() + <-m.stopChan + }() + + m.Close() + count := 0 + m.subscribers.Range(func(key string, ch chan CUPSState) bool { + count++ + return true + }) + assert.Equal(t, 0, count) +} + +func TestStateChanged(t *testing.T) { + tests := []struct { + name string + oldState *CUPSState + newState *CUPSState + want bool + }{ + { + name: "no change", + oldState: &CUPSState{ + Printers: map[string]*Printer{ + "p1": {Name: "p1", State: "idle"}, + }, + }, + newState: &CUPSState{ + Printers: map[string]*Printer{ + "p1": {Name: "p1", State: "idle"}, + }, + }, + want: false, + }, + { + name: "state changed", + oldState: &CUPSState{ + Printers: map[string]*Printer{ + "p1": {Name: "p1", State: "idle"}, + }, + }, + newState: &CUPSState{ + Printers: map[string]*Printer{ + "p1": {Name: "p1", State: "processing"}, + }, + }, + want: true, + }, + { + name: "printer added", + oldState: &CUPSState{ + Printers: map[string]*Printer{}, + }, + newState: &CUPSState{ + Printers: map[string]*Printer{ + "p1": {Name: "p1", State: "idle"}, + }, + }, + want: true, + }, + { + name: "printer removed", + oldState: &CUPSState{ + Printers: map[string]*Printer{ + "p1": {Name: "p1", State: "idle"}, + }, + }, + newState: &CUPSState{ + Printers: map[string]*Printer{}, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stateChanged(tt.oldState, tt.newState) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParsePrinterState(t *testing.T) { + tests := []struct { + name string + attrs ipp.Attributes + want string + }{ + { + name: "idle", + attrs: ipp.Attributes{ + ipp.AttributePrinterState: []ipp.Attribute{{Value: 3}}, + }, + want: "idle", + }, + { + name: "processing", + attrs: ipp.Attributes{ + ipp.AttributePrinterState: []ipp.Attribute{{Value: 4}}, + }, + want: "processing", + }, + { + name: "stopped", + attrs: ipp.Attributes{ + ipp.AttributePrinterState: []ipp.Attribute{{Value: 5}}, + }, + want: "stopped", + }, + { + name: "unknown", + attrs: ipp.Attributes{}, + want: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parsePrinterState(tt.attrs) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseJobState(t *testing.T) { + tests := []struct { + name string + attrs ipp.Attributes + want string + }{ + { + name: "pending", + attrs: ipp.Attributes{ + ipp.AttributeJobState: []ipp.Attribute{{Value: 3}}, + }, + want: "pending", + }, + { + name: "processing", + attrs: ipp.Attributes{ + ipp.AttributeJobState: []ipp.Attribute{{Value: 5}}, + }, + want: "processing", + }, + { + name: "completed", + attrs: ipp.Attributes{ + ipp.AttributeJobState: []ipp.Attribute{{Value: 9}}, + }, + want: "completed", + }, + { + name: "unknown", + attrs: ipp.Attributes{}, + want: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseJobState(tt.attrs) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGetStringAttr(t *testing.T) { + tests := []struct { + name string + attrs ipp.Attributes + key string + want string + }{ + { + name: "string value", + attrs: ipp.Attributes{ + "test-key": []ipp.Attribute{{Value: "test-value"}}, + }, + key: "test-key", + want: "test-value", + }, + { + name: "missing key", + attrs: ipp.Attributes{}, + key: "missing", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getStringAttr(tt.attrs, tt.key) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGetIntAttr(t *testing.T) { + tests := []struct { + name string + attrs ipp.Attributes + key string + want int + }{ + { + name: "int value", + attrs: ipp.Attributes{ + "test-key": []ipp.Attribute{{Value: 42}}, + }, + key: "test-key", + want: 42, + }, + { + name: "missing key", + attrs: ipp.Attributes{}, + key: "missing", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getIntAttr(tt.attrs, tt.key) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGetBoolAttr(t *testing.T) { + tests := []struct { + name string + attrs ipp.Attributes + key string + want bool + }{ + { + name: "true value", + attrs: ipp.Attributes{ + "test-key": []ipp.Attribute{{Value: true}}, + }, + key: "test-key", + want: true, + }, + { + name: "false value", + attrs: ipp.Attributes{ + "test-key": []ipp.Attribute{{Value: false}}, + }, + key: "test-key", + want: false, + }, + { + name: "missing key", + attrs: ipp.Attributes{}, + key: "missing", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getBoolAttr(tt.attrs, tt.key) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper.go new file mode 100644 index 0000000..81dd458 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper.go @@ -0,0 +1,184 @@ +package cups + +import ( + "fmt" + "strings" + + "github.com/godbus/dbus/v5" +) + +const ( + pkHelperDest = "org.opensuse.CupsPkHelper.Mechanism" + pkHelperPath = "/" + pkHelperInterface = "org.opensuse.CupsPkHelper.Mechanism" +) + +type PkHelper interface { + DevicesGet(timeout, limit int, includeSchemes, excludeSchemes []string) ([]Device, error) + PrinterAdd(name, uri, ppd, info, location string) error + PrinterDelete(name string) error + PrinterSetEnabled(name string, enabled bool) error + PrinterSetAcceptJobs(name string, enabled bool, reason string) error + PrinterSetInfo(name, info string) error + PrinterSetLocation(name, location string) error + PrinterSetShared(name string, shared bool) error + ClassAddPrinter(className, printerName string) error + ClassDeletePrinter(className, printerName string) error + ClassDelete(className string) error + JobCancelPurge(jobID int, purge bool) error + JobRestart(jobID int) error + JobSetHoldUntil(jobID int, holdUntil string) error +} + +type DBusPkHelper struct { + conn *dbus.Conn + obj dbus.BusObject +} + +func NewPkHelper() (*DBusPkHelper, error) { + conn, err := dbus.SystemBus() + if err != nil { + return nil, fmt.Errorf("failed to connect to system bus: %w", err) + } + + return &DBusPkHelper{ + conn: conn, + obj: conn.Object(pkHelperDest, pkHelperPath), + }, nil +} + +func (p *DBusPkHelper) DevicesGet(timeout, limit int, includeSchemes, excludeSchemes []string) ([]Device, error) { + if includeSchemes == nil { + includeSchemes = []string{} + } + if excludeSchemes == nil { + excludeSchemes = []string{} + } + + var errStr string + var devicesMap map[string]string + + call := p.obj.Call(pkHelperInterface+".DevicesGet", 0, int32(timeout), int32(limit), includeSchemes, excludeSchemes) + if call.Err != nil { + return nil, call.Err + } + if err := call.Store(&errStr, &devicesMap); err != nil { + return nil, err + } + if errStr != "" { + return nil, fmt.Errorf("%s", errStr) + } + + return parseDevicesMap(devicesMap), nil +} + +func parseDevicesMap(devicesMap map[string]string) []Device { + devicesByIndex := make(map[string]*Device) + + for key, value := range devicesMap { + idx := strings.LastIndex(key, ":") + if idx == -1 { + continue + } + + attr := key[:idx] + index := key[idx+1:] + + dev, ok := devicesByIndex[index] + if !ok { + dev = &Device{} + devicesByIndex[index] = dev + } + + switch attr { + case "device-uri": + dev.URI = value + case "device-class": + dev.Class = value + case "device-info": + dev.Info = value + case "device-make-and-model": + dev.MakeModel = value + case "device-id": + dev.ID = value + case "device-location": + dev.Location = value + } + } + + devices := make([]Device, 0, len(devicesByIndex)) + for _, dev := range devicesByIndex { + if dev.URI != "" { + devices = append(devices, *dev) + } + } + return devices +} + +func (p *DBusPkHelper) PrinterAdd(name, uri, ppd, info, location string) error { + return p.callSimple("PrinterAdd", name, uri, ppd, info, location) +} + +func (p *DBusPkHelper) PrinterDelete(name string) error { + return p.callSimple("PrinterDelete", name) +} + +func (p *DBusPkHelper) PrinterSetEnabled(name string, enabled bool) error { + return p.callSimple("PrinterSetEnabled", name, enabled) +} + +func (p *DBusPkHelper) PrinterSetAcceptJobs(name string, enabled bool, reason string) error { + return p.callSimple("PrinterSetAcceptJobs", name, enabled, reason) +} + +func (p *DBusPkHelper) PrinterSetInfo(name, info string) error { + return p.callSimple("PrinterSetInfo", name, info) +} + +func (p *DBusPkHelper) PrinterSetLocation(name, location string) error { + return p.callSimple("PrinterSetLocation", name, location) +} + +func (p *DBusPkHelper) PrinterSetShared(name string, shared bool) error { + return p.callSimple("PrinterSetShared", name, shared) +} + +func (p *DBusPkHelper) ClassAddPrinter(className, printerName string) error { + return p.callSimple("ClassAddPrinter", className, printerName) +} + +func (p *DBusPkHelper) ClassDeletePrinter(className, printerName string) error { + return p.callSimple("ClassDeletePrinter", className, printerName) +} + +func (p *DBusPkHelper) ClassDelete(className string) error { + return p.callSimple("ClassDelete", className) +} + +func (p *DBusPkHelper) JobCancelPurge(jobID int, purge bool) error { + return p.callSimple("JobCancelPurge", int32(jobID), purge) +} + +func (p *DBusPkHelper) JobRestart(jobID int) error { + return p.callSimple("JobRestart", int32(jobID)) +} + +func (p *DBusPkHelper) JobSetHoldUntil(jobID int, holdUntil string) error { + return p.callSimple("JobSetHoldUntil", int32(jobID), holdUntil) +} + +func (p *DBusPkHelper) callSimple(method string, args ...any) error { + var errStr string + + call := p.obj.Call(pkHelperInterface+"."+method, 0, args...) + if call.Err != nil { + return call.Err + } + if err := call.Store(&errStr); err != nil { + return err + } + if errStr != "" { + return fmt.Errorf("%s", errStr) + } + return nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper_test.go new file mode 100644 index 0000000..596f1d0 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper_test.go @@ -0,0 +1,95 @@ +package cups + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseDevicesMap(t *testing.T) { + tests := []struct { + name string + input map[string]string + wantLen int + wantURIs []string + }{ + { + name: "empty", + input: map[string]string{}, + wantLen: 0, + wantURIs: nil, + }, + { + name: "single_device", + input: map[string]string{ + "device-uri:0": "usb://HP/LaserJet", + "device-class:0": "direct", + "device-info:0": "HP LaserJet", + "device-make-and-model:0": "HP LaserJet 1020", + "device-id:0": "MFG:HP;MDL:LaserJet", + }, + wantLen: 1, + wantURIs: []string{"usb://HP/LaserJet"}, + }, + { + name: "multiple_devices", + input: map[string]string{ + "device-uri:0": "usb://HP/LaserJet", + "device-class:0": "direct", + "device-info:0": "HP LaserJet", + "device-uri:1": "socket://192.168.1.100", + "device-class:1": "network", + "device-info:1": "Network Printer", + }, + wantLen: 2, + wantURIs: []string{"usb://HP/LaserJet", "socket://192.168.1.100"}, + }, + { + name: "malformed_key", + input: map[string]string{ + "no-colon-here": "value", + }, + wantLen: 0, + wantURIs: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseDevicesMap(tt.input) + assert.Len(t, got, tt.wantLen) + + if tt.wantURIs != nil { + gotURIs := make(map[string]bool) + for _, d := range got { + gotURIs[d.URI] = true + } + for _, uri := range tt.wantURIs { + assert.True(t, gotURIs[uri], "expected URI %s not found", uri) + } + } + }) + } +} + +func TestParseDevicesMap_Attributes(t *testing.T) { + input := map[string]string{ + "device-uri:0": "usb://HP/LaserJet", + "device-class:0": "direct", + "device-info:0": "HP LaserJet", + "device-make-and-model:0": "HP LaserJet 1020", + "device-id:0": "MFG:HP;MDL:LaserJet", + "device-location:0": "USB Port", + } + + got := parseDevicesMap(input) + assert.Len(t, got, 1) + + dev := got[0] + assert.Equal(t, "usb://HP/LaserJet", dev.URI) + assert.Equal(t, "direct", dev.Class) + assert.Equal(t, "HP LaserJet", dev.Info) + assert.Equal(t, "HP LaserJet 1020", dev.MakeModel) + assert.Equal(t, "MFG:HP;MDL:LaserJet", dev.ID) + assert.Equal(t, "USB Port", dev.Location) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription.go new file mode 100644 index 0000000..6b369b4 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription.go @@ -0,0 +1,245 @@ +package cups + +import ( + "fmt" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp" +) + +type SubscriptionManager struct { + client CUPSClientInterface + subscriptionID int + sequenceNumber int + eventChan chan SubscriptionEvent + stopChan chan struct{} + wg sync.WaitGroup + baseURL string + running bool + mu sync.Mutex +} + +func NewSubscriptionManager(client CUPSClientInterface, baseURL string) *SubscriptionManager { + return &SubscriptionManager{ + client: client, + eventChan: make(chan SubscriptionEvent, 100), + stopChan: make(chan struct{}), + baseURL: baseURL, + } +} + +func (sm *SubscriptionManager) Start() error { + sm.mu.Lock() + if sm.running { + sm.mu.Unlock() + return fmt.Errorf("subscription manager already running") + } + sm.running = true + sm.mu.Unlock() + + subID, err := sm.createSubscription() + if err != nil { + sm.mu.Lock() + sm.running = false + sm.mu.Unlock() + return fmt.Errorf("failed to create subscription: %w", err) + } + + sm.subscriptionID = subID + log.Infof("[CUPS] Created IPP subscription with ID %d", subID) + + sm.wg.Add(1) + go sm.notificationLoop() + + return nil +} + +func (sm *SubscriptionManager) createSubscription() (int, error) { + req := ipp.NewRequest(ipp.OperationCreatePrinterSubscriptions, 1) + req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL) + req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms" + + // Subscription attributes go in SubscriptionAttributes (subscription-attributes-tag in IPP) + req.SubscriptionAttributes = map[string]any{ + "notify-events": []string{ + "printer-state-changed", + "printer-added", + "printer-deleted", + "job-created", + "job-completed", + "job-state-changed", + }, + "notify-pull-method": "ippget", + "notify-lease-duration": 0, + } + + // Send to root IPP endpoint + resp, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil) + if err != nil { + return 0, fmt.Errorf("SendRequest failed: %w", err) + } + + // Check for IPP errors + if err := resp.CheckForErrors(); err != nil { + return 0, fmt.Errorf("IPP error: %w", err) + } + + // Subscription ID comes back in SubscriptionAttributes + if len(resp.SubscriptionAttributes) > 0 { + if idAttr, ok := resp.SubscriptionAttributes[0]["notify-subscription-id"]; ok && len(idAttr) > 0 { + if val, ok := idAttr[0].Value.(int); ok { + return val, nil + } + } + } + + return 0, fmt.Errorf("no subscription ID returned") +} + +func (sm *SubscriptionManager) notificationLoop() { + defer sm.wg.Done() + + backoff := 1 * time.Second + + for { + select { + case <-sm.stopChan: + return + default: + } + + gotAny, err := sm.fetchNotificationsWithWait() + if err != nil { + log.Warnf("[CUPS] Error fetching notifications: %v", err) + jitter := time.Duration(50+(time.Now().UnixNano()%200)) * time.Millisecond + sleepTime := backoff + jitter + if sleepTime > 30*time.Second { + sleepTime = 30 * time.Second + } + select { + case <-sm.stopChan: + return + case <-time.After(sleepTime): + } + if backoff < 30*time.Second { + backoff *= 2 + } + continue + } + + backoff = 1 * time.Second + + if gotAny { + continue + } + + select { + case <-sm.stopChan: + return + case <-time.After(2 * time.Second): + } + } +} + +func (sm *SubscriptionManager) fetchNotificationsWithWait() (bool, error) { + req := ipp.NewRequest(ipp.OperationGetNotifications, 1) + req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL) + req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms" + req.OperationAttributes["notify-subscription-ids"] = sm.subscriptionID + if sm.sequenceNumber > 0 { + req.OperationAttributes["notify-sequence-numbers"] = sm.sequenceNumber + } + + resp, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil) + if err != nil { + return false, err + } + + gotAny := false + for _, eventGroup := range resp.SubscriptionAttributes { + if seqAttr, ok := eventGroup["notify-sequence-number"]; ok && len(seqAttr) > 0 { + if seqNum, ok := seqAttr[0].Value.(int); ok { + sm.sequenceNumber = seqNum + 1 + } + } + + event := sm.parseEvent(eventGroup) + gotAny = true + select { + case sm.eventChan <- event: + case <-sm.stopChan: + return gotAny, nil + default: + log.Warn("[CUPS] Event channel full, dropping event") + } + } + + return gotAny, nil +} + +func (sm *SubscriptionManager) parseEvent(attrs ipp.Attributes) SubscriptionEvent { + event := SubscriptionEvent{ + SubscribedAt: time.Now(), + } + + if attr, ok := attrs["notify-subscribed-event"]; ok && len(attr) > 0 { + if val, ok := attr[0].Value.(string); ok { + event.EventName = val + } + } + + if attr, ok := attrs["printer-name"]; ok && len(attr) > 0 { + if val, ok := attr[0].Value.(string); ok { + event.PrinterName = val + } + } + + if attr, ok := attrs["notify-job-id"]; ok && len(attr) > 0 { + if val, ok := attr[0].Value.(int); ok { + event.JobID = val + } + } + + return event +} + +func (sm *SubscriptionManager) Events() <-chan SubscriptionEvent { + return sm.eventChan +} + +func (sm *SubscriptionManager) Stop() { + sm.mu.Lock() + if !sm.running { + sm.mu.Unlock() + return + } + sm.running = false + sm.mu.Unlock() + + close(sm.stopChan) + sm.wg.Wait() + + if sm.subscriptionID != 0 { + sm.cancelSubscription() + sm.subscriptionID = 0 + sm.sequenceNumber = 0 + } + + sm.stopChan = make(chan struct{}) +} + +func (sm *SubscriptionManager) cancelSubscription() { + req := ipp.NewRequest(ipp.OperationCancelSubscription, 1) + req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL) + req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms" + req.OperationAttributes["notify-subscription-id"] = sm.subscriptionID + + _, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil) + if err != nil { + log.Warnf("[CUPS] Failed to cancel subscription %d: %v", sm.subscriptionID, err) + } else { + log.Infof("[CUPS] Cancelled subscription %d", sm.subscriptionID) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription_dbus.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription_dbus.go new file mode 100644 index 0000000..b205a6b --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/subscription_dbus.go @@ -0,0 +1,295 @@ +package cups + +import ( + "fmt" + "strings" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp" + "github.com/godbus/dbus/v5" +) + +type DBusSubscriptionManager struct { + client CUPSClientInterface + subscriptionID int + eventChan chan SubscriptionEvent + stopChan chan struct{} + wg sync.WaitGroup + baseURL string + running bool + mu sync.Mutex + conn *dbus.Conn +} + +func NewDBusSubscriptionManager(client CUPSClientInterface, baseURL string) *DBusSubscriptionManager { + return &DBusSubscriptionManager{ + client: client, + eventChan: make(chan SubscriptionEvent, 100), + stopChan: make(chan struct{}), + baseURL: baseURL, + } +} + +func (sm *DBusSubscriptionManager) Start() error { + sm.mu.Lock() + if sm.running { + sm.mu.Unlock() + return fmt.Errorf("subscription manager already running") + } + sm.running = true + sm.mu.Unlock() + + conn, err := dbus.ConnectSystemBus() + if err != nil { + sm.mu.Lock() + sm.running = false + sm.mu.Unlock() + return fmt.Errorf("connect to system bus: %w", err) + } + sm.conn = conn + + subID, err := sm.createDBusSubscription() + if err != nil { + sm.conn.Close() + sm.mu.Lock() + sm.running = false + sm.mu.Unlock() + return fmt.Errorf("failed to create D-Bus subscription: %w", err) + } + + sm.subscriptionID = subID + log.Infof("[CUPS] Created D-Bus subscription with ID %d", subID) + + if err := sm.conn.AddMatchSignal( + dbus.WithMatchInterface("org.cups.cupsd.Notifier"), + ); err != nil { + sm.cancelSubscription() + sm.conn.Close() + sm.mu.Lock() + sm.running = false + sm.mu.Unlock() + return fmt.Errorf("failed to add D-Bus match: %w", err) + } + + sm.wg.Add(1) + go sm.dbusListenerLoop() + + return nil +} + +func (sm *DBusSubscriptionManager) createDBusSubscription() (int, error) { + req := ipp.NewRequest(ipp.OperationCreatePrinterSubscriptions, 2) + req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL) + req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms" + + req.SubscriptionAttributes = map[string]any{ + "notify-events": []string{ + "printer-state-changed", + "printer-added", + "printer-deleted", + "job-created", + "job-completed", + "job-state-changed", + }, + "notify-recipient-uri": "dbus:/", + "notify-lease-duration": 86400, + } + + resp, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil) + if err != nil { + return 0, fmt.Errorf("SendRequest failed: %w", err) + } + + if err := resp.CheckForErrors(); err != nil { + return 0, fmt.Errorf("IPP error: %w", err) + } + + if len(resp.SubscriptionAttributes) > 0 { + if idAttr, ok := resp.SubscriptionAttributes[0]["notify-subscription-id"]; ok && len(idAttr) > 0 { + if val, ok := idAttr[0].Value.(int); ok { + return val, nil + } + } + } + + return 0, fmt.Errorf("no subscription ID returned") +} + +func (sm *DBusSubscriptionManager) dbusListenerLoop() { + defer sm.wg.Done() + + signalChan := make(chan *dbus.Signal, 10) + sm.conn.Signal(signalChan) + defer sm.conn.RemoveSignal(signalChan) + + for { + select { + case <-sm.stopChan: + return + case sig := <-signalChan: + if sig == nil { + continue + } + + event := sm.parseDBusSignal(sig) + if event.EventName == "" { + continue + } + + select { + case sm.eventChan <- event: + case <-sm.stopChan: + return + default: + log.Warn("[CUPS] Event channel full, dropping event") + } + } + } +} + +func (sm *DBusSubscriptionManager) parseDBusSignal(sig *dbus.Signal) SubscriptionEvent { + event := SubscriptionEvent{} + + switch sig.Name { + case "org.cups.cupsd.Notifier.JobStateChanged": + if len(sig.Body) >= 6 { + if text, ok := sig.Body[0].(string); ok { + event.EventName = "job-state-changed" + parts := strings.Split(text, " ") + if len(parts) >= 2 { + event.PrinterName = parts[0] + } + } + if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" { + if idx := strings.LastIndex(printerURI, "/"); idx != -1 { + event.PrinterName = printerURI[idx+1:] + } + } + if jobID, ok := sig.Body[3].(uint32); ok { + event.JobID = int(jobID) + } + } + + case "org.cups.cupsd.Notifier.JobCreated": + if len(sig.Body) >= 6 { + if text, ok := sig.Body[0].(string); ok { + event.EventName = "job-created" + parts := strings.Split(text, " ") + if len(parts) >= 2 { + event.PrinterName = parts[0] + } + } + if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" { + if idx := strings.LastIndex(printerURI, "/"); idx != -1 { + event.PrinterName = printerURI[idx+1:] + } + } + if jobID, ok := sig.Body[3].(uint32); ok { + event.JobID = int(jobID) + } + } + + case "org.cups.cupsd.Notifier.JobCompleted": + if len(sig.Body) >= 6 { + if text, ok := sig.Body[0].(string); ok { + event.EventName = "job-completed" + parts := strings.Split(text, " ") + if len(parts) >= 2 { + event.PrinterName = parts[0] + } + } + if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" { + if idx := strings.LastIndex(printerURI, "/"); idx != -1 { + event.PrinterName = printerURI[idx+1:] + } + } + if jobID, ok := sig.Body[3].(uint32); ok { + event.JobID = int(jobID) + } + } + + case "org.cups.cupsd.Notifier.PrinterStateChanged": + if len(sig.Body) >= 6 { + if text, ok := sig.Body[0].(string); ok { + event.EventName = "printer-state-changed" + parts := strings.Split(text, " ") + if len(parts) >= 2 { + event.PrinterName = parts[0] + } + } + if printerURI, ok := sig.Body[1].(string); ok && event.PrinterName == "" { + if idx := strings.LastIndex(printerURI, "/"); idx != -1 { + event.PrinterName = printerURI[idx+1:] + } + } + } + + case "org.cups.cupsd.Notifier.PrinterAdded": + if len(sig.Body) >= 6 { + if text, ok := sig.Body[0].(string); ok { + event.EventName = "printer-added" + parts := strings.Split(text, " ") + if len(parts) >= 2 { + event.PrinterName = parts[0] + } + } + } + + case "org.cups.cupsd.Notifier.PrinterDeleted": + if len(sig.Body) >= 6 { + if text, ok := sig.Body[0].(string); ok { + event.EventName = "printer-deleted" + parts := strings.Split(text, " ") + if len(parts) >= 2 { + event.PrinterName = parts[0] + } + } + } + } + + return event +} + +func (sm *DBusSubscriptionManager) Events() <-chan SubscriptionEvent { + return sm.eventChan +} + +func (sm *DBusSubscriptionManager) Stop() { + sm.mu.Lock() + if !sm.running { + sm.mu.Unlock() + return + } + sm.running = false + sm.mu.Unlock() + + close(sm.stopChan) + sm.wg.Wait() + + if sm.subscriptionID != 0 { + sm.cancelSubscription() + sm.subscriptionID = 0 + } + + if sm.conn != nil { + sm.conn.Close() + sm.conn = nil + } + + sm.stopChan = make(chan struct{}) +} + +func (sm *DBusSubscriptionManager) cancelSubscription() { + req := ipp.NewRequest(ipp.OperationCancelSubscription, 1) + req.OperationAttributes[ipp.AttributePrinterURI] = fmt.Sprintf("%s/", sm.baseURL) + req.OperationAttributes[ipp.AttributeRequestingUserName] = "dms" + req.OperationAttributes["notify-subscription-id"] = sm.subscriptionID + + _, err := sm.client.SendRequest(fmt.Sprintf("%s/", sm.baseURL), req, nil) + if err != nil { + log.Warnf("[CUPS] Failed to cancel subscription %d: %v", sm.subscriptionID, err) + } else { + log.Infof("[CUPS] Cancelled subscription %d", sm.subscriptionID) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/test_connection.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/test_connection.go new file mode 100644 index 0000000..d0203e9 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/test_connection.go @@ -0,0 +1,176 @@ +package cups + +import ( + "errors" + "fmt" + "net" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp" +) + +var validProtocols = map[string]bool{ + "ipp": true, + "ipps": true, + "lpd": true, + "socket": true, +} + +func validateTestConnectionParams(host string, port int, protocol string) error { + if host == "" { + return errors.New("host is required") + } + if strings.ContainsAny(host, " \t\n\r/\\") { + return errors.New("host contains invalid characters") + } + if port < 1 || port > 65535 { + return errors.New("port must be between 1 and 65535") + } + if protocol != "" && !validProtocols[protocol] { + return errors.New("protocol must be one of: ipp, ipps, lpd, socket") + } + return nil +} + +const probeTimeout = 10 * time.Second + +func probeRemotePrinter(host string, port int, useTLS bool) (*RemotePrinterInfo, error) { + addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) + + // Fast fail: TCP reachability check + conn, err := net.DialTimeout("tcp", addr, probeTimeout) + if err != nil { + return &RemotePrinterInfo{ + Reachable: false, + Error: fmt.Sprintf("cannot reach %s: %s", addr, err.Error()), + }, nil + } + conn.Close() + + // Create a temporary IPP client pointing at the remote host. + // The TCP dial above provides fast-fail for unreachable hosts. + // The IPP adapter's ResponseHeaderTimeout (90s) bounds stalling servers. + client := ipp.NewIPPClient(host, port, "", "", useTLS) + + // Try /ipp/print first (modern driverless printers), then / (legacy) + info, err := probeIPPEndpoint(client, host, port, useTLS, "/ipp/print") + if err != nil { + // If we got an auth error, the printer exists but requires credentials. + // Report it as reachable with the URI that triggered the auth challenge. + if isAuthError(err) { + proto := "ipp" + if useTLS { + proto = "ipps" + } + return &RemotePrinterInfo{ + Reachable: true, + URI: fmt.Sprintf("%s://%s:%d/ipp/print", proto, host, port), + Info: "authentication required", + }, nil + } + info, err = probeIPPEndpoint(client, host, port, useTLS, "/") + } + if err != nil { + if isAuthError(err) { + proto := "ipp" + if useTLS { + proto = "ipps" + } + return &RemotePrinterInfo{ + Reachable: true, + URI: fmt.Sprintf("%s://%s:%d/", proto, host, port), + Info: "authentication required", + }, nil + } + // TCP reachable but not an IPP printer + return &RemotePrinterInfo{ + Reachable: true, + Error: fmt.Sprintf("host is reachable but does not appear to be an IPP printer: %s", err.Error()), + }, nil + } + + return info, nil +} + +func probeIPPEndpoint(client *ipp.IPPClient, host string, port int, useTLS bool, resourcePath string) (*RemotePrinterInfo, error) { + proto := "ipp" + if useTLS { + proto = "ipps" + } + printerURI := fmt.Sprintf("%s://%s:%d%s", proto, host, port, resourcePath) + + httpProto := "http" + if useTLS { + httpProto = "https" + } + httpURL := fmt.Sprintf("%s://%s:%d%s", httpProto, host, port, resourcePath) + + req := ipp.NewRequest(ipp.OperationGetPrinterAttributes, 1) + req.OperationAttributes[ipp.AttributePrinterURI] = printerURI + req.OperationAttributes[ipp.AttributeRequestedAttributes] = []string{ + ipp.AttributePrinterName, + ipp.AttributePrinterMakeAndModel, + ipp.AttributePrinterState, + ipp.AttributePrinterInfo, + ipp.AttributePrinterUriSupported, + } + + resp, err := client.SendRequest(httpURL, req, nil) + if err != nil { + return nil, err + } + + if len(resp.PrinterAttributes) == 0 { + return nil, errors.New("no printer attributes returned") + } + + attrs := resp.PrinterAttributes[0] + + return &RemotePrinterInfo{ + Reachable: true, + MakeModel: getStringAttr(attrs, ipp.AttributePrinterMakeAndModel), + Name: getStringAttr(attrs, ipp.AttributePrinterName), + Info: getStringAttr(attrs, ipp.AttributePrinterInfo), + State: parsePrinterState(attrs), + URI: printerURI, + }, nil +} + +// TestRemotePrinter validates inputs and probes a remote printer via IPP. +// For lpd/socket protocols, only TCP reachability is tested. +func (m *Manager) TestRemotePrinter(host string, port int, protocol string) (*RemotePrinterInfo, error) { + if protocol == "" { + protocol = "ipp" + } + + if err := validateTestConnectionParams(host, port, protocol); err != nil { + return nil, err + } + + // For non-IPP protocols, only check TCP reachability + if protocol == "lpd" || protocol == "socket" { + addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) + conn, err := net.DialTimeout("tcp", addr, probeTimeout) + if err != nil { + return &RemotePrinterInfo{ + Reachable: false, + Error: fmt.Sprintf("cannot reach %s: %s", addr, err.Error()), + }, nil + } + conn.Close() + return &RemotePrinterInfo{ + Reachable: true, + URI: fmt.Sprintf("%s://%s:%d", protocol, host, port), + }, nil + } + + useTLS := protocol == "ipps" + + probeFn := m.probeRemoteFn + if probeFn == nil { + probeFn = probeRemotePrinter + } + + return probeFn(host, port, useTLS) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/test_connection_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/test_connection_test.go new file mode 100644 index 0000000..b835028 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/test_connection_test.go @@ -0,0 +1,397 @@ +package cups + +import ( + "bytes" + "encoding/json" + "fmt" + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp" + "github.com/stretchr/testify/assert" +) + +func TestValidateTestConnectionParams(t *testing.T) { + tests := []struct { + name string + host string + port int + protocol string + wantErr string + }{ + { + name: "valid ipp", + host: "192.168.0.5", + port: 631, + protocol: "ipp", + wantErr: "", + }, + { + name: "valid ipps", + host: "printer.local", + port: 443, + protocol: "ipps", + wantErr: "", + }, + { + name: "valid lpd", + host: "10.0.0.1", + port: 515, + protocol: "lpd", + wantErr: "", + }, + { + name: "valid socket", + host: "10.0.0.1", + port: 9100, + protocol: "socket", + wantErr: "", + }, + { + name: "empty host", + host: "", + port: 631, + protocol: "ipp", + wantErr: "host is required", + }, + { + name: "port too low", + host: "192.168.0.5", + port: 0, + protocol: "ipp", + wantErr: "port must be between 1 and 65535", + }, + { + name: "port too high", + host: "192.168.0.5", + port: 70000, + protocol: "ipp", + wantErr: "port must be between 1 and 65535", + }, + { + name: "invalid protocol", + host: "192.168.0.5", + port: 631, + protocol: "ftp", + wantErr: "protocol must be one of: ipp, ipps, lpd, socket", + }, + { + name: "empty protocol treated as ipp", + host: "192.168.0.5", + port: 631, + protocol: "", + wantErr: "", + }, + { + name: "host with slash", + host: "192.168.0.5/admin", + port: 631, + protocol: "ipp", + wantErr: "host contains invalid characters", + }, + { + name: "host with space", + host: "192.168.0.5 ", + port: 631, + protocol: "ipp", + wantErr: "host contains invalid characters", + }, + { + name: "host with newline", + host: "192.168.0.5\n", + port: 631, + protocol: "ipp", + wantErr: "host contains invalid characters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateTestConnectionParams(tt.host, tt.port, tt.protocol) + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tt.wantErr) + } + }) + } +} + +func TestManager_TestRemotePrinter_Validation(t *testing.T) { + m := NewTestManager(nil, nil) + + tests := []struct { + name string + host string + port int + protocol string + wantErr string + }{ + { + name: "empty host returns error", + host: "", + port: 631, + protocol: "ipp", + wantErr: "host is required", + }, + { + name: "invalid port returns error", + host: "192.168.0.5", + port: 0, + protocol: "ipp", + wantErr: "port must be between 1 and 65535", + }, + { + name: "invalid protocol returns error", + host: "192.168.0.5", + port: 631, + protocol: "ftp", + wantErr: "protocol must be one of: ipp, ipps, lpd, socket", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := m.TestRemotePrinter(tt.host, tt.port, tt.protocol) + assert.EqualError(t, err, tt.wantErr) + }) + } +} + +func TestManager_TestRemotePrinter_IPP(t *testing.T) { + tests := []struct { + name string + protocol string + probeRet *RemotePrinterInfo + probeErr error + wantTLS bool + wantReach bool + wantModel string + }{ + { + name: "successful ipp probe", + protocol: "ipp", + probeRet: &RemotePrinterInfo{ + Reachable: true, + MakeModel: "HP OfficeJet 8010", + Name: "OfficeJet", + State: "idle", + URI: "ipp://192.168.0.5:631/ipp/print", + }, + wantTLS: false, + wantReach: true, + wantModel: "HP OfficeJet 8010", + }, + { + name: "successful ipps probe", + protocol: "ipps", + probeRet: &RemotePrinterInfo{ + Reachable: true, + MakeModel: "HP OfficeJet 8010", + URI: "ipps://192.168.0.5:631/ipp/print", + }, + wantTLS: true, + wantReach: true, + wantModel: "HP OfficeJet 8010", + }, + { + name: "unreachable host", + protocol: "ipp", + probeRet: &RemotePrinterInfo{ + Reachable: false, + Error: "cannot reach 192.168.0.5:631: connection refused", + }, + wantReach: false, + }, + { + name: "empty protocol defaults to ipp", + protocol: "", + probeRet: &RemotePrinterInfo{ + Reachable: true, + MakeModel: "Test Printer", + }, + wantTLS: false, + wantReach: true, + wantModel: "Test Printer", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedTLS bool + m := NewTestManager(nil, nil) + m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) { + capturedTLS = useTLS + return tt.probeRet, tt.probeErr + } + + result, err := m.TestRemotePrinter("192.168.0.5", 631, tt.protocol) + if tt.probeErr != nil { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantReach, result.Reachable) + assert.Equal(t, tt.wantModel, result.MakeModel) + assert.Equal(t, tt.wantTLS, capturedTLS) + }) + } +} + +func TestManager_TestRemotePrinter_AuthRequired(t *testing.T) { + m := NewTestManager(nil, nil) + m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) { + // Simulate what happens when the printer returns HTTP 401 + return probeRemotePrinterWithAuthError(host, port, useTLS) + } + + result, err := m.TestRemotePrinter("192.168.0.107", 631, "ipp") + assert.NoError(t, err) + assert.True(t, result.Reachable) + assert.Equal(t, "authentication required", result.Info) + assert.Contains(t, result.URI, "ipp://192.168.0.107:631") +} + +// probeRemotePrinterWithAuthError simulates a probe where the printer +// returns HTTP 401 on both endpoints. +func probeRemotePrinterWithAuthError(host string, port int, useTLS bool) (*RemotePrinterInfo, error) { + // This simulates what probeRemotePrinter does when both endpoints + // return auth errors. We test the auth detection logic directly. + err := ipp.HTTPError{Code: 401} + if isAuthError(err) { + proto := "ipp" + if useTLS { + proto = "ipps" + } + return &RemotePrinterInfo{ + Reachable: true, + URI: fmt.Sprintf("%s://%s:%d/ipp/print", proto, host, port), + Info: "authentication required", + }, nil + } + return nil, err +} + +func TestManager_TestRemotePrinter_NonIPPProtocol(t *testing.T) { + m := NewTestManager(nil, nil) + probeCalled := false + m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) { + probeCalled = true + return nil, nil + } + + // These will fail at TCP dial (no real server), but the important + // thing is that probeRemoteFn is NOT called for lpd/socket. + m.TestRemotePrinter("192.168.0.5", 9100, "socket") + assert.False(t, probeCalled, "probe function should not be called for socket protocol") + + m.TestRemotePrinter("192.168.0.5", 515, "lpd") + assert.False(t, probeCalled, "probe function should not be called for lpd protocol") +} + +func TestHandleTestConnection_Success(t *testing.T) { + m := NewTestManager(nil, nil) + m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) { + return &RemotePrinterInfo{ + Reachable: true, + MakeModel: "HP OfficeJet 8010", + Name: "OfficeJet", + State: "idle", + URI: "ipp://192.168.0.5:631/ipp/print", + }, nil + } + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.testConnection", + Params: map[string]any{ + "host": "192.168.0.5", + }, + } + + handleTestConnection(conn, req, m) + + var resp models.Response[RemotePrinterInfo] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Reachable) + assert.Equal(t, "HP OfficeJet 8010", resp.Result.MakeModel) +} + +func TestHandleTestConnection_MissingHost(t *testing.T) { + m := NewTestManager(nil, nil) + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.testConnection", + Params: map[string]any{}, + } + + handleTestConnection(conn, req, m) + + var resp models.Response[any] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.Nil(t, resp.Result) + assert.NotNil(t, resp.Error) +} + +func TestHandleTestConnection_CustomPortAndProtocol(t *testing.T) { + m := NewTestManager(nil, nil) + m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) { + assert.Equal(t, 9631, port) + assert.True(t, useTLS) + return &RemotePrinterInfo{Reachable: true, URI: "ipps://192.168.0.5:9631/ipp/print"}, nil + } + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.testConnection", + Params: map[string]any{ + "host": "192.168.0.5", + "port": float64(9631), + "protocol": "ipps", + }, + } + + handleTestConnection(conn, req, m) + + var resp models.Response[RemotePrinterInfo] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Reachable) +} + +func TestHandleRequest_TestConnection(t *testing.T) { + m := NewTestManager(nil, nil) + m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) { + return &RemotePrinterInfo{Reachable: true}, nil + } + + buf := &bytes.Buffer{} + conn := &mockConn{Buffer: buf} + + req := models.Request{ + ID: 1, + Method: "cups.testConnection", + Params: map[string]any{"host": "192.168.0.5"}, + } + + HandleRequest(conn, req, m) + + var resp models.Response[RemotePrinterInfo] + err := json.NewDecoder(buf).Decode(&resp) + assert.NoError(t, err) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Reachable) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/testing.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/testing.go new file mode 100644 index 0000000..4820e34 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/testing.go @@ -0,0 +1,13 @@ +package cups + +func NewTestManager(client CUPSClientInterface, pkHelper PkHelper) *Manager { + return &Manager{ + client: client, + pkHelper: pkHelper, + state: &CUPSState{ + Printers: make(map[string]*Printer), + }, + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/types.go new file mode 100644 index 0000000..52e9d2c --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/cups/types.go @@ -0,0 +1,132 @@ +package cups + +import ( + "io" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type CUPSState struct { + Printers map[string]*Printer `json:"printers"` +} + +type Printer struct { + Name string `json:"name"` + URI string `json:"uri"` + State string `json:"state"` + StateReason string `json:"stateReason"` + Location string `json:"location"` + Info string `json:"info"` + MakeModel string `json:"makeModel"` + Accepting bool `json:"accepting"` + Jobs []Job `json:"jobs"` +} + +type Job struct { + ID int `json:"id"` + Name string `json:"name"` + State string `json:"state"` + Printer string `json:"printer"` + User string `json:"user"` + Size int `json:"size"` + TimeCreated time.Time `json:"timeCreated"` +} + +type Device struct { + URI string `json:"uri"` + Class string `json:"class"` + Info string `json:"info"` + MakeModel string `json:"makeModel"` + ID string `json:"id"` + Location string `json:"location"` + IP string `json:"ip,omitempty"` +} + +type PPD struct { + Name string `json:"name"` + NaturalLanguage string `json:"naturalLanguage"` + MakeModel string `json:"makeModel"` + DeviceID string `json:"deviceId"` + Product string `json:"product"` + PSVersion string `json:"psVersion"` + Type string `json:"type"` +} + +type RemotePrinterInfo struct { + Reachable bool `json:"reachable"` + MakeModel string `json:"makeModel"` + Name string `json:"name"` + Info string `json:"info"` + State string `json:"state"` + URI string `json:"uri"` + Error string `json:"error,omitempty"` +} + +type PrinterClass struct { + Name string `json:"name"` + URI string `json:"uri"` + State string `json:"state"` + Members []string `json:"members"` + Location string `json:"location"` + Info string `json:"info"` +} + +type Manager struct { + state *CUPSState + client CUPSClientInterface + pkHelper PkHelper + subscription SubscriptionManagerInterface + stateMutex sync.RWMutex + subscribers syncmap.Map[string, chan CUPSState] + stopChan chan struct{} + eventWG sync.WaitGroup + dirty chan struct{} + notifierWg sync.WaitGroup + lastNotifiedState *CUPSState + baseURL string + probeRemoteFn func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) +} + +type SubscriptionManagerInterface interface { + Start() error + Stop() + Events() <-chan SubscriptionEvent +} + +type CUPSClientInterface interface { + GetPrinters(attributes []string) (map[string]ipp.Attributes, error) + GetJobs(printer, class string, whichJobs string, myJobs bool, firstJobId, limit int, attributes []string) (map[int]ipp.Attributes, error) + CancelJob(jobID int, purge bool) error + PausePrinter(printer string) error + ResumePrinter(printer string) error + CancelAllJob(printer string, purge bool) error + SendRequest(url string, req *ipp.Request, additionalResponseData io.Writer) (*ipp.Response, error) + + GetDevices() (map[string]ipp.Attributes, error) + GetPPDs() (map[string]ipp.Attributes, error) + GetClasses(attributes []string) (map[string]ipp.Attributes, error) + CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy, information, location string) error + DeletePrinter(printer string) error + AcceptJobs(printer string) error + RejectJobs(printer string) error + SetPrinterIsShared(printer string, shared bool) error + SetPrinterLocation(printer, location string) error + SetPrinterInformation(printer, information string) error + MoveJob(jobID int, destPrinter string) error + PrintTestPage(printer string, testPageData io.Reader, size int) (int, error) + AddPrinterToClass(class, printer string) error + DeletePrinterFromClass(class, printer string) error + DeleteClass(class string) error + RestartJob(jobID int) error + HoldJobUntil(jobID int, holdUntil string) error +} + +type SubscriptionEvent struct { + EventName string + PrinterName string + JobID int + SubscribedAt time.Time +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dbus/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dbus/handlers.go new file mode 100644 index 0000000..e625d79 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dbus/handlers.go @@ -0,0 +1,237 @@ +package dbus + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +type objectParams struct { + bus string + dest string + path string + iface string +} + +func extractObjectParams(p map[string]any, requirePath bool) (objectParams, error) { + bus, err := params.String(p, "bus") + if err != nil { + return objectParams{}, err + } + dest, err := params.String(p, "dest") + if err != nil { + return objectParams{}, err + } + + var path string + if requirePath { + path, err = params.String(p, "path") + if err != nil { + return objectParams{}, err + } + } else { + path = params.StringOpt(p, "path", "/") + } + + iface, err := params.String(p, "interface") + if err != nil { + return objectParams{}, err + } + + return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil +} + +func HandleRequest(conn net.Conn, req models.Request, m *Manager, clientID string) { + switch req.Method { + case "dbus.call": + handleCall(conn, req, m) + case "dbus.getProperty": + handleGetProperty(conn, req, m) + case "dbus.setProperty": + handleSetProperty(conn, req, m) + case "dbus.getAllProperties": + handleGetAllProperties(conn, req, m) + case "dbus.introspect": + handleIntrospect(conn, req, m) + case "dbus.listNames": + handleListNames(conn, req, m) + case "dbus.subscribe": + handleSubscribe(conn, req, m, clientID) + case "dbus.unsubscribe": + handleUnsubscribe(conn, req, m) + default: + models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) + } +} + +func handleCall(conn net.Conn, req models.Request, m *Manager) { + op, err := extractObjectParams(req.Params, true) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + method, err := params.String(req.Params, "method") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + var args []any + if argsRaw, ok := params.Any(req.Params, "args"); ok { + if argsSlice, ok := argsRaw.([]any); ok { + args = argsSlice + } + } + + result, err := m.Call(op.bus, op.dest, op.path, op.iface, method, args) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, result) +} + +func handleGetProperty(conn net.Conn, req models.Request, m *Manager) { + op, err := extractObjectParams(req.Params, true) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + property, err := params.String(req.Params, "property") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + result, err := m.GetProperty(op.bus, op.dest, op.path, op.iface, property) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, result) +} + +func handleSetProperty(conn net.Conn, req models.Request, m *Manager) { + op, err := extractObjectParams(req.Params, true) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + property, err := params.String(req.Params, "property") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + value, ok := params.Any(req.Params, "value") + if !ok { + models.RespondError(conn, req.ID, "missing 'value' parameter") + return + } + + if err := m.SetProperty(op.bus, op.dest, op.path, op.iface, property, value); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true}) +} + +func handleGetAllProperties(conn net.Conn, req models.Request, m *Manager) { + op, err := extractObjectParams(req.Params, true) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + result, err := m.GetAllProperties(op.bus, op.dest, op.path, op.iface) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, result) +} + +func handleIntrospect(conn net.Conn, req models.Request, m *Manager) { + bus, err := params.String(req.Params, "bus") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + dest, err := params.String(req.Params, "dest") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + path := params.StringOpt(req.Params, "path", "/") + + result, err := m.Introspect(bus, dest, path) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, result) +} + +func handleListNames(conn net.Conn, req models.Request, m *Manager) { + bus, err := params.String(req.Params, "bus") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + result, err := m.ListNames(bus) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, result) +} + +func handleSubscribe(conn net.Conn, req models.Request, m *Manager, clientID string) { + bus, err := params.String(req.Params, "bus") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + sender := params.StringOpt(req.Params, "sender", "") + path := params.StringOpt(req.Params, "path", "") + iface := params.StringOpt(req.Params, "interface", "") + member := params.StringOpt(req.Params, "member", "") + + result, err := m.Subscribe(clientID, bus, sender, path, iface, member) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, result) +} + +func handleUnsubscribe(conn net.Conn, req models.Request, m *Manager) { + subID, err := params.String(req.Params, "subscriptionId") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := m.Unsubscribe(subID); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true}) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dbus/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dbus/manager.go new file mode 100644 index 0000000..86da6bd --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dbus/manager.go @@ -0,0 +1,392 @@ +package dbus + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" + "github.com/godbus/dbus/v5" +) + +func NewManager() (*Manager, error) { + systemConn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, fmt.Errorf("failed to connect to system bus: %w", err) + } + + sessionConn, err := dbus.ConnectSessionBus() + if err != nil { + systemConn.Close() + return nil, fmt.Errorf("failed to connect to session bus: %w", err) + } + + m := &Manager{ + systemConn: systemConn, + sessionConn: sessionConn, + } + + go m.processSystemSignals() + go m.processSessionSignals() + + return m, nil +} + +func (m *Manager) getConn(bus string) (*dbus.Conn, error) { + switch bus { + case "system": + if m.systemConn == nil { + return nil, fmt.Errorf("system bus not connected") + } + return m.systemConn, nil + case "session": + if m.sessionConn == nil { + return nil, fmt.Errorf("session bus not connected") + } + return m.sessionConn, nil + default: + return nil, fmt.Errorf("invalid bus: %s (must be 'system' or 'session')", bus) + } +} + +func (m *Manager) Call(bus, dest, path, iface, method string, args []any) (*CallResult, error) { + conn, err := m.getConn(bus) + if err != nil { + return nil, err + } + + obj := conn.Object(dest, dbus.ObjectPath(path)) + fullMethod := iface + "." + method + + convertedArgs := convertArgs(args) + call := obj.Call(fullMethod, 0, convertedArgs...) + if call.Err != nil { + return nil, fmt.Errorf("dbus call failed: %w", call.Err) + } + + return &CallResult{Values: dbusutil.NormalizeSlice(call.Body)}, nil +} + +func convertArgs(args []any) []any { + result := make([]any, len(args)) + for i, arg := range args { + result[i] = convertArg(arg) + } + return result +} + +func convertArg(arg any) any { + switch v := arg.(type) { + case float64: + if v == float64(uint32(v)) && v >= 0 && v <= float64(^uint32(0)) { + return uint32(v) + } + if v == float64(int32(v)) { + return int32(v) + } + return v + case []any: + return convertArgs(v) + case map[string]any: + result := make(map[string]any) + for k, val := range v { + result[k] = convertArg(val) + } + return result + default: + return arg + } +} + +func (m *Manager) GetProperty(bus, dest, path, iface, property string) (*PropertyResult, error) { + conn, err := m.getConn(bus) + if err != nil { + return nil, err + } + + obj := conn.Object(dest, dbus.ObjectPath(path)) + + var variant dbus.Variant + err = obj.Call("org.freedesktop.DBus.Properties.Get", 0, iface, property).Store(&variant) + if err != nil { + return nil, fmt.Errorf("failed to get property: %w", err) + } + + return &PropertyResult{Value: dbusutil.Normalize(variant.Value())}, nil +} + +func (m *Manager) SetProperty(bus, dest, path, iface, property string, value any) error { + conn, err := m.getConn(bus) + if err != nil { + return err + } + + obj := conn.Object(dest, dbus.ObjectPath(path)) + + call := obj.Call("org.freedesktop.DBus.Properties.Set", 0, iface, property, dbus.MakeVariant(value)) + if call.Err != nil { + return fmt.Errorf("failed to set property: %w", call.Err) + } + + return nil +} + +func (m *Manager) GetAllProperties(bus, dest, path, iface string) (map[string]any, error) { + conn, err := m.getConn(bus) + if err != nil { + return nil, err + } + + obj := conn.Object(dest, dbus.ObjectPath(path)) + + var props map[string]dbus.Variant + err = obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, iface).Store(&props) + if err != nil { + return nil, fmt.Errorf("failed to get properties: %w", err) + } + + result := make(map[string]any) + for k, v := range props { + result[k] = dbusutil.Normalize(v.Value()) + } + + return result, nil +} + +func (m *Manager) Introspect(bus, dest, path string) (*IntrospectResult, error) { + conn, err := m.getConn(bus) + if err != nil { + return nil, err + } + + obj := conn.Object(dest, dbus.ObjectPath(path)) + + var xml string + err = obj.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml) + if err != nil { + return nil, fmt.Errorf("failed to introspect: %w", err) + } + + return &IntrospectResult{XML: xml}, nil +} + +func (m *Manager) ListNames(bus string) (*ListNamesResult, error) { + conn, err := m.getConn(bus) + if err != nil { + return nil, err + } + + var names []string + err = conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names) + if err != nil { + return nil, fmt.Errorf("failed to list names: %w", err) + } + + return &ListNamesResult{Names: names}, nil +} + +func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) { + conn, err := m.getConn(bus) + if err != nil { + return nil, err + } + + subID := generateSubscriptionID() + + parts := []string{"type='signal'"} + if sender != "" { + parts = append(parts, fmt.Sprintf("sender='%s'", sender)) + } + if path != "" { + parts = append(parts, fmt.Sprintf("path='%s'", path)) + } + if iface != "" { + parts = append(parts, fmt.Sprintf("interface='%s'", iface)) + } + if member != "" { + parts = append(parts, fmt.Sprintf("member='%s'", member)) + } + matchRule := strings.Join(parts, ",") + + call := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule) + if call.Err != nil { + return nil, fmt.Errorf("failed to add match rule: %w", call.Err) + } + + sub := &signalSubscription{ + Bus: bus, + Sender: sender, + Path: path, + Interface: iface, + Member: member, + ClientID: clientID, + } + m.subscriptions.Store(subID, sub) + + log.Debugf("dbus: subscribed %s to %s", subID, matchRule) + + return &SubscribeResult{SubscriptionID: subID}, nil +} + +func (m *Manager) Unsubscribe(subID string) error { + sub, ok := m.subscriptions.LoadAndDelete(subID) + if !ok { + return fmt.Errorf("subscription not found: %s", subID) + } + + conn, err := m.getConn(sub.Bus) + if err != nil { + return err + } + + parts := []string{"type='signal'"} + if sub.Sender != "" { + parts = append(parts, fmt.Sprintf("sender='%s'", sub.Sender)) + } + if sub.Path != "" { + parts = append(parts, fmt.Sprintf("path='%s'", sub.Path)) + } + if sub.Interface != "" { + parts = append(parts, fmt.Sprintf("interface='%s'", sub.Interface)) + } + if sub.Member != "" { + parts = append(parts, fmt.Sprintf("member='%s'", sub.Member)) + } + matchRule := strings.Join(parts, ",") + + call := conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule) + if call.Err != nil { + log.Warnf("dbus: failed to remove match rule: %v", call.Err) + } + + log.Debugf("dbus: unsubscribed %s", subID) + + return nil +} + +func (m *Manager) UnsubscribeClient(clientID string) { + var toDelete []string + m.subscriptions.Range(func(subID string, sub *signalSubscription) bool { + if sub.ClientID == clientID { + toDelete = append(toDelete, subID) + } + return true + }) + + for _, subID := range toDelete { + _ = m.Unsubscribe(subID) + } +} + +func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent { + ch := make(chan SignalEvent, 64) + existing, loaded := m.signalSubscribers.LoadOrStore(clientID, ch) + if loaded { + return existing + } + return ch +} + +func (m *Manager) UnsubscribeSignals(clientID string) { + if ch, ok := m.signalSubscribers.LoadAndDelete(clientID); ok { + close(ch) + } + m.UnsubscribeClient(clientID) +} + +func (m *Manager) processSystemSignals() { + if m.systemConn == nil { + return + } + ch := make(chan *dbus.Signal, 256) + m.systemConn.Signal(ch) + + for sig := range ch { + m.dispatchSignal("system", sig) + } +} + +func (m *Manager) processSessionSignals() { + if m.sessionConn == nil { + return + } + ch := make(chan *dbus.Signal, 256) + m.sessionConn.Signal(ch) + + for sig := range ch { + m.dispatchSignal("session", sig) + } +} + +func (m *Manager) dispatchSignal(bus string, sig *dbus.Signal) { + path := string(sig.Path) + iface := "" + member := sig.Name + + if idx := strings.LastIndex(sig.Name, "."); idx != -1 { + iface = sig.Name[:idx] + member = sig.Name[idx+1:] + } + + m.subscriptions.Range(func(subID string, sub *signalSubscription) bool { + if sub.Bus != bus { + return true + } + if sub.Path != "" && sub.Path != path && !strings.HasPrefix(path, sub.Path) { + return true + } + if sub.Interface != "" && sub.Interface != iface { + return true + } + if sub.Member != "" && sub.Member != member { + return true + } + + event := SignalEvent{ + SubscriptionID: subID, + Sender: sig.Sender, + Path: path, + Interface: iface, + Member: member, + Body: dbusutil.NormalizeSlice(sig.Body), + } + + ch, ok := m.signalSubscribers.Load(sub.ClientID) + if !ok { + return true + } + + select { + case ch <- event: + default: + log.Warnf("dbus: channel full for %s, dropping signal", subID) + } + + return true + }) +} + +func (m *Manager) Close() { + m.signalSubscribers.Range(func(clientID string, ch chan SignalEvent) bool { + close(ch) + m.signalSubscribers.Delete(clientID) + return true + }) + + if m.systemConn != nil { + m.systemConn.Close() + } + if m.sessionConn != nil { + m.sessionConn.Close() + } +} + +func generateSubscriptionID() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + log.Warnf("dbus: failed to generate random subscription ID: %v", err) + } + return hex.EncodeToString(b) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dbus/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dbus/types.go new file mode 100644 index 0000000..55c45bf --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dbus/types.go @@ -0,0 +1,52 @@ +package dbus + +import ( + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" + "github.com/godbus/dbus/v5" +) + +type Manager struct { + systemConn *dbus.Conn + sessionConn *dbus.Conn + + subscriptions syncmap.Map[string, *signalSubscription] + signalSubscribers syncmap.Map[string, chan SignalEvent] +} + +type signalSubscription struct { + Bus string + Sender string + Path string + Interface string + Member string + ClientID string +} + +type SignalEvent struct { + SubscriptionID string `json:"subscriptionId"` + Sender string `json:"sender"` + Path string `json:"path"` + Interface string `json:"interface"` + Member string `json:"member"` + Body []any `json:"body"` +} + +type CallResult struct { + Values []any `json:"values"` +} + +type PropertyResult struct { + Value any `json:"value"` +} + +type IntrospectResult struct { + XML string `json:"xml"` +} + +type ListNamesResult struct { + Names []string `json:"names"` +} + +type SubscribeResult struct { + SubscriptionID string `json:"subscriptionId"` +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dwl/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dwl/handlers.go new file mode 100644 index 0000000..e83cc84 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dwl/handlers.go @@ -0,0 +1,138 @@ +package dwl + +import ( + "encoding/json" + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +type SuccessResult struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + if manager == nil { + models.RespondError(conn, req.ID, "dwl manager not initialized") + return + } + + switch req.Method { + case "dwl.getState": + handleGetState(conn, req, manager) + case "dwl.setTags": + handleSetTags(conn, req, manager) + case "dwl.setClientTags": + handleSetClientTags(conn, req, manager) + case "dwl.setLayout": + handleSetLayout(conn, req, manager) + case "dwl.subscribe": + handleSubscribe(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) { + state := manager.GetState() + models.Respond(conn, req.ID, state) +} + +func handleSetTags(conn net.Conn, req models.Request, manager *Manager) { + output, ok := models.Get[string](req, "output") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'output' parameter") + return + } + + tagmask, ok := models.Get[float64](req, "tagmask") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'tagmask' parameter") + return + } + + toggleTagset, ok := models.Get[float64](req, "toggleTagset") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'toggleTagset' parameter") + return + } + + if err := manager.SetTags(output, uint32(tagmask), uint32(toggleTagset)); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "tags set"}) +} + +func handleSetClientTags(conn net.Conn, req models.Request, manager *Manager) { + output, ok := models.Get[string](req, "output") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'output' parameter") + return + } + + andTags, ok := models.Get[float64](req, "andTags") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'andTags' parameter") + return + } + + xorTags, ok := models.Get[float64](req, "xorTags") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'xorTags' parameter") + return + } + + if err := manager.SetClientTags(output, uint32(andTags), uint32(xorTags)); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "client tags set"}) +} + +func handleSetLayout(conn net.Conn, req models.Request, manager *Manager) { + output, ok := models.Get[string](req, "output") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'output' parameter") + return + } + + index, ok := models.Get[float64](req, "index") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'index' parameter") + return + } + + if err := manager.SetLayout(output, uint32(index)); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "layout set"}) +} + +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() + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + ID: req.ID, + Result: &initialState, + }); err != nil { + return + } + + for state := range stateChan { + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + Result: &state, + }); err != nil { + return + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dwl/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dwl/manager.go new file mode 100644 index 0000000..0d21afa --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dwl/manager.go @@ -0,0 +1,522 @@ +package dwl + +import ( + "fmt" + "time" + + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/dwl_ipc" +) + +func NewManager(display wlclient.WaylandDisplay) (*Manager, error) { + m := &Manager{ + display: display, + ctx: display.Context(), + cmdq: make(chan cmd, 128), + outputSetupReq: make(chan uint32, 16), + stopChan: make(chan struct{}), + + dirty: make(chan struct{}, 1), + layouts: make([]string, 0), + } + + if err := m.setupRegistry(); err != nil { + return nil, err + } + + m.updateState() + + m.notifierWg.Add(1) + go m.notifier() + + m.wg.Add(1) + go m.waylandActor() + + return m, nil +} + +func (m *Manager) post(fn func()) { + select { + case m.cmdq <- cmd{fn: fn}: + default: + log.Warn("DWL actor command queue full, dropping command") + } +} + +func (m *Manager) waylandActor() { + defer m.wg.Done() + + for { + select { + case <-m.stopChan: + return + case c := <-m.cmdq: + c.fn() + case outputID := <-m.outputSetupReq: + out, exists := m.outputs.Load(outputID) + if !exists { + log.Warnf("DWL: Output %d no longer exists, skipping setup", outputID) + continue + } + + if out.ipcOutput != nil { + continue + } + + mgr, ok := m.manager.(*dwl_ipc.ZdwlIpcManagerV2) + if !ok || mgr == nil { + log.Errorf("DWL: Manager not available for output %d setup", outputID) + continue + } + + log.Infof("DWL: Setting up ipcOutput for dynamically added output %d", outputID) + if err := m.setupOutput(mgr, out.output); err != nil { + log.Errorf("DWL: Failed to setup output %d: %v", outputID, err) + } else { + m.updateState() + } + } + } +} + +func (m *Manager) setupRegistry() error { + log.Info("DWL: starting registry setup") + + registry, err := m.display.GetRegistry() + if err != nil { + return fmt.Errorf("failed to get registry: %w", err) + } + m.registry = registry + + outputs := make([]*wlclient.Output, 0) + outputRegNames := make(map[uint32]uint32) + var dwlMgr *dwl_ipc.ZdwlIpcManagerV2 + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case dwl_ipc.ZdwlIpcManagerV2InterfaceName: + log.Infof("DWL: found %s", dwl_ipc.ZdwlIpcManagerV2InterfaceName) + manager := dwl_ipc.NewZdwlIpcManagerV2(m.ctx) + version := e.Version + if version > 2 { + version = 2 + } + if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil { + dwlMgr = manager + log.Info("DWL: manager bound successfully") + + // Set handlers immediately after binding, before roundtrips + manager.SetTagsHandler(func(e dwl_ipc.ZdwlIpcManagerV2TagsEvent) { + log.Infof("DWL: Tags count: %d", e.Amount) + m.tagCount = e.Amount + m.updateState() + }) + + manager.SetLayoutHandler(func(e dwl_ipc.ZdwlIpcManagerV2LayoutEvent) { + log.Infof("DWL: Layout: %s", e.Name) + m.layouts = append(m.layouts, e.Name) + m.updateState() + }) + } else { + log.Errorf("DWL: failed to bind manager: %v", err) + } + case "wl_output": + log.Debugf("DWL: found wl_output (name=%d)", e.Name) + output := wlclient.NewOutput(m.ctx) + + outState := &outputState{ + registryName: e.Name, + output: output, + tags: make([]TagState, 0), + } + + output.SetNameHandler(func(ev wlclient.OutputNameEvent) { + log.Debugf("DWL: Output name: %s (registry=%d)", ev.Name, e.Name) + outState.name = ev.Name + }) + + output.SetDescriptionHandler(func(ev wlclient.OutputDescriptionEvent) { + log.Debugf("DWL: Output description: %s", ev.Description) + }) + + version := e.Version + if version > 4 { + version = 4 + } + if err := registry.Bind(e.Name, e.Interface, version, output); err == nil { + outputID := output.ID() + outState.id = outputID + log.Infof("DWL: Bound wl_output id=%d registry_name=%d", outputID, e.Name) + outputs = append(outputs, output) + outputRegNames[outputID] = e.Name + + m.outputs.Store(outputID, outState) + + if m.manager != nil { + select { + case m.outputSetupReq <- outputID: + log.Debugf("DWL: Queued setup for output %d", outputID) + default: + log.Warnf("DWL: Setup queue full, output %d will not be initialized", outputID) + } + } + } else { + log.Errorf("DWL: Failed to bind wl_output: %v", err) + } + } + }) + + registry.SetGlobalRemoveHandler(func(e wlclient.RegistryGlobalRemoveEvent) { + m.post(func() { + var outToRelease *outputState + m.outputs.Range(func(id uint32, out *outputState) bool { + if out.registryName == e.Name { + log.Infof("DWL: Output %d removed", id) + outToRelease = out + m.outputs.Delete(id) + return false + } + return true + }) + + if outToRelease != nil { + if ipcOut, ok := outToRelease.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2); ok && ipcOut != nil { + m.wlMutex.Lock() + ipcOut.Release() + m.wlMutex.Unlock() + log.Debugf("DWL: Released ipcOutput for removed output %d", outToRelease.id) + } + m.updateState() + } + }) + }) + + if err := m.display.Roundtrip(); err != nil { + return fmt.Errorf("first roundtrip failed: %w", err) + } + if err := m.display.Roundtrip(); err != nil { + return fmt.Errorf("second roundtrip failed: %w", err) + } + + if dwlMgr == nil { + log.Info("DWL: manager not found in registry") + return fmt.Errorf("dwl_ipc_manager_v2 not available") + } + + m.manager = dwlMgr + + for _, output := range outputs { + if err := m.setupOutput(dwlMgr, output); err != nil { + log.Warnf("DWL: Failed to setup output %d: %v", output.ID(), err) + } + } + + if err := m.display.Roundtrip(); err != nil { + return fmt.Errorf("final roundtrip failed: %w", err) + } + + log.Info("DWL: registry setup complete") + return nil +} + +func (m *Manager) setupOutput(manager *dwl_ipc.ZdwlIpcManagerV2, output *wlclient.Output) error { + m.wlMutex.Lock() + ipcOutput, err := manager.GetOutput(output) + m.wlMutex.Unlock() + if err != nil { + return fmt.Errorf("failed to get dwl output: %w", err) + } + + outState, exists := m.outputs.Load(output.ID()) + if !exists { + return fmt.Errorf("output state not found for id %d", output.ID()) + } + outState.ipcOutput = ipcOutput + + ipcOutput.SetActiveHandler(func(e dwl_ipc.ZdwlIpcOutputV2ActiveEvent) { + outState.active = e.Active + }) + + ipcOutput.SetTagHandler(func(e dwl_ipc.ZdwlIpcOutputV2TagEvent) { + updated := false + for i, tag := range outState.tags { + if tag.Tag == e.Tag { + outState.tags[i] = TagState{ + Tag: e.Tag, + State: e.State, + Clients: e.Clients, + Focused: e.Focused, + } + updated = true + break + } + } + + if !updated { + outState.tags = append(outState.tags, TagState{ + Tag: e.Tag, + State: e.State, + Clients: e.Clients, + Focused: e.Focused, + }) + } + + m.updateState() + }) + + ipcOutput.SetLayoutHandler(func(e dwl_ipc.ZdwlIpcOutputV2LayoutEvent) { + outState.layout = e.Layout + }) + + ipcOutput.SetTitleHandler(func(e dwl_ipc.ZdwlIpcOutputV2TitleEvent) { + outState.title = e.Title + }) + + ipcOutput.SetAppidHandler(func(e dwl_ipc.ZdwlIpcOutputV2AppidEvent) { + outState.appID = e.Appid + }) + + ipcOutput.SetLayoutSymbolHandler(func(e dwl_ipc.ZdwlIpcOutputV2LayoutSymbolEvent) { + outState.layoutSymbol = e.Layout + }) + + ipcOutput.SetKbLayoutHandler(func(e dwl_ipc.ZdwlIpcOutputV2KbLayoutEvent) { + outState.kbLayout = e.KbLayout + }) + + ipcOutput.SetKeymodeHandler(func(e dwl_ipc.ZdwlIpcOutputV2KeymodeEvent) { + outState.keymode = e.Keymode + }) + + ipcOutput.SetFrameHandler(func(e dwl_ipc.ZdwlIpcOutputV2FrameEvent) { + m.updateState() + }) + + return nil +} + +func (m *Manager) updateState() { + outputs := make(map[string]*OutputState) + activeOutput := "" + + m.outputs.Range(func(key uint32, out *outputState) bool { + name := out.name + if name == "" { + name = fmt.Sprintf("output-%d", out.id) + } + + tagsCopy := make([]TagState, len(out.tags)) + copy(tagsCopy, out.tags) + + outputs[name] = &OutputState{ + Name: name, + Active: out.active, + Tags: tagsCopy, + Layout: out.layout, + LayoutSymbol: out.layoutSymbol, + Title: out.title, + AppID: out.appID, + KbLayout: out.kbLayout, + Keymode: out.keymode, + } + + if out.active != 0 { + activeOutput = name + } + return true + }) + + newState := State{ + Outputs: outputs, + TagCount: m.tagCount, + Layouts: m.layouts, + ActiveOutput: activeOutput, + } + + m.stateMutex.Lock() + m.state = &newState + m.stateMutex.Unlock() + + m.notifySubscribers() +} + +func (m *Manager) notifier() { + defer m.notifierWg.Done() + const minGap = 100 * 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 + } + + currentState := m.GetState() + + if m.lastNotified != nil && !stateChanged(m.lastNotified, ¤tState) { + pending = false + continue + } + + m.subscribers.Range(func(key string, ch chan State) bool { + select { + case ch <- currentState: + default: + log.Warn("DWL: subscriber channel full, dropping update") + } + return true + }) + + stateCopy := currentState + m.lastNotified = &stateCopy + pending = false + } + } +} + +func (m *Manager) ensureOutputSetup(out *outputState) error { + if out.ipcOutput != nil { + return nil + } + + return fmt.Errorf("output not yet initialized - setup in progress, retry in a moment") +} + +func (m *Manager) SetTags(outputName string, tagmask uint32, toggleTagset uint32) error { + availableOutputs := make([]string, 0) + var targetOut *outputState + m.outputs.Range(func(key uint32, out *outputState) bool { + name := out.name + if name == "" { + name = fmt.Sprintf("output-%d", out.id) + } + availableOutputs = append(availableOutputs, name) + if name == outputName { + targetOut = out + return false + } + return true + }) + + if targetOut == nil { + return fmt.Errorf("output not found: %s (available: %v)", outputName, availableOutputs) + } + + if err := m.ensureOutputSetup(targetOut); err != nil { + return fmt.Errorf("failed to setup output %s: %w", outputName, err) + } + + ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2) + if !ok { + return fmt.Errorf("output %s has invalid ipcOutput type", outputName) + } + + m.wlMutex.Lock() + err := ipcOut.SetTags(tagmask, toggleTagset) + m.wlMutex.Unlock() + return err +} + +func (m *Manager) SetClientTags(outputName string, andTags uint32, xorTags uint32) error { + var targetOut *outputState + m.outputs.Range(func(key uint32, out *outputState) bool { + name := out.name + if name == "" { + name = fmt.Sprintf("output-%d", out.id) + } + if name == outputName { + targetOut = out + return false + } + return true + }) + + if targetOut == nil { + return fmt.Errorf("output not found: %s", outputName) + } + + if err := m.ensureOutputSetup(targetOut); err != nil { + return fmt.Errorf("failed to setup output %s: %w", outputName, err) + } + + ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2) + if !ok { + return fmt.Errorf("output %s has invalid ipcOutput type", outputName) + } + + m.wlMutex.Lock() + err := ipcOut.SetClientTags(andTags, xorTags) + m.wlMutex.Unlock() + return err +} + +func (m *Manager) SetLayout(outputName string, index uint32) error { + var targetOut *outputState + m.outputs.Range(func(key uint32, out *outputState) bool { + name := out.name + if name == "" { + name = fmt.Sprintf("output-%d", out.id) + } + if name == outputName { + targetOut = out + return false + } + return true + }) + + if targetOut == nil { + return fmt.Errorf("output not found: %s", outputName) + } + + if err := m.ensureOutputSetup(targetOut); err != nil { + return fmt.Errorf("failed to setup output %s: %w", outputName, err) + } + + ipcOut, ok := targetOut.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2) + if !ok { + return fmt.Errorf("output %s has invalid ipcOutput type", outputName) + } + + m.wlMutex.Lock() + err := ipcOut.SetLayout(index) + m.wlMutex.Unlock() + return err +} + +func (m *Manager) Close() { + close(m.stopChan) + m.wg.Wait() + m.notifierWg.Wait() + + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + + m.outputs.Range(func(key uint32, out *outputState) bool { + if ipcOut, ok := out.ipcOutput.(*dwl_ipc.ZdwlIpcOutputV2); ok { + ipcOut.Release() + } + m.outputs.Delete(key) + return true + }) + + if mgr, ok := m.manager.(*dwl_ipc.ZdwlIpcManagerV2); ok { + mgr.Release() + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dwl/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dwl/manager_test.go new file mode 100644 index 0000000..13af137 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dwl/manager_test.go @@ -0,0 +1,366 @@ +package dwl + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient" +) + +func TestStateChanged_BothNil(t *testing.T) { + assert.True(t, stateChanged(nil, nil)) +} + +func TestStateChanged_OneNil(t *testing.T) { + s := &State{TagCount: 9} + assert.True(t, stateChanged(s, nil)) + assert.True(t, stateChanged(nil, s)) +} + +func TestStateChanged_TagCountDiffers(t *testing.T) { + a := &State{TagCount: 9, Outputs: make(map[string]*OutputState), Layouts: []string{}} + b := &State{TagCount: 10, Outputs: make(map[string]*OutputState), Layouts: []string{}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_LayoutLengthDiffers(t *testing.T) { + a := &State{TagCount: 9, Layouts: []string{"tile"}, Outputs: make(map[string]*OutputState)} + b := &State{TagCount: 9, Layouts: []string{"tile", "monocle"}, Outputs: make(map[string]*OutputState)} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_ActiveOutputDiffers(t *testing.T) { + a := &State{TagCount: 9, ActiveOutput: "eDP-1", Outputs: make(map[string]*OutputState), Layouts: []string{}} + b := &State{TagCount: 9, ActiveOutput: "HDMI-A-1", Outputs: make(map[string]*OutputState), Layouts: []string{}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputCountDiffers(t *testing.T) { + a := &State{ + TagCount: 9, + Outputs: map[string]*OutputState{"eDP-1": {}}, + Layouts: []string{}, + } + b := &State{ + TagCount: 9, + Outputs: map[string]*OutputState{}, + Layouts: []string{}, + } + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputFieldsDiffer(t *testing.T) { + a := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Active: 1, Layout: 0, Title: "Firefox"}, + }, + } + b := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Active: 0, Layout: 0, Title: "Firefox"}, + }, + } + assert.True(t, stateChanged(a, b)) + + b.Outputs["eDP-1"].Active = 1 + b.Outputs["eDP-1"].Layout = 1 + assert.True(t, stateChanged(a, b)) + + b.Outputs["eDP-1"].Layout = 0 + b.Outputs["eDP-1"].Title = "Code" + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_TagsDiffer(t *testing.T) { + a := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}}}, + }, + } + b := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Tags: []TagState{{Tag: 1, State: 2, Clients: 2, Focused: 1}}}, + }, + } + assert.True(t, stateChanged(a, b)) + + b.Outputs["eDP-1"].Tags[0].State = 1 + b.Outputs["eDP-1"].Tags[0].Clients = 3 + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_Equal(t *testing.T) { + a := &State{ + TagCount: 9, + ActiveOutput: "eDP-1", + Layouts: []string{"tile", "monocle"}, + Outputs: map[string]*OutputState{ + "eDP-1": { + Name: "eDP-1", + Active: 1, + Layout: 0, + LayoutSymbol: "[]=", + Title: "Firefox", + AppID: "firefox", + KbLayout: "us", + Keymode: "", + Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}}, + }, + }, + } + b := &State{ + TagCount: 9, + ActiveOutput: "eDP-1", + Layouts: []string{"tile", "monocle"}, + Outputs: map[string]*OutputState{ + "eDP-1": { + Name: "eDP-1", + Active: 1, + Layout: 0, + LayoutSymbol: "[]=", + Title: "Firefox", + AppID: "firefox", + KbLayout: "us", + Keymode: "", + Tags: []TagState{{Tag: 1, State: 1, Clients: 2, Focused: 1}}, + }, + }, + } + assert.False(t, stateChanged(a, b)) +} + +func TestManager_ConcurrentGetState(t *testing.T) { + m := &Manager{ + state: &State{ + TagCount: 9, + Layouts: []string{"tile"}, + Outputs: map[string]*OutputState{"eDP-1": {Name: "eDP-1"}}, + }, + } + + var wg sync.WaitGroup + const goroutines = 50 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + s := m.GetState() + _ = s.TagCount + _ = s.Outputs + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.stateMutex.Lock() + m.state = &State{ + TagCount: uint32(j % 10), + Layouts: []string{"tile", "monocle"}, + Outputs: map[string]*OutputState{"eDP-1": {Active: uint32(j % 2)}}, + } + m.stateMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentSubscriberAccess(t *testing.T) { + m := &Manager{ + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + + var wg sync.WaitGroup + const goroutines = 20 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + subID := string(rune('a' + id)) + ch := m.Subscribe(subID) + assert.NotNil(t, ch) + time.Sleep(time.Millisecond) + m.Unsubscribe(subID) + }(i) + } + + wg.Wait() +} + +func TestManager_SyncmapOutputsConcurrentAccess(t *testing.T) { + m := &Manager{} + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 50 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + key := uint32(id) + + for j := 0; j < iterations; j++ { + state := &outputState{ + id: key, + name: "test-output", + active: uint32(j % 2), + tags: []TagState{{Tag: uint32(j), State: 1}}, + } + m.outputs.Store(key, state) + + if loaded, ok := m.outputs.Load(key); ok { + assert.Equal(t, key, loaded.id) + } + + m.outputs.Range(func(k uint32, v *outputState) bool { + _ = v.name + _ = v.active + return true + }) + } + + m.outputs.Delete(key) + }(i) + } + + wg.Wait() +} + +func TestManager_NotifySubscribersNonBlocking(t *testing.T) { + m := &Manager{ + dirty: make(chan struct{}, 1), + } + + for i := 0; i < 10; i++ { + m.notifySubscribers() + } + + assert.Len(t, m.dirty, 1) +} + +func TestManager_PostQueueFull(t *testing.T) { + m := &Manager{ + cmdq: make(chan cmd, 2), + stopChan: make(chan struct{}), + } + + m.post(func() {}) + m.post(func() {}) + m.post(func() {}) + m.post(func() {}) + + assert.Len(t, m.cmdq, 2) +} + +func TestManager_GetStateNilState(t *testing.T) { + m := &Manager{} + + s := m.GetState() + assert.NotNil(t, s.Outputs) + assert.NotNil(t, s.Layouts) + assert.Equal(t, uint32(0), s.TagCount) +} + +func TestTagState_Fields(t *testing.T) { + tag := TagState{ + Tag: 1, + State: 2, + Clients: 3, + Focused: 1, + } + + assert.Equal(t, uint32(1), tag.Tag) + assert.Equal(t, uint32(2), tag.State) + assert.Equal(t, uint32(3), tag.Clients) + assert.Equal(t, uint32(1), tag.Focused) +} + +func TestOutputState_Fields(t *testing.T) { + out := OutputState{ + Name: "eDP-1", + Active: 1, + Tags: []TagState{{Tag: 1}}, + Layout: 0, + LayoutSymbol: "[]=", + Title: "Firefox", + AppID: "firefox", + KbLayout: "us", + Keymode: "", + } + + assert.Equal(t, "eDP-1", out.Name) + assert.Equal(t, uint32(1), out.Active) + assert.Len(t, out.Tags, 1) + assert.Equal(t, "[]=", out.LayoutSymbol) +} + +func TestStateChanged_NewOutputAppears(t *testing.T) { + a := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Name: "eDP-1"}, + }, + } + b := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Name: "eDP-1"}, + "HDMI-A-1": {Name: "HDMI-A-1"}, + }, + } + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_TagsLengthDiffers(t *testing.T) { + a := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Tags: []TagState{{Tag: 1}}}, + }, + } + b := &State{ + TagCount: 9, + Layouts: []string{}, + Outputs: map[string]*OutputState{ + "eDP-1": {Tags: []TagState{{Tag: 1}, {Tag: 2}}}, + }, + } + assert.True(t, stateChanged(a, b)) +} + +func TestNewManager_GetRegistryError(t *testing.T) { + mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) + + mockDisplay.EXPECT().Context().Return(nil) + mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry")) + + _, err := NewManager(mockDisplay) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get registry") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dwl/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dwl/types.go new file mode 100644 index 0000000..2b17537 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/dwl/types.go @@ -0,0 +1,176 @@ +package dwl + +import ( + "sync" + + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type TagState struct { + Tag uint32 `json:"tag"` + State uint32 `json:"state"` + Clients uint32 `json:"clients"` + Focused uint32 `json:"focused"` +} + +type OutputState struct { + Name string `json:"name"` + Active uint32 `json:"active"` + Tags []TagState `json:"tags"` + Layout uint32 `json:"layout"` + LayoutSymbol string `json:"layoutSymbol"` + Title string `json:"title"` + AppID string `json:"appId"` + KbLayout string `json:"kbLayout"` + Keymode string `json:"keymode"` +} + +type State struct { + Outputs map[string]*OutputState `json:"outputs"` + TagCount uint32 `json:"tagCount"` + Layouts []string `json:"layouts"` + ActiveOutput string `json:"activeOutput"` +} + +type cmd struct { + fn func() +} + +type Manager struct { + display wlclient.WaylandDisplay + ctx *wlclient.Context + registry *wlclient.Registry + manager any + + outputs syncmap.Map[uint32, *outputState] + + tagCount uint32 + layouts []string + + wlMutex sync.Mutex + cmdq chan cmd + outputSetupReq chan uint32 + stopChan chan struct{} + wg sync.WaitGroup + + subscribers syncmap.Map[string, chan State] + dirty chan struct{} + notifierWg sync.WaitGroup + lastNotified *State + + stateMutex sync.RWMutex + state *State +} + +type outputState struct { + id uint32 + registryName uint32 + output *wlclient.Output + ipcOutput any + name string + active uint32 + tags []TagState + layout uint32 + layoutSymbol string + title string + appID string + kbLayout string + keymode string +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + if m.state == nil { + return State{ + Outputs: make(map[string]*OutputState), + Layouts: []string{}, + TagCount: 0, + } + } + stateCopy := *m.state + return stateCopy +} + +func (m *Manager) Subscribe(id string) chan State { + ch := make(chan State, 64) + + m.subscribers.Store(id, ch) + + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) notifySubscribers() { + select { + case m.dirty <- struct{}{}: + default: + } +} + +func stateChanged(old, new *State) bool { + if old == nil || new == nil { + return true + } + if old.TagCount != new.TagCount { + return true + } + if len(old.Layouts) != len(new.Layouts) { + return true + } + if old.ActiveOutput != new.ActiveOutput { + return true + } + if len(old.Outputs) != len(new.Outputs) { + return true + } + + for name, newOut := range new.Outputs { + oldOut, exists := old.Outputs[name] + if !exists { + return true + } + if oldOut.Active != newOut.Active { + return true + } + if oldOut.Layout != newOut.Layout { + return true + } + if oldOut.LayoutSymbol != newOut.LayoutSymbol { + return true + } + if oldOut.Title != newOut.Title { + return true + } + if oldOut.AppID != newOut.AppID { + return true + } + if oldOut.KbLayout != newOut.KbLayout { + return true + } + if oldOut.Keymode != newOut.Keymode { + return true + } + if len(oldOut.Tags) != len(newOut.Tags) { + return true + } + for i, newTag := range newOut.Tags { + if i >= len(oldOut.Tags) { + return true + } + oldTag := oldOut.Tags[i] + if oldTag.Tag != newTag.Tag || oldTag.State != newTag.State || + oldTag.Clients != newTag.Clients || oldTag.Focused != newTag.Focused { + return true + } + } + } + + return false +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers.go new file mode 100644 index 0000000..54a7bca --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers.go @@ -0,0 +1,20 @@ +package evdev + +import ( + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +func HandleRequest(conn net.Conn, req models.Request, m *Manager) { + switch req.Method { + case "evdev.getState": + handleGetState(conn, req, m) + default: + models.RespondError(conn, req.ID, "unknown method: "+req.Method) + } +} + +func handleGetState(conn net.Conn, req models.Request, m *Manager) { + models.Respond(conn, req.ID, m.GetState()) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers_test.go new file mode 100644 index 0000000..f9bd21d --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/handlers_test.go @@ -0,0 +1,130 @@ +package evdev + +import ( + "bytes" + "encoding/json" + "errors" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + mocks "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/evdev" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +type mockNetConn struct { + net.Conn + readBuf *bytes.Buffer + writeBuf *bytes.Buffer + closed bool +} + +func newMockNetConn() *mockNetConn { + return &mockNetConn{ + readBuf: &bytes.Buffer{}, + writeBuf: &bytes.Buffer{}, + } +} + +func (m *mockNetConn) Read(b []byte) (n int, err error) { + return m.readBuf.Read(b) +} + +func (m *mockNetConn) Write(b []byte) (n int, err error) { + return m.writeBuf.Write(b) +} + +func (m *mockNetConn) Close() error { + m.closed = true + return nil +} + +func TestHandleRequest(t *testing.T) { + t.Run("getState request", func(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe() + + m := &Manager{ + devices: []EvdevDevice{mockDevice}, + state: State{Available: true, CapsLock: true}, + closeChan: make(chan struct{}), + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "evdev.getState", + Params: map[string]any{}, + } + + HandleRequest(conn, req, m) + + var resp models.Response[State] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Available) + assert.True(t, resp.Result.CapsLock) + }) + + t.Run("unknown method", func(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe() + + m := &Manager{ + devices: []EvdevDevice{mockDevice}, + state: State{Available: true, CapsLock: false}, + closeChan: make(chan struct{}), + } + + conn := newMockNetConn() + req := models.Request{ + ID: 456, + Method: "evdev.unknownMethod", + Params: map[string]any{}, + } + + HandleRequest(conn, req, m) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 456, resp.ID) + assert.NotEmpty(t, resp.Error) + assert.Contains(t, resp.Error, "unknown method") + }) +} + +func TestHandleGetState(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe() + + m := &Manager{ + devices: []EvdevDevice{mockDevice}, + state: State{Available: true, CapsLock: false}, + closeChan: make(chan struct{}), + } + + conn := newMockNetConn() + req := models.Request{ + ID: 789, + Method: "evdev.getState", + Params: map[string]any{}, + } + + handleGetState(conn, req, m) + + var resp models.Response[State] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 789, resp.ID) + assert.NotNil(t, resp.Result) + assert.True(t, resp.Result.Available) + assert.False(t, resp.Result.CapsLock) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager.go new file mode 100644 index 0000000..b578481 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager.go @@ -0,0 +1,413 @@ +package evdev + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" + "github.com/fsnotify/fsnotify" + evdev "github.com/holoplot/go-evdev" +) + +const ( + evKeyType = 0x01 + evLedType = 0x11 + keyCapslockKey = 58 + ledCapslockKey = 1 + keyStateOn = 1 +) + +type EvdevDevice interface { + Name() (string, error) + Path() string + Close() error + ReadOne() (*evdev.InputEvent, error) + State(t evdev.EvType) (evdev.StateMap, error) +} + +type Manager struct { + devices []EvdevDevice + devicesMutex sync.RWMutex + monitoredPaths map[string]bool + state State + stateMutex sync.RWMutex + subscribers syncmap.Map[string, chan State] + closeChan chan struct{} + closeOnce sync.Once + watcher *fsnotify.Watcher +} + +func NewManager() (*Manager, error) { + devices, err := findKeyboards() + if err != nil { + return nil, fmt.Errorf("failed to find keyboards: %w", err) + } + + initialCapsLock := readInitialCapsLockState(devices[0]) + + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Warnf("Failed to create fsnotify watcher, hotplug detection disabled: %v", err) + watcher = nil + } else if err := watcher.Add("/dev/input"); err != nil { + log.Warnf("Failed to watch /dev/input, hotplug detection disabled: %v", err) + watcher.Close() + watcher = nil + } + + monitoredPaths := make(map[string]bool) + for _, device := range devices { + monitoredPaths[device.Path()] = true + } + + m := &Manager{ + devices: devices, + monitoredPaths: monitoredPaths, + state: State{Available: true, CapsLock: initialCapsLock}, + + closeChan: make(chan struct{}), + watcher: watcher, + } + + for i, device := range devices { + go m.monitorDevice(device, i) + } + + if watcher != nil { + go m.watchForNewKeyboards() + } + + return m, nil +} + +func readInitialCapsLockState(device EvdevDevice) bool { + ledStates, err := device.State(evLedType) + if err != nil { + log.Debugf("Could not read LED state: %v", err) + return false + } + + return ledStates[ledCapslockKey] +} + +func findKeyboards() ([]EvdevDevice, error) { + pattern := "/dev/input/event*" + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("failed to glob input devices: %w", err) + } + + if len(matches) == 0 { + return nil, fmt.Errorf("no input devices found") + } + + var keyboards []EvdevDevice + for _, path := range matches { + device, err := evdev.Open(path) + if err != nil { + continue + } + + if !isKeyboard(device) { + device.Close() + continue + } + + deviceName, _ := device.Name() + log.Debugf("Found keyboard: %s at %s", deviceName, path) + keyboards = append(keyboards, device) + } + + if len(keyboards) == 0 { + return nil, fmt.Errorf("no keyboard device found") + } + + return keyboards, nil +} + +func isKeyboard(device EvdevDevice) bool { + deviceName, err := device.Name() + if err != nil { + return false + } + + name := strings.ToLower(deviceName) + + switch { + case strings.Contains(name, "keyboard"): + return true + case strings.Contains(name, "kbd"): + return true + case strings.Contains(name, "input") && strings.Contains(name, "key"): + return true + } + + keyStates, err := device.State(evKeyType) + if err != nil { + return false + } + + hasKeyA := len(keyStates) > 30 + hasKeyZ := len(keyStates) > 44 + hasEnter := len(keyStates) > 28 + + return hasKeyA && hasKeyZ && hasEnter && len(keyStates) > 100 +} + +func (m *Manager) watchForNewKeyboards() { + defer func() { + if r := recover(); r != nil { + log.Errorf("Panic in keyboard hotplug monitor: %v", r) + } + }() + + for { + select { + case <-m.closeChan: + return + case event, ok := <-m.watcher.Events: + if !ok { + return + } + + if !strings.HasPrefix(filepath.Base(event.Name), "event") { + continue + } + + if event.Op&fsnotify.Create == fsnotify.Create { + time.Sleep(100 * time.Millisecond) + + m.devicesMutex.Lock() + if m.monitoredPaths[event.Name] { + m.devicesMutex.Unlock() + continue + } + + device, err := evdev.Open(event.Name) + if err != nil { + m.devicesMutex.Unlock() + continue + } + + if !isKeyboard(device) { + device.Close() + m.devicesMutex.Unlock() + continue + } + + deviceName, _ := device.Name() + log.Debugf("Hotplugged keyboard: %s at %s", deviceName, event.Name) + + m.devices = append(m.devices, device) + m.monitoredPaths[event.Name] = true + deviceIndex := len(m.devices) - 1 + m.devicesMutex.Unlock() + + go m.monitorDevice(device, deviceIndex) + } else if event.Op&fsnotify.Remove == fsnotify.Remove { + m.devicesMutex.Lock() + if !m.monitoredPaths[event.Name] { + m.devicesMutex.Unlock() + continue + } + + delete(m.monitoredPaths, event.Name) + for i, device := range m.devices { + if device != nil && device.Path() == event.Name { + log.Debugf("Keyboard removed: %s", event.Name) + device.Close() + m.devices[i] = nil + break + } + } + m.devicesMutex.Unlock() + } + + case err, ok := <-m.watcher.Errors: + if !ok { + return + } + log.Warnf("Keyboard hotplug watcher error: %v", err) + } + } +} + +func (m *Manager) monitorDevice(device EvdevDevice, deviceIndex int) { + defer func() { + if r := recover(); r != nil { + log.Errorf("Panic in evdev monitor: %v", r) + } + }() + + for { + select { + case <-m.closeChan: + return + default: + } + + event, err := device.ReadOne() + if err != nil { + if isClosedError(err) { + return + } + log.Warnf("Failed to read evdev event: %v", err) + time.Sleep(100 * time.Millisecond) + continue + } + + if event == nil { + continue + } + + if event.Type == evKeyType && event.Code == keyCapslockKey && event.Value == keyStateOn { + time.Sleep(50 * time.Millisecond) + m.readAndUpdateCapsLockState(deviceIndex) + } else if event.Type == evLedType && event.Code == ledCapslockKey { + capsLockState := event.Value == keyStateOn + m.updateCapsLockStateDirect(capsLockState) + } + } +} + +func isClosedError(err error) bool { + if err == nil { + return false + } + + errStr := err.Error() + switch { + case strings.Contains(errStr, "closed"): + return true + case strings.Contains(errStr, "bad file descriptor"): + return true + default: + return false + } +} + +func (m *Manager) readAndUpdateCapsLockState(deviceIndex int) { + m.devicesMutex.RLock() + if deviceIndex >= len(m.devices) { + m.devicesMutex.RUnlock() + return + } + device := m.devices[deviceIndex] + m.devicesMutex.RUnlock() + + ledStates, err := device.State(evLedType) + if err != nil { + log.Warnf("Failed to read LED state: %v", err) + return + } + + if len(ledStates) == 0 { + log.Debug("No LED state available (empty map)") + + // This means the device either: + // - doesn't support LED reporting at all, or + // - the kernel returned an empty state + return + } + + capsLockState := ledStates[ledCapslockKey] + m.updateCapsLockStateDirect(capsLockState) +} + +func (m *Manager) updateCapsLockStateDirect(capsLockState bool) { + m.stateMutex.Lock() + if m.state.CapsLock == capsLockState { + m.stateMutex.Unlock() + return + } + + m.state.CapsLock = capsLockState + newState := m.state + m.stateMutex.Unlock() + + log.Debugf("Caps lock state: %v", newState.CapsLock) + m.notifySubscribers(newState) +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + return m.state +} + +func (m *Manager) Subscribe(id string) chan State { + ch := make(chan State, 16) + m.subscribers.Store(id, ch) + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) notifySubscribers(state State) { + m.subscribers.Range(func(key string, ch chan State) bool { + select { + case ch <- state: + default: + } + return true + }) +} + +func (m *Manager) Close() { + m.closeOnce.Do(func() { + close(m.closeChan) + + if m.watcher != nil { + m.watcher.Close() + } + + m.devicesMutex.Lock() + for _, device := range m.devices { + if device == nil { + continue + } + if err := device.Close(); err != nil && !isClosedError(err) { + log.Warnf("Error closing evdev device: %v", err) + } + } + m.devicesMutex.Unlock() + + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + }) +} + +func InitializeManager() (*Manager, error) { + if os.Getuid() != 0 && !hasInputGroupAccess() { + return nil, fmt.Errorf("insufficient permissions to access input devices") + } + + return NewManager() +} + +func hasInputGroupAccess() bool { + pattern := "/dev/input/event*" + matches, err := filepath.Glob(pattern) + if err != nil || len(matches) == 0 { + return false + } + + testFile, err := os.Open(matches[0]) + if err != nil { + return false + } + testFile.Close() + return true +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager_test.go new file mode 100644 index 0000000..667c078 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/manager_test.go @@ -0,0 +1,344 @@ +package evdev + +import ( + "errors" + "testing" + + evdev "github.com/holoplot/go-evdev" + "github.com/stretchr/testify/assert" + + mocks "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/evdev" +) + +func TestManager_Creation(t *testing.T) { + t.Run("manager created successfully with caps lock off", func(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe() + + m := &Manager{ + devices: []EvdevDevice{mockDevice}, + state: State{Available: true, CapsLock: false}, + closeChan: make(chan struct{}), + } + + assert.NotNil(t, m) + assert.True(t, m.state.Available) + assert.False(t, m.state.CapsLock) + }) + + t.Run("manager created successfully with caps lock on", func(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe() + + m := &Manager{ + devices: []EvdevDevice{mockDevice}, + state: State{Available: true, CapsLock: true}, + closeChan: make(chan struct{}), + } + + assert.NotNil(t, m) + assert.True(t, m.state.Available) + assert.True(t, m.state.CapsLock) + }) +} + +func TestManager_GetState(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe() + + m := &Manager{ + devices: []EvdevDevice{mockDevice}, + monitoredPaths: make(map[string]bool), + state: State{Available: true, CapsLock: false}, + closeChan: make(chan struct{}), + } + + state := m.GetState() + assert.True(t, state.Available) + assert.False(t, state.CapsLock) +} + +func TestManager_Subscribe(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe() + + m := &Manager{ + devices: []EvdevDevice{mockDevice}, + monitoredPaths: make(map[string]bool), + state: State{Available: true, CapsLock: false}, + closeChan: make(chan struct{}), + } + + ch := m.Subscribe("test-client") + assert.NotNil(t, ch) + count := 0 + m.subscribers.Range(func(key string, ch chan State) bool { + count++ + return true + }) + assert.Equal(t, 1, count) +} + +func TestManager_Unsubscribe(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe() + + m := &Manager{ + devices: []EvdevDevice{mockDevice}, + monitoredPaths: make(map[string]bool), + state: State{Available: true, CapsLock: false}, + closeChan: make(chan struct{}), + } + + ch := m.Subscribe("test-client") + count := 0 + m.subscribers.Range(func(key string, ch chan State) bool { + count++ + return true + }) + assert.Equal(t, 1, count) + + m.Unsubscribe("test-client") + count = 0 + m.subscribers.Range(func(key string, ch chan State) bool { + count++ + return true + }) + assert.Equal(t, 0, count) + + select { + case _, ok := <-ch: + assert.False(t, ok, "channel should be closed") + default: + t.Error("channel should be closed") + } +} + +func TestManager_UpdateCapsLock(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe() + + m := &Manager{ + devices: []EvdevDevice{mockDevice}, + monitoredPaths: make(map[string]bool), + state: State{Available: true, CapsLock: false}, + closeChan: make(chan struct{}), + } + + ch := m.Subscribe("test-client") + + ledStateOn := evdev.StateMap{ledCapslockKey: true} + mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledStateOn, nil).Once() + + go func() { + m.readAndUpdateCapsLockState(0) + }() + + newState := <-ch + assert.True(t, newState.CapsLock) + + ledStateOff := evdev.StateMap{ledCapslockKey: false} + mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledStateOff, nil).Once() + + go func() { + m.readAndUpdateCapsLockState(0) + }() + + newState = <-ch + assert.False(t, newState.CapsLock) +} + +func TestManager_Close(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().Close().Return(nil).Once() + mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe() + + m := &Manager{ + devices: []EvdevDevice{mockDevice}, + monitoredPaths: make(map[string]bool), + state: State{Available: true, CapsLock: false}, + closeChan: make(chan struct{}), + } + + ch1 := m.Subscribe("client1") + ch2 := m.Subscribe("client2") + + m.Close() + + select { + case _, ok := <-ch1: + assert.False(t, ok, "channel 1 should be closed") + default: + t.Error("channel 1 should be closed") + } + + select { + case _, ok := <-ch2: + assert.False(t, ok, "channel 2 should be closed") + default: + t.Error("channel 2 should be closed") + } + + count := 0 + m.subscribers.Range(func(key string, ch chan State) bool { + count++ + return true + }) + assert.Equal(t, 0, count) + + m.Close() +} + +func TestIsKeyboard(t *testing.T) { + tests := []struct { + name string + devName string + expected bool + }{ + {"keyboard in name", "AT Translated Set 2 keyboard", true}, + {"kbd in name", "USB kbd", true}, + {"input and key", "input key device", true}, + {"random device", "Mouse", false}, + {"empty name", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().Name().Return(tt.devName, nil).Once() + + if !tt.expected { + mockDevice.EXPECT().State(evdev.EvType(evKeyType)).Return(evdev.StateMap{}, nil).Maybe() + } + + result := isKeyboard(mockDevice) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsKeyboard_ErrorHandling(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().Name().Return("", errors.New("device error")).Once() + + result := isKeyboard(mockDevice) + assert.False(t, result) +} + +func TestManager_MonitorDevice(t *testing.T) { + t.Run("caps lock key press updates state", func(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + + capsLockEvent := &evdev.InputEvent{ + Type: evKeyType, + Code: keyCapslockKey, + Value: keyStateOn, + } + + ledState := evdev.StateMap{ledCapslockKey: true} + + mockDevice.EXPECT().ReadOne().Return(capsLockEvent, nil).Once() + mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once() + mockDevice.EXPECT().ReadOne().Return(nil, errors.New("stop")).Maybe() + mockDevice.EXPECT().Close().Return(nil).Maybe() + + m := &Manager{ + devices: []EvdevDevice{mockDevice}, + state: State{Available: true, CapsLock: false}, + closeChan: make(chan struct{}), + } + + ch := m.Subscribe("test") + + go m.monitorDevice(mockDevice, 0) + + state := <-ch + assert.True(t, state.CapsLock) + + m.Close() + }) +} + +func TestIsClosedError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + {"nil error", nil, false}, + {"closed error", errors.New("device closed"), true}, + {"bad file descriptor", errors.New("bad file descriptor"), true}, + {"other error", errors.New("some other error"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isClosedError(tt.err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNotifySubscribers(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().ReadOne().Return(nil, errors.New("test")).Maybe() + mockDevice.EXPECT().Close().Return(nil).Maybe() + + m := &Manager{ + devices: []EvdevDevice{mockDevice}, + monitoredPaths: make(map[string]bool), + state: State{Available: true, CapsLock: false}, + closeChan: make(chan struct{}), + } + + ch1 := m.Subscribe("client1") + ch2 := m.Subscribe("client2") + + newState := State{Available: true, CapsLock: true} + go m.notifySubscribers(newState) + + state1 := <-ch1 + state2 := <-ch2 + + assert.Equal(t, newState, state1) + assert.Equal(t, newState, state2) + + m.Close() +} + +func TestReadInitialCapsLockState(t *testing.T) { + t.Run("caps lock is on", func(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + ledState := evdev.StateMap{ + ledCapslockKey: true, + } + mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once() + + result := readInitialCapsLockState(mockDevice) + assert.True(t, result) + }) + + t.Run("caps lock is off", func(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + ledState := evdev.StateMap{ + ledCapslockKey: false, + } + mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once() + + result := readInitialCapsLockState(mockDevice) + assert.False(t, result) + }) + + t.Run("error reading LED state", func(t *testing.T) { + mockDevice := mocks.NewMockEvdevDevice(t) + mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(nil, errors.New("read error")).Once() + + result := readInitialCapsLockState(mockDevice) + assert.False(t, result) + }) +} + +func TestHasInputGroupAccess(t *testing.T) { + result := hasInputGroupAccess() + t.Logf("hasInputGroupAccess: %v", result) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/models.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/models.go new file mode 100644 index 0000000..df8ec9e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/evdev/models.go @@ -0,0 +1,6 @@ +package evdev + +type State struct { + Available bool `json:"available"` + CapsLock bool `json:"capsLock"` +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/extworkspace/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/extworkspace/handlers.go new file mode 100644 index 0000000..3f7e17f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/extworkspace/handlers.go @@ -0,0 +1,134 @@ +package extworkspace + +import ( + "encoding/json" + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +type SuccessResult struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + if manager == nil { + models.RespondError(conn, req.ID, "extworkspace manager not initialized") + return + } + + switch req.Method { + case "extworkspace.getState": + handleGetState(conn, req, manager) + case "extworkspace.activateWorkspace": + handleActivateWorkspace(conn, req, manager) + case "extworkspace.deactivateWorkspace": + handleDeactivateWorkspace(conn, req, manager) + case "extworkspace.removeWorkspace": + handleRemoveWorkspace(conn, req, manager) + case "extworkspace.createWorkspace": + handleCreateWorkspace(conn, req, manager) + case "extworkspace.subscribe": + handleSubscribe(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) { + state := manager.GetState() + models.Respond(conn, req.ID, state) +} + +func handleActivateWorkspace(conn net.Conn, req models.Request, manager *Manager) { + groupID := models.GetOr(req, "groupID", "") + workspaceID, ok := models.Get[string](req, "workspaceID") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter") + return + } + + if err := manager.ActivateWorkspace(groupID, workspaceID); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace activated"}) +} + +func handleDeactivateWorkspace(conn net.Conn, req models.Request, manager *Manager) { + groupID := models.GetOr(req, "groupID", "") + workspaceID, ok := models.Get[string](req, "workspaceID") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter") + return + } + + if err := manager.DeactivateWorkspace(groupID, workspaceID); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace deactivated"}) +} + +func handleRemoveWorkspace(conn net.Conn, req models.Request, manager *Manager) { + groupID := models.GetOr(req, "groupID", "") + workspaceID, ok := models.Get[string](req, "workspaceID") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter") + return + } + + if err := manager.RemoveWorkspace(groupID, workspaceID); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace removed"}) +} + +func handleCreateWorkspace(conn net.Conn, req models.Request, manager *Manager) { + groupID, ok := models.Get[string](req, "groupID") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'groupID' parameter") + return + } + + workspaceName, ok := models.Get[string](req, "name") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'name' parameter") + return + } + + if err := manager.CreateWorkspace(groupID, workspaceName); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace create requested"}) +} + +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() + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + ID: req.ID, + Result: &initialState, + }); err != nil { + return + } + + for state := range stateChan { + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + Result: &state, + }); err != nil { + return + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager.go new file mode 100644 index 0000000..b791b39 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager.go @@ -0,0 +1,598 @@ +package extworkspace + +import ( + "fmt" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_workspace" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +func CheckCapability() bool { + display, err := wlclient.Connect("") + if err != nil { + return false + } + defer display.Destroy() + + registry, err := display.GetRegistry() + if err != nil { + return false + } + defer registry.Destroy() + + found := false + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + if e.Interface == ext_workspace.ExtWorkspaceManagerV1InterfaceName { + found = true + } + }) + + // Roundtrip to ensure all registry events are processed + if err := display.Roundtrip(); err != nil { + return false + } + + return found +} + +func NewManager(display wlclient.WaylandDisplay) (*Manager, error) { + m := &Manager{ + display: display, + ctx: display.Context(), + cmdq: make(chan cmd, 128), + stopChan: make(chan struct{}), + + dirty: make(chan struct{}, 1), + } + + m.wg.Add(1) + go m.waylandActor() + + if err := m.setupRegistry(); err != nil { + close(m.stopChan) + m.wg.Wait() + return nil, err + } + + m.updateState() + + m.notifierWg.Add(1) + go m.notifier() + + return m, nil +} + +func (m *Manager) post(fn func()) { + select { + case m.cmdq <- cmd{fn: fn}: + default: + log.Warn("ExtWorkspace actor command queue full, dropping command") + } +} + +func (m *Manager) waylandActor() { + defer m.wg.Done() + + for { + select { + case <-m.stopChan: + return + case c := <-m.cmdq: + c.fn() + } + } +} + +func (m *Manager) setupRegistry() error { + log.Info("ExtWorkspace: starting registry setup") + + registry, err := m.display.GetRegistry() + if err != nil { + return fmt.Errorf("failed to get registry: %w", err) + } + m.registry = registry + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + if e.Interface == "wl_output" { + output := wlclient.NewOutput(m.ctx) + if err := registry.Bind(e.Name, e.Interface, 4, output); err == nil { + outputID := output.ID() + + output.SetNameHandler(func(ev wlclient.OutputNameEvent) { + m.outputNames.Store(outputID, ev.Name) + log.Debugf("ExtWorkspace: Output %d (%s) name received", outputID, ev.Name) + m.post(func() { + m.updateState() + }) + }) + } + return + } + + if e.Interface == ext_workspace.ExtWorkspaceManagerV1InterfaceName { + log.Infof("ExtWorkspace: found %s", ext_workspace.ExtWorkspaceManagerV1InterfaceName) + manager := ext_workspace.NewExtWorkspaceManagerV1(m.ctx) + version := e.Version + if version > 1 { + version = 1 + } + + manager.SetWorkspaceGroupHandler(func(e ext_workspace.ExtWorkspaceManagerV1WorkspaceGroupEvent) { + m.handleWorkspaceGroup(e) + }) + + manager.SetWorkspaceHandler(func(e ext_workspace.ExtWorkspaceManagerV1WorkspaceEvent) { + m.handleWorkspace(e) + }) + + manager.SetDoneHandler(func(e ext_workspace.ExtWorkspaceManagerV1DoneEvent) { + log.Debug("ExtWorkspace: done event received") + m.post(func() { + m.updateState() + }) + }) + + manager.SetFinishedHandler(func(e ext_workspace.ExtWorkspaceManagerV1FinishedEvent) { + log.Info("ExtWorkspace: finished event received") + }) + + if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil { + m.manager = manager + log.Info("ExtWorkspace: manager bound successfully") + } else { + log.Errorf("ExtWorkspace: failed to bind manager: %v", err) + } + } + }) + + log.Info("ExtWorkspace: registry setup complete (events will be processed async)") + return nil +} + +func (m *Manager) handleWorkspaceGroup(e ext_workspace.ExtWorkspaceManagerV1WorkspaceGroupEvent) { + handle := e.WorkspaceGroup + groupID := handle.ID() + + log.Debugf("ExtWorkspace: New workspace group (id=%d)", groupID) + + group := &workspaceGroupState{ + id: groupID, + handle: handle, + outputIDs: make(map[uint32]bool), + workspaceIDs: make([]uint32, 0), + } + + m.groups.Store(groupID, group) + + handle.SetCapabilitiesHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1CapabilitiesEvent) { + log.Debugf("ExtWorkspace: Group %d capabilities: %d", groupID, e.Capabilities) + }) + + handle.SetOutputEnterHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1OutputEnterEvent) { + outputID := e.Output.ID() + log.Debugf("ExtWorkspace: Group %d output enter (output=%d)", groupID, outputID) + + m.post(func() { + group.outputIDs[outputID] = true + m.updateState() + }) + }) + + handle.SetOutputLeaveHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1OutputLeaveEvent) { + outputID := e.Output.ID() + log.Debugf("ExtWorkspace: Group %d output leave (output=%d)", groupID, outputID) + m.post(func() { + delete(group.outputIDs, outputID) + m.updateState() + }) + }) + + handle.SetWorkspaceEnterHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1WorkspaceEnterEvent) { + workspaceID := e.Workspace.ID() + log.Debugf("ExtWorkspace: Group %d workspace enter (workspace=%d)", groupID, workspaceID) + + m.post(func() { + if ws, ok := m.workspaces.Load(workspaceID); ok { + ws.groupID = groupID + } + + group.workspaceIDs = append(group.workspaceIDs, workspaceID) + m.updateState() + }) + }) + + handle.SetWorkspaceLeaveHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent) { + workspaceID := e.Workspace.ID() + log.Debugf("ExtWorkspace: Group %d workspace leave (workspace=%d)", groupID, workspaceID) + + m.post(func() { + if ws, ok := m.workspaces.Load(workspaceID); ok { + ws.groupID = 0 + } + + for i, id := range group.workspaceIDs { + if id == workspaceID { + group.workspaceIDs = append(group.workspaceIDs[:i], group.workspaceIDs[i+1:]...) + break + } + } + m.updateState() + }) + }) + + handle.SetRemovedHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1RemovedEvent) { + log.Debugf("ExtWorkspace: Group %d removed", groupID) + + m.post(func() { + group.removed = true + + m.groups.Delete(groupID) + + m.wlMutex.Lock() + handle.Destroy() + m.wlMutex.Unlock() + + m.updateState() + }) + }) +} + +func (m *Manager) handleWorkspace(e ext_workspace.ExtWorkspaceManagerV1WorkspaceEvent) { + handle := e.Workspace + workspaceID := handle.ID() + + log.Debugf("ExtWorkspace: New workspace (proxy_id=%d)", workspaceID) + + ws := &workspaceState{ + id: workspaceID, + handle: handle, + coordinates: make([]uint32, 0), + } + + m.workspaces.Store(workspaceID, ws) + + handle.SetIdHandler(func(e ext_workspace.ExtWorkspaceHandleV1IdEvent) { + log.Debugf("ExtWorkspace: Workspace %d id: %s", workspaceID, e.Id) + m.post(func() { + ws.workspaceID = e.Id + m.updateState() + }) + }) + + handle.SetNameHandler(func(e ext_workspace.ExtWorkspaceHandleV1NameEvent) { + log.Debugf("ExtWorkspace: Workspace %d name: %s", workspaceID, e.Name) + m.post(func() { + ws.name = e.Name + m.updateState() + }) + }) + + handle.SetCoordinatesHandler(func(e ext_workspace.ExtWorkspaceHandleV1CoordinatesEvent) { + coords := make([]uint32, 0) + for i := 0; i < len(e.Coordinates); i += 4 { + if i+4 <= len(e.Coordinates) { + val := uint32(e.Coordinates[i]) | + uint32(e.Coordinates[i+1])<<8 | + uint32(e.Coordinates[i+2])<<16 | + uint32(e.Coordinates[i+3])<<24 + coords = append(coords, val) + } + } + log.Debugf("ExtWorkspace: Workspace %d coordinates: %v", workspaceID, coords) + m.post(func() { + ws.coordinates = coords + m.updateState() + }) + }) + + handle.SetStateHandler(func(e ext_workspace.ExtWorkspaceHandleV1StateEvent) { + log.Debugf("ExtWorkspace: Workspace %d state: %d", workspaceID, e.State) + m.post(func() { + ws.state = e.State + m.updateState() + }) + }) + + handle.SetCapabilitiesHandler(func(e ext_workspace.ExtWorkspaceHandleV1CapabilitiesEvent) { + log.Debugf("ExtWorkspace: Workspace %d capabilities: %d", workspaceID, e.Capabilities) + }) + + handle.SetRemovedHandler(func(e ext_workspace.ExtWorkspaceHandleV1RemovedEvent) { + log.Debugf("ExtWorkspace: Workspace %d removed", workspaceID) + + m.post(func() { + ws.removed = true + + m.workspaces.Delete(workspaceID) + + m.wlMutex.Lock() + handle.Destroy() + m.wlMutex.Unlock() + + m.updateState() + }) + }) +} + +func (m *Manager) updateState() { + groups := make([]*WorkspaceGroup, 0) + + m.groups.Range(func(key uint32, group *workspaceGroupState) bool { + if group.removed { + return true + } + + outputs := make([]string, 0) + for outputID := range group.outputIDs { + if name, ok := m.outputNames.Load(outputID); ok && name != "" { + outputs = append(outputs, name) + } + } + + workspaces := make([]*Workspace, 0) + for _, wsID := range group.workspaceIDs { + ws, exists := m.workspaces.Load(wsID) + if !exists { + continue + } + if ws.removed { + continue + } + + workspace := &Workspace{ + ID: ws.workspaceID, + Name: ws.name, + Coordinates: ws.coordinates, + State: ws.state, + Active: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateActive) != 0, + Urgent: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateUrgent) != 0, + Hidden: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateHidden) != 0, + } + workspaces = append(workspaces, workspace) + } + + groupState := &WorkspaceGroup{ + ID: fmt.Sprintf("group-%d", group.id), + Outputs: outputs, + Workspaces: workspaces, + } + groups = append(groups, groupState) + return true + }) + + newState := State{ + Groups: groups, + } + + m.stateMutex.Lock() + m.state = &newState + m.stateMutex.Unlock() + + m.notifySubscribers() +} + +func (m *Manager) notifier() { + defer m.notifierWg.Done() + const minGap = 100 * 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 + } + + currentState := m.GetState() + + if m.lastNotified != nil && !stateChanged(m.lastNotified, ¤tState) { + pending = false + continue + } + + m.subscribers.Range(func(key string, ch chan State) bool { + select { + case ch <- currentState: + default: + log.Warn("ExtWorkspace: subscriber channel full, dropping update") + } + return true + }) + + stateCopy := currentState + m.lastNotified = &stateCopy + pending = false + } + } +} + +func (m *Manager) ActivateWorkspace(groupID, workspaceID string) error { + errChan := make(chan error, 1) + + m.post(func() { + var targetGroupID uint32 + if groupID != "" { + var parsedID uint32 + if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil { + targetGroupID = parsedID + } + } + + var found bool + m.workspaces.Range(func(key uint32, ws *workspaceState) bool { + if targetGroupID != 0 && ws.groupID != targetGroupID { + return true + } + if ws.workspaceID == workspaceID || ws.name == workspaceID { + m.wlMutex.Lock() + err := ws.handle.Activate() + if err == nil { + err = m.manager.Commit() + } + m.wlMutex.Unlock() + errChan <- err + found = true + return false + } + return true + }) + + if !found { + errChan <- fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID) + } + }) + + return <-errChan +} + +func (m *Manager) DeactivateWorkspace(groupID, workspaceID string) error { + errChan := make(chan error, 1) + + m.post(func() { + var targetGroupID uint32 + if groupID != "" { + var parsedID uint32 + if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil { + targetGroupID = parsedID + } + } + + var found bool + m.workspaces.Range(func(key uint32, ws *workspaceState) bool { + if targetGroupID != 0 && ws.groupID != targetGroupID { + return true + } + if ws.workspaceID == workspaceID || ws.name == workspaceID { + m.wlMutex.Lock() + err := ws.handle.Deactivate() + if err == nil { + err = m.manager.Commit() + } + m.wlMutex.Unlock() + errChan <- err + found = true + return false + } + return true + }) + + if !found { + errChan <- fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID) + } + }) + + return <-errChan +} + +func (m *Manager) RemoveWorkspace(groupID, workspaceID string) error { + errChan := make(chan error, 1) + + m.post(func() { + var targetGroupID uint32 + if groupID != "" { + var parsedID uint32 + if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil { + targetGroupID = parsedID + } + } + + var found bool + m.workspaces.Range(func(key uint32, ws *workspaceState) bool { + if targetGroupID != 0 && ws.groupID != targetGroupID { + return true + } + if ws.workspaceID == workspaceID || ws.name == workspaceID { + m.wlMutex.Lock() + err := ws.handle.Remove() + if err == nil { + err = m.manager.Commit() + } + m.wlMutex.Unlock() + errChan <- err + found = true + return false + } + return true + }) + + if !found { + errChan <- fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID) + } + }) + + return <-errChan +} + +func (m *Manager) CreateWorkspace(groupID, workspaceName string) error { + errChan := make(chan error, 1) + + m.post(func() { + var found bool + m.groups.Range(func(key uint32, group *workspaceGroupState) bool { + if fmt.Sprintf("group-%d", group.id) == groupID { + m.wlMutex.Lock() + err := group.handle.CreateWorkspace(workspaceName) + if err == nil { + err = m.manager.Commit() + } + m.wlMutex.Unlock() + errChan <- err + found = true + return false + } + return true + }) + + if !found { + errChan <- fmt.Errorf("workspace group not found: %s", groupID) + } + }) + + return <-errChan +} + +func (m *Manager) Close() { + close(m.stopChan) + m.wg.Wait() + m.notifierWg.Wait() + + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + + m.workspaces.Range(func(key uint32, ws *workspaceState) bool { + if ws.handle != nil { + ws.handle.Destroy() + } + m.workspaces.Delete(key) + return true + }) + + m.groups.Range(func(key uint32, group *workspaceGroupState) bool { + if group.handle != nil { + group.handle.Destroy() + } + m.groups.Delete(key) + return true + }) + + if m.manager != nil { + m.manager.Stop() + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager_test.go new file mode 100644 index 0000000..b20d457 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager_test.go @@ -0,0 +1,392 @@ +package extworkspace + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient" +) + +func TestStateChanged_BothNil(t *testing.T) { + assert.True(t, stateChanged(nil, nil)) +} + +func TestStateChanged_OneNil(t *testing.T) { + s := &State{Groups: []*WorkspaceGroup{}} + assert.True(t, stateChanged(s, nil)) + assert.True(t, stateChanged(nil, s)) +} + +func TestStateChanged_GroupCountDiffers(t *testing.T) { + a := &State{Groups: []*WorkspaceGroup{{ID: "group-1"}}} + b := &State{Groups: []*WorkspaceGroup{}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_GroupIDDiffers(t *testing.T) { + a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{}, Workspaces: []*Workspace{}}}} + b := &State{Groups: []*WorkspaceGroup{{ID: "group-2", Outputs: []string{}, Workspaces: []*Workspace{}}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputCountDiffers(t *testing.T) { + a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}, Workspaces: []*Workspace{}}}} + b := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{}, Workspaces: []*Workspace{}}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputNameDiffers(t *testing.T) { + a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}, Workspaces: []*Workspace{}}}} + b := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"HDMI-A-1"}, Workspaces: []*Workspace{}}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_WorkspaceCountDiffers(t *testing.T) { + a := &State{Groups: []*WorkspaceGroup{{ + ID: "group-1", + Outputs: []string{}, + Workspaces: []*Workspace{{ID: "1", Name: "ws1"}}, + }}} + b := &State{Groups: []*WorkspaceGroup{{ + ID: "group-1", + Outputs: []string{}, + Workspaces: []*Workspace{}, + }}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_WorkspaceFieldsDiffer(t *testing.T) { + a := &State{Groups: []*WorkspaceGroup{{ + ID: "group-1", + Outputs: []string{}, + Workspaces: []*Workspace{{ + ID: "1", Name: "ws1", State: 0, Active: false, Urgent: false, Hidden: false, + }}, + }}} + b := &State{Groups: []*WorkspaceGroup{{ + ID: "group-1", + Outputs: []string{}, + Workspaces: []*Workspace{{ + ID: "2", Name: "ws1", State: 0, Active: false, Urgent: false, Hidden: false, + }}, + }}} + assert.True(t, stateChanged(a, b)) + + b.Groups[0].Workspaces[0].ID = "1" + b.Groups[0].Workspaces[0].Name = "ws2" + assert.True(t, stateChanged(a, b)) + + b.Groups[0].Workspaces[0].Name = "ws1" + b.Groups[0].Workspaces[0].State = 1 + assert.True(t, stateChanged(a, b)) + + b.Groups[0].Workspaces[0].State = 0 + b.Groups[0].Workspaces[0].Active = true + assert.True(t, stateChanged(a, b)) + + b.Groups[0].Workspaces[0].Active = false + b.Groups[0].Workspaces[0].Urgent = true + assert.True(t, stateChanged(a, b)) + + b.Groups[0].Workspaces[0].Urgent = false + b.Groups[0].Workspaces[0].Hidden = true + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_WorkspaceCoordinatesDiffer(t *testing.T) { + a := &State{Groups: []*WorkspaceGroup{{ + ID: "group-1", + Outputs: []string{}, + Workspaces: []*Workspace{{ + ID: "1", Name: "ws1", Coordinates: []uint32{0, 0}, + }}, + }}} + b := &State{Groups: []*WorkspaceGroup{{ + ID: "group-1", + Outputs: []string{}, + Workspaces: []*Workspace{{ + ID: "1", Name: "ws1", Coordinates: []uint32{1, 0}, + }}, + }}} + assert.True(t, stateChanged(a, b)) + + b.Groups[0].Workspaces[0].Coordinates = []uint32{0} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_Equal(t *testing.T) { + a := &State{Groups: []*WorkspaceGroup{{ + ID: "group-1", + Outputs: []string{"eDP-1", "HDMI-A-1"}, + Workspaces: []*Workspace{ + {ID: "1", Name: "ws1", Coordinates: []uint32{0, 0}, State: 1, Active: true}, + {ID: "2", Name: "ws2", Coordinates: []uint32{1, 0}, State: 0, Active: false}, + }, + }}} + b := &State{Groups: []*WorkspaceGroup{{ + ID: "group-1", + Outputs: []string{"eDP-1", "HDMI-A-1"}, + Workspaces: []*Workspace{ + {ID: "1", Name: "ws1", Coordinates: []uint32{0, 0}, State: 1, Active: true}, + {ID: "2", Name: "ws2", Coordinates: []uint32{1, 0}, State: 0, Active: false}, + }, + }}} + assert.False(t, stateChanged(a, b)) +} + +func TestManager_ConcurrentGetState(t *testing.T) { + m := &Manager{ + state: &State{ + Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}}}, + }, + } + + var wg sync.WaitGroup + const goroutines = 50 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + s := m.GetState() + _ = s.Groups + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.stateMutex.Lock() + m.state = &State{ + Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}}}, + } + m.stateMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentSubscriberAccess(t *testing.T) { + m := &Manager{ + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + + var wg sync.WaitGroup + const goroutines = 20 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + subID := string(rune('a' + id)) + ch := m.Subscribe(subID) + assert.NotNil(t, ch) + time.Sleep(time.Millisecond) + m.Unsubscribe(subID) + }(i) + } + + wg.Wait() +} + +func TestManager_SyncmapGroupsConcurrentAccess(t *testing.T) { + m := &Manager{} + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 50 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + key := uint32(id) + + for j := 0; j < iterations; j++ { + state := &workspaceGroupState{ + id: key, + outputIDs: map[uint32]bool{1: true}, + workspaceIDs: []uint32{uint32(j)}, + } + m.groups.Store(key, state) + + if loaded, ok := m.groups.Load(key); ok { + assert.Equal(t, key, loaded.id) + } + + m.groups.Range(func(k uint32, v *workspaceGroupState) bool { + _ = v.id + _ = v.outputIDs + return true + }) + } + + m.groups.Delete(key) + }(i) + } + + wg.Wait() +} + +func TestManager_SyncmapWorkspacesConcurrentAccess(t *testing.T) { + m := &Manager{} + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 50 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + key := uint32(id) + + for j := 0; j < iterations; j++ { + state := &workspaceState{ + id: key, + workspaceID: "ws-1", + name: "workspace", + state: uint32(j % 4), + coordinates: []uint32{uint32(j), 0}, + } + m.workspaces.Store(key, state) + + if loaded, ok := m.workspaces.Load(key); ok { + assert.Equal(t, key, loaded.id) + } + + m.workspaces.Range(func(k uint32, v *workspaceState) bool { + _ = v.name + _ = v.state + return true + }) + } + + m.workspaces.Delete(key) + }(i) + } + + wg.Wait() +} + +func TestManager_SyncmapOutputNamesConcurrentAccess(t *testing.T) { + m := &Manager{} + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 50 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + key := uint32(id) + + for j := 0; j < iterations; j++ { + m.outputNames.Store(key, "eDP-1") + + if loaded, ok := m.outputNames.Load(key); ok { + assert.NotEmpty(t, loaded) + } + + m.outputNames.Range(func(k uint32, v string) bool { + _ = v + return true + }) + } + + m.outputNames.Delete(key) + }(i) + } + + wg.Wait() +} + +func TestManager_NotifySubscribersNonBlocking(t *testing.T) { + m := &Manager{ + dirty: make(chan struct{}, 1), + } + + for i := 0; i < 10; i++ { + m.notifySubscribers() + } + + assert.Len(t, m.dirty, 1) +} + +func TestManager_PostQueueFull(t *testing.T) { + m := &Manager{ + cmdq: make(chan cmd, 2), + stopChan: make(chan struct{}), + } + + m.post(func() {}) + m.post(func() {}) + m.post(func() {}) + m.post(func() {}) + + assert.Len(t, m.cmdq, 2) +} + +func TestManager_GetStateNilState(t *testing.T) { + m := &Manager{} + + s := m.GetState() + assert.NotNil(t, s.Groups) + assert.Empty(t, s.Groups) +} + +func TestWorkspace_Fields(t *testing.T) { + ws := Workspace{ + ID: "ws-1", + Name: "workspace 1", + Coordinates: []uint32{0, 0}, + State: 1, + Active: true, + Urgent: false, + Hidden: false, + } + + assert.Equal(t, "ws-1", ws.ID) + assert.Equal(t, "workspace 1", ws.Name) + assert.True(t, ws.Active) + assert.False(t, ws.Urgent) + assert.False(t, ws.Hidden) +} + +func TestWorkspaceGroup_Fields(t *testing.T) { + group := WorkspaceGroup{ + ID: "group-1", + Outputs: []string{"eDP-1", "HDMI-A-1"}, + Workspaces: []*Workspace{ + {ID: "ws-1", Name: "workspace 1"}, + }, + } + + assert.Equal(t, "group-1", group.ID) + assert.Len(t, group.Outputs, 2) + assert.Len(t, group.Workspaces, 1) +} + +func TestNewManager_GetRegistryError(t *testing.T) { + mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) + + mockDisplay.EXPECT().Context().Return(nil) + mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry")) + + _, err := NewManager(mockDisplay) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get registry") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/extworkspace/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/extworkspace/types.go new file mode 100644 index 0000000..903a1cb --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/extworkspace/types.go @@ -0,0 +1,169 @@ +package extworkspace + +import ( + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_workspace" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type Workspace struct { + ID string `json:"id"` + Name string `json:"name"` + Coordinates []uint32 `json:"coordinates"` + State uint32 `json:"state"` + Active bool `json:"active"` + Urgent bool `json:"urgent"` + Hidden bool `json:"hidden"` +} + +type WorkspaceGroup struct { + ID string `json:"id"` + Outputs []string `json:"outputs"` + Workspaces []*Workspace `json:"workspaces"` +} + +type State struct { + Groups []*WorkspaceGroup `json:"groups"` +} + +type cmd struct { + fn func() +} + +type Manager struct { + display wlclient.WaylandDisplay + ctx *wlclient.Context + registry *wlclient.Registry + manager *ext_workspace.ExtWorkspaceManagerV1 + + outputNames syncmap.Map[uint32, string] + + groups syncmap.Map[uint32, *workspaceGroupState] + + workspaces syncmap.Map[uint32, *workspaceState] + + wlMutex sync.Mutex + cmdq chan cmd + stopChan chan struct{} + wg sync.WaitGroup + + subscribers syncmap.Map[string, chan State] + dirty chan struct{} + notifierWg sync.WaitGroup + lastNotified *State + + stateMutex sync.RWMutex + state *State +} + +type workspaceGroupState struct { + id uint32 + handle *ext_workspace.ExtWorkspaceGroupHandleV1 + outputIDs map[uint32]bool + workspaceIDs []uint32 + removed bool +} + +type workspaceState struct { + id uint32 + handle *ext_workspace.ExtWorkspaceHandleV1 + workspaceID string + name string + coordinates []uint32 + state uint32 + groupID uint32 + removed bool +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + if m.state == nil { + return State{ + Groups: []*WorkspaceGroup{}, + } + } + stateCopy := *m.state + return stateCopy +} + +func (m *Manager) Subscribe(id string) chan State { + ch := make(chan State, 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) notifySubscribers() { + select { + case m.dirty <- struct{}{}: + default: + } +} + +func stateChanged(old, new *State) bool { + if old == nil || new == nil { + return true + } + if len(old.Groups) != len(new.Groups) { + return true + } + + for i, newGroup := range new.Groups { + if i >= len(old.Groups) { + return true + } + oldGroup := old.Groups[i] + if oldGroup.ID != newGroup.ID { + return true + } + if len(oldGroup.Outputs) != len(newGroup.Outputs) { + return true + } + for j, newOutput := range newGroup.Outputs { + if j >= len(oldGroup.Outputs) { + return true + } + if oldGroup.Outputs[j] != newOutput { + return true + } + } + if len(oldGroup.Workspaces) != len(newGroup.Workspaces) { + return true + } + for j, newWs := range newGroup.Workspaces { + if j >= len(oldGroup.Workspaces) { + return true + } + oldWs := oldGroup.Workspaces[j] + if oldWs.ID != newWs.ID || oldWs.Name != newWs.Name || oldWs.State != newWs.State { + return true + } + if oldWs.Active != newWs.Active || oldWs.Urgent != newWs.Urgent || oldWs.Hidden != newWs.Hidden { + return true + } + if len(oldWs.Coordinates) != len(newWs.Coordinates) { + return true + } + for k, coord := range newWs.Coordinates { + if k >= len(oldWs.Coordinates) { + return true + } + if oldWs.Coordinates[k] != coord { + return true + } + } + } + } + + return false +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions.go new file mode 100644 index 0000000..52515d1 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions.go @@ -0,0 +1,112 @@ +package freedesktop + +import ( + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/godbus/dbus/v5" +) + +func (m *Manager) SetIconFile(iconPath string) error { + if !m.state.Accounts.Available || m.accountsObj == nil { + return fmt.Errorf("accounts service not available") + } + + err := m.accountsObj.Call(dbusAccountsUserInterface+".SetIconFile", 0, iconPath).Err + if err != nil { + return fmt.Errorf("failed to set icon file: %w", err) + } + + m.updateAccountsState() + return nil +} + +func (m *Manager) SetRealName(name string) error { + if !m.state.Accounts.Available || m.accountsObj == nil { + return fmt.Errorf("accounts service not available") + } + + err := m.accountsObj.Call(dbusAccountsUserInterface+".SetRealName", 0, name).Err + if err != nil { + return fmt.Errorf("failed to set real name: %w", err) + } + + m.updateAccountsState() + return nil +} + +func (m *Manager) SetEmail(email string) error { + if !m.state.Accounts.Available || m.accountsObj == nil { + return fmt.Errorf("accounts service not available") + } + + err := m.accountsObj.Call(dbusAccountsUserInterface+".SetEmail", 0, email).Err + if err != nil { + return fmt.Errorf("failed to set email: %w", err) + } + + m.updateAccountsState() + return nil +} + +func (m *Manager) SetLanguage(language string) error { + if !m.state.Accounts.Available || m.accountsObj == nil { + return fmt.Errorf("accounts service not available") + } + + err := m.accountsObj.Call(dbusAccountsUserInterface+".SetLanguage", 0, language).Err + if err != nil { + return fmt.Errorf("failed to set language: %w", err) + } + + m.updateAccountsState() + return nil +} + +func (m *Manager) SetLocation(location string) error { + if !m.state.Accounts.Available || m.accountsObj == nil { + return fmt.Errorf("accounts service not available") + } + + err := m.accountsObj.Call(dbusAccountsUserInterface+".SetLocation", 0, location).Err + if err != nil { + return fmt.Errorf("failed to set location: %w", err) + } + + m.updateAccountsState() + return nil +} + +func (m *Manager) GetUserIconFile(username string) (string, error) { + if m.systemConn == nil { + return "", fmt.Errorf("accounts service not available") + } + + accountsManager := m.systemConn.Object(dbusAccountsDest, dbus.ObjectPath(dbusAccountsPath)) + + var userPath dbus.ObjectPath + err := accountsManager.Call(dbusAccountsInterface+".FindUserByName", 0, username).Store(&userPath) + if err != nil { + return "", fmt.Errorf("user not found: %w", err) + } + + userObj := m.systemConn.Object(dbusAccountsDest, userPath) + variant, err := userObj.GetProperty(dbusAccountsUserInterface + ".IconFile") + if err != nil { + return "", err + } + + var iconFile string + if err := variant.Store(&iconFile); err != nil { + return "", err + } + + return iconFile, nil +} + +func (m *Manager) SetIconTheme(iconTheme string) error { + if err := utils.GsettingsSet("org.gnome.desktop.interface", "icon-theme", iconTheme); err != nil { + return fmt.Errorf("failed to set icon theme: %w", err) + } + return nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions_test.go new file mode 100644 index 0000000..e2c21e8 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions_test.go @@ -0,0 +1,145 @@ +package freedesktop + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestManager_SetIconFile(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.SetIconFile("/path/to/icon.png") + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + }) +} + +func TestManager_SetRealName(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.SetRealName("New Name") + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + }) +} + +func TestManager_SetEmail(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.SetEmail("test@example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + }) +} + +func TestManager_SetLanguage(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.SetLanguage("en_US.UTF-8") + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + }) +} + +func TestManager_SetLocation(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.SetLocation("Test Location") + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + }) +} + +func TestManager_GetUserIconFile(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + iconFile, err := manager.GetUserIconFile("testuser") + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + assert.Empty(t, iconFile) + }) +} + +func TestManager_UpdateAccountsState(t *testing.T) { + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.updateAccountsState() + assert.Error(t, err) + assert.Contains(t, err.Error(), "accounts service not available") + }) +} + +func TestManager_UpdateSettingsState(t *testing.T) { + t.Run("settings not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Settings: SettingsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + err := manager.updateSettingsState() + assert.Error(t, err) + assert.Contains(t, err.Error(), "settings portal not available") + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/constants.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/constants.go new file mode 100644 index 0000000..12db87b --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/constants.go @@ -0,0 +1,23 @@ +package freedesktop + +const ( + dbusAccountsDest = "org.freedesktop.Accounts" + dbusAccountsPath = "/org/freedesktop/Accounts" + dbusAccountsInterface = "org.freedesktop.Accounts" + dbusAccountsUserInterface = "org.freedesktop.Accounts.User" + + dbusPortalDest = "org.freedesktop.portal.Desktop" + dbusPortalPath = "/org/freedesktop/portal/desktop" + dbusPortalSettingsInterface = "org.freedesktop.portal.Settings" + + dbusPropsInterface = "org.freedesktop.DBus.Properties" + + dbusScreensaverName = "org.freedesktop.ScreenSaver" + dbusScreensaverPath = "/ScreenSaver" + dbusScreensaverPath2 = "/org/freedesktop/ScreenSaver" + dbusScreensaverInterface = "org.freedesktop.ScreenSaver" + + dbusGnomeScreensaverName = "org.gnome.ScreenSaver" + dbusGnomeScreensaverPath = "/org/gnome/ScreenSaver" + dbusGnomeScreensaverInterface = "org.gnome.ScreenSaver" +) diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers.go new file mode 100644 index 0000000..c3090fa --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers.go @@ -0,0 +1,154 @@ +package freedesktop + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + switch req.Method { + case "freedesktop.getState": + handleGetState(conn, req, manager) + case "freedesktop.accounts.setIconFile": + handleSetIconFile(conn, req, manager) + case "freedesktop.accounts.setRealName": + handleSetRealName(conn, req, manager) + case "freedesktop.accounts.setEmail": + handleSetEmail(conn, req, manager) + case "freedesktop.accounts.setLanguage": + handleSetLanguage(conn, req, manager) + case "freedesktop.accounts.setLocation": + handleSetLocation(conn, req, manager) + case "freedesktop.accounts.getUserIconFile": + handleGetUserIconFile(conn, req, manager) + case "freedesktop.settings.getColorScheme": + handleGetColorScheme(conn, req, manager) + case "freedesktop.settings.setIconTheme": + handleSetIconTheme(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 handleSetIconFile(conn net.Conn, req models.Request, manager *Manager) { + iconPath, err := params.String(req.Params, "path") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetIconFile(iconPath); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "icon file set"}) +} + +func handleSetRealName(conn net.Conn, req models.Request, manager *Manager) { + name, err := params.String(req.Params, "name") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetRealName(name); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "real name set"}) +} + +func handleSetEmail(conn net.Conn, req models.Request, manager *Manager) { + email, err := params.String(req.Params, "email") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetEmail(email); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "email set"}) +} + +func handleSetLanguage(conn net.Conn, req models.Request, manager *Manager) { + language, err := params.String(req.Params, "language") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetLanguage(language); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "language set"}) +} + +func handleSetLocation(conn net.Conn, req models.Request, manager *Manager) { + location, err := params.String(req.Params, "location") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetLocation(location); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "location set"}) +} + +func handleGetUserIconFile(conn net.Conn, req models.Request, manager *Manager) { + username, err := params.String(req.Params, "username") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + iconFile, err := manager.GetUserIconFile(username) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Value: iconFile}) +} + +func handleGetColorScheme(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.updateSettingsState(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + state := manager.GetState() + models.Respond(conn, req.ID, map[string]uint32{"colorScheme": state.Settings.ColorScheme}) +} + +func handleSetIconTheme(conn net.Conn, req models.Request, manager *Manager) { + iconTheme, err := params.String(req.Params, "iconTheme") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetIconTheme(iconTheme); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "icon theme set"}) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers_test.go new file mode 100644 index 0000000..f2cb5a2 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers_test.go @@ -0,0 +1,581 @@ +package freedesktop + +import ( + "bytes" + "encoding/json" + "net" + "sync" + "testing" + + mockdbus "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type mockNetConn struct { + net.Conn + readBuf *bytes.Buffer + writeBuf *bytes.Buffer + closed bool +} + +func newMockNetConn() *mockNetConn { + return &mockNetConn{ + readBuf: &bytes.Buffer{}, + writeBuf: &bytes.Buffer{}, + } +} + +func (m *mockNetConn) Read(b []byte) (n int, err error) { + return m.readBuf.Read(b) +} + +func (m *mockNetConn) Write(b []byte) (n int, err error) { + return m.writeBuf.Write(b) +} + +func (m *mockNetConn) Close() error { + m.closed = true + return nil +} + +func mockGetAllAccountsProperties() *dbus.Call { + props := map[string]dbus.Variant{ + "IconFile": dbus.MakeVariant("/path/to/icon.png"), + "RealName": dbus.MakeVariant("Test"), + "UserName": dbus.MakeVariant("test"), + "AccountType": dbus.MakeVariant(int32(0)), + "HomeDirectory": dbus.MakeVariant("/home/test"), + "Shell": dbus.MakeVariant("/bin/bash"), + "Email": dbus.MakeVariant(""), + "Language": dbus.MakeVariant(""), + "Location": dbus.MakeVariant(""), + "Locked": dbus.MakeVariant(false), + "PasswordMode": dbus.MakeVariant(int32(1)), + } + return &dbus.Call{Err: nil, Body: []any{props}} +} + +func TestRespondError_Freedesktop(t *testing.T) { + conn := newMockNetConn() + models.RespondError(conn, 123, "test error") + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Equal(t, "test error", resp.Error) + assert.Nil(t, resp.Result) +} + +func TestRespond_Freedesktop(t *testing.T) { + conn := newMockNetConn() + result := models.SuccessResult{Success: true, Message: "test"} + models.Respond(conn, 123, result) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.Equal(t, "test", resp.Result.Message) +} + +func TestHandleGetState(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + UserName: "testuser", + RealName: "Test User", + UID: 1000, + }, + Settings: SettingsState{ + Available: true, + ColorScheme: 1, + }, + }, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "freedesktop.getState"} + + handleGetState(conn, req, manager) + + var resp models.Response[FreedeskState] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Accounts.Available) + assert.Equal(t, "testuser", resp.Result.Accounts.UserName) + assert.True(t, resp.Result.Settings.Available) + assert.Equal(t, uint32(1), resp.Result.Settings.ColorScheme) +} + +func TestHandleSetIconFile(t *testing.T) { + t.Run("missing path parameter", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setIconFile", + Params: map[string]any{}, + } + + handleSetIconFile(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "missing or invalid 'path' parameter") + }) + + t.Run("successful set icon file", func(t *testing.T) { + mockAccountsObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: nil} + mockAccountsObj.EXPECT().Call("org.freedesktop.Accounts.User.SetIconFile", dbus.Flags(0), "/path/to/icon.png").Return(mockCall) + mockAccountsObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.Accounts.User").Return(mockGetAllAccountsProperties()) + + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + }, + }, + stateMutex: sync.RWMutex{}, + accountsObj: mockAccountsObj, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setIconFile", + Params: map[string]any{ + "path": "/path/to/icon.png", + }, + } + + handleSetIconFile(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.Equal(t, "icon file set", resp.Result.Message) + }) + + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setIconFile", + Params: map[string]any{ + "path": "/path/to/icon.png", + }, + } + + handleSetIconFile(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "accounts service not available") + }) +} + +func TestHandleSetRealName(t *testing.T) { + t.Run("missing name parameter", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setRealName", + Params: map[string]any{}, + } + + handleSetRealName(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "missing or invalid 'name' parameter") + }) + + t.Run("successful set real name", func(t *testing.T) { + mockAccountsObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: nil} + mockAccountsObj.EXPECT().Call("org.freedesktop.Accounts.User.SetRealName", dbus.Flags(0), "New Name").Return(mockCall) + mockAccountsObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.Accounts.User").Return(mockGetAllAccountsProperties()) + + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + }, + }, + stateMutex: sync.RWMutex{}, + accountsObj: mockAccountsObj, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setRealName", + Params: map[string]any{ + "name": "New Name", + }, + } + + handleSetRealName(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.Equal(t, "real name set", resp.Result.Message) + }) +} + +func TestHandleSetEmail(t *testing.T) { + t.Run("missing email parameter", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setEmail", + Params: map[string]any{}, + } + + handleSetEmail(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "missing or invalid 'email' parameter") + }) + + t.Run("successful set email", func(t *testing.T) { + mockAccountsObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: nil} + mockAccountsObj.EXPECT().Call("org.freedesktop.Accounts.User.SetEmail", dbus.Flags(0), "test@example.com").Return(mockCall) + mockAccountsObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.Accounts.User").Return(mockGetAllAccountsProperties()) + + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + }, + }, + stateMutex: sync.RWMutex{}, + accountsObj: mockAccountsObj, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setEmail", + Params: map[string]any{ + "email": "test@example.com", + }, + } + + handleSetEmail(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.Equal(t, "email set", resp.Result.Message) + }) +} + +func TestHandleSetLanguage(t *testing.T) { + t.Run("missing language parameter", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setLanguage", + Params: map[string]any{}, + } + + handleSetLanguage(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "missing or invalid 'language' parameter") + }) +} + +func TestHandleSetLocation(t *testing.T) { + t.Run("missing location parameter", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.setLocation", + Params: map[string]any{}, + } + + handleSetLocation(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "missing or invalid 'location' parameter") + }) +} + +func TestHandleGetUserIconFile(t *testing.T) { + t.Run("missing username parameter", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.getUserIconFile", + Params: map[string]any{}, + } + + handleGetUserIconFile(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "missing or invalid 'username' parameter") + }) + + t.Run("accounts not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.accounts.getUserIconFile", + Params: map[string]any{ + "username": "testuser", + }, + } + + handleGetUserIconFile(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "accounts service not available") + }) +} + +func TestHandleGetColorScheme(t *testing.T) { + t.Run("settings not available", func(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Settings: SettingsState{ + Available: false, + }, + }, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "freedesktop.settings.getColorScheme"} + + handleGetColorScheme(conn, req, manager) + + var resp models.Response[map[string]uint32] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "settings portal not available") + }) + + t.Run("successful get color scheme", func(t *testing.T) { + mockSettingsObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{ + Err: nil, + Body: []any{dbus.MakeVariant(uint32(1))}, + } + mockSettingsObj.EXPECT().Call("org.freedesktop.portal.Settings.ReadOne", dbus.Flags(0), "org.freedesktop.appearance", "color-scheme").Return(mockCall) + + manager := &Manager{ + state: &FreedeskState{ + Settings: SettingsState{ + Available: true, + }, + }, + stateMutex: sync.RWMutex{}, + settingsObj: mockSettingsObj, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "freedesktop.settings.getColorScheme"} + + handleGetColorScheme(conn, req, manager) + + var resp models.Response[map[string]uint32] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.Equal(t, uint32(1), (*resp.Result)["colorScheme"]) + }) +} + +func TestHandleRequest(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + UserName: "testuser", + }, + }, + stateMutex: sync.RWMutex{}, + } + + t.Run("unknown method", func(t *testing.T) { + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.unknown", + } + + HandleRequest(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "unknown method") + }) + + t.Run("valid method - getState", func(t *testing.T) { + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "freedesktop.getState", + } + + HandleRequest(conn, req, manager) + + var resp models.Response[FreedeskState] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + }) + + t.Run("all method routes", func(t *testing.T) { + tests := []string{ + "freedesktop.accounts.setIconFile", + "freedesktop.accounts.setRealName", + "freedesktop.accounts.setEmail", + "freedesktop.accounts.setLanguage", + "freedesktop.accounts.setLocation", + "freedesktop.accounts.getUserIconFile", + "freedesktop.settings.getColorScheme", + } + + for _, method := range tests { + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: method, + Params: map[string]any{}, + } + + HandleRequest(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + // Will have errors due to missing params or service unavailable + // but the method routing should work + } + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager.go new file mode 100644 index 0000000..054f859 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager.go @@ -0,0 +1,206 @@ +package freedesktop + +import ( + "context" + "fmt" + "os" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" + "github.com/godbus/dbus/v5" +) + +func NewManager() (*Manager, error) { + systemConn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, fmt.Errorf("failed to connect to system bus: %w", err) + } + + sessionConn, err := dbus.ConnectSessionBus() + if err != nil { + sessionConn = nil + } + + m := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{}, + Settings: SettingsState{}, + Screensaver: ScreensaverState{}, + }, + stateMutex: sync.RWMutex{}, + systemConn: systemConn, + sessionConn: sessionConn, + currentUID: uint64(os.Getuid()), + } + + m.initializeAccounts() + m.initializeSettings() + m.initializeScreensaver() + + return m, nil +} + +func (m *Manager) initializeAccounts() error { + accountsManager := m.systemConn.Object(dbusAccountsDest, dbus.ObjectPath(dbusAccountsPath)) + + var userPath dbus.ObjectPath + err := accountsManager.Call(dbusAccountsInterface+".FindUserById", 0, int64(m.currentUID)).Store(&userPath) + if err != nil { + m.stateMutex.Lock() + m.state.Accounts.Available = false + m.stateMutex.Unlock() + return err + } + + m.accountsObj = m.systemConn.Object(dbusAccountsDest, userPath) + + m.stateMutex.Lock() + m.state.Accounts.Available = true + m.state.Accounts.UserPath = string(userPath) + m.state.Accounts.UID = m.currentUID + m.stateMutex.Unlock() + + if err := m.updateAccountsState(); err != nil { + return fmt.Errorf("failed to update accounts state: %w", err) + } + + return nil +} + +func (m *Manager) initializeSettings() error { + if m.sessionConn == nil { + m.stateMutex.Lock() + m.state.Settings.Available = false + m.stateMutex.Unlock() + return fmt.Errorf("no session bus connection") + } + + m.settingsObj = m.sessionConn.Object(dbusPortalDest, dbus.ObjectPath(dbusPortalPath)) + + var variant dbus.Variant + err := m.settingsObj.Call(dbusPortalSettingsInterface+".ReadOne", 0, "org.freedesktop.appearance", "color-scheme").Store(&variant) + if err != nil { + m.stateMutex.Lock() + m.state.Settings.Available = false + m.stateMutex.Unlock() + return err + } + + m.stateMutex.Lock() + m.state.Settings.Available = true + m.stateMutex.Unlock() + + if err := m.updateSettingsState(); err != nil { + return fmt.Errorf("failed to update settings state: %w", err) + } + + return nil +} + +func (m *Manager) updateAccountsState() error { + if !m.state.Accounts.Available || m.accountsObj == nil { + return fmt.Errorf("accounts service not available") + } + + ctx := context.Background() + props, err := m.getAccountProperties(ctx) + if err != nil { + return err + } + + m.stateMutex.Lock() + defer m.stateMutex.Unlock() + + m.state.Accounts.IconFile = dbusutil.GetOr(props, "IconFile", "") + m.state.Accounts.RealName = dbusutil.GetOr(props, "RealName", "") + m.state.Accounts.UserName = dbusutil.GetOr(props, "UserName", "") + m.state.Accounts.AccountType = dbusutil.GetOr(props, "AccountType", int32(0)) + m.state.Accounts.HomeDirectory = dbusutil.GetOr(props, "HomeDirectory", "") + m.state.Accounts.Shell = dbusutil.GetOr(props, "Shell", "") + m.state.Accounts.Email = dbusutil.GetOr(props, "Email", "") + m.state.Accounts.Language = dbusutil.GetOr(props, "Language", "") + m.state.Accounts.Location = dbusutil.GetOr(props, "Location", "") + m.state.Accounts.Locked = dbusutil.GetOr(props, "Locked", false) + m.state.Accounts.PasswordMode = dbusutil.GetOr(props, "PasswordMode", int32(0)) + + return nil +} + +func (m *Manager) updateSettingsState() error { + if !m.state.Settings.Available || m.settingsObj == nil { + return fmt.Errorf("settings portal not available") + } + + var variant dbus.Variant + err := m.settingsObj.Call(dbusPortalSettingsInterface+".ReadOne", 0, "org.freedesktop.appearance", "color-scheme").Store(&variant) + if err != nil { + return err + } + + if colorScheme, ok := dbusutil.As[uint32](variant); ok { + m.stateMutex.Lock() + m.state.Settings.ColorScheme = colorScheme + m.stateMutex.Unlock() + } + + return nil +} + +func (m *Manager) getAccountProperties(ctx context.Context) (map[string]dbus.Variant, error) { + var props map[string]dbus.Variant + err := m.accountsObj.CallWithContext(ctx, dbusPropsInterface+".GetAll", 0, dbusAccountsUserInterface).Store(&props) + if err != nil { + return nil, err + } + return props, nil +} + +func (m *Manager) GetState() FreedeskState { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + return *m.state +} + +func (m *Manager) Subscribe(id string) chan FreedeskState { + ch := make(chan FreedeskState, 64) + m.subscribers.Store(id, ch) + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) NotifySubscribers() { + state := m.GetState() + m.subscribers.Range(func(key string, ch chan FreedeskState) bool { + select { + case ch <- state: + default: + } + return true + }) +} + +func (m *Manager) Close() { + m.subscribers.Range(func(key string, ch chan FreedeskState) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + + m.screensaverSubscribers.Range(func(key string, ch chan ScreensaverState) bool { + close(ch) + m.screensaverSubscribers.Delete(key) + return true + }) + + if m.systemConn != nil { + m.systemConn.Close() + } + if m.sessionConn != nil { + m.sessionConn.Close() + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager_test.go new file mode 100644 index 0000000..b3617f5 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager_test.go @@ -0,0 +1,143 @@ +package freedesktop + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestManager_GetState(t *testing.T) { + state := &FreedeskState{ + Accounts: AccountsState{ + Available: true, + UserName: "testuser", + RealName: "Test User", + UID: 1000, + }, + Settings: SettingsState{ + Available: true, + ColorScheme: 1, + }, + } + + manager := &Manager{ + state: state, + stateMutex: sync.RWMutex{}, + } + + result := manager.GetState() + assert.True(t, result.Accounts.Available) + assert.Equal(t, "testuser", result.Accounts.UserName) + assert.Equal(t, "Test User", result.Accounts.RealName) + assert.Equal(t, uint64(1000), result.Accounts.UID) + assert.True(t, result.Settings.Available) + assert.Equal(t, uint32(1), result.Settings.ColorScheme) +} + +func TestManager_GetState_ThreadSafe(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + UserName: "testuser", + }, + Settings: SettingsState{ + Available: true, + ColorScheme: 1, + }, + }, + stateMutex: sync.RWMutex{}, + } + + done := make(chan bool) + for i := 0; i < 10; i++ { + go func() { + state := manager.GetState() + assert.True(t, state.Accounts.Available) + assert.Equal(t, "testuser", state.Accounts.UserName) + done <- true + }() + } + + for i := 0; i < 10; i++ { + <-done + } +} + +func TestManager_Close(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + systemConn: nil, + sessionConn: nil, + } + + assert.NotPanics(t, func() { + manager.Close() + }) +} + +func TestNewManager(t *testing.T) { + t.Run("attempts to create manager", func(t *testing.T) { + manager, err := NewManager() + if err != nil { + assert.Nil(t, manager) + } else { + assert.NotNil(t, manager) + assert.NotNil(t, manager.state) + assert.NotNil(t, manager.systemConn) + + manager.Close() + } + }) +} + +func TestManager_GetState_EmptyState(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{}, + stateMutex: sync.RWMutex{}, + } + + result := manager.GetState() + assert.False(t, result.Accounts.Available) + assert.Empty(t, result.Accounts.UserName) + assert.False(t, result.Settings.Available) + assert.Equal(t, uint32(0), result.Settings.ColorScheme) +} + +func TestManager_AccountsState_Modification(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Accounts: AccountsState{ + Available: true, + UserName: "testuser", + }, + }, + stateMutex: sync.RWMutex{}, + } + + state := manager.GetState() + state.Accounts.UserName = "modifieduser" + + original := manager.GetState() + assert.Equal(t, "testuser", original.Accounts.UserName) +} + +func TestManager_SettingsState_Modification(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Settings: SettingsState{ + Available: true, + ColorScheme: 0, + }, + }, + stateMutex: sync.RWMutex{}, + } + + state := manager.GetState() + state.Settings.ColorScheme = 1 + + original := manager.GetState() + assert.Equal(t, uint32(0), original.Settings.ColorScheme) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver.go new file mode 100644 index 0000000..095a4c0 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver.go @@ -0,0 +1,349 @@ +package freedesktop + +import ( + "fmt" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" +) + +type screensaverHandler struct { + manager *Manager +} + +func screensaverIntrospectIface(ifaceName string) introspect.Interface { + return introspect.Interface{ + Name: ifaceName, + Methods: []introspect.Method{ + { + Name: "Inhibit", + Args: []introspect.Arg{ + {Name: "application_name", Type: "s", Direction: "in"}, + {Name: "reason_for_inhibit", Type: "s", Direction: "in"}, + {Name: "cookie", Type: "u", Direction: "out"}, + }, + }, + { + Name: "UnInhibit", + Args: []introspect.Arg{ + {Name: "cookie", Type: "u", Direction: "in"}, + }, + }, + { + Name: "GetActive", + Args: []introspect.Arg{ + {Name: "active", Type: "b", Direction: "out"}, + }, + }, + { + Name: "SetActive", + Args: []introspect.Arg{ + {Name: "active", Type: "b", Direction: "in"}, + }, + }, + { + Name: "Lock", + }, + }, + Signals: []introspect.Signal{ + { + Name: "ActiveChanged", + Args: []introspect.Arg{ + {Name: "new_value", Type: "b"}, + }, + }, + }, + } +} + +func (m *Manager) initializeScreensaver() error { + if m.sessionConn == nil { + m.stateMutex.Lock() + m.state.Screensaver.Available = false + m.stateMutex.Unlock() + return nil + } + + handler := &screensaverHandler{manager: m} + + m.screensaverFreedesktopClaimed = m.claimScreensaverName(handler, + dbusScreensaverName, dbusScreensaverInterface, dbusScreensaverPath, dbusScreensaverPath2) + m.screensaverGnomeClaimed = m.claimScreensaverName(handler, + dbusGnomeScreensaverName, dbusGnomeScreensaverInterface, dbusGnomeScreensaverPath) + + if !m.screensaverFreedesktopClaimed && !m.screensaverGnomeClaimed { + log.Warn("No screensaver interface could be claimed") + m.stateMutex.Lock() + m.state.Screensaver.Available = false + m.stateMutex.Unlock() + return nil + } + + go m.watchPeerDisconnects() + + m.stateMutex.Lock() + m.state.Screensaver.Available = true + m.state.Screensaver.Active = false + m.state.Screensaver.Inhibited = false + m.state.Screensaver.Inhibitors = []ScreensaverInhibitor{} + m.stateMutex.Unlock() + + log.Info("Screensaver listener initialized") + return nil +} + +func (m *Manager) claimScreensaverName(handler *screensaverHandler, name, iface string, paths ...dbus.ObjectPath) bool { + reply, err := m.sessionConn.RequestName(name, dbus.NameFlagDoNotQueue) + if err != nil { + log.Warnf("Failed to request screensaver name %s: %v", name, err) + return false + } + if reply != dbus.RequestNameReplyPrimaryOwner { + log.Warnf("Screensaver name %s already owned by another process", name) + return false + } + if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil { + log.Warnf("Failed to export screensaver on %s: %v", name, err) + return false + } + log.Infof("Claimed %s on session bus", name) + return true +} + +// exportScreensaverOnPaths exports the handler and introspection on the given +// paths under the specified interface name. +func (m *Manager) exportScreensaverOnPaths(handler *screensaverHandler, ifaceName string, paths ...dbus.ObjectPath) error { + iface := screensaverIntrospectIface(ifaceName) + for _, path := range paths { + if err := m.sessionConn.Export(handler, path, ifaceName); err != nil { + return fmt.Errorf("export handler on %s: %w", path, err) + } + node := &introspect.Node{ + Name: string(path), + Interfaces: []introspect.Interface{ + introspect.IntrospectData, + iface, + }, + } + if err := m.sessionConn.Export(introspect.NewIntrospectable(node), path, "org.freedesktop.DBus.Introspectable"); err != nil { + log.Warnf("Failed to export introspectable on %s: %v", path, err) + } + } + return nil +} + +func (h *screensaverHandler) Inhibit(sender dbus.Sender, appName, reason string) (uint32, *dbus.Error) { + if appName == "" { + return 0, dbus.NewError("org.freedesktop.DBus.Error.InvalidArgs", []any{"application name required"}) + } + + if reason == "" { + return 0, dbus.NewError("org.freedesktop.DBus.Error.InvalidArgs", []any{"reason required"}) + } + + if strings.Contains(strings.ToLower(reason), "audio") && !strings.Contains(strings.ToLower(reason), "video") { + log.Debugf("Ignoring audio-only inhibit from %s: %s", appName, reason) + return 0, nil + } + + if idx := strings.LastIndex(appName, "/"); idx != -1 && idx < len(appName)-1 { + appName = appName[idx+1:] + } + appName = filepath.Base(appName) + + cookie := atomic.AddUint32(&h.manager.screensaverCookieCounter, 1) + + inhibitor := ScreensaverInhibitor{ + Cookie: cookie, + AppName: appName, + Reason: reason, + Peer: string(sender), + StartTime: time.Now().Unix(), + } + + h.manager.stateMutex.Lock() + h.manager.state.Screensaver.Inhibitors = append(h.manager.state.Screensaver.Inhibitors, inhibitor) + h.manager.state.Screensaver.Inhibited = len(h.manager.state.Screensaver.Inhibitors) > 0 + h.manager.stateMutex.Unlock() + + log.Infof("Screensaver inhibited by %s (%s): %s -> cookie %08X", appName, sender, reason, cookie) + + h.manager.NotifyScreensaverSubscribers() + + return cookie, nil +} + +func (h *screensaverHandler) UnInhibit(sender dbus.Sender, cookie uint32) *dbus.Error { + h.manager.stateMutex.Lock() + defer h.manager.stateMutex.Unlock() + + found := false + inhibitors := h.manager.state.Screensaver.Inhibitors + for i, inh := range inhibitors { + if inh.Cookie != cookie { + continue + } + log.Infof("Screensaver uninhibited by %s (%s) cookie %08X", inh.AppName, sender, cookie) + h.manager.state.Screensaver.Inhibitors = append(inhibitors[:i], inhibitors[i+1:]...) + found = true + break + } + + if !found { + log.Debugf("UnInhibit: no match for cookie %08X", cookie) + return nil + } + + h.manager.state.Screensaver.Inhibited = len(h.manager.state.Screensaver.Inhibitors) > 0 + + go h.manager.NotifyScreensaverSubscribers() + + return nil +} + +func (m *Manager) watchPeerDisconnects() { + if m.sessionConn == nil { + return + } + + if err := m.sessionConn.AddMatchSignal( + dbus.WithMatchInterface("org.freedesktop.DBus"), + dbus.WithMatchMember("NameOwnerChanged"), + ); err != nil { + log.Warnf("Failed to watch peer disconnects: %v", err) + return + } + + signals := make(chan *dbus.Signal, 64) + m.sessionConn.Signal(signals) + + for sig := range signals { + if sig.Name != "org.freedesktop.DBus.NameOwnerChanged" { + continue + } + if len(sig.Body) < 3 { + continue + } + + name, ok1 := sig.Body[0].(string) + newOwner, ok2 := sig.Body[2].(string) + if !ok1 || !ok2 { + continue + } + if newOwner != "" { + continue + } + + m.removeInhibitorsByPeer(name) + } +} + +func (m *Manager) removeInhibitorsByPeer(peer string) { + m.stateMutex.Lock() + defer m.stateMutex.Unlock() + + var remaining []ScreensaverInhibitor + var removed []ScreensaverInhibitor + for _, inh := range m.state.Screensaver.Inhibitors { + if inh.Peer == peer { + removed = append(removed, inh) + continue + } + remaining = append(remaining, inh) + } + + if len(removed) == 0 { + return + } + + for _, inh := range removed { + log.Infof("Screensaver: peer %s died, removing inhibitor from %s (cookie %08X)", peer, inh.AppName, inh.Cookie) + } + + m.state.Screensaver.Inhibitors = remaining + m.state.Screensaver.Inhibited = len(remaining) > 0 + + go m.NotifyScreensaverSubscribers() +} + +func (m *Manager) GetScreensaverState() ScreensaverState { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + return m.state.Screensaver +} + +func (m *Manager) SubscribeScreensaver(id string) chan ScreensaverState { + ch := make(chan ScreensaverState, 64) + m.screensaverSubscribers.Store(id, ch) + return ch +} + +func (m *Manager) UnsubscribeScreensaver(id string) { + if val, ok := m.screensaverSubscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) NotifyScreensaverSubscribers() { + state := m.GetScreensaverState() + m.screensaverSubscribers.Range(func(key string, ch chan ScreensaverState) bool { + select { + case ch <- state: + default: + } + return true + }) +} + +func (h *screensaverHandler) GetActive() (bool, *dbus.Error) { + h.manager.stateMutex.RLock() + active := h.manager.state.Screensaver.Active + h.manager.stateMutex.RUnlock() + return active, nil +} + +func (h *screensaverHandler) SetActive(active bool) *dbus.Error { + h.manager.SetScreenLockActive(active) + return nil +} + +func (h *screensaverHandler) Lock() *dbus.Error { + h.manager.SetScreenLockActive(true) + return nil +} + +func (m *Manager) SetScreenLockActive(active bool) { + m.stateMutex.Lock() + changed := m.state.Screensaver.Active != active + m.state.Screensaver.Active = active + m.stateMutex.Unlock() + + if !changed { + return + } + + log.Infof("Screen lock active changed: %v", active) + defer m.NotifyScreensaverSubscribers() + + if m.sessionConn == nil { + return + } + if m.screensaverFreedesktopClaimed { + if err := m.sessionConn.Emit(dbusScreensaverPath, dbusScreensaverInterface+".ActiveChanged", active); err != nil { + log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusScreensaverPath, err) + } + if err := m.sessionConn.Emit(dbusScreensaverPath2, dbusScreensaverInterface+".ActiveChanged", active); err != nil { + log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusScreensaverPath2, err) + } + } + if m.screensaverGnomeClaimed { + if err := m.sessionConn.Emit(dbusGnomeScreensaverPath, dbusGnomeScreensaverInterface+".ActiveChanged", active); err != nil { + log.Warnf("Failed to emit ActiveChanged on %s: %v", dbusGnomeScreensaverPath, err) + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver_test.go new file mode 100644 index 0000000..324c17e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver_test.go @@ -0,0 +1,102 @@ +package freedesktop + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestSetScreenLockActive_ChangesState(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true}, + }, + stateMutex: sync.RWMutex{}, + } + + assert.False(t, manager.GetScreensaverState().Active) + + manager.SetScreenLockActive(true) + assert.True(t, manager.GetScreensaverState().Active) + + manager.SetScreenLockActive(false) + assert.False(t, manager.GetScreensaverState().Active) +} + +func TestSetScreenLockActive_NoChangeNoDuplicate(t *testing.T) { + ch := make(chan ScreensaverState, 64) + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true, Active: false}, + }, + stateMutex: sync.RWMutex{}, + } + manager.screensaverSubscribers.Store("test", ch) + defer manager.screensaverSubscribers.Delete("test") + + // Setting to same value should not notify + manager.SetScreenLockActive(false) + + select { + case <-ch: + t.Fatal("should not have received notification for no-change") + case <-time.After(50 * time.Millisecond): + // Expected: no notification + } +} + +func TestSetScreenLockActive_NotifiesSubscribers(t *testing.T) { + ch := make(chan ScreensaverState, 64) + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true, Active: false}, + }, + stateMutex: sync.RWMutex{}, + } + manager.screensaverSubscribers.Store("test", ch) + defer manager.screensaverSubscribers.Delete("test") + + manager.SetScreenLockActive(true) + + select { + case state := <-ch: + assert.True(t, state.Active) + case <-time.After(time.Second): + t.Fatal("timeout waiting for subscriber notification") + } +} + +func TestSetScreenLockActive_NilSessionConn(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true}, + }, + stateMutex: sync.RWMutex{}, + } + + assert.NotPanics(t, func() { + manager.SetScreenLockActive(true) + }) + assert.True(t, manager.GetScreensaverState().Active) +} + +func TestGetActive_ReturnsCurrentState(t *testing.T) { + manager := &Manager{ + state: &FreedeskState{ + Screensaver: ScreensaverState{Available: true, Active: true}, + }, + stateMutex: sync.RWMutex{}, + } + + handler := &screensaverHandler{manager: manager} + active, dbusErr := handler.GetActive() + assert.Nil(t, dbusErr) + assert.True(t, active) +} + +func TestScreensaverState_ActiveDefaultsFalse(t *testing.T) { + state := ScreensaverState{} + assert.False(t, state.Active) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/types.go new file mode 100644 index 0000000..0a9517a --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/types.go @@ -0,0 +1,66 @@ +package freedesktop + +import ( + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" + "github.com/godbus/dbus/v5" +) + +type AccountsState struct { + Available bool `json:"available"` + UserPath string `json:"userPath"` + IconFile string `json:"iconFile"` + RealName string `json:"realName"` + UserName string `json:"userName"` + AccountType int32 `json:"accountType"` + HomeDirectory string `json:"homeDirectory"` + Shell string `json:"shell"` + Email string `json:"email"` + Language string `json:"language"` + Location string `json:"location"` + Locked bool `json:"locked"` + PasswordMode int32 `json:"passwordMode"` + UID uint64 `json:"uid"` +} + +type SettingsState struct { + Available bool `json:"available"` + ColorScheme uint32 `json:"colorScheme"` +} + +type ScreensaverInhibitor struct { + Cookie uint32 `json:"cookie"` + AppName string `json:"appName"` + Reason string `json:"reason"` + Peer string `json:"peer"` + StartTime int64 `json:"startTime"` +} + +type ScreensaverState struct { + Available bool `json:"available"` + Active bool `json:"active"` + Inhibited bool `json:"inhibited"` + Inhibitors []ScreensaverInhibitor `json:"inhibitors"` +} + +type FreedeskState struct { + Accounts AccountsState `json:"accounts"` + Settings SettingsState `json:"settings"` + Screensaver ScreensaverState `json:"screensaver"` +} + +type Manager struct { + state *FreedeskState + stateMutex sync.RWMutex + systemConn *dbus.Conn + sessionConn *dbus.Conn + accountsObj dbus.BusObject + settingsObj dbus.BusObject + currentUID uint64 + subscribers syncmap.Map[string, chan FreedeskState] + screensaverSubscribers syncmap.Map[string, chan ScreensaverState] + screensaverCookieCounter uint32 + screensaverFreedesktopClaimed bool + screensaverGnomeClaimed bool +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/types_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/types_test.go new file mode 100644 index 0000000..deb0327 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/freedesktop/types_test.go @@ -0,0 +1,70 @@ +package freedesktop + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAccountsState_Struct(t *testing.T) { + state := AccountsState{ + Available: true, + UserPath: "/org/freedesktop/Accounts/User1000", + RealName: "Test User", + UserName: "testuser", + Locked: false, + UID: 1000, + } + + assert.True(t, state.Available) + assert.Equal(t, "/org/freedesktop/Accounts/User1000", state.UserPath) + assert.Equal(t, "Test User", state.RealName) + assert.Equal(t, "testuser", state.UserName) + assert.Equal(t, uint64(1000), state.UID) + assert.False(t, state.Locked) +} + +func TestSettingsState_Struct(t *testing.T) { + state := SettingsState{ + Available: true, + ColorScheme: 1, // Dark mode + } + + assert.True(t, state.Available) + assert.Equal(t, uint32(1), state.ColorScheme) +} + +func TestFreedeskState_Struct(t *testing.T) { + state := FreedeskState{ + Accounts: AccountsState{ + Available: true, + UserName: "testuser", + UID: 1000, + }, + Settings: SettingsState{ + Available: true, + ColorScheme: 0, // Light mode + }, + } + + assert.True(t, state.Accounts.Available) + assert.Equal(t, "testuser", state.Accounts.UserName) + assert.True(t, state.Settings.Available) + assert.Equal(t, uint32(0), state.Settings.ColorScheme) +} + +func TestAccountsState_DefaultValues(t *testing.T) { + state := AccountsState{} + + assert.False(t, state.Available) + assert.Empty(t, state.UserPath) + assert.Empty(t, state.UserName) + assert.Equal(t, uint64(0), state.UID) +} + +func TestSettingsState_DefaultValues(t *testing.T) { + state := SettingsState{} + + assert.False(t, state.Available) + assert.Equal(t, uint32(0), state.ColorScheme) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/location/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/location/handlers.go new file mode 100644 index 0000000..c9d556c --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/location/handlers.go @@ -0,0 +1,61 @@ +package location + +import ( + "encoding/json" + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +type LocationEvent struct { + Type string `json:"type"` + Data State `json:"data"` +} + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + switch req.Method { + case "location.getState": + handleGetState(conn, req, manager) + case "location.subscribe": + handleSubscribe(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 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 := LocationEvent{ + Type: "state_changed", + Data: initialState, + } + + if err := json.NewEncoder(conn).Encode(models.Response[LocationEvent]{ + ID: req.ID, + Result: &event, + }); err != nil { + return + } + + for state := range stateChan { + event := LocationEvent{ + Type: "state_changed", + Data: state, + } + if err := json.NewEncoder(conn).Encode(models.Response[LocationEvent]{ + Result: &event, + }); err != nil { + return + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/location/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/location/manager.go new file mode 100644 index 0000000..24bcd95 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/location/manager.go @@ -0,0 +1,175 @@ +package location + +import ( + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +func NewManager(client geolocation.Client) (*Manager, error) { + currLocation, err := client.GetLocation() + if err != nil { + log.Warnf("Failed to get initial location: %v", err) + } + + m := &Manager{ + client: client, + dirty: make(chan struct{}), + stopChan: make(chan struct{}), + + state: &State{ + Latitude: currLocation.Latitude, + Longitude: currLocation.Longitude, + }, + } + + if err := m.startSignalPump(); err != nil { + return nil, err + } + + m.notifierWg.Add(1) + go m.notifier() + + return m, nil +} + +func (m *Manager) Close() { + close(m.stopChan) + m.notifierWg.Wait() + + m.sigWG.Wait() + + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) +} + +func (m *Manager) Subscribe(id string) chan State { + ch := make(chan State, 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) startSignalPump() error { + m.sigWG.Add(1) + go func() { + defer m.sigWG.Done() + + subscription := m.client.Subscribe("locationManager") + defer m.client.Unsubscribe("locationManager") + + for { + select { + case <-m.stopChan: + return + case location, ok := <-subscription: + if !ok { + return + } + + m.handleLocationChange(location) + } + } + }() + + return nil +} + +func (m *Manager) handleLocationChange(location geolocation.Location) { + m.stateMutex.Lock() + defer m.stateMutex.Unlock() + + m.state.Latitude = location.Latitude + m.state.Longitude = location.Longitude + + m.notifySubscribers() +} + +func (m *Manager) notifySubscribers() { + select { + case m.dirty <- struct{}{}: + default: + } +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + if m.state == nil { + return State{ + Latitude: 0.0, + Longitude: 0.0, + } + } + stateCopy := *m.state + return stateCopy +} + +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 + } + + currentState := m.GetState() + + if m.lastNotified != nil && !stateChanged(m.lastNotified, ¤tState) { + pending = false + continue + } + + m.subscribers.Range(func(key string, ch chan State) bool { + select { + case ch <- currentState: + default: + log.Warn("Location: subscriber channel full, dropping update") + } + return true + }) + + stateCopy := currentState + m.lastNotified = &stateCopy + pending = false + } + } +} + +func stateChanged(old, new *State) bool { + if old == nil || new == nil { + return true + } + if old.Latitude != new.Latitude { + return true + } + if old.Longitude != new.Longitude { + return true + } + + return false +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/location/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/location/types.go new file mode 100644 index 0000000..1f3d518 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/location/types.go @@ -0,0 +1,28 @@ +package location + +import ( + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type State struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + +type Manager struct { + state *State + stateMutex sync.RWMutex + + client geolocation.Client + + stopChan chan struct{} + sigWG sync.WaitGroup + + subscribers syncmap.Map[string, chan State] + dirty chan struct{} + notifierWg sync.WaitGroup + lastNotified *State +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/actions.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/actions.go new file mode 100644 index 0000000..858248e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/actions.go @@ -0,0 +1,88 @@ +package loginctl + +import ( + "fmt" +) + +func (m *Manager) Lock() error { + if m.sessionObj == nil { + return fmt.Errorf("session object not available") + } + err := m.sessionObj.Call(dbusSessionInterface+".Lock", 0).Err + if err != nil { + if refreshErr := m.refreshSessionBinding(); refreshErr == nil { + err = m.sessionObj.Call(dbusSessionInterface+".Lock", 0).Err + } + if err != nil { + return fmt.Errorf("failed to lock session: %w", err) + } + } + return nil +} + +func (m *Manager) Unlock() error { + err := m.sessionObj.Call(dbusSessionInterface+".Unlock", 0).Err + if err != nil { + if refreshErr := m.refreshSessionBinding(); refreshErr == nil { + err = m.sessionObj.Call(dbusSessionInterface+".Unlock", 0).Err + } + if err != nil { + return fmt.Errorf("failed to unlock session: %w", err) + } + } + return nil +} + +func (m *Manager) Activate() error { + err := m.sessionObj.Call(dbusSessionInterface+".Activate", 0).Err + if err != nil { + if refreshErr := m.refreshSessionBinding(); refreshErr == nil { + err = m.sessionObj.Call(dbusSessionInterface+".Activate", 0).Err + } + if err != nil { + return fmt.Errorf("failed to activate session: %w", err) + } + } + return nil +} + +func (m *Manager) SetIdleHint(idle bool) error { + err := m.sessionObj.Call(dbusSessionInterface+".SetIdleHint", 0, idle).Err + if err != nil { + if refreshErr := m.refreshSessionBinding(); refreshErr == nil { + err = m.sessionObj.Call(dbusSessionInterface+".SetIdleHint", 0, idle).Err + } + if err != nil { + return fmt.Errorf("failed to set idle hint: %w", err) + } + } + return nil +} + +func (m *Manager) Terminate() error { + err := m.sessionObj.Call(dbusSessionInterface+".Terminate", 0).Err + if err != nil { + if refreshErr := m.refreshSessionBinding(); refreshErr == nil { + err = m.sessionObj.Call(dbusSessionInterface+".Terminate", 0).Err + } + if err != nil { + return fmt.Errorf("failed to terminate session: %w", err) + } + } + return nil +} + +func (m *Manager) SetLockBeforeSuspend(enabled bool) { + m.lockBeforeSuspend.Store(enabled) +} + +func (m *Manager) SetSleepInhibitorEnabled(enabled bool) { + m.sleepInhibitorEnabled.Store(enabled) + if enabled { + // Re-acquire inhibitor if enabled + m.acquireSleepInhibitor() + } else { + // Release inhibitor if disabled + m.releaseSleepInhibitor() + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/constants.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/constants.go new file mode 100644 index 0000000..1a29588 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/constants.go @@ -0,0 +1,10 @@ +package loginctl + +const ( + dbusDest = "org.freedesktop.login1" + dbusPath = "/org/freedesktop/login1" + dbusManagerInterface = "org.freedesktop.login1.Manager" + dbusSessionInterface = "org.freedesktop.login1.Session" + dbusUserInterface = "org.freedesktop.login1.User" + dbusPropsInterface = "org.freedesktop.DBus.Properties" +) diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers.go new file mode 100644 index 0000000..564e996 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers.go @@ -0,0 +1,156 @@ +package loginctl + +import ( + "encoding/json" + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + switch req.Method { + case "loginctl.getState": + handleGetState(conn, req, manager) + case "loginctl.lock": + handleLock(conn, req, manager) + case "loginctl.unlock": + handleUnlock(conn, req, manager) + case "loginctl.activate": + handleActivate(conn, req, manager) + case "loginctl.setIdleHint": + handleSetIdleHint(conn, req, manager) + case "loginctl.setLockBeforeSuspend": + handleSetLockBeforeSuspend(conn, req, manager) + case "loginctl.setSleepInhibitorEnabled": + handleSetSleepInhibitorEnabled(conn, req, manager) + case "loginctl.lockerReady": + handleLockerReady(conn, req, manager) + case "loginctl.terminate": + handleTerminate(conn, req, manager) + case "loginctl.subscribe": + handleSubscribe(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 handleLock(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.Lock(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "locked"}) +} + +func handleUnlock(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.Unlock(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "unlocked"}) +} + +func handleActivate(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.Activate(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "activated"}) +} + +func handleSetIdleHint(conn net.Conn, req models.Request, manager *Manager) { + idle, err := params.Bool(req.Params, "idle") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetIdleHint(idle); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "idle hint set"}) +} + +func handleSetLockBeforeSuspend(conn net.Conn, req models.Request, manager *Manager) { + enabled, err := params.Bool(req.Params, "enabled") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + manager.SetLockBeforeSuspend(enabled) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "lock before suspend set"}) +} + +func handleSetSleepInhibitorEnabled(conn net.Conn, req models.Request, manager *Manager) { + enabled, err := params.Bool(req.Params, "enabled") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + manager.SetSleepInhibitorEnabled(enabled) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "sleep inhibitor setting updated"}) +} + +func handleLockerReady(conn net.Conn, req models.Request, manager *Manager) { + manager.lockTimerMu.Lock() + if manager.lockTimer != nil { + manager.lockTimer.Stop() + manager.lockTimer = nil + } + manager.lockTimerMu.Unlock() + + id := manager.sleepCycleID.Load() + manager.releaseForCycle(id) + + if manager.inSleepCycle.Load() { + manager.signalLockerReady() + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "ok"}) +} + +func handleTerminate(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.Terminate(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "terminated"}) +} + +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 := SessionEvent{ + Type: EventStateChanged, + Data: initialState, + } + if err := json.NewEncoder(conn).Encode(models.Response[SessionEvent]{ + ID: req.ID, + Result: &event, + }); err != nil { + return + } + + for state := range stateChan { + event := SessionEvent{ + Type: EventStateChanged, + Data: state, + } + if err := json.NewEncoder(conn).Encode(models.Response[SessionEvent]{ + Result: &event, + }); err != nil { + return + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers_test.go new file mode 100644 index 0000000..d3169e4 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers_test.go @@ -0,0 +1,500 @@ +package loginctl + +import ( + "bytes" + "encoding/json" + "net" + "sync" + "testing" + "time" + + mockdbus "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type mockNetConn struct { + net.Conn + readBuf *bytes.Buffer + writeBuf *bytes.Buffer + closed bool +} + +func newMockNetConn() *mockNetConn { + return &mockNetConn{ + readBuf: &bytes.Buffer{}, + writeBuf: &bytes.Buffer{}, + } +} + +func (m *mockNetConn) Read(b []byte) (n int, err error) { + return m.readBuf.Read(b) +} + +func (m *mockNetConn) Write(b []byte) (n int, err error) { + return m.writeBuf.Write(b) +} + +func (m *mockNetConn) Close() error { + m.closed = true + return nil +} + +func TestRespondError_Loginctl(t *testing.T) { + conn := newMockNetConn() + models.RespondError(conn, 123, "test error") + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Equal(t, "test error", resp.Error) + assert.Nil(t, resp.Result) +} + +func TestRespond_Loginctl(t *testing.T) { + conn := newMockNetConn() + result := models.SuccessResult{Success: true, Message: "test"} + models.Respond(conn, 123, result) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.Equal(t, "test", resp.Result.Message) +} + +func TestHandleGetState(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + SessionID: "1", + Locked: false, + Active: true, + SessionType: "wayland", + SessionClass: "user", + UserName: "testuser", + }, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "loginctl.getState"} + + handleGetState(conn, req, manager) + + var resp models.Response[SessionState] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.Equal(t, "1", resp.Result.SessionID) + assert.False(t, resp.Result.Locked) + assert.True(t, resp.Result.Active) +} + +func TestHandleLock(t *testing.T) { + t.Run("successful lock", func(t *testing.T) { + mockSessionObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: nil} + mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Lock", dbus.Flags(0)).Return(mockCall) + + manager := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + sessionObj: mockSessionObj, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "loginctl.lock"} + handleLock(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.Equal(t, "locked", resp.Result.Message) + }) + + t.Run("lock fails", func(t *testing.T) { + mockSessionObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: assert.AnError} + mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Lock", dbus.Flags(0)).Return(mockCall) + + manager := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + sessionObj: mockSessionObj, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "loginctl.lock"} + handleLock(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "failed to lock session") + }) +} + +func TestHandleUnlock(t *testing.T) { + t.Run("successful unlock", func(t *testing.T) { + mockSessionObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: nil} + mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Unlock", dbus.Flags(0)).Return(mockCall) + + manager := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + sessionObj: mockSessionObj, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "loginctl.unlock"} + handleUnlock(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.Equal(t, "unlocked", resp.Result.Message) + }) + + t.Run("unlock fails", func(t *testing.T) { + mockSessionObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: assert.AnError} + mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Unlock", dbus.Flags(0)).Return(mockCall) + + manager := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + sessionObj: mockSessionObj, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "loginctl.unlock"} + handleUnlock(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "failed to unlock session") + }) +} + +func TestHandleActivate(t *testing.T) { + t.Run("successful activate", func(t *testing.T) { + mockSessionObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: nil} + mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Activate", dbus.Flags(0)).Return(mockCall) + + manager := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + sessionObj: mockSessionObj, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "loginctl.activate"} + handleActivate(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.Equal(t, "activated", resp.Result.Message) + }) + + t.Run("activate fails", func(t *testing.T) { + mockSessionObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: assert.AnError} + mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Activate", dbus.Flags(0)).Return(mockCall) + + manager := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + sessionObj: mockSessionObj, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "loginctl.activate"} + handleActivate(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "failed to activate session") + }) +} + +func TestHandleSetIdleHint(t *testing.T) { + t.Run("missing idle parameter", func(t *testing.T) { + manager := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "loginctl.setIdleHint", + Params: map[string]any{}, + } + + handleSetIdleHint(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "missing or invalid 'idle' parameter") + }) + + t.Run("successful set idle hint true", func(t *testing.T) { + mockSessionObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: nil} + mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.SetIdleHint", dbus.Flags(0), true).Return(mockCall) + + manager := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + sessionObj: mockSessionObj, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "loginctl.setIdleHint", + Params: map[string]any{ + "idle": true, + }, + } + + handleSetIdleHint(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.Equal(t, "idle hint set", resp.Result.Message) + }) + + t.Run("set idle hint fails", func(t *testing.T) { + mockSessionObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: assert.AnError} + mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.SetIdleHint", dbus.Flags(0), false).Return(mockCall) + + manager := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + sessionObj: mockSessionObj, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "loginctl.setIdleHint", + Params: map[string]any{ + "idle": false, + }, + } + + handleSetIdleHint(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "failed to set idle hint") + }) +} + +func TestHandleTerminate(t *testing.T) { + t.Run("successful terminate", func(t *testing.T) { + mockSessionObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: nil} + mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Terminate", dbus.Flags(0)).Return(mockCall) + + manager := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + sessionObj: mockSessionObj, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "loginctl.terminate"} + handleTerminate(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.Equal(t, "terminated", resp.Result.Message) + }) + + t.Run("terminate fails", func(t *testing.T) { + mockSessionObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: assert.AnError} + mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Terminate", dbus.Flags(0)).Return(mockCall) + + manager := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + sessionObj: mockSessionObj, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "loginctl.terminate"} + handleTerminate(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "failed to terminate session") + }) +} + +func TestHandleRequest(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + SessionID: "1", + Locked: false, + }, + stateMutex: sync.RWMutex{}, + } + + t.Run("unknown method", func(t *testing.T) { + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "loginctl.unknown", + } + + HandleRequest(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "unknown method") + }) + + t.Run("valid method - getState", func(t *testing.T) { + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "loginctl.getState", + } + + HandleRequest(conn, req, manager) + + var resp models.Response[SessionState] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + }) + + t.Run("lock method", func(t *testing.T) { + mockSessionObj := mockdbus.NewMockBusObject(t) + mockCall := &dbus.Call{Err: nil} + mockSessionObj.EXPECT().Call("org.freedesktop.login1.Session.Lock", mock.Anything).Return(mockCall) + + manager.sessionObj = mockSessionObj + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "loginctl.lock", + } + + HandleRequest(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + }) +} + +func TestHandleSubscribe(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + SessionID: "1", + Locked: false, + }, + stateMutex: sync.RWMutex{}, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "loginctl.subscribe"} + + done := make(chan bool) + go func() { + handleSubscribe(conn, req, manager) + done <- true + }() + + time.Sleep(50 * time.Millisecond) + + conn.Close() + + if conn.writeBuf.Len() > 0 { + var resp models.Response[SessionEvent] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + if err == nil { + assert.Equal(t, 123, resp.ID) + require.NotNil(t, resp.Result) + assert.Equal(t, EventStateChanged, resp.Result.Type) + assert.Equal(t, "1", resp.Result.Data.SessionID) + } + } + + select { + case <-done: + case <-time.After(100 * time.Millisecond): + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager.go new file mode 100644 index 0000000..0c51e64 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager.go @@ -0,0 +1,723 @@ +package loginctl + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" + "github.com/godbus/dbus/v5" +) + +func NewManager() (*Manager, error) { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, fmt.Errorf("failed to connect to system bus: %w", err) + } + + m := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + + stopChan: make(chan struct{}), + conn: conn, + dirty: make(chan struct{}, 1), + signals: make(chan *dbus.Signal, 256), + } + m.sleepInhibitorEnabled.Store(true) + + if err := m.initialize(); err != nil { + conn.Close() + return nil, err + } + + if err := m.acquireSleepInhibitor(); err != nil { + fmt.Fprintf(os.Stderr, "sleep inhibitor unavailable: %v\n", err) + } + + m.notifierWg.Add(1) + go m.notifier() + + if err := m.startSignalPump(); err != nil { + m.Close() + return nil, err + } + + return m, nil +} + +func (m *Manager) initialize() error { + m.managerObj = m.conn.Object(dbusDest, dbus.ObjectPath(dbusPath)) + + m.initializeFallbackDelay() + + sessionID, sessionPath, err := m.discoverSession() + if err != nil { + return fmt.Errorf("failed to get session path: %w", err) + } + + m.stateMutex.Lock() + m.state.SessionID = sessionID + m.state.SessionPath = string(sessionPath) + m.sessionPath = sessionPath + m.stateMutex.Unlock() + + m.sessionObj = m.conn.Object(dbusDest, sessionPath) + + if err := m.updateSessionState(); err != nil { + return err + } + + return nil +} + +func (m *Manager) discoverSession() (string, dbus.ObjectPath, error) { + // 1. Explicit XDG_SESSION_ID + if id := os.Getenv("XDG_SESSION_ID"); id != "" { + if path, err := m.getSession(id); err == nil { + fmt.Fprintf(os.Stderr, "loginctl: using XDG_SESSION_ID=%s\n", id) + return id, path, nil + } + } + + // 2. PID-based lookup (works when caller is inside a session cgroup) + if id, path, err := m.getSessionByPID(uint32(os.Getpid())); err == nil { + fmt.Fprintf(os.Stderr, "loginctl: found session %s via PID\n", id) + return id, path, nil + } + + // 3. User's primary display session (handles UWSM and similar) + if id, path, err := m.getUserDisplaySession(); err == nil { + fmt.Fprintf(os.Stderr, "loginctl: found session %s via User.Display\n", id) + return id, path, nil + } + + // 4. Score all sessions for current UID + if id, path, err := m.findBestSession(); err == nil { + fmt.Fprintf(os.Stderr, "loginctl: found session %s via ListSessions scoring\n", id) + return id, path, nil + } + + // 5. Last resort: "self" + path, err := m.getSession("self") + if err != nil { + return "", "", fmt.Errorf("%w", err) + } + return "self", path, nil +} + +func (m *Manager) getSession(id string) (dbus.ObjectPath, error) { + var out dbus.ObjectPath + err := m.managerObj.Call(dbusManagerInterface+".GetSession", 0, id).Store(&out) + if err != nil { + return "", err + } + return out, nil +} + +func (m *Manager) getSessionByPID(pid uint32) (string, dbus.ObjectPath, error) { + var path dbus.ObjectPath + if err := m.managerObj.Call(dbusManagerInterface+".GetSessionByPID", 0, pid).Store(&path); err != nil { + return "", "", err + } + + sessionObj := m.conn.Object(dbusDest, path) + var id dbus.Variant + if err := sessionObj.Call(dbusPropsInterface+".Get", 0, dbusSessionInterface, "Id").Store(&id); err != nil { + return "", "", err + } + return id.Value().(string), path, nil +} + +func (m *Manager) getUserDisplaySession() (string, dbus.ObjectPath, error) { + uid := uint32(os.Getuid()) + + var userPath dbus.ObjectPath + if err := m.managerObj.Call(dbusManagerInterface+".GetUser", 0, uid).Store(&userPath); err != nil { + return "", "", err + } + + userObj := m.conn.Object(dbusDest, userPath) + var display dbus.Variant + if err := userObj.Call(dbusPropsInterface+".Get", 0, dbusUserInterface, "Display").Store(&display); err != nil { + return "", "", err + } + + pair, ok := display.Value().([]any) + if !ok || len(pair) < 2 { + return "", "", fmt.Errorf("unexpected Display format") + } + + sessionID, _ := pair[0].(string) + sessionPath, _ := pair[1].(dbus.ObjectPath) + if sessionID == "" || sessionPath == "" { + return "", "", fmt.Errorf("empty Display session") + } + + return sessionID, sessionPath, nil +} + +type sessionCandidate struct { + id string + path dbus.ObjectPath +} + +func (m *Manager) findBestSession() (string, dbus.ObjectPath, error) { + // ListSessions returns a(susso): [][]any where each entry is [id, uid, name, seat, path] + var raw [][]any + if err := m.managerObj.Call(dbusManagerInterface+".ListSessions", 0).Store(&raw); err != nil { + return "", "", err + } + + uid := uint32(os.Getuid()) + var candidates []sessionCandidate + for _, entry := range raw { + if len(entry) < 5 { + continue + } + entryUID, _ := entry[1].(uint32) + if entryUID != uid { + continue + } + id, _ := entry[0].(string) + path, _ := entry[4].(dbus.ObjectPath) + if id != "" && path != "" { + candidates = append(candidates, sessionCandidate{id: id, path: path}) + } + } + if len(candidates) == 0 { + return "", "", fmt.Errorf("no sessions for uid %d", uid) + } + + bestScore := -1 + var best sessionCandidate + for _, c := range candidates { + score := m.scoreSession(c.path) + if score > bestScore { + bestScore = score + best = c + } + } + if bestScore < 0 { + return "", "", fmt.Errorf("no viable session found") + } + return best.id, best.path, nil +} + +func (m *Manager) scoreSession(path dbus.ObjectPath) int { + obj := m.conn.Object(dbusDest, path) + var props map[string]dbus.Variant + if err := obj.Call(dbusPropsInterface+".GetAll", 0, dbusSessionInterface).Store(&props); err != nil { + return -1 + } + + getStr := func(key string) string { + if v, ok := props[key]; ok { + if s, ok := v.Value().(string); ok { + return s + } + } + return "" + } + getBool := func(key string) bool { + if v, ok := props[key]; ok { + if b, ok := v.Value().(bool); ok { + return b + } + } + return false + } + getUint32 := func(key string) uint32 { + if v, ok := props[key]; ok { + if u, ok := v.Value().(uint32); ok { + return u + } + } + return 0 + } + + class := getStr("Class") + if class != "user" { + return -1 + } + if getBool("Remote") { + return -1 + } + + score := 0 + + if getBool("Active") { + score += 100 + } + + switch getStr("Type") { + case "wayland", "x11": + score += 80 + case "tty": + score += 10 + } + + if v, ok := props["Seat"]; ok { + if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 { + if seat, ok := seatArr[0].(string); ok && seat != "" { + score += 40 + if seat == "seat0" { + score += 10 + } + } + } + } + + if getUint32("VTNr") > 0 { + score += 20 + } + + return score +} + +func (m *Manager) refreshSessionBinding() error { + if m.managerObj == nil || m.conn == nil { + return fmt.Errorf("manager not fully initialized") + } + + sessionPath, err := m.getSession(m.state.SessionID) + if err != nil { + return fmt.Errorf("failed to get session path: %w", err) + } + + m.stateMutex.RLock() + currentPath := m.sessionPath + m.stateMutex.RUnlock() + + if sessionPath == currentPath { + return nil + } + + m.stopSignalPump() + + m.stateMutex.Lock() + m.state.SessionPath = string(sessionPath) + m.sessionPath = sessionPath + m.stateMutex.Unlock() + + m.sessionObj = m.conn.Object(dbusDest, sessionPath) + + if err := m.updateSessionState(); err != nil { + return err + } + + m.signals = make(chan *dbus.Signal, 256) + return m.startSignalPump() +} + +func (m *Manager) updateSessionState() error { + ctx := context.Background() + props, err := m.getSessionProperties(ctx) + if err != nil { + return err + } + + m.stateMutex.Lock() + defer m.stateMutex.Unlock() + + m.state.Active = dbusutil.GetOr(props, "Active", m.state.Active) + m.state.IdleHint = dbusutil.GetOr(props, "IdleHint", m.state.IdleHint) + m.state.IdleSinceHint = dbusutil.GetOr(props, "IdleSinceHint", m.state.IdleSinceHint) + if lockedHint, ok := dbusutil.Get[bool](props, "LockedHint"); ok { + m.state.LockedHint = lockedHint + m.state.Locked = lockedHint + } + m.state.SessionType = dbusutil.GetOr(props, "Type", m.state.SessionType) + m.state.SessionClass = dbusutil.GetOr(props, "Class", m.state.SessionClass) + if v, ok := props["User"]; ok { + if userArr, ok := v.Value().([]any); ok && len(userArr) >= 1 { + if uid, ok := userArr[0].(uint32); ok { + m.state.User = uid + } + } + } + m.state.UserName = dbusutil.GetOr(props, "Name", m.state.UserName) + m.state.RemoteHost = dbusutil.GetOr(props, "RemoteHost", m.state.RemoteHost) + m.state.Service = dbusutil.GetOr(props, "Service", m.state.Service) + m.state.TTY = dbusutil.GetOr(props, "TTY", m.state.TTY) + m.state.Display = dbusutil.GetOr(props, "Display", m.state.Display) + m.state.Remote = dbusutil.GetOr(props, "Remote", m.state.Remote) + if v, ok := props["Seat"]; ok { + if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 { + if seatID, ok := seatArr[0].(string); ok { + m.state.Seat = seatID + } + } + } + m.state.VTNr = dbusutil.GetOr(props, "VTNr", m.state.VTNr) + + return nil +} + +func (m *Manager) getSessionProperties(ctx context.Context) (map[string]dbus.Variant, error) { + var props map[string]dbus.Variant + err := m.sessionObj.CallWithContext(ctx, dbusPropsInterface+".GetAll", 0, dbusSessionInterface).Store(&props) + if err != nil { + return nil, err + } + return props, nil +} + +func (m *Manager) acquireSleepInhibitor() error { + if !m.sleepInhibitorEnabled.Load() { + return nil + } + + m.inhibitMu.Lock() + defer m.inhibitMu.Unlock() + + if m.inhibitFile != nil { + return nil + } + + if m.managerObj == nil { + return fmt.Errorf("manager object not available") + } + + file, err := m.inhibit("sleep", "DankMaterialShell", "Lock before suspend", "delay") + if err != nil { + return err + } + + m.inhibitFile = file + return nil +} + +func (m *Manager) inhibit(what, who, why, mode string) (*os.File, error) { + var fd dbus.UnixFD + err := m.managerObj.Call(dbusManagerInterface+".Inhibit", 0, what, who, why, mode).Store(&fd) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(fd), "inhibit"), nil +} + +func (m *Manager) releaseSleepInhibitor() { + m.inhibitMu.Lock() + f := m.inhibitFile + m.inhibitFile = nil + m.inhibitMu.Unlock() + if f != nil { + f.Close() + } +} + +func (m *Manager) releaseForCycle(id uint64) { + if !m.inSleepCycle.Load() || m.sleepCycleID.Load() != id { + return + } + m.releaseSleepInhibitor() +} + +func (m *Manager) initializeFallbackDelay() { + var maxDelayUSec uint64 + err := m.managerObj.Call( + dbusPropsInterface+".Get", + 0, + dbusManagerInterface, + "InhibitDelayMaxUSec", + ).Store(&maxDelayUSec) + + if err != nil { + m.fallbackDelay = 2 * time.Second + return + } + + maxDelay := time.Duration(maxDelayUSec) * time.Microsecond + computed := (maxDelay * 8) / 10 + + if computed < 2*time.Second { + m.fallbackDelay = 2 * time.Second + } else if computed > 4*time.Second { + m.fallbackDelay = 4 * time.Second + } else { + m.fallbackDelay = computed + } +} + +func (m *Manager) newLockerReadyCh() chan struct{} { + m.lockerReadyChMu.Lock() + defer m.lockerReadyChMu.Unlock() + m.lockerReadyCh = make(chan struct{}) + return m.lockerReadyCh +} + +func (m *Manager) signalLockerReady() { + m.lockerReadyChMu.Lock() + ch := m.lockerReadyCh + if ch != nil { + close(ch) + m.lockerReadyCh = nil + } + m.lockerReadyChMu.Unlock() +} + +func (m *Manager) snapshotState() SessionState { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + return *m.state +} + +func stateChangedMeaningfully(old, new *SessionState) bool { + if old.Locked != new.Locked { + return true + } + if old.LockedHint != new.LockedHint { + return true + } + if old.Active != new.Active { + return true + } + if old.IdleHint != new.IdleHint { + return true + } + if old.PreparingForSleep != new.PreparingForSleep { + return true + } + return false +} + +func (m *Manager) GetState() SessionState { + return m.snapshotState() +} + +func (m *Manager) Subscribe(id string) chan SessionState { + ch := make(chan SessionState, 64) + m.subscribers.Store(id, ch) + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) notifier() { + defer m.notifierWg.Done() + const minGap = 100 * 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 + } + + currentState := m.snapshotState() + + if m.lastNotifiedState != nil && !stateChangedMeaningfully(m.lastNotifiedState, ¤tState) { + pending = false + continue + } + + m.subscribers.Range(func(key string, ch chan SessionState) 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) startSignalPump() error { + m.conn.Signal(m.signals) + + if err := m.conn.AddMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ); err != nil { + m.conn.RemoveSignal(m.signals) + return err + } + if err := m.conn.AddMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusSessionInterface), + dbus.WithMatchMember("Lock"), + ); err != nil { + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + m.conn.RemoveSignal(m.signals) + return err + } + if err := m.conn.AddMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusSessionInterface), + dbus.WithMatchMember("Unlock"), + ); err != nil { + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusSessionInterface), + dbus.WithMatchMember("Lock"), + ) + m.conn.RemoveSignal(m.signals) + return err + } + if err := m.conn.AddMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusPath)), + dbus.WithMatchInterface(dbusManagerInterface), + dbus.WithMatchMember("PrepareForSleep"), + ); err != nil { + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusSessionInterface), + dbus.WithMatchMember("Lock"), + ) + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusSessionInterface), + dbus.WithMatchMember("Unlock"), + ) + m.conn.RemoveSignal(m.signals) + return err + } + + if err := m.conn.AddMatchSignal( + dbus.WithMatchObjectPath("/org/freedesktop/DBus"), + dbus.WithMatchInterface("org.freedesktop.DBus"), + dbus.WithMatchMember("NameOwnerChanged"), + ); err != nil { + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusSessionInterface), + dbus.WithMatchMember("Lock"), + ) + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusSessionInterface), + dbus.WithMatchMember("Unlock"), + ) + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusPath)), + dbus.WithMatchInterface(dbusManagerInterface), + dbus.WithMatchMember("PrepareForSleep"), + ) + m.conn.RemoveSignal(m.signals) + 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.handleDBusSignal(sig) + } + } + }() + return nil +} + +func (m *Manager) stopSignalPump() { + if m.conn == nil { + return + } + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusSessionInterface), + dbus.WithMatchMember("Lock"), + ) + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(m.sessionPath), + dbus.WithMatchInterface(dbusSessionInterface), + dbus.WithMatchMember("Unlock"), + ) + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusPath)), + dbus.WithMatchInterface(dbusManagerInterface), + dbus.WithMatchMember("PrepareForSleep"), + ) + m.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath("/org/freedesktop/DBus"), + dbus.WithMatchInterface("org.freedesktop.DBus"), + dbus.WithMatchMember("NameOwnerChanged"), + ) + + m.conn.RemoveSignal(m.signals) + close(m.signals) + + m.sigWG.Wait() +} + +func (m *Manager) Close() { + close(m.stopChan) + m.notifierWg.Wait() + + m.stopSignalPump() + + m.releaseSleepInhibitor() + + m.subscribers.Range(func(key string, ch chan SessionState) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + + if m.conn != nil { + m.conn.Close() + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager_test.go new file mode 100644 index 0000000..148ee60 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/manager_test.go @@ -0,0 +1,296 @@ +package loginctl + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestManager_GetState(t *testing.T) { + state := &SessionState{ + SessionID: "1", + Locked: false, + Active: true, + IdleHint: false, + SessionType: "wayland", + SessionClass: "user", + UserName: "testuser", + } + + manager := &Manager{ + state: state, + stateMutex: sync.RWMutex{}, + } + + result := manager.GetState() + assert.Equal(t, "1", result.SessionID) + assert.False(t, result.Locked) + assert.True(t, result.Active) + assert.Equal(t, "wayland", result.SessionType) + assert.Equal(t, "testuser", result.UserName) +} + +func TestManager_Subscribe(t *testing.T) { + manager := &Manager{ + state: &SessionState{}, + } + + ch := manager.Subscribe("test-client") + assert.NotNil(t, ch) + assert.Equal(t, 64, cap(ch)) + + _, exists := manager.subscribers.Load("test-client") + assert.True(t, exists) +} + +func TestManager_Unsubscribe(t *testing.T) { + manager := &Manager{ + state: &SessionState{}, + } + + ch := manager.Subscribe("test-client") + + manager.Unsubscribe("test-client") + + _, ok := <-ch + assert.False(t, ok) + + _, exists := manager.subscribers.Load("test-client") + assert.False(t, exists) +} + +func TestManager_Unsubscribe_NonExistent(t *testing.T) { + manager := &Manager{ + state: &SessionState{}, + } + + // Unsubscribe a non-existent client should not panic + assert.NotPanics(t, func() { + manager.Unsubscribe("non-existent") + }) +} + +func TestManager_NotifySubscribers(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + SessionID: "1", + Locked: false, + }, + stateMutex: sync.RWMutex{}, + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + manager.notifierWg.Add(1) + go manager.notifier() + + ch := make(chan SessionState, 10) + manager.subscribers.Store("test-client", ch) + + manager.notifySubscribers() + + select { + case state := <-ch: + assert.Equal(t, "1", state.SessionID) + assert.False(t, state.Locked) + case <-time.After(200 * time.Millisecond): + t.Fatal("did not receive state update") + } + + close(manager.stopChan) + manager.notifierWg.Wait() +} + +func TestManager_NotifySubscribers_Debounce(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + SessionID: "1", + Locked: false, + }, + stateMutex: sync.RWMutex{}, + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + manager.notifierWg.Add(1) + go manager.notifier() + + ch := make(chan SessionState, 10) + manager.subscribers.Store("test-client", ch) + + manager.notifySubscribers() + manager.notifySubscribers() + manager.notifySubscribers() + + receivedCount := 0 + timeout := time.After(200 * time.Millisecond) + for { + select { + case <-ch: + receivedCount++ + case <-timeout: + assert.Equal(t, 1, receivedCount, "should receive exactly one debounced update") + close(manager.stopChan) + manager.notifierWg.Wait() + return + } + } +} + +func TestManager_Close(t *testing.T) { + manager := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + stopChan: make(chan struct{}), + } + + ch1 := make(chan SessionState, 1) + ch2 := make(chan SessionState, 1) + manager.subscribers.Store("client1", ch1) + manager.subscribers.Store("client2", ch2) + + manager.Close() + + select { + case <-manager.stopChan: + case <-time.After(100 * time.Millisecond): + t.Fatal("stopChan not closed") + } + + _, ok1 := <-ch1 + _, ok2 := <-ch2 + assert.False(t, ok1, "ch1 should be closed") + assert.False(t, ok2, "ch2 should be closed") + + count := 0 + manager.subscribers.Range(func(key string, ch chan SessionState) bool { + count++ + return true + }) + assert.Equal(t, 0, count) +} + +func TestManager_GetState_ThreadSafe(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + SessionID: "1", + Locked: false, + Active: true, + }, + stateMutex: sync.RWMutex{}, + } + + done := make(chan bool) + for i := 0; i < 10; i++ { + go func() { + state := manager.GetState() + assert.Equal(t, "1", state.SessionID) + assert.True(t, state.Active) + done <- true + }() + } + + for i := 0; i < 10; i++ { + select { + case <-done: + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for goroutines") + } + } +} + +func TestStateChangedMeaningfully(t *testing.T) { + tests := []struct { + name string + old *SessionState + new *SessionState + expected bool + }{ + { + name: "no change", + old: &SessionState{Locked: false, Active: true, IdleHint: false}, + new: &SessionState{Locked: false, Active: true, IdleHint: false}, + expected: false, + }, + { + name: "locked changed", + old: &SessionState{Locked: false, Active: true, IdleHint: false}, + new: &SessionState{Locked: true, Active: true, IdleHint: false}, + expected: true, + }, + { + name: "active changed", + old: &SessionState{Locked: false, Active: true, IdleHint: false}, + new: &SessionState{Locked: false, Active: false, IdleHint: false}, + expected: true, + }, + { + name: "idle hint changed", + old: &SessionState{Locked: false, Active: true, IdleHint: false}, + new: &SessionState{Locked: false, Active: true, IdleHint: true}, + expected: true, + }, + { + name: "locked hint changed", + old: &SessionState{Locked: false, Active: true, LockedHint: false}, + new: &SessionState{Locked: false, Active: true, LockedHint: true}, + expected: true, + }, + { + name: "preparing for sleep changed", + old: &SessionState{Locked: false, Active: true, PreparingForSleep: false}, + new: &SessionState{Locked: false, Active: true, PreparingForSleep: true}, + expected: true, + }, + { + name: "non-meaningful change (username)", + old: &SessionState{Locked: false, Active: true, UserName: "user1"}, + new: &SessionState{Locked: false, Active: true, UserName: "user2"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := stateChangedMeaningfully(tt.old, tt.new) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestManager_SnapshotState(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + SessionID: "1", + Locked: false, + Active: true, + UserName: "testuser", + }, + stateMutex: sync.RWMutex{}, + } + + snapshot := manager.snapshotState() + assert.Equal(t, "1", snapshot.SessionID) + assert.False(t, snapshot.Locked) + assert.True(t, snapshot.Active) + assert.Equal(t, "testuser", snapshot.UserName) + + snapshot.Locked = true + assert.False(t, manager.state.Locked) +} + +func TestNewManager(t *testing.T) { + t.Run("attempts to create manager", func(t *testing.T) { + manager, err := NewManager() + if err != nil { + assert.Nil(t, manager) + } else { + assert.NotNil(t, manager) + assert.NotNil(t, manager.state) + assert.NotNil(t, &manager.subscribers) + assert.NotNil(t, manager.stopChan) + + manager.Close() + } + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor.go new file mode 100644 index 0000000..4c0605a --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor.go @@ -0,0 +1,155 @@ +package loginctl + +import ( + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" + "github.com/godbus/dbus/v5" +) + +func (m *Manager) handleDBusSignal(sig *dbus.Signal) { + switch sig.Name { + case dbusSessionInterface + ".Lock": + m.stateMutex.Lock() + m.state.Locked = true + m.state.LockedHint = true + m.stateMutex.Unlock() + m.notifySubscribers() + + if m.sleepInhibitorEnabled.Load() && m.inSleepCycle.Load() { + id := m.sleepCycleID.Load() + m.lockTimerMu.Lock() + if m.lockTimer != nil { + m.lockTimer.Stop() + } + m.lockTimer = time.AfterFunc(m.fallbackDelay, func() { + m.releaseForCycle(id) + }) + m.lockTimerMu.Unlock() + } + + case dbusSessionInterface + ".Unlock": + m.stateMutex.Lock() + m.state.Locked = false + m.state.LockedHint = false + m.stateMutex.Unlock() + m.notifySubscribers() + + // Cancel the lock timer if it's still running + m.lockTimerMu.Lock() + if m.lockTimer != nil { + m.lockTimer.Stop() + m.lockTimer = nil + } + m.lockTimerMu.Unlock() + + // Re-acquire the sleep inhibitor (acquireSleepInhibitor checks the enabled flag) + m.acquireSleepInhibitor() + + case dbusManagerInterface + ".PrepareForSleep": + if len(sig.Body) == 0 { + return + } + preparing, _ := sig.Body[0].(bool) + + if preparing { + cycleID := m.sleepCycleID.Add(1) + m.inSleepCycle.Store(true) + + if m.lockBeforeSuspend.Load() { + m.Lock() + } + + readyCh := m.newLockerReadyCh() + go func(id uint64, ch <-chan struct{}) { + <-ch + if m.inSleepCycle.Load() && m.sleepCycleID.Load() == id { + m.releaseSleepInhibitor() + } + }(cycleID, readyCh) + } else { + m.inSleepCycle.Store(false) + m.signalLockerReady() + m.refreshSessionBinding() + m.acquireSleepInhibitor() + } + + m.stateMutex.Lock() + m.state.PreparingForSleep = preparing + m.stateMutex.Unlock() + m.notifySubscribers() + + case dbusPropsInterface + ".PropertiesChanged": + m.handlePropertiesChanged(sig) + + case "org.freedesktop.DBus.NameOwnerChanged": + if len(sig.Body) == 3 { + name, _ := sig.Body[0].(string) + oldOwner, _ := sig.Body[1].(string) + newOwner, _ := sig.Body[2].(string) + if name == dbusDest && oldOwner != "" && newOwner != "" { + m.updateSessionState() + if !m.inSleepCycle.Load() { + m.acquireSleepInhibitor() + } + m.notifySubscribers() + } + } + } +} + +func (m *Manager) handlePropertiesChanged(sig *dbus.Signal) { + if len(sig.Body) < 2 { + return + } + + iface, ok := sig.Body[0].(string) + if !ok || iface != dbusSessionInterface { + return + } + + changes, ok := sig.Body[1].(map[string]dbus.Variant) + if !ok { + return + } + + var needsUpdate bool + + for key, variant := range changes { + switch key { + case "Active": + if val, ok := dbusutil.As[bool](variant); ok { + m.stateMutex.Lock() + m.state.Active = val + m.stateMutex.Unlock() + needsUpdate = true + } + case "IdleHint": + if val, ok := dbusutil.As[bool](variant); ok { + m.stateMutex.Lock() + m.state.IdleHint = val + m.stateMutex.Unlock() + needsUpdate = true + } + case "IdleSinceHint": + if val, ok := dbusutil.As[uint64](variant); ok { + m.stateMutex.Lock() + m.state.IdleSinceHint = val + m.stateMutex.Unlock() + needsUpdate = true + } + case "LockedHint": + if val, ok := dbusutil.As[bool](variant); ok { + m.stateMutex.Lock() + m.state.LockedHint = val + m.state.Locked = val + m.stateMutex.Unlock() + needsUpdate = true + } + } + } + + if needsUpdate { + m.notifySubscribers() + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor_test.go new file mode 100644 index 0000000..ecbb57f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor_test.go @@ -0,0 +1,298 @@ +package loginctl + +import ( + "sync" + "testing" + + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" +) + +func TestManager_HandleDBusSignal_Lock(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + Locked: false, + LockedHint: false, + }, + stateMutex: sync.RWMutex{}, + dirty: make(chan struct{}, 1), + } + + sig := &dbus.Signal{ + Name: "org.freedesktop.login1.Session.Lock", + } + + manager.handleDBusSignal(sig) + + manager.stateMutex.RLock() + defer manager.stateMutex.RUnlock() + assert.True(t, manager.state.Locked) + assert.True(t, manager.state.LockedHint) +} + +func TestManager_HandleDBusSignal_Unlock(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + Locked: true, + LockedHint: true, + }, + stateMutex: sync.RWMutex{}, + dirty: make(chan struct{}, 1), + } + + sig := &dbus.Signal{ + Name: "org.freedesktop.login1.Session.Unlock", + } + + manager.handleDBusSignal(sig) + + manager.stateMutex.RLock() + defer manager.stateMutex.RUnlock() + assert.False(t, manager.state.Locked) + assert.False(t, manager.state.LockedHint) +} + +func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) { + t.Run("preparing for sleep - true", func(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + PreparingForSleep: false, + }, + stateMutex: sync.RWMutex{}, + dirty: make(chan struct{}, 1), + } + + sig := &dbus.Signal{ + Name: "org.freedesktop.login1.Manager.PrepareForSleep", + Body: []any{true}, + } + + manager.handleDBusSignal(sig) + + manager.stateMutex.RLock() + defer manager.stateMutex.RUnlock() + assert.True(t, manager.state.PreparingForSleep) + }) + + t.Run("preparing for sleep - false", func(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + PreparingForSleep: true, + }, + stateMutex: sync.RWMutex{}, + dirty: make(chan struct{}, 1), + } + + sig := &dbus.Signal{ + Name: "org.freedesktop.login1.Manager.PrepareForSleep", + Body: []any{false}, + } + + manager.handleDBusSignal(sig) + + manager.stateMutex.RLock() + defer manager.stateMutex.RUnlock() + assert.False(t, manager.state.PreparingForSleep) + }) + + t.Run("empty body", func(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + PreparingForSleep: false, + }, + stateMutex: sync.RWMutex{}, + dirty: make(chan struct{}, 1), + } + + sig := &dbus.Signal{ + Name: "org.freedesktop.login1.Manager.PrepareForSleep", + Body: []any{}, + } + + manager.handleDBusSignal(sig) + + manager.stateMutex.RLock() + defer manager.stateMutex.RUnlock() + assert.False(t, manager.state.PreparingForSleep) + }) +} + +func TestManager_HandlePropertiesChanged(t *testing.T) { + t.Run("active property changed", func(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + Active: false, + }, + stateMutex: sync.RWMutex{}, + dirty: make(chan struct{}, 1), + } + + sig := &dbus.Signal{ + Name: "org.freedesktop.DBus.Properties.PropertiesChanged", + Body: []any{ + "org.freedesktop.login1.Session", + map[string]dbus.Variant{ + "Active": dbus.MakeVariant(true), + }, + }, + } + + manager.handlePropertiesChanged(sig) + + manager.stateMutex.RLock() + defer manager.stateMutex.RUnlock() + assert.True(t, manager.state.Active) + }) + + t.Run("idle hint property changed", func(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + IdleHint: false, + }, + stateMutex: sync.RWMutex{}, + dirty: make(chan struct{}, 1), + } + + sig := &dbus.Signal{ + Name: "org.freedesktop.DBus.Properties.PropertiesChanged", + Body: []any{ + "org.freedesktop.login1.Session", + map[string]dbus.Variant{ + "IdleHint": dbus.MakeVariant(true), + }, + }, + } + + manager.handlePropertiesChanged(sig) + + manager.stateMutex.RLock() + defer manager.stateMutex.RUnlock() + assert.True(t, manager.state.IdleHint) + }) + + t.Run("idle since hint property changed", func(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + IdleSinceHint: 0, + }, + stateMutex: sync.RWMutex{}, + dirty: make(chan struct{}, 1), + } + + sig := &dbus.Signal{ + Name: "org.freedesktop.DBus.Properties.PropertiesChanged", + Body: []any{ + "org.freedesktop.login1.Session", + map[string]dbus.Variant{ + "IdleSinceHint": dbus.MakeVariant(uint64(123456789)), + }, + }, + } + + manager.handlePropertiesChanged(sig) + + manager.stateMutex.RLock() + defer manager.stateMutex.RUnlock() + assert.Equal(t, uint64(123456789), manager.state.IdleSinceHint) + }) + + t.Run("locked hint property changed", func(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + LockedHint: false, + Locked: false, + }, + stateMutex: sync.RWMutex{}, + dirty: make(chan struct{}, 1), + } + + sig := &dbus.Signal{ + Name: "org.freedesktop.DBus.Properties.PropertiesChanged", + Body: []any{ + "org.freedesktop.login1.Session", + map[string]dbus.Variant{ + "LockedHint": dbus.MakeVariant(true), + }, + }, + } + + manager.handlePropertiesChanged(sig) + + manager.stateMutex.RLock() + defer manager.stateMutex.RUnlock() + assert.True(t, manager.state.LockedHint) + assert.True(t, manager.state.Locked) + }) + + t.Run("wrong interface", func(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + Active: false, + }, + stateMutex: sync.RWMutex{}, + dirty: make(chan struct{}, 1), + } + + sig := &dbus.Signal{ + Name: "org.freedesktop.DBus.Properties.PropertiesChanged", + Body: []any{ + "org.freedesktop.SomeOtherInterface", + map[string]dbus.Variant{ + "Active": dbus.MakeVariant(true), + }, + }, + } + + manager.handlePropertiesChanged(sig) + + manager.stateMutex.RLock() + defer manager.stateMutex.RUnlock() + assert.False(t, manager.state.Active) + }) + + t.Run("empty body", func(t *testing.T) { + manager := &Manager{ + state: &SessionState{}, + stateMutex: sync.RWMutex{}, + dirty: make(chan struct{}, 1), + } + + sig := &dbus.Signal{ + Name: "org.freedesktop.DBus.Properties.PropertiesChanged", + Body: []any{}, + } + + assert.NotPanics(t, func() { + manager.handlePropertiesChanged(sig) + }) + }) + + t.Run("multiple properties changed", func(t *testing.T) { + manager := &Manager{ + state: &SessionState{ + Active: false, + IdleHint: false, + }, + stateMutex: sync.RWMutex{}, + dirty: make(chan struct{}, 1), + } + + sig := &dbus.Signal{ + Name: "org.freedesktop.DBus.Properties.PropertiesChanged", + Body: []any{ + "org.freedesktop.login1.Session", + map[string]dbus.Variant{ + "Active": dbus.MakeVariant(true), + "IdleHint": dbus.MakeVariant(true), + }, + }, + } + + manager.handlePropertiesChanged(sig) + + manager.stateMutex.RLock() + defer manager.stateMutex.RUnlock() + assert.True(t, manager.state.Active) + assert.True(t, manager.state.IdleHint) + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types.go new file mode 100644 index 0000000..76c7414 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types.go @@ -0,0 +1,76 @@ +package loginctl + +import ( + "os" + "sync" + "sync/atomic" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" + "github.com/godbus/dbus/v5" +) + +type SessionState struct { + SessionID string `json:"sessionId"` + SessionPath string `json:"sessionPath"` + Locked bool `json:"locked"` + Active bool `json:"active"` + IdleHint bool `json:"idleHint"` + IdleSinceHint uint64 `json:"idleSinceHint"` + LockedHint bool `json:"lockedHint"` + SessionType string `json:"sessionType"` + SessionClass string `json:"sessionClass"` + User uint32 `json:"user"` + UserName string `json:"userName"` + RemoteHost string `json:"remoteHost"` + Service string `json:"service"` + TTY string `json:"tty"` + Display string `json:"display"` + Remote bool `json:"remote"` + Seat string `json:"seat"` + VTNr uint32 `json:"vtnr"` + PreparingForSleep bool `json:"preparingForSleep"` +} + +type EventType string + +const ( + EventStateChanged EventType = "state_changed" + EventLock EventType = "lock" + EventUnlock EventType = "unlock" + EventPrepareForSleep EventType = "prepare_for_sleep" + EventIdleHintChanged EventType = "idle_hint_changed" + EventLockedHintChanged EventType = "locked_hint_changed" +) + +type SessionEvent struct { + Type EventType `json:"type"` + Data SessionState `json:"data"` +} + +type Manager struct { + state *SessionState + stateMutex sync.RWMutex + subscribers syncmap.Map[string, chan SessionState] + stopChan chan struct{} + conn *dbus.Conn + sessionPath dbus.ObjectPath + managerObj dbus.BusObject + sessionObj dbus.BusObject + dirty chan struct{} + notifierWg sync.WaitGroup + lastNotifiedState *SessionState + signals chan *dbus.Signal + sigWG sync.WaitGroup + inhibitMu sync.Mutex + inhibitFile *os.File + lockBeforeSuspend atomic.Bool + inSleepCycle atomic.Bool + sleepCycleID atomic.Uint64 + lockerReadyChMu sync.Mutex + lockerReadyCh chan struct{} + lockTimerMu sync.Mutex + lockTimer *time.Timer + sleepInhibitorEnabled atomic.Bool + fallbackDelay time.Duration +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types_test.go new file mode 100644 index 0000000..383300f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/loginctl/types_test.go @@ -0,0 +1,50 @@ +package loginctl + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEventType_Constants(t *testing.T) { + assert.Equal(t, EventType("state_changed"), EventStateChanged) + assert.Equal(t, EventType("lock"), EventLock) + assert.Equal(t, EventType("unlock"), EventUnlock) + assert.Equal(t, EventType("prepare_for_sleep"), EventPrepareForSleep) + assert.Equal(t, EventType("idle_hint_changed"), EventIdleHintChanged) + assert.Equal(t, EventType("locked_hint_changed"), EventLockedHintChanged) +} + +func TestSessionState_Struct(t *testing.T) { + state := SessionState{ + SessionID: "1", + Locked: false, + Active: true, + SessionType: "wayland", + User: 1000, + UserName: "testuser", + } + + assert.Equal(t, "1", state.SessionID) + assert.True(t, state.Active) + assert.False(t, state.Locked) + assert.Equal(t, "wayland", state.SessionType) + assert.Equal(t, uint32(1000), state.User) + assert.Equal(t, "testuser", state.UserName) +} + +func TestSessionEvent_Struct(t *testing.T) { + state := SessionState{ + SessionID: "1", + Locked: true, + } + + event := SessionEvent{ + Type: EventLock, + Data: state, + } + + assert.Equal(t, EventLock, event.Type) + assert.Equal(t, "1", event.Data.SessionID) + assert.True(t, event.Data.Locked) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/matugen_handler.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/matugen_handler.go new file mode 100644 index 0000000..73c6ad6 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/matugen_handler.go @@ -0,0 +1,79 @@ +package server + +import ( + "context" + "net" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +type MatugenQueueResult struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +func handleMatugenQueue(conn net.Conn, req models.Request) { + opts := matugen.Options{ + StateDir: models.GetOr(req, "stateDir", ""), + ShellDir: models.GetOr(req, "shellDir", ""), + ConfigDir: models.GetOr(req, "configDir", ""), + Kind: models.GetOr(req, "kind", ""), + Value: models.GetOr(req, "value", ""), + Mode: matugen.ColorMode(models.GetOr(req, "mode", "")), + IconTheme: models.GetOr(req, "iconTheme", ""), + MatugenType: models.GetOr(req, "matugenType", ""), + RunUserTemplates: models.GetOr(req, "runUserTemplates", true), + StockColors: models.GetOr(req, "stockColors", ""), + SyncModeWithPortal: models.GetOr(req, "syncModeWithPortal", false), + TerminalsAlwaysDark: models.GetOr(req, "terminalsAlwaysDark", false), + SkipTemplates: models.GetOr(req, "skipTemplates", ""), + Contrast: models.GetOr(req, "contrast", 0.0), + } + + wait := models.GetOr(req, "wait", true) + + queue := matugen.GetQueue() + resultCh := queue.Submit(opts) + + if !wait { + models.Respond(conn, req.ID, MatugenQueueResult{ + Success: true, + Message: "queued", + }) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + select { + case result := <-resultCh: + if result.Error != nil { + if result.Error == context.Canceled { + models.Respond(conn, req.ID, MatugenQueueResult{ + Success: false, + Message: "cancelled", + }) + return + } + models.RespondError(conn, req.ID, result.Error.Error()) + return + } + models.Respond(conn, req.ID, MatugenQueueResult{ + Success: true, + Message: "completed", + }) + case <-ctx.Done(): + models.RespondError(conn, req.ID, "timeout waiting for theme generation") + } +} + +func handleMatugenStatus(conn net.Conn, req models.Request) { + queue := matugen.GetQueue() + models.Respond(conn, req.ID, map[string]bool{ + "running": queue.IsRunning(), + "pending": queue.HasPending(), + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/models/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/models/types.go new file mode 100644 index 0000000..2933fb5 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/models/types.go @@ -0,0 +1,47 @@ +package models + +import ( + "encoding/json" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +type Request struct { + ID int `json:"id,omitempty"` + Method string `json:"method"` + Params map[string]any `json:"params,omitempty"` +} + +func Get[T any](r Request, key string) (T, bool) { + v, err := params.Get[T](r.Params, key) + return v, err == nil +} + +func GetOr[T any](r Request, key string, def T) T { + return params.GetOpt(r.Params, key, def) +} + +type Response[T any] struct { + ID int `json:"id,omitempty"` + Result *T `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +func RespondError(conn net.Conn, id int, errMsg string) { + log.Errorf("DMS API Error: id=%d error=%s", id, errMsg) + resp := Response[any]{ID: id, Error: errMsg} + json.NewEncoder(conn).Encode(resp) +} + +func Respond[T any](conn net.Conn, id int, result T) { + resp := Response[T]{ID: id, Result: &result} + json.NewEncoder(conn).Encode(resp) +} + +type SuccessResult struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Value string `json:"value,omitempty"` +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/models/types_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/models/types_test.go new file mode 100644 index 0000000..98f9528 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/models/types_test.go @@ -0,0 +1,52 @@ +package models + +import "testing" + +func TestGet(t *testing.T) { + req := Request{Params: map[string]any{"name": "test", "count": 42, "enabled": true}} + + name, ok := Get[string](req, "name") + if !ok || name != "test" { + t.Errorf("Get[string] = %q, %v; want 'test', true", name, ok) + } + + count, ok := Get[int](req, "count") + if !ok || count != 42 { + t.Errorf("Get[int] = %d, %v; want 42, true", count, ok) + } + + enabled, ok := Get[bool](req, "enabled") + if !ok || !enabled { + t.Errorf("Get[bool] = %v, %v; want true, true", enabled, ok) + } + + _, ok = Get[string](req, "missing") + if ok { + t.Error("Get missing key should return false") + } + + _, ok = Get[int](req, "name") + if ok { + t.Error("Get wrong type should return false") + } +} + +func TestGetOr(t *testing.T) { + req := Request{Params: map[string]any{"name": "test", "enabled": true}} + + if v := GetOr(req, "name", "default"); v != "test" { + t.Errorf("GetOr existing = %q; want 'test'", v) + } + + if v := GetOr(req, "missing", "default"); v != "default" { + t.Errorf("GetOr missing = %q; want 'default'", v) + } + + if v := GetOr(req, "enabled", false); !v { + t.Errorf("GetOr bool = %v; want true", v) + } + + if v := GetOr(req, "name", 0); v != 0 { + t.Errorf("GetOr wrong type = %d; want 0 (default)", v) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/API.md b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/API.md new file mode 100644 index 0000000..361ebac --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/API.md @@ -0,0 +1,552 @@ +# NetworkManager API Documentation + +## Overview + +The network manager API provides methods for managing WiFi connections, monitoring network state, and handling credential prompts through NetworkManager or iwd (and systemd-networkd for ethernet only). Communication occurs over a message-based protocol (websocket, IPC, etc.) with event subscriptions for state updates. + +## API Methods + +### network.wifi.connect + +Initiate a WiFi connection. + +**Request:** +```json +{ + "method": "network.wifi.connect", + "params": { + "ssid": "NetworkName", + "password": "optional-password", + "interactive": true + } +} +``` + +**Parameters:** +- `ssid` (string, required): Network SSID +- `password` (string, optional): Pre-shared key for WPA/WPA2/WPA3 networks +- `interactive` (boolean, optional): Enable credential prompting if authentication fails or password is missing. Automatically set to `true` when connecting to secured networks without providing a password. + +**Response:** +```json +{ + "success": true, + "message": "connecting" +} +``` + +**Behavior:** +- Returns immediately; connection happens asynchronously +- State updates delivered via `network` service subscription +- Credential prompts delivered via `network.credentials` service subscription + +### network.credentials.submit + +Submit credentials in response to a prompt. + +**Request:** +```json +{ + "method": "network.credentials.submit", + "params": { + "token": "correlation-token", + "secrets": { + "psk": "password" + }, + "save": true + } +} +``` + +**Parameters:** +- `token` (string, required): Token from credential prompt +- `secrets` (object, required): Key-value map of credential fields +- `save` (boolean, optional): Whether to persist credentials (default: false) + +**Common secret fields:** +- `psk`: Pre-shared key for WPA2/WPA3 personal networks +- `identity`: Username for 802.1X enterprise networks +- `password`: Password for 802.1X enterprise networks + +### network.credentials.cancel + +Cancel a credential prompt. + +**Request:** +```json +{ + "method": "network.credentials.cancel", + "params": { + "token": "correlation-token" + } +} +``` + +## Event Subscriptions + +### Subscribing to Events + +Subscribe to receive network state updates and credential prompts: + +```json +{ + "method": "subscribe", + "params": { + "services": ["network", "network.credentials"] + } +} +``` + +Both services are required for full connection handling. Missing `network.credentials` means credential prompts won't be received. + +### network Service Events + +State updates are sent whenever network configuration changes: + +```json +{ + "service": "network", + "data": { + "networkStatus": "wifi", + "isConnecting": false, + "connectingSSID": "", + "wifiConnected": true, + "wifiSSID": "MyNetwork", + "wifiIP": "192.168.1.100", + "lastError": "" + } +} +``` + +**State fields:** +- `networkStatus`: Current connection type (`wifi`, `ethernet`, `disconnected`) +- `isConnecting`: Whether a connection attempt is in progress +- `connectingSSID`: SSID being connected to (empty when idle) +- `wifiConnected`: Whether associated with an access point +- `wifiSSID`: Currently connected network name +- `wifiIP`: Assigned IP address (empty until DHCP completes) +- `lastError`: Error message from last failed connection attempt + +### network.credentials Service Events + +Credential prompts are sent when authentication is required: + +```json +{ + "service": "network.credentials", + "data": { + "token": "unique-prompt-id", + "ssid": "NetworkName", + "setting": "802-11-wireless-security", + "fields": ["psk"], + "hints": ["wpa3", "sae"], + "reason": "Credentials required" + } +} +``` + +**Prompt fields:** +- `token`: Unique identifier for this prompt (use in submit/cancel) +- `ssid`: Network requesting credentials +- `setting`: Authentication type (`802-11-wireless-security` for personal WiFi, `802-1x` for enterprise) +- `fields`: Array of required credential field names +- `hints`: Additional context about the network type +- `reason`: Human-readable explanation (e.g., "Previous password was incorrect") + +## Connection Flow + +### Typical Timeline + +``` +T+0ms Call network.wifi.connect +T+10ms Receive {"success": true, "message": "connecting"} +T+100ms State update: isConnecting=true, connectingSSID="Network" +T+500ms Credential prompt (if needed) +T+1000ms Submit credentials +T+3000ms State update: wifiConnected=true, wifiIP="192.168.x.x" +``` + +### State Machine + +``` +IDLE + | + | network.wifi.connect + v +CONNECTING (isConnecting=true, connectingSSID set) + | + +-- Needs credentials + | | + | v + | PROMPTING (credential prompt event) + | | + | | network.credentials.submit + | v + | back to CONNECTING + | + +-- Success + | | + | v + | CONNECTED (wifiConnected=true, wifiIP set, isConnecting=false) + | + +-- Failure + | + v + ERROR (isConnecting=false, !wifiConnected, lastError set) +``` + +## Connection Success Detection + +A connection is successful when all of the following are true: + +1. `wifiConnected` is `true` +2. `wifiIP` is set and non-empty +3. `wifiSSID` matches the target network +4. `isConnecting` is `false` + +Do not rely on `wifiConnected` alone - the device may be associated with an access point but not have an IP address yet. + +**Example:** +```javascript +function isConnectionComplete(state, targetSSID) { + return state.wifiConnected && + state.wifiIP && + state.wifiIP !== "" && + state.wifiSSID === targetSSID && + !state.isConnecting; +} +``` + +## Error Handling + +### Error Detection + +Errors occur when a connection attempt stops without success: + +```javascript +function checkForFailure(state, wasConnecting, targetSSID) { + // Was connecting, now idle, but not connected + if (wasConnecting && + !state.isConnecting && + state.connectingSSID === "" && + !state.wifiConnected) { + return state.lastError || "Connection failed"; + } + return null; +} +``` + +### Common Error Scenarios + +#### Wrong Password + +**Detection methods:** + +1. Quick failure (< 3 seconds from start) +2. `lastError` contains "password", "auth", or "secrets" +3. Second credential prompt with `reason: "Previous password was incorrect"` + +**Handling:** +```javascript +if (prompt.reason === "Previous password was incorrect") { + // Show error, clear password field, re-focus input +} +``` + +#### Network Out of Range + +**Detection:** +- `lastError` contains "not-found" or "connection-attempt-failed" + +#### Connection Timeout + +**Detection:** +- `isConnecting` remains true for > 30 seconds + +**Implementation:** +```javascript +let timeout = setTimeout(() => { + if (currentState.isConnecting) { + handleTimeout(); + } +}, 30000); +``` + +#### DHCP Failure + +**Detection:** +- `wifiConnected` is true +- `wifiIP` is empty after 15+ seconds + +### Error Message Translation + +Map technical errors to user-friendly messages: + +| lastError value | Meaning | User message | +|----------------|---------|--------------| +| `secrets-required` | Password needed | "Please enter password" | +| `authentication-failed` | Wrong password | "Incorrect password" | +| `connection-removed` | Profile deleted | "Network configuration removed" | +| `connection-attempt-failed` | Generic failure | "Failed to connect" | +| `network-not-found` | Out of range | "Network not found" | +| `(timeout)` | Timeout | "Connection timed out" | + +## Credential Handling + +### Secret Agent Architecture + +The credential system uses a broker pattern: + +``` +NetworkManager -> SecretAgent -> PromptBroker -> UI -> User + ^ + | + User Response + | +NetworkManager <- SecretAgent <- PromptBroker <- UI +``` + +### Implementing a Broker + +```go +type CustomBroker struct { + ui UIInterface + pending map[string]chan network.PromptReply +} + +func (b *CustomBroker) Ask(ctx context.Context, req network.PromptRequest) (string, error) { + token := generateToken() + b.pending[token] = make(chan network.PromptReply, 1) + + // Send to UI + b.ui.ShowCredentialPrompt(token, req) + + return token, nil +} + +func (b *CustomBroker) Wait(ctx context.Context, token string) (network.PromptReply, error) { + select { + case <-ctx.Done(): + return network.PromptReply{}, errors.New("timeout") + case reply := <-b.pending[token]: + return reply, nil + } +} + +func (b *CustomBroker) Resolve(token string, reply network.PromptReply) error { + if ch, ok := b.pending[token]; ok { + ch <- reply + close(ch) + delete(b.pending, token) + } + return nil +} +``` + +### Credential Field Types + +**Personal WiFi (802-11-wireless-security):** +- Fields: `["psk"]` +- UI: Single password input + +**Enterprise WiFi (802-1x):** +- Fields: `["identity", "password"]` +- UI: Username and password inputs + +### Building Secrets Object + +```javascript +function buildSecrets(setting, fields, formData) { + let secrets = {}; + + if (setting === "802-11-wireless-security") { + secrets.psk = formData.password; + } else if (setting === "802-1x") { + secrets.identity = formData.username; + secrets.password = formData.password; + } + + return secrets; +} +``` + +## Best Practices + +### Track Target Network + +Always store which network you're connecting to: + +```javascript +let targetSSID = null; + +function connect(ssid) { + targetSSID = ssid; + // send request +} + +function onStateUpdate(state) { + if (!targetSSID) return; + + if (state.wifiSSID === targetSSID && state.wifiConnected && state.wifiIP) { + // Success for the network we care about + targetSSID = null; + } +} +``` + +### Implement Timeouts + +Never wait indefinitely for a connection: + +```javascript +const CONNECTION_TIMEOUT = 30000; // 30 seconds +const DHCP_TIMEOUT = 15000; // 15 seconds + +let timer = setTimeout(() => { + if (stillConnecting) { + handleTimeout(); + } +}, CONNECTION_TIMEOUT); +``` + +### Handle Credential Re-prompts + +Wrong passwords trigger a second prompt: + +```javascript +function onCredentialPrompt(prompt) { + if (prompt.reason.includes("incorrect")) { + // Show error, but keep dialog open + showError("Wrong password"); + clearPasswordField(); + } else { + // First time prompt + showDialog(prompt); + } +} +``` + +### Clean Up State + +Reset tracking variables on success, failure, or cancellation: + +```javascript +function cleanup() { + clearTimeout(timer); + targetSSID = null; + closeDialogs(); +} +``` + +### Subscribe to Both Services + +Missing `network.credentials` means prompts won't arrive: + +```javascript +// Correct +services: ["network", "network.credentials"] + +// Wrong - will miss credential prompts +services: ["network"] +``` + +## Testing + +### Connection Test Checklist + +- [ ] Connect to open network +- [ ] Connect to WPA2 network with password provided +- [ ] Connect to WPA2 network without password (triggers prompt) +- [ ] Enter wrong password (verify error and re-prompt) +- [ ] Cancel credential prompt +- [ ] Connection timeout after 30 seconds +- [ ] DHCP timeout detection +- [ ] Network out of range +- [ ] Reconnect to already-configured network + +### Verifying Secret Agent Setup + +Check connection profile flags: +```bash +nmcli connection show "NetworkName" | grep flags +# Should show: 802-11-wireless-security.psk-flags: 1 (agent-owned) +``` + +Check agent registration in logs: +``` +INFO: Registered with NetworkManager as secret agent +``` + +## Security + +- Never log credential values (passwords, PSKs) +- Clear password fields when dialogs close +- Implement prompt timeouts (default: 2 minutes) +- Validate user input before submission +- Use secure channels for credential transmission + +## Troubleshooting + +### Credential prompt doesn't appear + +**Check:** +- Subscribed to both `network` and `network.credentials` +- Connection has `interactive: true` +- Secret flags set to AGENT_OWNED (value: 1) +- Broker registered successfully + +### Connection succeeds without prompting + +**Cause:** NetworkManager found saved credentials + +**Solution:** Delete existing connection first, or use different credentials + +### State updates seem delayed + +**Expected behavior:** State changes occur in rapid succession during connection + +**Solution:** Debounce UI updates; only act on final state + +### Multiple rapid credential prompts + +**Cause:** Connection profile has incorrect flags or conflicting agents + +**Solution:** +- Check only one agent is running +- Verify psk-flags value +- Check NetworkManager logs for agent conflicts + +## Data Structures Reference + +### PromptRequest +```go +type PromptRequest struct { + SSID string `json:"ssid"` + SettingName string `json:"setting"` + Fields []string `json:"fields"` + Hints []string `json:"hints"` + Reason string `json:"reason"` +} +``` + +### PromptReply +```go +type PromptReply struct { + Secrets map[string]string `json:"secrets"` + Save bool `json:"save"` + Cancel bool `json:"cancel"` +} +``` + +### NetworkState +```go +type NetworkState struct { + NetworkStatus string `json:"networkStatus"` + IsConnecting bool `json:"isConnecting"` + ConnectingSSID string `json:"connectingSSID"` + WifiConnected bool `json:"wifiConnected"` + WifiSSID string `json:"wifiSSID"` + WifiIP string `json:"wifiIP"` + LastError string `json:"lastError"` +} +``` diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/agent_iwd.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/agent_iwd.go new file mode 100644 index 0000000..5d49f04 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/agent_iwd.go @@ -0,0 +1,306 @@ +package network + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/godbus/dbus/v5" +) + +const ( + iwdAgentManagerPath = "/net/connman/iwd" + iwdAgentManagerIface = "net.connman.iwd.AgentManager" + iwdAgentInterface = "net.connman.iwd.Agent" + iwdAgentObjectPath = "/com/danklinux/iwdagent" +) + +type ConnectionStateChecker interface { + IsConnectingTo(ssid string) bool +} + +type IWDAgent struct { + conn *dbus.Conn + objPath dbus.ObjectPath + prompts PromptBroker + onUserCanceled func() + onPromptRetry func(ssid string) + lastRequestSSID string + stateChecker ConnectionStateChecker +} + +const iwdAgentIntrospectXML = ` +<node> + <interface name="net.connman.iwd.Agent"> + <method name="Release"> + <annotation name="org.freedesktop.DBus.Method.NoReply" value="true"/> + </method> + <method name="RequestPassphrase"> + <arg type="o" name="network" direction="in"/> + <arg type="s" name="passphrase" direction="out"/> + </method> + <method name="RequestPrivateKeyPassphrase"> + <arg type="o" name="network" direction="in"/> + <arg type="s" name="passphrase" direction="out"/> + </method> + <method name="RequestUserNameAndPassword"> + <arg type="o" name="network" direction="in"/> + <arg type="s" name="username" direction="out"/> + <arg type="s" name="password" direction="out"/> + </method> + <method name="RequestUserPassword"> + <arg type="o" name="network" direction="in"/> + <arg type="s" name="user" direction="in"/> + <arg type="s" name="password" direction="out"/> + </method> + <method name="Cancel"> + <arg type="s" name="reason" direction="in"/> + <annotation name="org.freedesktop.DBus.Method.NoReply" value="true"/> + </method> + </interface> +</node>` + +func NewIWDAgent(conn *dbus.Conn, prompts PromptBroker) (*IWDAgent, error) { + if conn == nil { + return nil, fmt.Errorf("dbus connection is nil") + } + + agent := &IWDAgent{ + conn: conn, + objPath: dbus.ObjectPath(iwdAgentObjectPath), + prompts: prompts, + } + + if err := conn.Export(agent, agent.objPath, iwdAgentInterface); err != nil { + return nil, fmt.Errorf("failed to export IWD agent: %w", err) + } + + if err := conn.Export(agent, agent.objPath, "org.freedesktop.DBus.Introspectable"); err != nil { + return nil, fmt.Errorf("failed to export introspection: %w", err) + } + + mgr := conn.Object("net.connman.iwd", dbus.ObjectPath(iwdAgentManagerPath)) + call := mgr.Call(iwdAgentManagerIface+".RegisterAgent", 0, agent.objPath) + if call.Err != nil { + return nil, fmt.Errorf("failed to register agent with iwd: %w", call.Err) + } + + return agent, nil +} + +func (a *IWDAgent) Close() { + if a.conn != nil { + mgr := a.conn.Object("net.connman.iwd", dbus.ObjectPath(iwdAgentManagerPath)) + mgr.Call(iwdAgentManagerIface+".UnregisterAgent", 0, a.objPath) + } +} + +func (a *IWDAgent) SetStateChecker(checker ConnectionStateChecker) { + a.stateChecker = checker +} + +func (a *IWDAgent) getNetworkName(networkPath dbus.ObjectPath) string { + netObj := a.conn.Object("net.connman.iwd", networkPath) + nameVar, err := netObj.GetProperty("net.connman.iwd.Network.Name") + if err == nil { + if name, ok := nameVar.Value().(string); ok { + return name + } + } + return string(networkPath) +} + +func (a *IWDAgent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Error) { + ssid := a.getNetworkName(network) + + if a.stateChecker != nil && !a.stateChecker.IsConnectingTo(ssid) { + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + if a.prompts == nil { + if a.onUserCanceled != nil { + a.onUserCanceled() + } + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + if a.lastRequestSSID == ssid { + if a.onPromptRetry != nil { + a.onPromptRetry(ssid) + } + } + a.lastRequestSSID = ssid + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := a.prompts.Ask(ctx, PromptRequest{ + SSID: ssid, + Fields: []string{"psk"}, + }) + if err != nil { + if a.onUserCanceled != nil { + a.onUserCanceled() + } + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + reply, err := a.prompts.Wait(ctx, token) + if err != nil { + if reply.Cancel || errors.Is(err, errdefs.ErrSecretPromptCancelled) { + if a.onUserCanceled != nil { + a.onUserCanceled() + } + } + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + if passphrase, ok := reply.Secrets["psk"]; ok { + return passphrase, nil + } + + if a.onUserCanceled != nil { + a.onUserCanceled() + } + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) +} + +func (a *IWDAgent) RequestPrivateKeyPassphrase(network dbus.ObjectPath) (string, *dbus.Error) { + ssid := a.getNetworkName(network) + + if a.stateChecker != nil && !a.stateChecker.IsConnectingTo(ssid) { + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + if a.prompts == nil { + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + if a.lastRequestSSID == ssid { + if a.onPromptRetry != nil { + a.onPromptRetry(ssid) + } + } + a.lastRequestSSID = ssid + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := a.prompts.Ask(ctx, PromptRequest{ + SSID: ssid, + Fields: []string{"private-key-password"}, + }) + if err != nil { + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + reply, err := a.prompts.Wait(ctx, token) + if err != nil || reply.Cancel { + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + if passphrase, ok := reply.Secrets["private-key-password"]; ok { + return passphrase, nil + } + + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) +} + +func (a *IWDAgent) RequestUserNameAndPassword(network dbus.ObjectPath) (string, string, *dbus.Error) { + ssid := a.getNetworkName(network) + + if a.stateChecker != nil && !a.stateChecker.IsConnectingTo(ssid) { + return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + if a.prompts == nil { + return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + if a.lastRequestSSID == ssid { + if a.onPromptRetry != nil { + a.onPromptRetry(ssid) + } + } + a.lastRequestSSID = ssid + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := a.prompts.Ask(ctx, PromptRequest{ + SSID: ssid, + Fields: []string{"identity", "password"}, + }) + if err != nil { + return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + reply, err := a.prompts.Wait(ctx, token) + if err != nil || reply.Cancel { + return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + username, hasUser := reply.Secrets["identity"] + password, hasPass := reply.Secrets["password"] + + if hasUser && hasPass { + return username, password, nil + } + + return "", "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) +} + +func (a *IWDAgent) RequestUserPassword(network dbus.ObjectPath, user string) (string, *dbus.Error) { + ssid := a.getNetworkName(network) + + if a.stateChecker != nil && !a.stateChecker.IsConnectingTo(ssid) { + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + if a.prompts == nil { + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + if a.lastRequestSSID == ssid { + if a.onPromptRetry != nil { + a.onPromptRetry(ssid) + } + } + a.lastRequestSSID = ssid + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := a.prompts.Ask(ctx, PromptRequest{ + SSID: ssid, + Fields: []string{"password"}, + }) + if err != nil { + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + reply, err := a.prompts.Wait(ctx, token) + if err != nil || reply.Cancel { + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) + } + + if password, ok := reply.Secrets["password"]; ok { + return password, nil + } + + return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) +} + +func (a *IWDAgent) Cancel(reason string) *dbus.Error { + return nil +} + +func (a *IWDAgent) Release() *dbus.Error { + return nil +} + +func (a *IWDAgent) Introspect() (string, *dbus.Error) { + return iwdAgentIntrospectXML, nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager.go new file mode 100644 index 0000000..088b7fa --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager.go @@ -0,0 +1,897 @@ +package network + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/godbus/dbus/v5" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +const ( + nmAgentManagerPath = "/org/freedesktop/NetworkManager/AgentManager" + nmAgentManagerIface = "org.freedesktop.NetworkManager.AgentManager" + nmSecretAgentIface = "org.freedesktop.NetworkManager.SecretAgent" + agentObjectPath = "/org/freedesktop/NetworkManager/SecretAgent" + agentIdentifier = "com.danklinux.NMAgent" +) + +type SecretAgent struct { + conn *dbus.Conn + objPath dbus.ObjectPath + id string + prompts PromptBroker + manager *Manager + backend *NetworkManagerBackend +} + +type ( + nmVariantMap map[string]dbus.Variant + nmSettingMap map[string]nmVariantMap +) + +const introspectXML = ` +<node> + <interface name="org.freedesktop.NetworkManager.SecretAgent"> + <method name="GetSecrets"> + <arg type="a{sa{sv}}" name="connection" direction="in"/> + <arg type="o" name="connection_path" direction="in"/> + <arg type="s" name="setting_name" direction="in"/> + <arg type="as" name="hints" direction="in"/> + <arg type="u" name="flags" direction="in"/> + <arg type="a{sa{sv}}" name="secrets" direction="out"/> + </method> + <method name="DeleteSecrets"> + <arg type="a{sa{sv}}" name="connection" direction="in"/> + <arg type="o" name="connection_path" direction="in"/> + </method> + <method name="DeleteSecrets2"> + <arg type="o" name="connection_path" direction="in"/> + <arg type="s" name="setting" direction="in"/> + </method> + <method name="CancelGetSecrets"> + <arg type="o" name="connection_path" direction="in"/> + <arg type="s" name="setting_name" direction="in"/> + </method> + </interface> + <interface name="org.freedesktop.DBus.Introspectable"> + <method name="Introspect"> + <arg name="data" type="s" direction="out"/> + </method> + </interface> +</node>` + +func NewSecretAgent(prompts PromptBroker, manager *Manager, backend *NetworkManagerBackend) (*SecretAgent, error) { + c, err := dbus.ConnectSystemBus() + if err != nil { + return nil, fmt.Errorf("failed to connect to system bus: %w", err) + } + + sa := &SecretAgent{ + conn: c, + objPath: dbus.ObjectPath(agentObjectPath), + id: agentIdentifier, + prompts: prompts, + manager: manager, + backend: backend, + } + + if err := c.Export(sa, sa.objPath, nmSecretAgentIface); err != nil { + c.Close() + return nil, fmt.Errorf("failed to export secret agent: %w", err) + } + + if err := c.Export(sa, sa.objPath, "org.freedesktop.DBus.Introspectable"); err != nil { + c.Close() + return nil, fmt.Errorf("failed to export introspection: %w", err) + } + + mgr := c.Object("org.freedesktop.NetworkManager", dbus.ObjectPath(nmAgentManagerPath)) + call := mgr.Call(nmAgentManagerIface+".Register", 0, sa.id) + if call.Err != nil { + c.Close() + return nil, fmt.Errorf("failed to register agent with NetworkManager: %w", call.Err) + } + + log.Infof("[SecretAgent] Registered with NetworkManager (id=%s, unique name=%s, fixed path=%s)", sa.id, c.Names()[0], sa.objPath) + return sa, nil +} + +func (a *SecretAgent) Close() { + if a.conn != nil { + mgr := a.conn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath(nmAgentManagerPath)) + mgr.Call(nmAgentManagerIface+".Unregister", 0, a.id) + a.conn.Close() + } +} + +func (a *SecretAgent) GetSecrets( + conn map[string]nmVariantMap, + path dbus.ObjectPath, + settingName string, + hints []string, + flags uint32, +) (nmSettingMap, *dbus.Error) { + log.Infof("[SecretAgent] GetSecrets called: path=%s, setting=%s, hints=%v, flags=%d", + path, settingName, hints, flags) + + connType, displayName, vpnSvc := readConnTypeAndName(conn) + ssid := readSSID(conn) + fields := fieldsNeeded(settingName, hints, conn) + vpnPasswordFlags := readVPNPasswordFlags(conn, settingName) + + log.Infof("[SecretAgent] connType=%s, name=%s, vpnSvc=%s, fields=%v, flags=%d, vpnPasswordFlags=%d", connType, displayName, vpnSvc, fields, flags, vpnPasswordFlags) + + if a.backend != nil { + a.backend.stateMutex.RLock() + isConnecting := a.backend.state.IsConnecting + connectingSSID := a.backend.state.ConnectingSSID + isConnectingVPN := a.backend.state.IsConnectingVPN + connectingVPNUUID := a.backend.state.ConnectingVPNUUID + a.backend.stateMutex.RUnlock() + + switch connType { + case "802-11-wireless": + // If we're connecting to a WiFi network, only respond if it's the one we're connecting to + if isConnecting && connectingSSID != ssid { + log.Infof("[SecretAgent] Ignoring WiFi request for SSID '%s' - we're connecting to '%s'", ssid, connectingSSID) + return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil) + } + case "vpn", "wireguard": + var connUuid string + if c, ok := conn["connection"]; ok { + if v, ok := c["uuid"]; ok { + if s, ok2 := v.Value().(string); ok2 { + connUuid = s + } + } + } + + // If we're connecting to a VPN, only respond if it's the one we're connecting to + // This prevents interfering with nmcli/other tools when our app isn't connecting + if isConnectingVPN && connUuid != connectingVPNUUID { + log.Infof("[SecretAgent] Ignoring VPN request for UUID '%s' - we're connecting to '%s'", connUuid, connectingVPNUUID) + return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil) + } + } + } + + if len(fields) == 0 { + if settingName == "vpn" { + if a.backend != nil { + a.backend.stateMutex.RLock() + isConnectingVPN := a.backend.state.IsConnectingVPN + a.backend.stateMutex.RUnlock() + + if !isConnectingVPN { + log.Infof("[SecretAgent] VPN with empty hints - deferring to other agents for %s", vpnSvc) + return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil) + } + + fields = inferVPNFields(conn, vpnSvc) + log.Infof("[SecretAgent] VPN with empty hints but we're connecting - inferred fields: %v", fields) + } else { + log.Infof("[SecretAgent] VPN with empty hints - deferring to other agents for %s", vpnSvc) + return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil) + } + } + + if len(fields) == 0 { + const ( + NM_SETTING_SECRET_FLAG_NONE = 0 + NM_SETTING_SECRET_FLAG_AGENT_OWNED = 1 + NM_SETTING_SECRET_FLAG_NOT_SAVED = 2 + NM_SETTING_SECRET_FLAG_NOT_REQUIRED = 4 + ) + + var passwordFlags uint32 = 0xFFFF + switch settingName { + case "802-11-wireless-security": + if wifiSecSettings, ok := conn["802-11-wireless-security"]; ok { + if flagsVariant, ok := wifiSecSettings["psk-flags"]; ok { + if pwdFlags, ok := flagsVariant.Value().(uint32); ok { + passwordFlags = pwdFlags + } + } + } + case "802-1x": + if dot1xSettings, ok := conn["802-1x"]; ok { + if flagsVariant, ok := dot1xSettings["password-flags"]; ok { + if pwdFlags, ok := flagsVariant.Value().(uint32); ok { + passwordFlags = pwdFlags + } + } + } + } + + if passwordFlags == 0xFFFF { + log.Warnf("[SecretAgent] Could not determine password-flags for empty hints - returning NoSecrets error") + return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil) + } else if passwordFlags&NM_SETTING_SECRET_FLAG_NOT_REQUIRED != 0 { + log.Infof("[SecretAgent] Secrets not required (flags=%d)", passwordFlags) + out := nmSettingMap{} + out[settingName] = nmVariantMap{} + return out, nil + } else if passwordFlags&NM_SETTING_SECRET_FLAG_AGENT_OWNED != 0 { + switch settingName { + case "802-11-wireless-security": + fields = []string{"psk"} + case "802-1x": + fields = infer8021xFields(conn) + default: + log.Warnf("[SecretAgent] Agent-owned secrets for unhandled setting %s (flags=%d)", settingName, passwordFlags) + return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.NoSecrets", nil) + } + log.Infof("[SecretAgent] Agent-owned secrets, inferred fields: %v", fields) + } else { + log.Infof("[SecretAgent] No secrets needed, using system stored secrets (flags=%d)", passwordFlags) + out := nmSettingMap{} + out[settingName] = nmVariantMap{} + return out, nil + } + } + } + + reason := reasonFromFlags(flags) + if a.manager != nil && connType == "802-11-wireless" && a.manager.WasRecentlyFailed(ssid) { + reason = "wrong-password" + } + if settingName == "vpn" && isPKCS11Auth(conn, vpnSvc) { + reason = "pkcs11" + } + + var connId, connUuid string + if c, ok := conn["connection"]; ok { + if v, ok := c["id"]; ok { + if s, ok2 := v.Value().(string); ok2 { + connId = s + } + } + if v, ok := c["uuid"]; ok { + if s, ok2 := v.Value().(string); ok2 { + connUuid = s + } + } + } + + if settingName == "vpn" && a.backend != nil { + // Check for cached PKCS11 PIN first + isPKCS11Request := len(fields) == 1 && fields[0] == "key_pass" + if isPKCS11Request { + a.backend.cachedPKCS11Mu.Lock() + cached := a.backend.cachedPKCS11PIN + if cached != nil && cached.ConnectionUUID == connUuid { + a.backend.cachedPKCS11PIN = nil + a.backend.cachedPKCS11Mu.Unlock() + + log.Infof("[SecretAgent] Using cached PKCS11 PIN") + + out := nmSettingMap{} + vpnSec := nmVariantMap{} + vpnSec["secrets"] = dbus.MakeVariant(map[string]string{"key_pass": cached.PIN}) + out[settingName] = vpnSec + + return out, nil + } + a.backend.cachedPKCS11Mu.Unlock() + } + + // Check for cached VPN password + a.backend.cachedVPNCredsMu.Lock() + cached := a.backend.cachedVPNCreds + if cached != nil && cached.ConnectionUUID == connUuid { + a.backend.cachedVPNCreds = nil + a.backend.cachedVPNCredsMu.Unlock() + + log.Infof("[SecretAgent] Using cached password from pre-activation prompt") + + out := nmSettingMap{} + vpnSec := nmVariantMap{} + vpnSec["secrets"] = dbus.MakeVariant(map[string]string{"password": cached.Password}) + out[settingName] = vpnSec + + if cached.SavePassword { + a.backend.pendingVPNSaveMu.Lock() + a.backend.pendingVPNSave = &pendingVPNCredentials{ + ConnectionPath: string(path), + Password: cached.Password, + SavePassword: true, + } + a.backend.pendingVPNSaveMu.Unlock() + } + + return out, nil + } + a.backend.cachedVPNCredsMu.Unlock() + + a.backend.cachedGPSamlMu.Lock() + cachedGPSaml := a.backend.cachedGPSamlCookie + if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid { + a.backend.cachedGPSamlMu.Unlock() + + log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid) + + return buildGPSamlSecretsResponse(settingName, cachedGPSaml.Cookie, cachedGPSaml.Host, cachedGPSaml.Fingerprint), nil + } + a.backend.cachedGPSamlMu.Unlock() + + if len(fields) == 1 && fields[0] == "gp-saml" { + gateway := "" + protocol := "" + if vpnSettings, ok := conn["vpn"]; ok { + if dataVariant, ok := vpnSettings["data"]; ok { + if dataMap, ok := dataVariant.Value().(map[string]string); ok { + if gw, ok := dataMap["gateway"]; ok { + gateway = gw + } + if proto, ok := dataMap["protocol"]; ok && proto != "" { + protocol = proto + } + } + } + } + + if protocol != "gp" { + return nil, dbus.MakeFailedError(fmt.Errorf("gp-saml auth only supported for GlobalProtect (protocol=gp), got: %s", protocol)) + } + + log.Infof("[SecretAgent] Starting GlobalProtect SAML authentication for gateway=%s", gateway) + + samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer samlCancel() + + authResult, err := a.backend.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol) + if err != nil { + log.Warnf("[SecretAgent] GlobalProtect SAML authentication failed: %v", err) + return nil, dbus.MakeFailedError(fmt.Errorf("GlobalProtect SAML authentication failed: %w", err)) + } + + log.Infof("[SecretAgent] GlobalProtect SAML authentication successful, returning cookie to NetworkManager") + + a.backend.cachedGPSamlMu.Lock() + a.backend.cachedGPSamlCookie = &cachedGPSamlCookie{ + ConnectionUUID: connUuid, + Cookie: authResult.Cookie, + Host: authResult.Host, + User: authResult.User, + Fingerprint: authResult.Fingerprint, + } + a.backend.cachedGPSamlMu.Unlock() + + return buildGPSamlSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + token, err := a.prompts.Ask(ctx, PromptRequest{ + Name: displayName, + SSID: ssid, + ConnType: connType, + VpnService: vpnSvc, + SettingName: settingName, + Fields: fields, + FieldsInfo: buildFieldsInfo(settingName, fields, vpnSvc), + Hints: hints, + Reason: reason, + ConnectionId: connId, + ConnectionUuid: connUuid, + ConnectionPath: string(path), + }) + if err != nil { + log.Warnf("[SecretAgent] Failed to create prompt: %v", err) + return nil, dbus.MakeFailedError(err) + } + + log.Infof("[SecretAgent] Waiting for user input (token=%s)", token) + reply, err := a.prompts.Wait(ctx, token) + if err != nil { + log.Warnf("[SecretAgent] Prompt failed or cancelled: %v", err) + + // Clear connecting state immediately on cancellation + if a.backend != nil { + a.backend.stateMutex.Lock() + wasConnecting := a.backend.state.IsConnecting + wasConnectingVPN := a.backend.state.IsConnectingVPN + cancelledSSID := a.backend.state.ConnectingSSID + cancelledVPNUUID := a.backend.state.ConnectingVPNUUID + if wasConnecting || wasConnectingVPN { + log.Infof("[SecretAgent] Clearing connecting state due to cancelled prompt") + a.backend.state.IsConnecting = false + a.backend.state.ConnectingSSID = "" + a.backend.state.IsConnectingVPN = false + a.backend.state.ConnectingVPNUUID = "" + } + a.backend.stateMutex.Unlock() + + // If this was a WiFi connection that was just cancelled, remove the connection profile + // (it was created with AddConnection but activation was cancelled) + if wasConnecting && cancelledSSID != "" && connType == "802-11-wireless" { + log.Infof("[SecretAgent] Removing connection profile for cancelled WiFi connection: %s", cancelledSSID) + if err := a.backend.ForgetWiFiNetwork(cancelledSSID); err != nil { + log.Warnf("[SecretAgent] Failed to remove cancelled connection profile: %v", err) + } + } + + // If this was a VPN connection that was cancelled, deactivate it + if wasConnectingVPN && cancelledVPNUUID != "" { + log.Infof("[SecretAgent] Deactivating cancelled VPN connection: %s", cancelledVPNUUID) + if err := a.backend.DisconnectVPN(cancelledVPNUUID); err != nil { + log.Warnf("[SecretAgent] Failed to deactivate cancelled VPN: %v", err) + } + } + + if (wasConnecting || wasConnectingVPN) && a.backend.onStateChange != nil { + a.backend.onStateChange() + } + } + + if reply.Cancel || errors.Is(err, errdefs.ErrSecretPromptCancelled) { + return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.UserCanceled", nil) + } + + if errors.Is(err, errdefs.ErrSecretPromptTimeout) { + return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.Failed", nil) + } + return nil, dbus.NewError("org.freedesktop.NetworkManager.SecretAgent.Error.Failed", nil) + } + + log.Infof("[SecretAgent] User provided secrets, save=%v", reply.Save) + + out := nmSettingMap{} + sec := nmVariantMap{} + + var vpnUsername string + for k, v := range reply.Secrets { + if settingName == "vpn" && k == "username" { + vpnUsername = v + } + sec[k] = dbus.MakeVariant(v) + } + + // Check if this is PKCS11 auth (key_pass) + pin, isPKCS11 := reply.Secrets["key_pass"] + + switch settingName { + case "vpn": + // VPN secrets must be wrapped in a "secrets" key per NM spec + secretsDict := make(map[string]string) + for k, v := range reply.Secrets { + if k != "username" { + secretsDict[k] = v + } + } + vpnSec := nmVariantMap{} + vpnSec["secrets"] = dbus.MakeVariant(secretsDict) + out[settingName] = vpnSec + log.Infof("[SecretAgent] Returning VPN secrets with %d fields for %s", len(secretsDict), vpnSvc) + + // Cache PKCS11 PIN in case GetSecrets is called again during activation + if isPKCS11 && a.backend != nil { + a.backend.cachedPKCS11Mu.Lock() + a.backend.cachedPKCS11PIN = &cachedPKCS11PIN{ + ConnectionUUID: connUuid, + PIN: pin, + } + a.backend.cachedPKCS11Mu.Unlock() + log.Infof("[SecretAgent] Cached PKCS11 PIN for potential re-request") + } + case "802-1x": + secretsOnly := nmVariantMap{} + for k, v := range reply.Secrets { + switch k { + case "password", "private-key-password", "phase2-private-key-password", "pin": + secretsOnly[k] = dbus.MakeVariant(v) + } + } + out[settingName] = secretsOnly + + if identity, ok := reply.Secrets["identity"]; ok && identity != "" { + a.save8021xIdentity(path, identity) + } + log.Infof("[SecretAgent] Returning 802-1x enterprise secrets with %d fields", len(secretsOnly)) + default: + out[settingName] = sec + } + if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || reply.Save) { + pw := reply.Secrets["password"] + a.backend.pendingVPNSaveMu.Lock() + a.backend.pendingVPNSave = &pendingVPNCredentials{ + ConnectionPath: string(path), + Username: vpnUsername, + Password: pw, + SavePassword: reply.Save, + } + a.backend.pendingVPNSaveMu.Unlock() + log.Infof("[SecretAgent] Queued credentials persist for after connection succeeds") + } + + return out, nil +} + +func (a *SecretAgent) DeleteSecrets(conn map[string]nmVariantMap, path dbus.ObjectPath) *dbus.Error { + ssid := readSSID(conn) + log.Infof("[SecretAgent] DeleteSecrets called: path=%s, SSID=%s", path, ssid) + return nil +} + +func (a *SecretAgent) DeleteSecrets2(path dbus.ObjectPath, setting string) *dbus.Error { + log.Infof("[SecretAgent] DeleteSecrets2 (alternate) called: path=%s, setting=%s", path, setting) + return nil +} + +func (a *SecretAgent) CancelGetSecrets(path dbus.ObjectPath, settingName string) *dbus.Error { + log.Infof("[SecretAgent] CancelGetSecrets called: path=%s, setting=%s", path, settingName) + + if a.prompts != nil { + if err := a.prompts.Cancel(string(path), settingName); err != nil { + log.Warnf("[SecretAgent] Failed to cancel prompt: %v", err) + } + } + + return nil +} + +func (a *SecretAgent) Introspect() (string, *dbus.Error) { + return introspectXML, nil +} + +func (a *SecretAgent) save8021xIdentity(path dbus.ObjectPath, identity string) { + connObj := a.conn.Object("org.freedesktop.NetworkManager", path) + var existing map[string]map[string]dbus.Variant + if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existing); err != nil { + log.Warnf("[SecretAgent] Failed to get settings for identity save: %v", err) + return + } + + settings := make(map[string]map[string]dbus.Variant) + if connSection, ok := existing["connection"]; ok { + settings["connection"] = connSection + } + + dot1x, ok := existing["802-1x"] + if !ok { + dot1x = make(map[string]dbus.Variant) + } + dot1x["identity"] = dbus.MakeVariant(identity) + settings["802-1x"] = dot1x + + var result map[string]dbus.Variant + if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0, + settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil { + log.Warnf("[SecretAgent] Failed to save 802.1x identity: %v", err) + return + } + log.Infof("[SecretAgent] Saved 802.1x identity to connection profile") +} + +func readSSID(conn map[string]nmVariantMap) string { + if w, ok := conn["802-11-wireless"]; ok { + if v, ok := w["ssid"]; ok { + if b, ok := v.Value().([]byte); ok { + return string(b) + } + if s, ok := v.Value().(string); ok { + return s + } + } + } + return "" +} + +func readConnTypeAndName(conn map[string]nmVariantMap) (string, string, string) { + var connType, name, svc string + if c, ok := conn["connection"]; ok { + if v, ok := c["type"]; ok { + if s, ok2 := v.Value().(string); ok2 { + connType = s + } + } + if v, ok := c["id"]; ok { + if s, ok2 := v.Value().(string); ok2 { + name = s + } + } + } + if vpn, ok := conn["vpn"]; ok { + if v, ok := vpn["service-type"]; ok { + if s, ok2 := v.Value().(string); ok2 { + svc = s + } + } + } + if name == "" && connType == "802-11-wireless" { + name = readSSID(conn) + } + return connType, name, svc +} + +func fieldsNeeded(setting string, hints []string, conn map[string]nmVariantMap) []string { + switch setting { + case "802-11-wireless-security": + return []string{"psk"} + case "802-1x": + if len(hints) > 0 { + return hints + } + return infer8021xFields(conn) + case "vpn": + return hints + default: + return []string{} + } +} + +func infer8021xFields(conn map[string]nmVariantMap) []string { + dot1x, ok := conn["802-1x"] + if !ok { + return []string{"identity", "password"} + } + + var fields []string + + if v, ok := dot1x["identity"]; ok { + if id, ok := v.Value().(string); ok && id != "" { + // identity already stored, don't ask again + } else { + fields = append(fields, "identity") + } + } else { + fields = append(fields, "identity") + } + + var eapMethods []string + if v, ok := dot1x["eap"]; ok { + if methods, ok := v.Value().([]string); ok { + eapMethods = methods + } + } + + switch { + case len(eapMethods) > 0 && eapMethods[0] == "tls": + fields = append(fields, "private-key-password") + default: + fields = append(fields, "password") + } + + return fields +} + +func buildFieldsInfo(setting string, fields []string, vpnService string) []FieldInfo { + result := make([]FieldInfo, 0, len(fields)) + for _, f := range fields { + info := FieldInfo{Name: f} + switch setting { + case "802-11-wireless-security": + info.Label = "Password" + info.IsSecret = true + case "802-1x": + switch f { + case "identity": + info.Label = "Username" + info.IsSecret = false + case "password": + info.Label = "Password" + info.IsSecret = true + default: + info.Label = f + info.IsSecret = true + } + case "vpn": + info.Label, info.IsSecret = vpnFieldMeta(f, vpnService) + default: + info.Label = f + info.IsSecret = true + } + result = append(result, info) + } + return result +} + +func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string { + fields := []string{"password"} + + vpnSettings, ok := conn["vpn"] + if !ok { + return fields + } + + dataVariant, ok := vpnSettings["data"] + if !ok { + return fields + } + + dataMap, ok := dataVariant.Value().(map[string]string) + if !ok { + return fields + } + + connType := dataMap["connection-type"] + + switch { + case strings.Contains(vpnService, "openconnect"): + protocol := dataMap["protocol"] + authType := dataMap["authtype"] + username := dataMap["username"] + + if authType == "cert" && strings.HasPrefix(dataMap["usercert"], "pkcs11:") { + return []string{"key_pass"} + } + + if needsExternalBrowserAuth(protocol, authType, username, dataMap) { + switch protocol { + case "gp": + log.Infof("[SecretAgent] GlobalProtect SAML auth detected") + return []string{"gp-saml"} + default: + log.Infof("[SecretAgent] External browser auth detected for protocol '%s' but only GlobalProtect (gp) SAML is currently supported, falling back to credentials", protocol) + } + } + + if username == "" { + fields = []string{"username", "password"} + } + case strings.Contains(vpnService, "openvpn"): + if connType == "password" || connType == "password-tls" { + if dataMap["username"] == "" { + fields = []string{"username", "password"} + } + } + case strings.Contains(vpnService, "vpnc"), strings.Contains(vpnService, "l2tp"), + strings.Contains(vpnService, "pptp"): + if dataMap["username"] == "" { + fields = []string{"username", "password"} + } + } + + return fields +} + +func needsExternalBrowserAuth(protocol, authType, username string, data map[string]string) bool { + if method, ok := data["saml-auth-method"]; ok { + if method == "REDIRECT" || method == "POST" { + return true + } + } + + if authType != "" && authType != "password" && authType != "cert" { + return true + } + + switch protocol { + case "gp": + if authType == "" && username == "" { + return true + } + } + + return false +} + +func vpnFieldMeta(field, vpnService string) (label string, isSecret bool) { + switch field { + case "gp-saml": + return "GlobalProtect SAML/SSO", false + case "key_pass": + return "PIN", true + case "password": + return "Password", true + case "Xauth password": + return "IPSec Password", true + case "IPSec secret": + return "IPSec Pre-Shared Key", true + case "cert-pass": + return "Certificate Password", true + case "http-proxy-password": + return "HTTP Proxy Password", true + case "username": + return "Username", false + case "Xauth username": + return "IPSec Username", false + case "proxy-password": + return "Proxy Password", true + case "private-key-password": + return "Private Key Password", true + } + titleCaser := cases.Title(language.English) + if strings.HasSuffix(field, "password") || strings.HasSuffix(field, "secret") || + strings.HasSuffix(field, "pass") || strings.HasSuffix(field, "psk") { + return titleCaser.String(strings.ReplaceAll(field, "-", " ")), true + } + return titleCaser.String(strings.ReplaceAll(field, "-", " ")), false +} + +func isPKCS11Auth(conn map[string]nmVariantMap, vpnService string) bool { + if !strings.Contains(vpnService, "openconnect") { + return false + } + vpnSettings, ok := conn["vpn"] + if !ok { + return false + } + dataVariant, ok := vpnSettings["data"] + if !ok { + return false + } + dataMap, ok := dataVariant.Value().(map[string]string) + if !ok { + return false + } + return dataMap["authtype"] == "cert" && strings.HasPrefix(dataMap["usercert"], "pkcs11:") +} + +func readVPNPasswordFlags(conn map[string]nmVariantMap, settingName string) uint32 { + if settingName != "vpn" { + return 0xFFFF + } + + vpnSettings, ok := conn["vpn"] + if !ok { + return 0xFFFF + } + + dataVariant, ok := vpnSettings["data"] + if !ok { + return 0xFFFF + } + + dataMap, ok := dataVariant.Value().(map[string]string) + if !ok { + return 0xFFFF + } + + flagsStr, ok := dataMap["password-flags"] + if !ok { + return 0xFFFF + } + + flags64, err := strconv.ParseUint(flagsStr, 10, 32) + if err != nil { + return 0xFFFF + } + + return uint32(flags64) +} + +func reasonFromFlags(flags uint32) string { + const ( + NM_SECRET_AGENT_GET_SECRETS_FLAG_NONE = 0x0 + NM_SECRET_AGENT_GET_SECRETS_FLAG_ALLOW_INTERACTION = 0x1 + NM_SECRET_AGENT_GET_SECRETS_FLAG_REQUEST_NEW = 0x2 + NM_SECRET_AGENT_GET_SECRETS_FLAG_USER_REQUESTED = 0x4 + NM_SECRET_AGENT_GET_SECRETS_FLAG_WPS_PBC_ACTIVE = 0x8 + NM_SECRET_AGENT_GET_SECRETS_FLAG_ONLY_SYSTEM = 0x80000000 + NM_SECRET_AGENT_GET_SECRETS_FLAG_NO_ERRORS = 0x40000000 + ) + + if flags&NM_SECRET_AGENT_GET_SECRETS_FLAG_REQUEST_NEW != 0 { + return "wrong-password" + } + if flags&NM_SECRET_AGENT_GET_SECRETS_FLAG_USER_REQUESTED != 0 { + return "user-requested" + } + return "required" +} + +func buildGPSamlSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap { + out := nmSettingMap{} + vpnSec := nmVariantMap{} + + secrets := map[string]string{ + "cookie": cookie, + "gateway": host, + "gwcert": fingerprint, + } + vpnSec["secrets"] = dbus.MakeVariant(secrets) + + out[settingName] = vpnSec + return out +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager_test.go new file mode 100644 index 0000000..2040986 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager_test.go @@ -0,0 +1,355 @@ +package network + +import ( + "testing" + + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" +) + +func TestNeedsExternalBrowserAuth(t *testing.T) { + tests := []struct { + name string + protocol string + authType string + username string + data map[string]string + expected bool + }{ + { + name: "GP with saml-auth-method REDIRECT", + protocol: "gp", + authType: "password", + username: "user", + data: map[string]string{"saml-auth-method": "REDIRECT"}, + expected: true, + }, + { + name: "GP with saml-auth-method POST", + protocol: "gp", + authType: "password", + username: "user", + data: map[string]string{"saml-auth-method": "POST"}, + expected: true, + }, + { + name: "GP with no authtype and no username", + protocol: "gp", + authType: "", + username: "", + data: map[string]string{}, + expected: true, + }, + { + name: "GP with username and password authtype", + protocol: "gp", + authType: "password", + username: "john", + data: map[string]string{}, + expected: false, + }, + { + name: "GP with username but no authtype", + protocol: "gp", + authType: "", + username: "john", + data: map[string]string{}, + expected: false, + }, + { + name: "GP with authtype but no username - should detect SAML", + protocol: "gp", + authType: "", + username: "", + data: map[string]string{}, + expected: true, + }, + { + name: "pulse with SAML", + protocol: "pulse", + authType: "", + username: "", + data: map[string]string{"saml-auth-method": "REDIRECT"}, + expected: true, + }, + { + name: "fortinet with non-password authtype", + protocol: "fortinet", + authType: "saml", + username: "", + data: map[string]string{}, + expected: true, + }, + { + name: "anyconnect with cert", + protocol: "anyconnect", + authType: "cert", + username: "", + data: map[string]string{}, + expected: false, + }, + { + name: "anyconnect with password", + protocol: "anyconnect", + authType: "password", + username: "user", + data: map[string]string{}, + expected: false, + }, + { + name: "empty protocol", + protocol: "", + authType: "", + username: "", + data: map[string]string{}, + expected: false, + }, + { + name: "GP with cert authtype", + protocol: "gp", + authType: "cert", + username: "", + data: map[string]string{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := needsExternalBrowserAuth(tt.protocol, tt.authType, tt.username, tt.data) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBuildGPSamlSecretsResponse(t *testing.T) { + tests := []struct { + name string + settingName string + cookie string + host string + fingerprint string + }{ + { + name: "all fields populated", + settingName: "vpn", + cookie: "authcookie=abc123&portal=GATE", + host: "vpn.example.com", + fingerprint: "pin-sha256:ABCD1234", + }, + { + name: "empty fingerprint", + settingName: "vpn", + cookie: "authcookie=xyz", + host: "10.0.0.1", + fingerprint: "", + }, + { + name: "complex cookie with special chars", + settingName: "vpn", + cookie: "authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100", + host: "connect.seclore.com", + fingerprint: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildGPSamlSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint) + + assert.NotNil(t, result) + assert.Contains(t, result, tt.settingName) + + vpnSec := result[tt.settingName] + assert.NotNil(t, vpnSec) + + secretsVariant, ok := vpnSec["secrets"] + assert.True(t, ok, "secrets key should exist") + + secrets, ok := secretsVariant.Value().(map[string]string) + assert.True(t, ok, "secrets should be map[string]string") + + assert.Equal(t, tt.cookie, secrets["cookie"]) + assert.Equal(t, tt.host, secrets["gateway"]) + assert.Equal(t, tt.fingerprint, secrets["gwcert"]) + }) + } +} + +func TestVpnFieldMeta_GPSaml(t *testing.T) { + label, isSecret := vpnFieldMeta("gp-saml", "org.freedesktop.NetworkManager.openconnect") + + assert.Equal(t, "GlobalProtect SAML/SSO", label) + assert.False(t, isSecret, "gp-saml should not be marked as secret") +} + +func TestVpnFieldMeta_StandardFields(t *testing.T) { + tests := []struct { + field string + vpnService string + expectedLabel string + expectedSecret bool + }{ + { + field: "username", + vpnService: "org.freedesktop.NetworkManager.openconnect", + expectedLabel: "Username", + expectedSecret: false, + }, + { + field: "password", + vpnService: "org.freedesktop.NetworkManager.openconnect", + expectedLabel: "Password", + expectedSecret: true, + }, + { + field: "key_pass", + vpnService: "org.freedesktop.NetworkManager.openconnect", + expectedLabel: "PIN", + expectedSecret: true, + }, + } + + for _, tt := range tests { + t.Run(tt.field, func(t *testing.T) { + label, isSecret := vpnFieldMeta(tt.field, tt.vpnService) + assert.Equal(t, tt.expectedLabel, label) + assert.Equal(t, tt.expectedSecret, isSecret) + }) + } +} + +func TestInferVPNFields_GPSaml(t *testing.T) { + tests := []struct { + name string + vpnService string + dataMap map[string]string + expectedLen int + shouldHave []string + }{ + { + name: "GP with no authtype and no username - should require SAML", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "gp", + "gateway": "vpn.example.com", + }, + expectedLen: 1, + shouldHave: []string{"gp-saml"}, + }, + { + name: "GP with saml-auth-method REDIRECT", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "gp", + "gateway": "vpn.example.com", + "saml-auth-method": "REDIRECT", + "username": "john", + }, + expectedLen: 1, + shouldHave: []string{"gp-saml"}, + }, + { + name: "GP with saml-auth-method POST", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "gp", + "gateway": "vpn.example.com", + "saml-auth-method": "POST", + }, + expectedLen: 1, + shouldHave: []string{"gp-saml"}, + }, + { + name: "GP with username and password authtype - should use credentials", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "gp", + "gateway": "vpn.example.com", + "authtype": "password", + "username": "john", + }, + expectedLen: 1, + shouldHave: []string{"password"}, + }, + { + name: "GP with username but no authtype - password only", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "gp", + "gateway": "vpn.example.com", + "username": "john", + }, + expectedLen: 1, + shouldHave: []string{"password"}, + }, + { + name: "GP with PKCS11 cert", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "gp", + "gateway": "vpn.example.com", + "authtype": "cert", + "usercert": "pkcs11:model=PKCS%2315%20emulated;manufacturer=piv_II", + }, + expectedLen: 1, + shouldHave: []string{"key_pass"}, + }, + { + name: "non-GP protocol (anyconnect)", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "anyconnect", + "gateway": "vpn.example.com", + }, + expectedLen: 2, + shouldHave: []string{"username", "password"}, + }, + { + name: "OpenVPN with username", + vpnService: "org.freedesktop.NetworkManager.openvpn", + dataMap: map[string]string{ + "connection-type": "password", + "username": "john", + }, + expectedLen: 1, + shouldHave: []string{"password"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Convert dataMap to nmVariantMap + vpnSettings := make(nmVariantMap) + vpnSettings["data"] = dbus.MakeVariant(tt.dataMap) + vpnSettings["service-type"] = dbus.MakeVariant(tt.vpnService) + + conn := make(map[string]nmVariantMap) + conn["vpn"] = vpnSettings + + fields := inferVPNFields(conn, tt.vpnService) + + assert.Len(t, fields, tt.expectedLen, "unexpected number of fields") + if len(tt.shouldHave) > 0 { + for _, expected := range tt.shouldHave { + assert.Contains(t, fields, expected, "should contain field: %s", expected) + } + } + }) + } +} + +func TestNmVariantMap(t *testing.T) { + // Test that nmVariantMap and nmSettingMap work correctly + settingMap := make(nmSettingMap) + variantMap := make(nmVariantMap) + + variantMap["test-key"] = dbus.MakeVariant("test-value") + settingMap["test-setting"] = variantMap + + assert.Contains(t, settingMap, "test-setting") + assert.Contains(t, settingMap["test-setting"], "test-key") + + value := settingMap["test-setting"]["test-key"].Value() + assert.Equal(t, "test-value", value) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend.go new file mode 100644 index 0000000..f1bb04d --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend.go @@ -0,0 +1,80 @@ +package network + +type Backend interface { + Initialize() error + Close() + + GetWiFiEnabled() (bool, error) + SetWiFiEnabled(enabled bool) error + + ScanWiFi() error + ScanWiFiDevice(device string) error + GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) + GetWiFiQRCodeContent(ssid string) (string, error) + GetWiFiDevices() []WiFiDevice + + ConnectWiFi(req ConnectionRequest) error + DisconnectWiFi() error + DisconnectWiFiDevice(device string) error + ForgetWiFiNetwork(ssid string) error + SetWiFiAutoconnect(ssid string, autoconnect bool) error + + GetEthernetDevices() []EthernetDevice + GetWiredConnections() ([]WiredConnection, error) + GetWiredNetworkDetails(uuid string) (*WiredNetworkInfoResponse, error) + ConnectEthernet() error + DisconnectEthernet() error + DisconnectEthernetDevice(device string) error + ActivateWiredConnection(uuid string) error + + ListVPNProfiles() ([]VPNProfile, error) + ListActiveVPN() ([]VPNActive, error) + ConnectVPN(uuidOrName string, singleActive bool) error + DisconnectVPN(uuidOrName string) error + DisconnectAllVPN() error + ClearVPNCredentials(uuidOrName string) error + ListVPNPlugins() ([]VPNPlugin, error) + ImportVPN(filePath string, name string) (*VPNImportResult, error) + GetVPNConfig(uuidOrName string) (*VPNConfig, error) + UpdateVPNConfig(uuid string, updates map[string]any) error + SetVPNCredentials(uuid string, username string, password string, save bool) error + DeleteVPN(uuidOrName string) error + + GetCurrentState() (*BackendState, error) + + StartMonitoring(onStateChange func()) error + StopMonitoring() + + GetPromptBroker() PromptBroker + SetPromptBroker(broker PromptBroker) error + SubmitCredentials(token string, secrets map[string]string, save bool) error + CancelCredentials(token string) error +} + +type BackendState struct { + Backend string + NetworkStatus NetworkStatus + EthernetIP string + EthernetDevice string + EthernetConnected bool + EthernetConnectionUuid string + EthernetDevices []EthernetDevice + WiFiIP string + WiFiDevice string + WiFiConnected bool + WiFiEnabled bool + WiFiSSID string + WiFiBSSID string + WiFiSignal uint8 + WiFiNetworks []WiFiNetwork + WiFiDevices []WiFiDevice + WiredConnections []WiredConnection + VPNProfiles []VPNProfile + VPNActive []VPNActive + IsConnecting bool + ConnectingSSID string + ConnectingDevice string + IsConnectingVPN bool + ConnectingVPNUUID string + LastError string +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_iwd_networkd.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_iwd_networkd.go new file mode 100644 index 0000000..ce883ba --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_iwd_networkd.go @@ -0,0 +1,245 @@ +package network + +import ( + "fmt" +) + +type HybridIwdNetworkdBackend struct { + wifi *IWDBackend + l3 *SystemdNetworkdBackend + onStateChange func() +} + +func NewHybridIwdNetworkdBackend(w *IWDBackend, n *SystemdNetworkdBackend) (*HybridIwdNetworkdBackend, error) { + return &HybridIwdNetworkdBackend{ + wifi: w, + l3: n, + }, nil +} + +func (b *HybridIwdNetworkdBackend) Initialize() error { + if err := b.wifi.Initialize(); err != nil { + return fmt.Errorf("iwd init: %w", err) + } + if err := b.l3.Initialize(); err != nil { + return fmt.Errorf("networkd init: %w", err) + } + return nil +} + +func (b *HybridIwdNetworkdBackend) Close() { + b.wifi.Close() + b.l3.Close() +} + +func (b *HybridIwdNetworkdBackend) StartMonitoring(onStateChange func()) error { + b.onStateChange = onStateChange + + mergedCallback := func() { + ws, _ := b.wifi.GetCurrentState() + ls, _ := b.l3.GetCurrentState() + + if ws != nil && ls != nil && ws.WiFiDevice != "" && ls.WiFiIP != "" { + b.wifi.MarkIPConfigSeen() + } + + if b.onStateChange != nil { + b.onStateChange() + } + } + + if err := b.wifi.StartMonitoring(mergedCallback); err != nil { + return fmt.Errorf("wifi monitoring: %w", err) + } + if err := b.l3.StartMonitoring(mergedCallback); err != nil { + return fmt.Errorf("l3 monitoring: %w", err) + } + + return nil +} + +func (b *HybridIwdNetworkdBackend) StopMonitoring() { + b.wifi.StopMonitoring() + b.l3.StopMonitoring() +} + +func (b *HybridIwdNetworkdBackend) GetCurrentState() (*BackendState, error) { + ws, err := b.wifi.GetCurrentState() + if err != nil { + return nil, err + } + ls, err := b.l3.GetCurrentState() + if err != nil { + return nil, err + } + + merged := *ws + merged.Backend = "iwd+networkd" + + merged.WiFiIP = ls.WiFiIP + merged.EthernetConnected = ls.EthernetConnected + merged.EthernetIP = ls.EthernetIP + merged.EthernetDevice = ls.EthernetDevice + merged.EthernetConnectionUuid = ls.EthernetConnectionUuid + merged.WiredConnections = ls.WiredConnections + merged.EthernetDevices = ls.EthernetDevices + + if ls.EthernetConnected && ls.EthernetIP != "" { + merged.NetworkStatus = StatusEthernet + } else if ws.WiFiConnected && ls.WiFiIP != "" { + merged.NetworkStatus = StatusWiFi + } else { + merged.NetworkStatus = StatusDisconnected + } + + return &merged, nil +} + +func (b *HybridIwdNetworkdBackend) GetWiFiEnabled() (bool, error) { + return b.wifi.GetWiFiEnabled() +} + +func (b *HybridIwdNetworkdBackend) SetWiFiEnabled(enabled bool) error { + return b.wifi.SetWiFiEnabled(enabled) +} + +func (b *HybridIwdNetworkdBackend) ScanWiFi() error { + return b.wifi.ScanWiFi() +} + +func (b *HybridIwdNetworkdBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) { + return b.wifi.GetWiFiNetworkDetails(ssid) +} + +func (b *HybridIwdNetworkdBackend) GetWiFiQRCodeContent(ssid string) (string, error) { + return b.wifi.GetWiFiQRCodeContent(ssid) +} + +func (b *HybridIwdNetworkdBackend) ConnectWiFi(req ConnectionRequest) error { + if err := b.wifi.ConnectWiFi(req); err != nil { + return err + } + + ws, err := b.wifi.GetCurrentState() + if err == nil && ws.WiFiDevice != "" { + b.l3.EnsureDhcpUp(ws.WiFiDevice) //nolint:errcheck + } + + return nil +} + +func (b *HybridIwdNetworkdBackend) DisconnectWiFi() error { + return b.wifi.DisconnectWiFi() +} + +func (b *HybridIwdNetworkdBackend) ForgetWiFiNetwork(ssid string) error { + return b.wifi.ForgetWiFiNetwork(ssid) +} + +func (b *HybridIwdNetworkdBackend) GetWiredConnections() ([]WiredConnection, error) { + return b.l3.GetWiredConnections() +} + +func (b *HybridIwdNetworkdBackend) GetWiredNetworkDetails(uuid string) (*WiredNetworkInfoResponse, error) { + return b.l3.GetWiredNetworkDetails(uuid) +} + +func (b *HybridIwdNetworkdBackend) ConnectEthernet() error { + return b.l3.ConnectEthernet() +} + +func (b *HybridIwdNetworkdBackend) DisconnectEthernet() error { + return b.l3.DisconnectEthernet() +} + +func (b *HybridIwdNetworkdBackend) DisconnectEthernetDevice(device string) error { + return b.l3.DisconnectEthernetDevice(device) +} + +func (b *HybridIwdNetworkdBackend) GetEthernetDevices() []EthernetDevice { + return b.l3.GetEthernetDevices() +} + +func (b *HybridIwdNetworkdBackend) ActivateWiredConnection(uuid string) error { + return b.l3.ActivateWiredConnection(uuid) +} + +func (b *HybridIwdNetworkdBackend) ListVPNProfiles() ([]VPNProfile, error) { + return []VPNProfile{}, nil +} + +func (b *HybridIwdNetworkdBackend) ListActiveVPN() ([]VPNActive, error) { + return []VPNActive{}, nil +} + +func (b *HybridIwdNetworkdBackend) ConnectVPN(uuidOrName string, singleActive bool) error { + return fmt.Errorf("VPN not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) DisconnectVPN(uuidOrName string) error { + return fmt.Errorf("VPN not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) DisconnectAllVPN() error { + return fmt.Errorf("VPN not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) ClearVPNCredentials(uuidOrName string) error { + return fmt.Errorf("VPN not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) ListVPNPlugins() ([]VPNPlugin, error) { + return []VPNPlugin{}, nil +} + +func (b *HybridIwdNetworkdBackend) ImportVPN(filePath string, name string) (*VPNImportResult, error) { + return nil, fmt.Errorf("VPN not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) GetVPNConfig(uuidOrName string) (*VPNConfig, error) { + return nil, fmt.Errorf("VPN not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) UpdateVPNConfig(uuid string, updates map[string]any) error { + return fmt.Errorf("VPN not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) DeleteVPN(uuidOrName string) error { + return fmt.Errorf("VPN not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) GetPromptBroker() PromptBroker { + return b.wifi.GetPromptBroker() +} + +func (b *HybridIwdNetworkdBackend) SetPromptBroker(broker PromptBroker) error { + return b.wifi.SetPromptBroker(broker) +} + +func (b *HybridIwdNetworkdBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error { + return b.wifi.SubmitCredentials(token, secrets, save) +} + +func (b *HybridIwdNetworkdBackend) CancelCredentials(token string) error { + return b.wifi.CancelCredentials(token) +} + +func (b *HybridIwdNetworkdBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error { + return b.wifi.SetWiFiAutoconnect(ssid, autoconnect) +} + +func (b *HybridIwdNetworkdBackend) ScanWiFiDevice(device string) error { + return b.wifi.ScanWiFiDevice(device) +} + +func (b *HybridIwdNetworkdBackend) DisconnectWiFiDevice(device string) error { + return b.wifi.DisconnectWiFiDevice(device) +} + +func (b *HybridIwdNetworkdBackend) GetWiFiDevices() []WiFiDevice { + return b.wifi.GetWiFiDevices() +} + +func (b *HybridIwdNetworkdBackend) SetVPNCredentials(uuid, username, password string, save bool) error { + return fmt.Errorf("VPN not supported in hybrid mode") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_test.go new file mode 100644 index 0000000..cd061ed --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_test.go @@ -0,0 +1,135 @@ +package network + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHybridIwdNetworkdBackend_New(t *testing.T) { + wifi, _ := NewIWDBackend() + l3, _ := NewSystemdNetworkdBackend() + + hybrid, err := NewHybridIwdNetworkdBackend(wifi, l3) + assert.NoError(t, err) + assert.NotNil(t, hybrid) + assert.NotNil(t, hybrid.wifi) + assert.NotNil(t, hybrid.l3) +} + +func TestHybridIwdNetworkdBackend_GetCurrentState_MergesState(t *testing.T) { + wifi, _ := NewIWDBackend() + l3, _ := NewSystemdNetworkdBackend() + hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3) + + wifi.state.WiFiConnected = true + wifi.state.WiFiSSID = "TestNetwork" + wifi.state.WiFiBSSID = "00:11:22:33:44:55" + wifi.state.WiFiSignal = 75 + wifi.state.WiFiDevice = "wlan0" + + l3.state.WiFiIP = "192.168.1.100" + l3.state.EthernetConnected = false + + state, err := hybrid.GetCurrentState() + assert.NoError(t, err) + assert.NotNil(t, state) + assert.Equal(t, "iwd+networkd", state.Backend) + assert.Equal(t, "TestNetwork", state.WiFiSSID) + assert.Equal(t, "00:11:22:33:44:55", state.WiFiBSSID) + assert.Equal(t, uint8(75), state.WiFiSignal) + assert.Equal(t, "192.168.1.100", state.WiFiIP) + assert.True(t, state.WiFiConnected) + assert.False(t, state.EthernetConnected) + assert.Equal(t, StatusWiFi, state.NetworkStatus) +} + +func TestHybridIwdNetworkdBackend_GetCurrentState_EthernetPriority(t *testing.T) { + wifi, _ := NewIWDBackend() + l3, _ := NewSystemdNetworkdBackend() + hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3) + + wifi.state.WiFiConnected = true + wifi.state.WiFiSSID = "TestNetwork" + + l3.state.WiFiIP = "192.168.1.100" + l3.state.EthernetConnected = true + l3.state.EthernetIP = "192.168.1.50" + l3.state.EthernetDevice = "eth0" + + state, err := hybrid.GetCurrentState() + assert.NoError(t, err) + assert.Equal(t, StatusEthernet, state.NetworkStatus) + assert.Equal(t, "192.168.1.50", state.EthernetIP) + assert.Equal(t, "eth0", state.EthernetDevice) +} + +func TestHybridIwdNetworkdBackend_GetCurrentState_WiFiNoIP(t *testing.T) { + wifi, _ := NewIWDBackend() + l3, _ := NewSystemdNetworkdBackend() + hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3) + + wifi.state.WiFiConnected = true + wifi.state.WiFiSSID = "TestNetwork" + + l3.state.WiFiIP = "" + l3.state.EthernetConnected = false + + state, err := hybrid.GetCurrentState() + assert.NoError(t, err) + assert.Equal(t, StatusDisconnected, state.NetworkStatus) + assert.True(t, state.WiFiConnected) + assert.Empty(t, state.WiFiIP) +} + +func TestHybridIwdNetworkdBackend_WiFiDelegation(t *testing.T) { + wifi, _ := NewIWDBackend() + l3, _ := NewSystemdNetworkdBackend() + hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3) + + enabled, err := hybrid.GetWiFiEnabled() + assert.NoError(t, err) + assert.True(t, enabled) + + state, err := hybrid.GetCurrentState() + assert.NoError(t, err) + assert.NotNil(t, state) + assert.Equal(t, "iwd+networkd", state.Backend) +} + +func TestHybridIwdNetworkdBackend_WiredDelegation(t *testing.T) { + wifi, _ := NewIWDBackend() + l3, _ := NewSystemdNetworkdBackend() + hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3) + + conns, err := hybrid.GetWiredConnections() + assert.NoError(t, err) + assert.Empty(t, conns) +} + +func TestHybridIwdNetworkdBackend_VPNNotSupported(t *testing.T) { + wifi, _ := NewIWDBackend() + l3, _ := NewSystemdNetworkdBackend() + hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3) + + profiles, err := hybrid.ListVPNProfiles() + assert.NoError(t, err) + assert.Empty(t, profiles) + + active, err := hybrid.ListActiveVPN() + assert.NoError(t, err) + assert.Empty(t, active) + + err = hybrid.ConnectVPN("test", false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported") +} + +func TestHybridIwdNetworkdBackend_PromptBrokerDelegation(t *testing.T) { + wifi, _ := NewIWDBackend() + l3, _ := NewSystemdNetworkdBackend() + hybrid, _ := NewHybridIwdNetworkdBackend(wifi, l3) + + broker := hybrid.GetPromptBroker() + assert.Nil(t, broker) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd.go new file mode 100644 index 0000000..f73aaf4 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd.go @@ -0,0 +1,238 @@ +package network + +import ( + "fmt" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/godbus/dbus/v5" +) + +const ( + iwdBusName = "net.connman.iwd" + iwdObjectPath = "/" + iwdAdapterInterface = "net.connman.iwd.Adapter" + iwdDeviceInterface = "net.connman.iwd.Device" + iwdStationInterface = "net.connman.iwd.Station" + iwdNetworkInterface = "net.connman.iwd.Network" + iwdKnownNetworkInterface = "net.connman.iwd.KnownNetwork" + dbusObjectManager = "org.freedesktop.DBus.ObjectManager" + dbusPropertiesInterface = "org.freedesktop.DBus.Properties" +) + +type connectAttempt struct { + ssid string + netPath dbus.ObjectPath + start time.Time + deadline time.Time + sawAuthish bool + connectedAt time.Time + sawIPConfig bool + sawPromptRetry bool + finalized bool + mu sync.Mutex +} + +type IWDBackend struct { + conn *dbus.Conn + state *BackendState + stateMutex sync.RWMutex + promptBroker PromptBroker + onStateChange func() + + devicePath dbus.ObjectPath + stationPath dbus.ObjectPath + adapterPath dbus.ObjectPath + + iwdAgent *IWDAgent + + stopChan chan struct{} + sigWG sync.WaitGroup + curAttempt *connectAttempt + attemptMutex sync.RWMutex + recentScans map[string]time.Time + recentScansMu sync.Mutex +} + +func NewIWDBackend() (*IWDBackend, error) { + backend := &IWDBackend{ + state: &BackendState{ + Backend: "iwd", + WiFiEnabled: true, + }, + stopChan: make(chan struct{}), + recentScans: make(map[string]time.Time), + } + + return backend, nil +} + +func (b *IWDBackend) Initialize() error { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return fmt.Errorf("failed to connect to system bus: %w", err) + } + b.conn = conn + + if err := b.discoverDevices(); err != nil { + conn.Close() + return fmt.Errorf("failed to discover iwd devices: %w", err) + } + + if err := b.updateState(); err != nil { + conn.Close() + return fmt.Errorf("failed to get initial state: %w", err) + } + + return nil +} + +func (b *IWDBackend) Close() { + close(b.stopChan) + b.sigWG.Wait() + + if b.iwdAgent != nil { + b.iwdAgent.Close() + } + + if b.conn != nil { + b.conn.Close() + } +} + +func (b *IWDBackend) discoverDevices() error { + obj := b.conn.Object(iwdBusName, iwdObjectPath) + + var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects) + if err != nil { + return fmt.Errorf("failed to get managed objects: %w", err) + } + + for path, interfaces := range objects { + if _, hasStation := interfaces[iwdStationInterface]; hasStation { + b.stationPath = path + } + if _, hasDevice := interfaces[iwdDeviceInterface]; hasDevice { + b.devicePath = path + + if devProps, ok := interfaces[iwdDeviceInterface]; ok { + if nameVar, ok := devProps["Name"]; ok { + if name, ok := nameVar.Value().(string); ok { + b.stateMutex.Lock() + b.state.WiFiDevice = name + b.stateMutex.Unlock() + } + } + } + } + if _, hasAdapter := interfaces[iwdAdapterInterface]; hasAdapter { + b.adapterPath = path + } + } + + if b.stationPath == "" || b.devicePath == "" { + return fmt.Errorf("no WiFi device found") + } + + return nil +} + +func (b *IWDBackend) GetCurrentState() (*BackendState, error) { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + + state := *b.state + state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...) + state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...) + state.WiFiDevices = b.getWiFiDevicesLocked() + + return &state, nil +} + +func (b *IWDBackend) OnUserCanceledPrompt() { + b.stateMutex.RLock() + cancelledSSID := b.state.ConnectingSSID + b.stateMutex.RUnlock() + + b.setConnectError("user-canceled") + + if cancelledSSID != "" { + if err := b.ForgetWiFiNetwork(cancelledSSID); err != nil { + log.Warnf("failed to forget cancelled WiFi network %s: %v", cancelledSSID, err) + } + } + + if b.onStateChange != nil { + b.onStateChange() + } +} + +func (b *IWDBackend) OnPromptRetry(ssid string) { + b.attemptMutex.RLock() + att := b.curAttempt + b.attemptMutex.RUnlock() + + if att != nil && att.ssid == ssid { + att.mu.Lock() + att.sawPromptRetry = true + att.mu.Unlock() + } +} + +func (b *IWDBackend) MarkIPConfigSeen() { + b.attemptMutex.RLock() + att := b.curAttempt + b.attemptMutex.RUnlock() + if att != nil { + att.mu.Lock() + att.sawIPConfig = true + att.mu.Unlock() + } +} + +func (b *IWDBackend) GetPromptBroker() PromptBroker { + return b.promptBroker +} + +func (b *IWDBackend) SetPromptBroker(broker PromptBroker) error { + if broker == nil { + return fmt.Errorf("broker cannot be nil") + } + + b.promptBroker = broker + return nil +} + +func (b *IWDBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error { + if b.promptBroker == nil { + return fmt.Errorf("prompt broker not initialized") + } + + return b.promptBroker.Resolve(token, PromptReply{ + Secrets: secrets, + Save: save, + Cancel: false, + }) +} + +func (b *IWDBackend) CancelCredentials(token string) error { + if b.promptBroker == nil { + return fmt.Errorf("prompt broker not initialized") + } + + return b.promptBroker.Resolve(token, PromptReply{ + Cancel: true, + }) +} + +func (b *IWDBackend) StopMonitoring() { + select { + case <-b.stopChan: + return + default: + close(b.stopChan) + } + b.sigWG.Wait() +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_signals.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_signals.go new file mode 100644 index 0000000..bde4753 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_signals.go @@ -0,0 +1,355 @@ +package network + +import ( + "fmt" + "time" + + "github.com/godbus/dbus/v5" +) + +func (b *IWDBackend) StartMonitoring(onStateChange func()) error { + b.onStateChange = onStateChange + + if b.promptBroker != nil { + agent, err := NewIWDAgent(b.conn, b.promptBroker) + if err != nil { + return fmt.Errorf("failed to start IWD agent: %w", err) + } + agent.onUserCanceled = b.OnUserCanceledPrompt + agent.onPromptRetry = b.OnPromptRetry + b.iwdAgent = agent + } + + sigChan := make(chan *dbus.Signal, 100) + b.conn.Signal(sigChan) + + if b.devicePath != "" { + err := b.conn.AddMatchSignal( + dbus.WithMatchObjectPath(b.devicePath), + dbus.WithMatchInterface(dbusPropertiesInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + if err != nil { + return fmt.Errorf("failed to add device signal match: %w", err) + } + } + + if b.stationPath != "" { + err := b.conn.AddMatchSignal( + dbus.WithMatchObjectPath(b.stationPath), + dbus.WithMatchInterface(dbusPropertiesInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + if err != nil { + return fmt.Errorf("failed to add station signal match: %w", err) + } + } + + b.sigWG.Add(1) + go b.signalHandler(sigChan) + + return nil +} + +func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) { + defer b.sigWG.Done() + + for { + select { + case <-b.stopChan: + b.conn.RemoveSignal(sigChan) + close(sigChan) + return + + case sig := <-sigChan: + if sig == nil { + return + } + + if sig.Name != dbusPropertiesInterface+".PropertiesChanged" { + continue + } + + if len(sig.Body) < 2 { + continue + } + + iface, ok := sig.Body[0].(string) + if !ok { + continue + } + + changed, ok := sig.Body[1].(map[string]dbus.Variant) + if !ok { + continue + } + + stateChanged := false + + switch iface { + case iwdDeviceInterface: + if sig.Path == b.devicePath { + if poweredVar, ok := changed["Powered"]; ok { + if powered, ok := poweredVar.Value().(bool); ok { + b.stateMutex.Lock() + if b.state.WiFiEnabled != powered { + b.state.WiFiEnabled = powered + stateChanged = true + } + b.stateMutex.Unlock() + } + } + } + + case iwdStationInterface: + if sig.Path == b.stationPath { + if scanningVar, ok := changed["Scanning"]; ok { + if scanning, ok := scanningVar.Value().(bool); ok && !scanning { + networks, err := b.updateWiFiNetworks() + if err == nil { + b.stateMutex.Lock() + b.state.WiFiNetworks = networks + b.stateMutex.Unlock() + stateChanged = true + } + + b.stateMutex.RLock() + wifiConnected := b.state.WiFiConnected + b.stateMutex.RUnlock() + + if wifiConnected { + stationObj := b.conn.Object(iwdBusName, b.stationPath) + connNetVar, err := stationObj.GetProperty(iwdStationInterface + ".ConnectedNetwork") + if err == nil && connNetVar.Value() != nil { + if netPath, ok := connNetVar.Value().(dbus.ObjectPath); ok && netPath != "/" { + var orderedNetworks [][]dbus.Variant + err = stationObj.Call(iwdStationInterface+".GetOrderedNetworks", 0).Store(&orderedNetworks) + if err == nil { + for _, netData := range orderedNetworks { + if len(netData) < 2 { + continue + } + currentNetPath, ok := netData[0].Value().(dbus.ObjectPath) + if !ok || currentNetPath != netPath { + continue + } + signalStrength, ok := netData[1].Value().(int16) + if !ok { + continue + } + signalDbm := signalStrength / 100 + signal := uint8(signalDbm + 100) + if signalDbm > 0 { + signal = 100 + } else if signalDbm < -100 { + signal = 0 + } + b.stateMutex.Lock() + if b.state.WiFiSignal != signal { + b.state.WiFiSignal = signal + stateChanged = true + } + b.stateMutex.Unlock() + break + } + } + } + } + } + } + } + + if stateVar, ok := changed["State"]; ok { + if state, ok := stateVar.Value().(string); ok { + b.attemptMutex.RLock() + att := b.curAttempt + b.attemptMutex.RUnlock() + + var connPath dbus.ObjectPath + if v, ok := changed["ConnectedNetwork"]; ok { + if v.Value() != nil { + if p, ok := v.Value().(dbus.ObjectPath); ok { + connPath = p + } + } + } + if connPath == "" { + station := b.conn.Object(iwdBusName, b.stationPath) + if cnVar, err := station.GetProperty(iwdStationInterface + ".ConnectedNetwork"); err == nil && cnVar.Value() != nil { + cnVar.Store(&connPath) + } + } + + b.stateMutex.RLock() + prevConnected := b.state.WiFiConnected + prevSSID := b.state.WiFiSSID + b.stateMutex.RUnlock() + + targetPath := dbus.ObjectPath("") + if att != nil { + targetPath = att.netPath + } + + isTarget := att != nil && targetPath != "" && connPath == targetPath + + if att != nil { + switch state { + case "authenticating", "associating", "associated", "roaming": + att.mu.Lock() + att.sawAuthish = true + att.mu.Unlock() + } + } + + if att != nil && state == "connected" && isTarget { + att.mu.Lock() + if att.connectedAt.IsZero() { + att.connectedAt = time.Now() + } + att.mu.Unlock() + } + + if att != nil && state == "configuring" { + att.mu.Lock() + att.sawIPConfig = true + att.mu.Unlock() + } + + switch state { + case "connected": + b.stateMutex.Lock() + b.state.WiFiConnected = true + b.state.NetworkStatus = StatusWiFi + b.state.IsConnecting = false + b.state.ConnectingSSID = "" + b.state.LastError = "" + b.stateMutex.Unlock() + + if connPath != "" && connPath != "/" { + netObj := b.conn.Object(iwdBusName, connPath) + if nameVar, err := netObj.GetProperty(iwdNetworkInterface + ".Name"); err == nil { + if name, ok := nameVar.Value().(string); ok { + b.stateMutex.Lock() + b.state.WiFiSSID = name + b.stateMutex.Unlock() + } + } + } + + stateChanged = true + + if att != nil && isTarget { + go func(attLocal *connectAttempt, tgt dbus.ObjectPath) { + time.Sleep(3 * time.Second) + station := b.conn.Object(iwdBusName, b.stationPath) + var nowState string + if stVar, err := station.GetProperty(iwdStationInterface + ".State"); err == nil { + stVar.Store(&nowState) + } + var nowConn dbus.ObjectPath + if cnVar, err := station.GetProperty(iwdStationInterface + ".ConnectedNetwork"); err == nil && cnVar.Value() != nil { + cnVar.Store(&nowConn) + } + + if nowState == "connected" && nowConn == tgt { + b.finalizeAttempt(attLocal, "") + b.attemptMutex.Lock() + if b.curAttempt == attLocal { + b.curAttempt = nil + } + b.attemptMutex.Unlock() + } + }(att, targetPath) + } + + case "disconnecting", "disconnected": + if att != nil { + wasConnectedToTarget := prevConnected && prevSSID == att.ssid + if wasConnectedToTarget || isTarget { + code := b.classifyAttempt(att) + b.finalizeAttempt(att, code) + b.attemptMutex.Lock() + if b.curAttempt == att { + b.curAttempt = nil + } + b.attemptMutex.Unlock() + } + } + + b.stateMutex.Lock() + b.state.WiFiConnected = false + if state == "disconnected" { + b.state.NetworkStatus = StatusDisconnected + } + b.stateMutex.Unlock() + stateChanged = true + } + } + } + + if connNetVar, ok := changed["ConnectedNetwork"]; ok { + if netPath, ok := connNetVar.Value().(dbus.ObjectPath); ok && netPath != "/" { + netObj := b.conn.Object(iwdBusName, netPath) + nameVar, err := netObj.GetProperty(iwdNetworkInterface + ".Name") + if err == nil { + if name, ok := nameVar.Value().(string); ok { + b.stateMutex.Lock() + if b.state.WiFiSSID != name { + b.state.WiFiSSID = name + stateChanged = true + } + b.stateMutex.Unlock() + } + } + + stationObj := b.conn.Object(iwdBusName, b.stationPath) + var orderedNetworks [][]dbus.Variant + err = stationObj.Call(iwdStationInterface+".GetOrderedNetworks", 0).Store(&orderedNetworks) + if err == nil { + for _, netData := range orderedNetworks { + if len(netData) < 2 { + continue + } + currentNetPath, ok := netData[0].Value().(dbus.ObjectPath) + if !ok || currentNetPath != netPath { + continue + } + signalStrength, ok := netData[1].Value().(int16) + if !ok { + continue + } + signalDbm := signalStrength / 100 + signal := uint8(signalDbm + 100) + if signalDbm > 0 { + signal = 100 + } else if signalDbm < -100 { + signal = 0 + } + b.stateMutex.Lock() + if b.state.WiFiSignal != signal { + b.state.WiFiSignal = signal + stateChanged = true + } + b.stateMutex.Unlock() + break + } + } + } else { + b.stateMutex.Lock() + if b.state.WiFiSSID != "" { + b.state.WiFiSSID = "" + b.state.WiFiSignal = 0 + stateChanged = true + } + b.stateMutex.Unlock() + } + } + } + } + + if stateChanged && b.onStateChange != nil { + b.onStateChange() + } + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_test.go new file mode 100644 index 0000000..829e413 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_test.go @@ -0,0 +1,212 @@ +package network + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestIWDBackend_MarkIPConfigSeen(t *testing.T) { + backend, _ := NewIWDBackend() + + att := &connectAttempt{ + ssid: "TestNetwork", + netPath: "/net/connman/iwd/0/1/test", + start: time.Now(), + deadline: time.Now().Add(15 * time.Second), + } + + backend.attemptMutex.Lock() + backend.curAttempt = att + backend.attemptMutex.Unlock() + + backend.MarkIPConfigSeen() + + att.mu.Lock() + assert.True(t, att.sawIPConfig, "sawIPConfig should be true after MarkIPConfigSeen") + att.mu.Unlock() +} + +func TestIWDBackend_MarkIPConfigSeen_NoAttempt(t *testing.T) { + backend, _ := NewIWDBackend() + + backend.attemptMutex.Lock() + backend.curAttempt = nil + backend.attemptMutex.Unlock() + + backend.MarkIPConfigSeen() +} + +func TestIWDBackend_OnPromptRetry(t *testing.T) { + backend, _ := NewIWDBackend() + + att := &connectAttempt{ + ssid: "TestNetwork", + netPath: "/net/connman/iwd/0/1/test", + start: time.Now(), + deadline: time.Now().Add(15 * time.Second), + } + + backend.attemptMutex.Lock() + backend.curAttempt = att + backend.attemptMutex.Unlock() + + backend.OnPromptRetry("TestNetwork") + + att.mu.Lock() + assert.True(t, att.sawPromptRetry, "sawPromptRetry should be true after OnPromptRetry") + att.mu.Unlock() +} + +func TestIWDBackend_OnPromptRetry_WrongSSID(t *testing.T) { + backend, _ := NewIWDBackend() + + att := &connectAttempt{ + ssid: "TestNetwork", + netPath: "/net/connman/iwd/0/1/test", + start: time.Now(), + deadline: time.Now().Add(15 * time.Second), + } + + backend.attemptMutex.Lock() + backend.curAttempt = att + backend.attemptMutex.Unlock() + + backend.OnPromptRetry("DifferentNetwork") + + att.mu.Lock() + assert.False(t, att.sawPromptRetry, "sawPromptRetry should remain false for different SSID") + att.mu.Unlock() +} + +func TestIWDBackend_ClassifyAttempt_BadCredentials_PromptRetry(t *testing.T) { + backend, _ := NewIWDBackend() + + att := &connectAttempt{ + ssid: "TestNetwork", + netPath: "/test", + start: time.Now().Add(-5 * time.Second), + deadline: time.Now().Add(10 * time.Second), + sawPromptRetry: true, + } + + code := backend.classifyAttempt(att) + assert.Equal(t, "bad-credentials", code) +} + +func TestIWDBackend_ClassifyAttempt_DhcpTimeout(t *testing.T) { + backend, _ := NewIWDBackend() + + att := &connectAttempt{ + ssid: "TestNetwork", + netPath: "/test", + start: time.Now().Add(-13 * time.Second), + deadline: time.Now().Add(2 * time.Second), + sawAuthish: true, + sawIPConfig: false, + } + + code := backend.classifyAttempt(att) + assert.Equal(t, "dhcp-timeout", code) +} + +func TestIWDBackend_ClassifyAttempt_AssocTimeout(t *testing.T) { + backend, _ := NewIWDBackend() + + att := &connectAttempt{ + ssid: "TestNetwork", + netPath: "/test", + start: time.Now().Add(-5 * time.Second), + deadline: time.Now().Add(10 * time.Second), + } + + backend.recentScansMu.Lock() + backend.recentScans["TestNetwork"] = time.Now() + backend.recentScansMu.Unlock() + + code := backend.classifyAttempt(att) + assert.Equal(t, "assoc-timeout", code) +} + +func TestIWDBackend_ClassifyAttempt_NoSuchSSID(t *testing.T) { + backend, _ := NewIWDBackend() + + att := &connectAttempt{ + ssid: "TestNetwork", + netPath: "/test", + start: time.Now().Add(-5 * time.Second), + deadline: time.Now().Add(10 * time.Second), + } + + code := backend.classifyAttempt(att) + assert.Equal(t, "no-such-ssid", code) +} + +func TestIWDBackend_MapIwdDBusError(t *testing.T) { + backend, _ := NewIWDBackend() + + testCases := []struct { + name string + expected string + }{ + {"net.connman.iwd.Error.AlreadyConnected", "already-connected"}, + {"net.connman.iwd.Error.AuthenticationFailed", "bad-credentials"}, + {"net.connman.iwd.Error.InvalidKey", "bad-credentials"}, + {"net.connman.iwd.Error.IncorrectPassphrase", "bad-credentials"}, + {"net.connman.iwd.Error.NotFound", "no-such-ssid"}, + {"net.connman.iwd.Error.NotSupported", "connection-failed"}, + {"net.connman.iwd.Agent.Error.Canceled", "user-canceled"}, + {"net.connman.iwd.Error.Unknown", "connection-failed"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + code := backend.mapIwdDBusError(tc.name) + assert.Equal(t, tc.expected, code) + }) + } +} + +func TestConnectAttempt_Finalization(t *testing.T) { + backend, _ := NewIWDBackend() + backend.state = &BackendState{} + + att := &connectAttempt{ + ssid: "TestNetwork", + netPath: "/test", + start: time.Now(), + deadline: time.Now().Add(15 * time.Second), + } + + backend.finalizeAttempt(att, "bad-credentials") + + att.mu.Lock() + assert.True(t, att.finalized) + att.mu.Unlock() + + backend.stateMutex.RLock() + assert.False(t, backend.state.IsConnecting) + assert.Empty(t, backend.state.ConnectingSSID) + assert.Equal(t, "bad-credentials", backend.state.LastError) + backend.stateMutex.RUnlock() +} + +func TestConnectAttempt_DoubleFinalization(t *testing.T) { + backend, _ := NewIWDBackend() + backend.state = &BackendState{} + + att := &connectAttempt{ + ssid: "TestNetwork", + netPath: "/test", + start: time.Now(), + deadline: time.Now().Add(15 * time.Second), + } + + backend.finalizeAttempt(att, "bad-credentials") + backend.finalizeAttempt(att, "dhcp-timeout") + + backend.stateMutex.RLock() + assert.Equal(t, "bad-credentials", backend.state.LastError) + backend.stateMutex.RUnlock() +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_unimplemented.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_unimplemented.go new file mode 100644 index 0000000..f5a8b03 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_unimplemented.go @@ -0,0 +1,133 @@ +package network + +import ( + "fmt" + "os" +) + +func (b *IWDBackend) GetWiredConnections() ([]WiredConnection, error) { + return nil, fmt.Errorf("wired connections not supported by iwd") +} + +func (b *IWDBackend) GetWiredNetworkDetails(uuid string) (*WiredNetworkInfoResponse, error) { + return nil, fmt.Errorf("wired connections not supported by iwd") +} + +func (b *IWDBackend) ConnectEthernet() error { + return fmt.Errorf("wired connections not supported by iwd") +} + +func (b *IWDBackend) DisconnectEthernet() error { + return fmt.Errorf("wired connections not supported by iwd") +} + +func (b *IWDBackend) DisconnectEthernetDevice(device string) error { + return fmt.Errorf("wired connections not supported by iwd") +} + +func (b *IWDBackend) GetEthernetDevices() []EthernetDevice { + return []EthernetDevice{} +} + +func (b *IWDBackend) ActivateWiredConnection(uuid string) error { + return fmt.Errorf("wired connections not supported by iwd") +} + +func (b *IWDBackend) ListVPNProfiles() ([]VPNProfile, error) { + return nil, fmt.Errorf("VPN not supported by iwd backend") +} + +func (b *IWDBackend) ListActiveVPN() ([]VPNActive, error) { + return nil, fmt.Errorf("VPN not supported by iwd backend") +} + +func (b *IWDBackend) ConnectVPN(uuidOrName string, singleActive bool) error { + return fmt.Errorf("VPN not supported by iwd backend") +} + +func (b *IWDBackend) DisconnectVPN(uuidOrName string) error { + return fmt.Errorf("VPN not supported by iwd backend") +} + +func (b *IWDBackend) DisconnectAllVPN() error { + return fmt.Errorf("VPN not supported by iwd backend") +} + +func (b *IWDBackend) ClearVPNCredentials(uuidOrName string) error { + return fmt.Errorf("VPN not supported by iwd backend") +} + +func (b *IWDBackend) ListVPNPlugins() ([]VPNPlugin, error) { + return nil, fmt.Errorf("VPN not supported by iwd backend") +} + +func (b *IWDBackend) ImportVPN(filePath string, name string) (*VPNImportResult, error) { + return nil, fmt.Errorf("VPN not supported by iwd backend") +} + +func (b *IWDBackend) GetVPNConfig(uuidOrName string) (*VPNConfig, error) { + return nil, fmt.Errorf("VPN not supported by iwd backend") +} + +func (b *IWDBackend) UpdateVPNConfig(uuid string, updates map[string]any) error { + return fmt.Errorf("VPN not supported by iwd backend") +} + +func (b *IWDBackend) DeleteVPN(uuidOrName string) error { + return fmt.Errorf("VPN not supported by iwd backend") +} + +func (b *IWDBackend) SetVPNCredentials(uuid, username, password string, save bool) error { + return fmt.Errorf("VPN not supported by iwd backend") +} + +func (b *IWDBackend) ScanWiFiDevice(device string) error { + return b.ScanWiFi() +} + +func (b *IWDBackend) DisconnectWiFiDevice(device string) error { + return b.DisconnectWiFi() +} + +func (b *IWDBackend) GetWiFiDevices() []WiFiDevice { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + return b.getWiFiDevicesLocked() +} + +func (b *IWDBackend) getWiFiDevicesLocked() []WiFiDevice { + if b.state.WiFiDevice == "" { + return nil + } + + stateStr := "disconnected" + if b.state.WiFiConnected { + stateStr = "connected" + } + + return []WiFiDevice{{ + Name: b.state.WiFiDevice, + State: stateStr, + Connected: b.state.WiFiConnected, + SSID: b.state.WiFiSSID, + Signal: b.state.WiFiSignal, + IP: b.state.WiFiIP, + Networks: b.state.WiFiNetworks, + }} +} + +func (b *IWDBackend) GetWiFiQRCodeContent(ssid string) (string, error) { + path := iwdConfigPath(ssid) + + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("no saved iwd config for `%s`: %w", ssid, err) + } + + passphrase, err := parseIWDPassphrase(string(data)) + if err != nil { + return "", fmt.Errorf("failed to read passphrase for `%s`: %w", ssid, err) + } + + return FormatWiFiQRString("WPA", ssid, passphrase), nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_wifi.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_wifi.go new file mode 100644 index 0000000..b3bea13 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_wifi.go @@ -0,0 +1,662 @@ +package network + +import ( + "fmt" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/godbus/dbus/v5" +) + +func (b *IWDBackend) updateState() error { + if b.devicePath == "" { + return nil + } + + obj := b.conn.Object(iwdBusName, b.devicePath) + + poweredVar, err := obj.GetProperty(iwdDeviceInterface + ".Powered") + if err == nil { + if powered, ok := poweredVar.Value().(bool); ok { + b.stateMutex.Lock() + b.state.WiFiEnabled = powered + b.stateMutex.Unlock() + } + } + + if b.stationPath == "" { + return nil + } + + stationObj := b.conn.Object(iwdBusName, b.stationPath) + + stateVar, err := stationObj.GetProperty(iwdStationInterface + ".State") + if err == nil { + if state, ok := stateVar.Value().(string); ok { + b.stateMutex.Lock() + b.state.WiFiConnected = (state == "connected") + if state == "connected" { + b.state.NetworkStatus = StatusWiFi + } else { + b.state.NetworkStatus = StatusDisconnected + } + b.stateMutex.Unlock() + } + } + + connNetVar, err := stationObj.GetProperty(iwdStationInterface + ".ConnectedNetwork") + if err == nil && connNetVar.Value() != nil { + if netPath, ok := connNetVar.Value().(dbus.ObjectPath); ok && netPath != "/" { + netObj := b.conn.Object(iwdBusName, netPath) + + nameVar, err := netObj.GetProperty(iwdNetworkInterface + ".Name") + if err == nil { + if name, ok := nameVar.Value().(string); ok { + b.stateMutex.Lock() + b.state.WiFiSSID = name + b.stateMutex.Unlock() + } + } + + var orderedNetworks [][]dbus.Variant + err = stationObj.Call(iwdStationInterface+".GetOrderedNetworks", 0).Store(&orderedNetworks) + if err == nil { + for _, netData := range orderedNetworks { + if len(netData) < 2 { + continue + } + currentNetPath, ok := netData[0].Value().(dbus.ObjectPath) + if !ok || currentNetPath != netPath { + continue + } + signalStrength, ok := netData[1].Value().(int16) + if !ok { + continue + } + signalDbm := signalStrength / 100 + signal := uint8(signalDbm + 100) + if signalDbm > 0 { + signal = 100 + } else if signalDbm < -100 { + signal = 0 + } + b.stateMutex.Lock() + b.state.WiFiSignal = signal + b.stateMutex.Unlock() + break + } + } + } + } + + networks, err := b.updateWiFiNetworks() + if err == nil { + b.stateMutex.Lock() + b.state.WiFiNetworks = networks + b.stateMutex.Unlock() + } + + return nil +} + +func (b *IWDBackend) GetWiFiEnabled() (bool, error) { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + return b.state.WiFiEnabled, nil +} + +func (b *IWDBackend) SetWiFiEnabled(enabled bool) error { + if b.devicePath == "" { + return fmt.Errorf("no WiFi device available") + } + + obj := b.conn.Object(iwdBusName, b.devicePath) + call := obj.Call(dbusPropertiesInterface+".Set", 0, iwdDeviceInterface, "Powered", dbus.MakeVariant(enabled)) + if call.Err != nil { + return fmt.Errorf("failed to set WiFi enabled: %w", call.Err) + } + + b.stateMutex.Lock() + b.state.WiFiEnabled = enabled + b.stateMutex.Unlock() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *IWDBackend) ScanWiFi() error { + if b.stationPath == "" { + return fmt.Errorf("no WiFi device available") + } + + obj := b.conn.Object(iwdBusName, b.stationPath) + + scanningVar, err := obj.GetProperty(iwdStationInterface + ".Scanning") + if err != nil { + return fmt.Errorf("failed to check scanning state: %w", err) + } + + if scanning, ok := scanningVar.Value().(bool); ok && scanning { + return fmt.Errorf("scan already in progress") + } + + call := obj.Call(iwdStationInterface+".Scan", 0) + if call.Err != nil { + return fmt.Errorf("scan request failed: %w", call.Err) + } + + return nil +} + +func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) { + if b.stationPath == "" { + return nil, fmt.Errorf("no WiFi device available") + } + + obj := b.conn.Object(iwdBusName, b.stationPath) + + var orderedNetworks [][]dbus.Variant + err := obj.Call(iwdStationInterface+".GetOrderedNetworks", 0).Store(&orderedNetworks) + if err != nil { + return nil, fmt.Errorf("failed to get networks: %w", err) + } + + knownNetworks, err := b.getKnownNetworks() + if err != nil { + knownNetworks = make(map[string]bool) + } + + autoconnectMap, err := b.getAutoconnectSettings() + if err != nil { + autoconnectMap = make(map[string]bool) + } + + b.stateMutex.RLock() + currentSSID := b.state.WiFiSSID + wifiConnected := b.state.WiFiConnected + b.stateMutex.RUnlock() + + networks := make([]WiFiNetwork, 0, len(orderedNetworks)) + for _, netData := range orderedNetworks { + if len(netData) < 2 { + continue + } + + networkPath, ok := netData[0].Value().(dbus.ObjectPath) + if !ok { + continue + } + + signalStrength, ok := netData[1].Value().(int16) + if !ok { + continue + } + + netObj := b.conn.Object(iwdBusName, networkPath) + + nameVar, err := netObj.GetProperty(iwdNetworkInterface + ".Name") + if err != nil { + continue + } + name, ok := nameVar.Value().(string) + if !ok { + continue + } + + typeVar, err := netObj.GetProperty(iwdNetworkInterface + ".Type") + if err != nil { + continue + } + netType, ok := typeVar.Value().(string) + if !ok { + continue + } + + signalDbm := signalStrength / 100 + signal := uint8(signalDbm + 100) + if signalDbm > 0 { + signal = 100 + } else if signalDbm < -100 { + signal = 0 + } + + secured := netType != "open" + + network := WiFiNetwork{ + SSID: name, + Signal: signal, + Secured: secured, + Connected: wifiConnected && name == currentSSID, + Saved: knownNetworks[name], + Autoconnect: autoconnectMap[name], + Enterprise: netType == "8021x", + } + + networks = append(networks, network) + } + + sortWiFiNetworks(networks) + + b.stateMutex.Lock() + b.state.WiFiNetworks = networks + b.stateMutex.Unlock() + + now := time.Now() + b.recentScansMu.Lock() + for _, net := range networks { + b.recentScans[net.SSID] = now + } + b.recentScansMu.Unlock() + + return networks, nil +} + +func (b *IWDBackend) getKnownNetworks() (map[string]bool, error) { + obj := b.conn.Object(iwdBusName, iwdObjectPath) + + var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects) + if err != nil { + return nil, err + } + + known := make(map[string]bool) + for _, interfaces := range objects { + if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok { + if nameVar, ok := knownProps["Name"]; ok { + if name, ok := nameVar.Value().(string); ok { + known[name] = true + } + } + } + } + + return known, nil +} + +func (b *IWDBackend) getAutoconnectSettings() (map[string]bool, error) { + obj := b.conn.Object(iwdBusName, iwdObjectPath) + + var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects) + if err != nil { + return nil, err + } + + autoconnectMap := make(map[string]bool) + for _, interfaces := range objects { + if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok { + if nameVar, ok := knownProps["Name"]; ok { + if name, ok := nameVar.Value().(string); ok { + autoconnect := true + if acVar, ok := knownProps["AutoConnect"]; ok { + if ac, ok := acVar.Value().(bool); ok { + autoconnect = ac + } + } + autoconnectMap[name] = autoconnect + } + } + } + } + + return autoconnectMap, nil +} + +func (b *IWDBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) { + b.stateMutex.RLock() + networks := b.state.WiFiNetworks + b.stateMutex.RUnlock() + + var found *WiFiNetwork + for i := range networks { + if networks[i].SSID == ssid { + found = &networks[i] + break + } + } + + if found == nil { + return nil, fmt.Errorf("network not found: %s", ssid) + } + + return &NetworkInfoResponse{ + SSID: ssid, + Bands: []WiFiNetwork{*found}, + }, nil +} + +func (b *IWDBackend) setConnectError(code string) { + b.stateMutex.Lock() + b.state.IsConnecting = false + b.state.ConnectingSSID = "" + b.state.LastError = code + b.stateMutex.Unlock() +} + +func (b *IWDBackend) seenInRecentScan(ssid string) bool { + b.recentScansMu.Lock() + defer b.recentScansMu.Unlock() + lastSeen, ok := b.recentScans[ssid] + return ok && time.Since(lastSeen) < 30*time.Second +} + +func (b *IWDBackend) classifyAttempt(att *connectAttempt) string { + att.mu.Lock() + defer att.mu.Unlock() + + if att.sawPromptRetry { + return errdefs.ErrBadCredentials + } + + if !att.connectedAt.IsZero() && !att.sawIPConfig { + connDuration := time.Since(att.connectedAt) + if connDuration > 500*time.Millisecond && connDuration < 3*time.Second { + return errdefs.ErrBadCredentials + } + } + + if (att.sawAuthish || !att.connectedAt.IsZero()) && !att.sawIPConfig { + if time.Since(att.start) > 12*time.Second { + return errdefs.ErrDhcpTimeout + } + } + + if !att.sawAuthish && att.connectedAt.IsZero() { + if !b.seenInRecentScan(att.ssid) { + return errdefs.ErrNoSuchSSID + } + return errdefs.ErrAssocTimeout + } + + return errdefs.ErrAssocTimeout +} + +func (b *IWDBackend) finalizeAttempt(att *connectAttempt, code string) { + att.mu.Lock() + if att.finalized { + att.mu.Unlock() + return + } + att.finalized = true + att.mu.Unlock() + + b.stateMutex.Lock() + b.state.IsConnecting = false + b.state.ConnectingSSID = "" + b.state.LastError = code + b.stateMutex.Unlock() + + b.updateState() + + if b.onStateChange != nil { + b.onStateChange() + } +} + +func (b *IWDBackend) startAttemptWatchdog(att *connectAttempt) { + b.sigWG.Add(1) + go func() { + defer b.sigWG.Done() + + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + att.mu.Lock() + finalized := att.finalized + att.mu.Unlock() + + if finalized || time.Now().After(att.deadline) { + if !finalized { + b.finalizeAttempt(att, b.classifyAttempt(att)) + } + return + } + + station := b.conn.Object(iwdBusName, b.stationPath) + stVar, err := station.GetProperty(iwdStationInterface + ".State") + if err != nil { + continue + } + state, _ := stVar.Value().(string) + + cnVar, err := station.GetProperty(iwdStationInterface + ".ConnectedNetwork") + if err != nil { + continue + } + var connPath dbus.ObjectPath + if cnVar.Value() != nil { + connPath, _ = cnVar.Value().(dbus.ObjectPath) + } + + att.mu.Lock() + if connPath == att.netPath && state == "connected" && att.connectedAt.IsZero() { + att.connectedAt = time.Now() + } + if state == "configuring" { + att.sawIPConfig = true + } + att.mu.Unlock() + + case <-b.stopChan: + return + } + } + }() +} + +func (b *IWDBackend) mapIwdDBusError(name string) string { + switch name { + case "net.connman.iwd.Error.AlreadyConnected": + return errdefs.ErrAlreadyConnected + case "net.connman.iwd.Error.AuthenticationFailed", + "net.connman.iwd.Error.InvalidKey", + "net.connman.iwd.Error.IncorrectPassphrase": + return errdefs.ErrBadCredentials + case "net.connman.iwd.Error.NotFound": + return errdefs.ErrNoSuchSSID + case "net.connman.iwd.Error.NotSupported": + return errdefs.ErrConnectionFailed + case "net.connman.iwd.Agent.Error.Canceled": + return errdefs.ErrUserCanceled + default: + return errdefs.ErrConnectionFailed + } +} + +func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error { + if b.stationPath == "" { + b.setConnectError(errdefs.ErrWifiDisabled) + if b.onStateChange != nil { + b.onStateChange() + } + return fmt.Errorf("no WiFi device available") + } + + networkPath, err := b.findNetworkPath(req.SSID) + if err != nil { + b.setConnectError(errdefs.ErrNoSuchSSID) + if b.onStateChange != nil { + b.onStateChange() + } + return fmt.Errorf("network not found: %w", err) + } + + att := &connectAttempt{ + ssid: req.SSID, + netPath: networkPath, + start: time.Now(), + deadline: time.Now().Add(15 * time.Second), + } + + b.attemptMutex.Lock() + b.curAttempt = att + b.attemptMutex.Unlock() + + b.stateMutex.Lock() + b.state.IsConnecting = true + b.state.ConnectingSSID = req.SSID + b.state.LastError = "" + b.stateMutex.Unlock() + + if b.onStateChange != nil { + b.onStateChange() + } + + netObj := b.conn.Object(iwdBusName, networkPath) + go func() { + call := netObj.Call(iwdNetworkInterface+".Connect", 0) + if call.Err != nil { + var code string + if dbusErr, ok := call.Err.(dbus.Error); ok { + code = b.mapIwdDBusError(dbusErr.Name) + } else if dbusErrPtr, ok := call.Err.(*dbus.Error); ok { + code = b.mapIwdDBusError(dbusErrPtr.Name) + } else { + code = errdefs.ErrConnectionFailed + } + + att.mu.Lock() + if att.sawPromptRetry { + code = errdefs.ErrBadCredentials + } + att.mu.Unlock() + + b.finalizeAttempt(att, code) + return + } + + b.startAttemptWatchdog(att) + }() + + return nil +} + +func (b *IWDBackend) findNetworkPath(ssid string) (dbus.ObjectPath, error) { + obj := b.conn.Object(iwdBusName, iwdObjectPath) + + var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects) + if err != nil { + return "", err + } + + for path, interfaces := range objects { + if netProps, ok := interfaces[iwdNetworkInterface]; ok { + if nameVar, ok := netProps["Name"]; ok { + if name, ok := nameVar.Value().(string); ok && name == ssid { + return path, nil + } + } + } + } + + return "", fmt.Errorf("network not found") +} + +func (b *IWDBackend) DisconnectWiFi() error { + if b.stationPath == "" { + return fmt.Errorf("no WiFi device available") + } + + obj := b.conn.Object(iwdBusName, b.stationPath) + call := obj.Call(iwdStationInterface+".Disconnect", 0) + if call.Err != nil { + return fmt.Errorf("failed to disconnect: %w", call.Err) + } + + b.updateState() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error { + b.stateMutex.RLock() + currentSSID := b.state.WiFiSSID + isConnected := b.state.WiFiConnected + b.stateMutex.RUnlock() + + obj := b.conn.Object(iwdBusName, iwdObjectPath) + + var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects) + if err != nil { + return err + } + + for path, interfaces := range objects { + if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok { + if nameVar, ok := knownProps["Name"]; ok { + if name, ok := nameVar.Value().(string); ok && name == ssid { + knownObj := b.conn.Object(iwdBusName, path) + call := knownObj.Call(iwdKnownNetworkInterface+".Forget", 0) + if call.Err != nil { + return fmt.Errorf("failed to forget network: %w", call.Err) + } + + if isConnected && currentSSID == ssid { + b.stateMutex.Lock() + b.state.WiFiConnected = false + b.state.WiFiSSID = "" + b.state.WiFiSignal = 0 + b.state.WiFiIP = "" + b.state.NetworkStatus = StatusDisconnected + b.stateMutex.Unlock() + } + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil + } + } + } + } + + return fmt.Errorf("network not found") +} + +func (b *IWDBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error { + obj := b.conn.Object(iwdBusName, iwdObjectPath) + + var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects) + if err != nil { + return err + } + + for path, interfaces := range objects { + if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok { + if nameVar, ok := knownProps["Name"]; ok { + if name, ok := nameVar.Value().(string); ok && name == ssid { + knownObj := b.conn.Object(iwdBusName, path) + call := knownObj.Call(dbusPropertiesInterface+".Set", 0, iwdKnownNetworkInterface, "AutoConnect", dbus.MakeVariant(autoconnect)) + if call.Err != nil { + return fmt.Errorf("failed to set autoconnect: %w", call.Err) + } + + b.updateState() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil + } + } + } + } + + return fmt.Errorf("network not found") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd.go new file mode 100644 index 0000000..802cbf0 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd.go @@ -0,0 +1,301 @@ +package network + +import ( + "fmt" + "net" + "strings" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/godbus/dbus/v5" +) + +const ( + networkdBusName = "org.freedesktop.network1" + networkdManagerPath = "/org/freedesktop/network1" + networkdManagerIface = "org.freedesktop.network1.Manager" + networkdLinkIface = "org.freedesktop.network1.Link" +) + +type linkInfo struct { + ifindex int32 + name string + path dbus.ObjectPath + opState string +} + +type SystemdNetworkdBackend struct { + conn *dbus.Conn + managerPath dbus.ObjectPath + links map[string]*linkInfo + linksMutex sync.RWMutex + state *BackendState + stateMutex sync.RWMutex + onStateChange func() + stopChan chan struct{} + signals chan *dbus.Signal + sigWG sync.WaitGroup +} + +func NewSystemdNetworkdBackend() (*SystemdNetworkdBackend, error) { + return &SystemdNetworkdBackend{ + managerPath: networkdManagerPath, + links: make(map[string]*linkInfo), + state: &BackendState{ + Backend: "networkd", + WiFiNetworks: []WiFiNetwork{}, + }, + stopChan: make(chan struct{}), + }, nil +} + +func (b *SystemdNetworkdBackend) Initialize() error { + c, err := dbus.ConnectSystemBus() + if err != nil { + return fmt.Errorf("connect bus: %w", err) + } + b.conn = c + + if err := b.enumerateLinks(); err != nil { + c.Close() + return fmt.Errorf("enumerate links: %w", err) + } + + if err := b.updateState(); err != nil { + c.Close() + return fmt.Errorf("update initial state: %w", err) + } + + return nil +} + +func (b *SystemdNetworkdBackend) Close() { + close(b.stopChan) + b.StopMonitoring() + + if b.conn != nil { + b.conn.Close() + } +} + +func (b *SystemdNetworkdBackend) enumerateLinks() error { + obj := b.conn.Object(networkdBusName, b.managerPath) + + var links []struct { + Ifindex int32 + Name string + Path dbus.ObjectPath + } + err := obj.Call(networkdManagerIface+".ListLinks", 0).Store(&links) + if err != nil { + return fmt.Errorf("ListLinks: %w", err) + } + + b.linksMutex.Lock() + defer b.linksMutex.Unlock() + + for _, l := range links { + b.links[l.Name] = &linkInfo{ + ifindex: l.Ifindex, + name: l.Name, + path: l.Path, + } + log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s)", l.Name, l.Ifindex, l.Path) + } + + return nil +} + +func (b *SystemdNetworkdBackend) updateState() error { + b.linksMutex.RLock() + defer b.linksMutex.RUnlock() + + var wiredIface *linkInfo + var wifiIface *linkInfo + + for name, link := range b.links { + if b.isVirtualInterface(name) { + continue + } + + linkObj := b.conn.Object(networkdBusName, link.path) + opStateVar, err := linkObj.GetProperty(networkdLinkIface + ".OperationalState") + if err == nil { + if opState, ok := opStateVar.Value().(string); ok { + link.opState = opState + } + } + + if strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") { + if wifiIface == nil || link.opState == "routable" || link.opState == "carrier" { + wifiIface = link + } + } else if !b.isVirtualInterface(name) { + if wiredIface == nil || link.opState == "routable" || link.opState == "carrier" { + wiredIface = link + } + } + } + + var wiredConns []WiredConnection + var ethernetDevices []EthernetDevice + for name, link := range b.links { + if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") { + continue + } + + active := link.opState == "routable" || link.opState == "carrier" + wiredConns = append(wiredConns, WiredConnection{ + Path: link.path, + ID: name, + UUID: "wired:" + name, + Type: "ethernet", + IsActive: active, + }) + + var ip string + var hwAddr string + if iface, err := net.InterfaceByName(name); err == nil { + hwAddr = iface.HardwareAddr.String() + if addrs := b.getAddresses(name); len(addrs) > 0 { + ip = addrs[0] + } + } + + stateStr := "disconnected" + switch link.opState { + case "routable": + stateStr = "routable" + case "carrier": + stateStr = "carrier" + case "degraded": + stateStr = "degraded" + case "no-carrier": + stateStr = "no-carrier" + case "off": + stateStr = "off" + } + + ethernetDevices = append(ethernetDevices, EthernetDevice{ + Name: name, + HwAddress: hwAddr, + State: stateStr, + Connected: active, + IP: ip, + }) + } + + b.stateMutex.Lock() + defer b.stateMutex.Unlock() + + b.state.NetworkStatus = StatusDisconnected + b.state.EthernetConnected = false + b.state.EthernetIP = "" + b.state.WiFiConnected = false + b.state.WiFiIP = "" + b.state.WiredConnections = wiredConns + b.state.EthernetDevices = ethernetDevices + + if wiredIface != nil { + b.state.EthernetDevice = wiredIface.name + log.Debugf("networkd: wired interface %s opState=%s", wiredIface.name, wiredIface.opState) + if wiredIface.opState == "routable" || wiredIface.opState == "carrier" { + b.state.EthernetConnected = true + b.state.NetworkStatus = StatusEthernet + + if addrs := b.getAddresses(wiredIface.name); len(addrs) > 0 { + b.state.EthernetIP = addrs[0] + log.Debugf("networkd: ethernet IP %s on %s", addrs[0], wiredIface.name) + } + } + } + + if wifiIface != nil { + b.state.WiFiDevice = wifiIface.name + log.Debugf("networkd: wifi interface %s opState=%s", wifiIface.name, wifiIface.opState) + if wifiIface.opState == "routable" || wifiIface.opState == "carrier" { + b.state.WiFiConnected = true + + if addrs := b.getAddresses(wifiIface.name); len(addrs) > 0 { + b.state.WiFiIP = addrs[0] + log.Debugf("networkd: wifi IP %s on %s", addrs[0], wifiIface.name) + if b.state.NetworkStatus == StatusDisconnected { + b.state.NetworkStatus = StatusWiFi + } + } + } + } + + return nil +} + +func (b *SystemdNetworkdBackend) isVirtualInterface(name string) bool { + virtualPrefixes := []string{ + "lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap", + "vboxnet", "vmnet", "kube", "cni", "flannel", "cali", + } + for _, prefix := range virtualPrefixes { + if strings.HasPrefix(name, prefix) { + return true + } + } + return false +} + +func (b *SystemdNetworkdBackend) getAddresses(ifname string) []string { + iface, err := net.InterfaceByName(ifname) + if err != nil { + return nil + } + + addrs, err := iface.Addrs() + if err != nil { + return nil + } + + var result []string + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok { + if ipv4 := ipnet.IP.To4(); ipv4 != nil { + result = append(result, ipv4.String()) + } + } + } + return result +} + +func (b *SystemdNetworkdBackend) GetCurrentState() (*BackendState, error) { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + s := *b.state + return &s, nil +} + +func (b *SystemdNetworkdBackend) GetPromptBroker() PromptBroker { + return nil +} + +func (b *SystemdNetworkdBackend) SetPromptBroker(broker PromptBroker) error { + return nil +} + +func (b *SystemdNetworkdBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error { + return fmt.Errorf("credentials not needed by networkd backend") +} + +func (b *SystemdNetworkdBackend) CancelCredentials(token string) error { + return fmt.Errorf("credentials not needed by networkd backend") +} + +func (b *SystemdNetworkdBackend) EnsureDhcpUp(ifname string) error { + b.linksMutex.RLock() + link, exists := b.links[ifname] + b.linksMutex.RUnlock() + + if !exists { + return fmt.Errorf("interface %s not found", ifname) + } + + linkObj := b.conn.Object(networkdBusName, link.path) + return linkObj.Call(networkdLinkIface+".Reconfigure", 0).Err +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_ethernet.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_ethernet.go new file mode 100644 index 0000000..8f9b225 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_ethernet.go @@ -0,0 +1,120 @@ +package network + +import ( + "fmt" + "net" + "strings" +) + +func (b *SystemdNetworkdBackend) GetWiredConnections() ([]WiredConnection, error) { + b.linksMutex.RLock() + defer b.linksMutex.RUnlock() + + var conns []WiredConnection + for name, link := range b.links { + if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") { + continue + } + + active := link.opState == "routable" || link.opState == "carrier" + conns = append(conns, WiredConnection{ + Path: link.path, + ID: name, + UUID: "wired:" + name, + Type: "ethernet", + IsActive: active, + }) + } + + return conns, nil +} + +func (b *SystemdNetworkdBackend) GetWiredNetworkDetails(id string) (*WiredNetworkInfoResponse, error) { + ifname := strings.TrimPrefix(id, "wired:") + + b.linksMutex.RLock() + _, exists := b.links[ifname] + b.linksMutex.RUnlock() + + if !exists { + return nil, fmt.Errorf("interface %s not found", ifname) + } + + iface, err := net.InterfaceByName(ifname) + if err != nil { + return nil, fmt.Errorf("get interface: %w", err) + } + + addrs, _ := iface.Addrs() + var ipv4s, ipv6s []string + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok { + if ipv4 := ipnet.IP.To4(); ipv4 != nil { + ipv4s = append(ipv4s, ipnet.String()) + } else if ipv6 := ipnet.IP.To16(); ipv6 != nil { + ipv6s = append(ipv6s, ipnet.String()) + } + } + } + + return &WiredNetworkInfoResponse{ + UUID: id, + IFace: ifname, + HwAddr: iface.HardwareAddr.String(), + IPv4: WiredIPConfig{ + IPs: ipv4s, + }, + IPv6: WiredIPConfig{ + IPs: ipv6s, + }, + }, nil +} + +func (b *SystemdNetworkdBackend) ConnectEthernet() error { + b.linksMutex.RLock() + var primaryWired *linkInfo + for name, l := range b.links { + if strings.HasPrefix(name, "lo") || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") { + continue + } + primaryWired = l + break + } + b.linksMutex.RUnlock() + + if primaryWired == nil { + return fmt.Errorf("no wired interface found") + } + + linkObj := b.conn.Object(networkdBusName, primaryWired.path) + return linkObj.Call(networkdLinkIface+".Reconfigure", 0).Err +} + +func (b *SystemdNetworkdBackend) DisconnectEthernet() error { + return fmt.Errorf("not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ActivateWiredConnection(id string) error { + ifname := strings.TrimPrefix(id, "wired:") + + b.linksMutex.RLock() + link, exists := b.links[ifname] + b.linksMutex.RUnlock() + + if !exists { + return fmt.Errorf("interface %s not found", ifname) + } + + linkObj := b.conn.Object(networkdBusName, link.path) + return linkObj.Call(networkdLinkIface+".Reconfigure", 0).Err +} + +func (b *SystemdNetworkdBackend) GetEthernetDevices() []EthernetDevice { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + return append([]EthernetDevice(nil), b.state.EthernetDevices...) +} + +func (b *SystemdNetworkdBackend) DisconnectEthernetDevice(device string) error { + return fmt.Errorf("not supported by networkd backend") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_signals.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_signals.go new file mode 100644 index 0000000..dc642ed --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_signals.go @@ -0,0 +1,68 @@ +package network + +import ( + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/godbus/dbus/v5" +) + +func (b *SystemdNetworkdBackend) StartMonitoring(onStateChange func()) error { + b.onStateChange = onStateChange + + b.signals = make(chan *dbus.Signal, 64) + b.conn.Signal(b.signals) + + matchRules := []string{ + "type='signal',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path_namespace='/org/freedesktop/network1'", + "type='signal',interface='org.freedesktop.network1.Manager'", + } + + for _, rule := range matchRules { + if err := b.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule).Err; err != nil { + return fmt.Errorf("add match %q: %w", rule, err) + } + } + + b.sigWG.Add(1) + go b.signalLoop() + + return nil +} + +func (b *SystemdNetworkdBackend) StopMonitoring() { + b.sigWG.Wait() +} + +func (b *SystemdNetworkdBackend) signalLoop() { + defer b.sigWG.Done() + + for { + select { + case <-b.stopChan: + return + case sig := <-b.signals: + if sig == nil { + continue + } + + if sig.Name == "org.freedesktop.DBus.Properties.PropertiesChanged" { + if len(sig.Body) < 2 { + continue + } + iface, ok := sig.Body[0].(string) + if !ok || iface != networkdLinkIface { + continue + } + + b.enumerateLinks() + if err := b.updateState(); err != nil { + log.Warnf("networkd state update failed: %v", err) + } + if b.onStateChange != nil { + b.onStateChange() + } + } + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_test.go new file mode 100644 index 0000000..18c5281 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_test.go @@ -0,0 +1,147 @@ +package network + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSystemdNetworkdBackend_New(t *testing.T) { + backend, err := NewSystemdNetworkdBackend() + assert.NoError(t, err) + assert.NotNil(t, backend) + assert.Equal(t, "networkd", backend.state.Backend) + assert.NotNil(t, backend.links) + assert.NotNil(t, backend.stopChan) +} + +func TestSystemdNetworkdBackend_GetCurrentState(t *testing.T) { + backend, _ := NewSystemdNetworkdBackend() + backend.state.NetworkStatus = StatusEthernet + backend.state.EthernetConnected = true + backend.state.EthernetIP = "192.168.1.100" + + state, err := backend.GetCurrentState() + assert.NoError(t, err) + assert.NotNil(t, state) + assert.Equal(t, StatusEthernet, state.NetworkStatus) + assert.True(t, state.EthernetConnected) + assert.Equal(t, "192.168.1.100", state.EthernetIP) +} + +func TestSystemdNetworkdBackend_WiFiNotSupported(t *testing.T) { + backend, _ := NewSystemdNetworkdBackend() + + err := backend.ScanWiFi() + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported") + + req := ConnectionRequest{SSID: "test"} + err = backend.ConnectWiFi(req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported") + + err = backend.DisconnectWiFi() + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported") + + err = backend.ForgetWiFiNetwork("test") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported") + + _, err = backend.GetWiFiNetworkDetails("test") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported") +} + +func TestSystemdNetworkdBackend_VPNNotSupported(t *testing.T) { + backend, _ := NewSystemdNetworkdBackend() + + profiles, err := backend.ListVPNProfiles() + assert.NoError(t, err) + assert.Empty(t, profiles) + + active, err := backend.ListActiveVPN() + assert.NoError(t, err) + assert.Empty(t, active) + + err = backend.ConnectVPN("test", false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported") + + err = backend.DisconnectVPN("test") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported") + + err = backend.DisconnectAllVPN() + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported") + + err = backend.ClearVPNCredentials("test") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported") +} + +func TestSystemdNetworkdBackend_PromptBroker(t *testing.T) { + backend, _ := NewSystemdNetworkdBackend() + + broker := backend.GetPromptBroker() + assert.Nil(t, broker) + + err := backend.SetPromptBroker(nil) + assert.NoError(t, err) + + err = backend.SubmitCredentials("token", nil, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not needed") + + err = backend.CancelCredentials("token") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not needed") +} + +func TestSystemdNetworkdBackend_GetWiFiEnabled(t *testing.T) { + backend, _ := NewSystemdNetworkdBackend() + + enabled, err := backend.GetWiFiEnabled() + assert.NoError(t, err) + assert.True(t, enabled) +} + +func TestSystemdNetworkdBackend_SetWiFiEnabled(t *testing.T) { + backend, _ := NewSystemdNetworkdBackend() + + err := backend.SetWiFiEnabled(false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported") +} + +func TestSystemdNetworkdBackend_DisconnectEthernet(t *testing.T) { + backend, _ := NewSystemdNetworkdBackend() + + err := backend.DisconnectEthernet() + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported") +} + +func TestSystemdNetworkdBackend_GetEthernetDevices(t *testing.T) { + backend, _ := NewSystemdNetworkdBackend() + + backend.state.EthernetDevices = []EthernetDevice{ + {Name: "enp0s3", State: "routable", Connected: true}, + {Name: "enp0s8", State: "no-carrier", Connected: false}, + } + + devices := backend.GetEthernetDevices() + assert.Len(t, devices, 2) + assert.Equal(t, "enp0s3", devices[0].Name) + assert.True(t, devices[0].Connected) +} + +func TestSystemdNetworkdBackend_DisconnectEthernetDevice(t *testing.T) { + backend, _ := NewSystemdNetworkdBackend() + + err := backend.DisconnectEthernetDevice("enp0s3") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not supported") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_unimplemented.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_unimplemented.go new file mode 100644 index 0000000..695c3c5 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_unimplemented.go @@ -0,0 +1,99 @@ +package network + +import "fmt" + +func (b *SystemdNetworkdBackend) GetWiFiEnabled() (bool, error) { + return true, nil +} + +func (b *SystemdNetworkdBackend) SetWiFiEnabled(enabled bool) error { + return fmt.Errorf("WiFi control not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ScanWiFi() error { + return fmt.Errorf("WiFi scan not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) { + return nil, fmt.Errorf("WiFi details not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) GetWiFiQRCodeContent(ssid string) (string, error) { + return "", fmt.Errorf("WiFi QR Code not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ConnectWiFi(req ConnectionRequest) error { + return fmt.Errorf("WiFi connect not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) DisconnectWiFi() error { + return fmt.Errorf("WiFi disconnect not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ForgetWiFiNetwork(ssid string) error { + return fmt.Errorf("WiFi forget not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ListVPNProfiles() ([]VPNProfile, error) { + return []VPNProfile{}, nil +} + +func (b *SystemdNetworkdBackend) ListActiveVPN() ([]VPNActive, error) { + return []VPNActive{}, nil +} + +func (b *SystemdNetworkdBackend) ConnectVPN(uuidOrName string, singleActive bool) error { + return fmt.Errorf("VPN not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) DisconnectVPN(uuidOrName string) error { + return fmt.Errorf("VPN not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) DisconnectAllVPN() error { + return fmt.Errorf("VPN not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ClearVPNCredentials(uuidOrName string) error { + return fmt.Errorf("VPN not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ListVPNPlugins() ([]VPNPlugin, error) { + return []VPNPlugin{}, nil +} + +func (b *SystemdNetworkdBackend) ImportVPN(filePath string, name string) (*VPNImportResult, error) { + return nil, fmt.Errorf("VPN not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) GetVPNConfig(uuidOrName string) (*VPNConfig, error) { + return nil, fmt.Errorf("VPN not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) UpdateVPNConfig(uuid string, updates map[string]any) error { + return fmt.Errorf("VPN not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) DeleteVPN(uuidOrName string) error { + return fmt.Errorf("VPN not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) SetVPNCredentials(uuid, username, password string, save bool) error { + return fmt.Errorf("VPN not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error { + return fmt.Errorf("WiFi autoconnect not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ScanWiFiDevice(device string) error { + return fmt.Errorf("WiFi scan not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) DisconnectWiFiDevice(device string) error { + return fmt.Errorf("WiFi disconnect not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) GetWiFiDevices() []WiFiDevice { + return nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager.go new file mode 100644 index 0000000..2233975 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager.go @@ -0,0 +1,405 @@ +package network + +import ( + "fmt" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/Wifx/gonetworkmanager/v2" + "github.com/godbus/dbus/v5" +) + +const ( + dbusNMPath = "/org/freedesktop/NetworkManager" + dbusNMInterface = "org.freedesktop.NetworkManager" + dbusNMDeviceInterface = "org.freedesktop.NetworkManager.Device" + dbusNMWiredInterface = "org.freedesktop.NetworkManager.Device.Wired" + dbusNMWirelessInterface = "org.freedesktop.NetworkManager.Device.Wireless" + dbusNMAccessPointInterface = "org.freedesktop.NetworkManager.AccessPoint" + dbusPropsInterface = "org.freedesktop.DBus.Properties" + + NmDeviceStateReasonWrongPassword = 8 + NmDeviceStateReasonSupplicantTimeout = 24 + NmDeviceStateReasonSupplicantFailed = 25 + NmDeviceStateReasonSecretsRequired = 7 + NmDeviceStateReasonNoSecrets = 6 + NmDeviceStateReasonNoSsid = 10 + NmDeviceStateReasonDhcpClientFailed = 14 + NmDeviceStateReasonIpConfigUnavailable = 18 + NmDeviceStateReasonSupplicantDisconnect = 23 + NmDeviceStateReasonCarrier = 40 + NmDeviceStateReasonNewActivation = 60 +) + +type wifiDeviceInfo struct { + device gonetworkmanager.Device + wireless gonetworkmanager.DeviceWireless + name string + hwAddress string +} + +type ethernetDeviceInfo struct { + device gonetworkmanager.Device + wired gonetworkmanager.DeviceWired + name string + hwAddress string +} + +type NetworkManagerBackend struct { + nmConn any + ethernetDevice any + ethernetDevices map[string]*ethernetDeviceInfo + wifiDevice any + settings any + wifiDev any + wifiDevices map[string]*wifiDeviceInfo + + dbusConn *dbus.Conn + signals chan *dbus.Signal + sigWG sync.WaitGroup + stopChan chan struct{} + + secretAgent *SecretAgent + promptBroker PromptBroker + + state *BackendState + stateMutex sync.RWMutex + + lastFailedSSID string + lastFailedTime int64 + failedMutex sync.RWMutex + + pendingVPNSave *pendingVPNCredentials + pendingVPNSaveMu sync.Mutex + cachedVPNCreds *cachedVPNCredentials + cachedVPNCredsMu sync.Mutex + cachedPKCS11PIN *cachedPKCS11PIN + cachedPKCS11Mu sync.Mutex + cachedGPSamlCookie *cachedGPSamlCookie + cachedGPSamlMu sync.Mutex + + onStateChange func() +} + +type pendingVPNCredentials struct { + ConnectionPath string + Username string + Password string + SavePassword bool +} + +type cachedVPNCredentials struct { + ConnectionUUID string + Password string + SavePassword bool +} + +type cachedPKCS11PIN struct { + ConnectionUUID string + PIN string +} + +type cachedGPSamlCookie struct { + ConnectionUUID string + Cookie string + Host string + User string + Fingerprint string +} + +func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*NetworkManagerBackend, error) { + var nm gonetworkmanager.NetworkManager + var err error + + if len(nmConn) > 0 && nmConn[0] != nil { + // Use injected connection (for testing) + nm = nmConn[0] + } else { + // Create real connection + nm, err = gonetworkmanager.NewNetworkManager() + if err != nil { + return nil, fmt.Errorf("failed to connect to NetworkManager: %w", err) + } + } + + backend := &NetworkManagerBackend{ + nmConn: nm, + stopChan: make(chan struct{}), + ethernetDevices: make(map[string]*ethernetDeviceInfo), + wifiDevices: make(map[string]*wifiDeviceInfo), + state: &BackendState{ + Backend: "networkmanager", + }, + } + + return backend, nil +} + +func (b *NetworkManagerBackend) Initialize() error { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + + if s, err := gonetworkmanager.NewSettings(); err == nil { + b.settings = s + } + + devices, err := nm.GetDevices() + if err != nil { + return fmt.Errorf("failed to get devices: %w", err) + } + + for _, dev := range devices { + devType, err := dev.GetPropertyDeviceType() + if err != nil { + continue + } + + switch devType { + case gonetworkmanager.NmDeviceTypeEthernet: + if managed, _ := dev.GetPropertyManaged(); !managed { + continue + } + iface, err := dev.GetPropertyInterface() + if err != nil { + continue + } + w, err := gonetworkmanager.NewDeviceWired(dev.GetPath()) + if err != nil { + continue + } + hwAddr, _ := w.GetPropertyHwAddress() + + b.ethernetDevices[iface] = ðernetDeviceInfo{ + device: dev, + wired: w, + name: iface, + hwAddress: hwAddr, + } + + if b.ethernetDevice == nil { + b.ethernetDevice = dev + } + if err := b.updateEthernetState(); err != nil { + continue + } + _, err = b.listEthernetConnections() + if err != nil { + return fmt.Errorf("failed to get wired configurations: %w", err) + } + + case gonetworkmanager.NmDeviceTypeWifi: + iface, err := dev.GetPropertyInterface() + if err != nil { + continue + } + w, err := gonetworkmanager.NewDeviceWireless(dev.GetPath()) + if err != nil { + continue + } + hwAddr, _ := w.GetPropertyHwAddress() + + b.wifiDevices[iface] = &wifiDeviceInfo{ + device: dev, + wireless: w, + name: iface, + hwAddress: hwAddr, + } + + if b.wifiDevice == nil { + b.wifiDevice = dev + b.wifiDev = w + } + } + } + + wifiEnabled, err := nm.GetPropertyWirelessEnabled() + if err == nil { + b.stateMutex.Lock() + b.state.WiFiEnabled = wifiEnabled + b.stateMutex.Unlock() + } + + if err := b.updateWiFiState(); err != nil { + log.Warnf("Failed to update WiFi state: %v", err) + } + + if wifiEnabled { + if _, err := b.updateWiFiNetworks(); err != nil { + log.Warnf("Failed to get initial networks: %v", err) + } + b.updateAllWiFiDevices() + } + + b.updateAllEthernetDevices() + + if err := b.updatePrimaryConnection(); err != nil { + return err + } + + if _, err := b.ListVPNProfiles(); err != nil { + log.Warnf("Failed to get initial VPN profiles: %v", err) + } + + if _, err := b.ListActiveVPN(); err != nil { + log.Warnf("Failed to get initial active VPNs: %v", err) + } + + return nil +} + +func (b *NetworkManagerBackend) Close() { + close(b.stopChan) + b.StopMonitoring() + + if b.secretAgent != nil { + b.secretAgent.Close() + } +} + +func (b *NetworkManagerBackend) GetCurrentState() (*BackendState, error) { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + + state := *b.state + state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...) + state.WiFiDevices = append([]WiFiDevice(nil), b.state.WiFiDevices...) + state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...) + state.EthernetDevices = append([]EthernetDevice(nil), b.state.EthernetDevices...) + state.VPNProfiles = append([]VPNProfile(nil), b.state.VPNProfiles...) + state.VPNActive = append([]VPNActive(nil), b.state.VPNActive...) + + return &state, nil +} + +func (b *NetworkManagerBackend) StartMonitoring(onStateChange func()) error { + b.onStateChange = onStateChange + + if err := b.startSecretAgent(); err != nil { + return fmt.Errorf("failed to start secret agent: %w", err) + } + + if err := b.startSignalPump(); err != nil { + return err + } + + return nil +} + +func (b *NetworkManagerBackend) StopMonitoring() { + b.stopSignalPump() +} + +func (b *NetworkManagerBackend) GetPromptBroker() PromptBroker { + return b.promptBroker +} + +func (b *NetworkManagerBackend) SetPromptBroker(broker PromptBroker) error { + if broker == nil { + return fmt.Errorf("broker cannot be nil") + } + + hadAgent := b.secretAgent != nil + + b.promptBroker = broker + + if b.secretAgent != nil { + b.secretAgent.Close() + b.secretAgent = nil + } + + if hadAgent { + return b.startSecretAgent() + } + + return nil +} + +func (b *NetworkManagerBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error { + if b.promptBroker == nil { + return fmt.Errorf("prompt broker not initialized") + } + + return b.promptBroker.Resolve(token, PromptReply{ + Secrets: secrets, + Save: save, + Cancel: false, + }) +} + +func (b *NetworkManagerBackend) CancelCredentials(token string) error { + if b.promptBroker == nil { + return fmt.Errorf("prompt broker not initialized") + } + + return b.promptBroker.Resolve(token, PromptReply{ + Cancel: true, + }) +} + +func (b *NetworkManagerBackend) ensureWiFiDevice() error { + if b.wifiDev != nil { + return nil + } + + if b.wifiDevice == nil { + return fmt.Errorf("no WiFi device available") + } + + dev := b.wifiDevice.(gonetworkmanager.Device) + wifiDev, err := gonetworkmanager.NewDeviceWireless(dev.GetPath()) + if err != nil { + return fmt.Errorf("failed to get wireless device: %w", err) + } + b.wifiDev = wifiDev + return nil +} + +func (b *NetworkManagerBackend) startSecretAgent() error { + if b.promptBroker == nil { + return fmt.Errorf("prompt broker not set") + } + + agent, err := NewSecretAgent(b.promptBroker, nil, b) + if err != nil { + return err + } + + b.secretAgent = agent + return nil +} + +func (b *NetworkManagerBackend) getActiveConnections() (map[string]bool, error) { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + + activeUUIDs := make(map[string]bool) + + activeConns, err := nm.GetPropertyActiveConnections() + if err != nil { + return activeUUIDs, fmt.Errorf("failed to get active connections: %w", err) + } + + for _, activeConn := range activeConns { + connType, err := activeConn.GetPropertyType() + if err != nil { + continue + } + + if connType != "802-3-ethernet" { + continue + } + + state, err := activeConn.GetPropertyState() + if err != nil { + continue + } + if state < 1 || state > 2 { + continue + } + + uuid, err := activeConn.GetPropertyUUID() + if err != nil { + continue + } + activeUUIDs[uuid] = true + } + return activeUUIDs, nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet.go new file mode 100644 index 0000000..47f785b --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet.go @@ -0,0 +1,402 @@ +package network + +import ( + "fmt" + "net" + "strconv" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/Wifx/gonetworkmanager/v2" +) + +func (b *NetworkManagerBackend) GetWiredConnections() ([]WiredConnection, error) { + return b.listEthernetConnections() +} + +func (b *NetworkManagerBackend) GetWiredNetworkDetails(uuid string) (*WiredNetworkInfoResponse, error) { + if b.ethernetDevice == nil { + return nil, fmt.Errorf("no ethernet device available") + } + + dev := b.ethernetDevice.(gonetworkmanager.Device) + + iface, _ := dev.GetPropertyInterface() + driver, _ := dev.GetPropertyDriver() + + hwAddr := "Not available" + var speed uint32 = 0 + wiredDevice, err := gonetworkmanager.NewDeviceWired(dev.GetPath()) + if err == nil { + hwAddr, _ = wiredDevice.GetPropertyHwAddress() + speed, _ = wiredDevice.GetPropertySpeed() + } + var ipv4Config WiredIPConfig + var ipv6Config WiredIPConfig + + activeConn, err := dev.GetPropertyActiveConnection() + if err == nil && activeConn != nil { + ip4Config, err := activeConn.GetPropertyIP4Config() + if err == nil && ip4Config != nil { + var ips []string + addresses, err := ip4Config.GetPropertyAddressData() + if err == nil && len(addresses) > 0 { + for _, addr := range addresses { + ips = append(ips, fmt.Sprintf("%s/%s", addr.Address, strconv.Itoa(int(addr.Prefix)))) + } + } + + gateway, _ := ip4Config.GetPropertyGateway() + dnsAddrs := "" + dns, err := ip4Config.GetPropertyNameserverData() + if err == nil && len(dns) > 0 { + for _, d := range dns { + if len(dnsAddrs) > 0 { + dnsAddrs = strings.Join([]string{dnsAddrs, d.Address}, "; ") + } else { + dnsAddrs = d.Address + } + } + } + + ipv4Config = WiredIPConfig{ + IPs: ips, + Gateway: gateway, + DNS: dnsAddrs, + } + } + + ip6Config, err := activeConn.GetPropertyIP6Config() + if err == nil && ip6Config != nil { + var ips []string + addresses, err := ip6Config.GetPropertyAddressData() + if err == nil && len(addresses) > 0 { + for _, addr := range addresses { + ips = append(ips, fmt.Sprintf("%s/%s", addr.Address, strconv.Itoa(int(addr.Prefix)))) + } + } + + gateway, _ := ip6Config.GetPropertyGateway() + dnsAddrs := "" + dns, err := ip6Config.GetPropertyNameservers() + if err == nil && len(dns) > 0 { + for _, d := range dns { + if len(d) == 16 { + ip := net.IP(d) + if len(dnsAddrs) > 0 { + dnsAddrs = strings.Join([]string{dnsAddrs, ip.String()}, "; ") + } else { + dnsAddrs = ip.String() + } + } + } + } + + ipv6Config = WiredIPConfig{ + IPs: ips, + Gateway: gateway, + DNS: dnsAddrs, + } + } + } + + return &WiredNetworkInfoResponse{ + UUID: uuid, + IFace: iface, + Driver: driver, + HwAddr: hwAddr, + Speed: strconv.Itoa(int(speed)), + IPv4: ipv4Config, + IPv6: ipv6Config, + }, nil +} + +func (b *NetworkManagerBackend) ConnectEthernet() error { + if b.ethernetDevice == nil { + return fmt.Errorf("no ethernet device available") + } + + nm := b.nmConn.(gonetworkmanager.NetworkManager) + dev := b.ethernetDevice.(gonetworkmanager.Device) + + settingsMgr, err := gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + for _, conn := range connections { + connSettings, err := conn.GetSettings() + if err != nil { + continue + } + + if connMeta, ok := connSettings["connection"]; ok { + if connType, ok := connMeta["type"].(string); ok && connType == "802-3-ethernet" { + _, err := nm.ActivateConnection(conn, dev, nil) + if err != nil { + return fmt.Errorf("failed to activate ethernet: %w", err) + } + + b.updateEthernetState() + b.listEthernetConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil + } + } + } + + settings := make(map[string]map[string]any) + settings["connection"] = map[string]any{ + "id": "Wired connection", + "type": "802-3-ethernet", + } + + _, err = nm.AddAndActivateConnection(settings, dev) + if err != nil { + return fmt.Errorf("failed to create and activate ethernet: %w", err) + } + + b.updateEthernetState() + b.listEthernetConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) DisconnectEthernet() error { + if b.ethernetDevice == nil { + return fmt.Errorf("no ethernet device available") + } + + dev := b.ethernetDevice.(gonetworkmanager.Device) + + err := dev.Disconnect() + if err != nil { + return fmt.Errorf("failed to disconnect: %w", err) + } + + b.updateEthernetState() + b.listEthernetConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) ActivateWiredConnection(uuid string) error { + if b.ethernetDevice == nil { + return fmt.Errorf("no ethernet device available") + } + + nm := b.nmConn.(gonetworkmanager.NetworkManager) + dev := b.ethernetDevice.(gonetworkmanager.Device) + + settingsMgr, err := gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + var targetConnection gonetworkmanager.Connection + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + + if connectionSettings, ok := settings["connection"]; ok { + if connUUID, ok := connectionSettings["uuid"].(string); ok && connUUID == uuid { + targetConnection = conn + break + } + } + } + + if targetConnection == nil { + return fmt.Errorf("connection with UUID %s not found", uuid) + } + + _, err = nm.ActivateConnection(targetConnection, dev, nil) + if err != nil { + return fmt.Errorf("error activation connection: %w", err) + } + + b.updateEthernetState() + b.listEthernetConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) listEthernetConnections() ([]WiredConnection, error) { + if b.ethernetDevice == nil { + return nil, fmt.Errorf("no ethernet device available") + } + + s := b.settings + if s == nil { + s, err := gonetworkmanager.NewSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return nil, fmt.Errorf("failed to get connections: %w", err) + } + + wiredConfigs := make([]WiredConnection, 0) + activeUUIDs, err := b.getActiveConnections() + + if err != nil { + return nil, fmt.Errorf("failed to get active wired connections: %w", err) + } + + currentUuid := "" + for _, connection := range connections { + path := connection.GetPath() + settings, err := connection.GetSettings() + if err != nil { + log.Errorf("unable to get settings for %s: %v", path, err) + continue + } + + connectionSettings := settings["connection"] + connType, _ := connectionSettings["type"].(string) + connID, _ := connectionSettings["id"].(string) + connUUID, _ := connectionSettings["uuid"].(string) + + if connType == "802-3-ethernet" { + wiredConfigs = append(wiredConfigs, WiredConnection{ + Path: path, + ID: connID, + UUID: connUUID, + Type: connType, + IsActive: activeUUIDs[connUUID], + }) + if activeUUIDs[connUUID] { + currentUuid = connUUID + } + } + } + + b.stateMutex.Lock() + b.state.EthernetConnectionUuid = currentUuid + b.state.WiredConnections = wiredConfigs + b.stateMutex.Unlock() + + return wiredConfigs, nil +} + +func (b *NetworkManagerBackend) GetEthernetDevices() []EthernetDevice { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + return append([]EthernetDevice(nil), b.state.EthernetDevices...) +} + +func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error { + info, ok := b.ethernetDevices[device] + if !ok { + return fmt.Errorf("ethernet device %s not found", device) + } + + if err := info.device.Disconnect(); err != nil { + return fmt.Errorf("failed to disconnect %s: %w", device, err) + } + + b.updateAllEthernetDevices() + b.updateEthernetState() + b.listEthernetConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) updateAllEthernetDevices() { + devices := make([]EthernetDevice, 0, len(b.ethernetDevices)) + + for name, info := range b.ethernetDevices { + state, _ := info.device.GetPropertyState() + connected := state == gonetworkmanager.NmDeviceStateActivated + driver, _ := info.device.GetPropertyDriver() + + var ip string + var speed uint32 = 0 + if connected { + ip = b.getDeviceIP(info.device) + } + if info.wired != nil { + speed, _ = info.wired.GetPropertySpeed() + } + + stateStr := "disconnected" + switch state { + case gonetworkmanager.NmDeviceStateActivated: + stateStr = "activated" + case gonetworkmanager.NmDeviceStatePrepare: + stateStr = "preparing" + case gonetworkmanager.NmDeviceStateConfig: + stateStr = "configuring" + case gonetworkmanager.NmDeviceStateIpConfig: + stateStr = "ip-config" + case gonetworkmanager.NmDeviceStateIpCheck: + stateStr = "ip-check" + case gonetworkmanager.NmDeviceStateSecondaries: + stateStr = "secondaries" + case gonetworkmanager.NmDeviceStateDeactivating: + stateStr = "deactivating" + case gonetworkmanager.NmDeviceStateFailed: + stateStr = "failed" + case gonetworkmanager.NmDeviceStateUnavailable: + stateStr = "unavailable" + case gonetworkmanager.NmDeviceStateUnmanaged: + stateStr = "unmanaged" + } + + devices = append(devices, EthernetDevice{ + Name: name, + HwAddress: info.hwAddress, + State: stateStr, + Connected: connected, + IP: ip, + Speed: speed, + Driver: driver, + }) + } + + b.stateMutex.Lock() + b.state.EthernetDevices = devices + b.stateMutex.Unlock() +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet_test.go new file mode 100644 index 0000000..0901913 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet_test.go @@ -0,0 +1,134 @@ +package network + +import ( + "testing" + + mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2" + "github.com/stretchr/testify/assert" +) + +func TestNetworkManagerBackend_GetWiredConnections_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.ethernetDevice = nil + _, err = backend.GetWiredConnections() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no ethernet device available") +} + +func TestNetworkManagerBackend_GetWiredNetworkDetails_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.ethernetDevice = nil + _, err = backend.GetWiredNetworkDetails("test-uuid") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no ethernet device available") +} + +func TestNetworkManagerBackend_ConnectEthernet_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.ethernetDevice = nil + err = backend.ConnectEthernet() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no ethernet device available") +} + +func TestNetworkManagerBackend_DisconnectEthernet_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.ethernetDevice = nil + err = backend.DisconnectEthernet() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no ethernet device available") +} + +func TestNetworkManagerBackend_ActivateWiredConnection_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.ethernetDevice = nil + err = backend.ActivateWiredConnection("test-uuid") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no ethernet device available") +} + +func TestNetworkManagerBackend_ActivateWiredConnection_NotFound(t *testing.T) { + t.Skip("ActivateWiredConnection creates a new Settings instance internally, cannot be fully mocked") +} + +func TestNetworkManagerBackend_ListEthernetConnections_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.ethernetDevice = nil + _, err = backend.listEthernetConnections() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no ethernet device available") +} + +func TestNetworkManagerBackend_GetEthernetDevices_Empty(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + devices := backend.GetEthernetDevices() + assert.Empty(t, devices) +} + +func TestNetworkManagerBackend_GetEthernetDevices_WithState(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.state.EthernetDevices = []EthernetDevice{ + {Name: "enp0s3", HwAddress: "00:11:22:33:44:55", State: "activated", Connected: true, IP: "192.168.1.100"}, + {Name: "enp0s8", HwAddress: "00:11:22:33:44:66", State: "disconnected", Connected: false}, + } + + devices := backend.GetEthernetDevices() + assert.Len(t, devices, 2) + assert.Equal(t, "enp0s3", devices[0].Name) + assert.True(t, devices[0].Connected) + assert.Equal(t, "enp0s8", devices[1].Name) + assert.False(t, devices[1].Connected) +} + +func TestNetworkManagerBackend_DisconnectEthernetDevice_NotFound(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + err = backend.DisconnectEthernetDevice("nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestNetworkManagerBackend_UpdateAllEthernetDevices_Empty(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.updateAllEthernetDevices() + assert.Empty(t, backend.state.EthernetDevices) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml.go new file mode 100644 index 0000000..b1fa7fb --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml.go @@ -0,0 +1,203 @@ +package network + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +type gpSamlAuthResult struct { + Cookie string + Host string + User string + Fingerprint string +} + +// runGlobalProtectSAMLAuth handles GlobalProtect SAML/SSO authentication using gp-saml-gui. +// Only supports protocol=gp. Other protocols need their own implementations. +func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*gpSamlAuthResult, error) { + if gateway == "" { + return nil, fmt.Errorf("GP SAML auth: gateway is empty") + } + if protocol != "gp" { + return nil, fmt.Errorf("only GlobalProtect (protocol=gp) SAML is supported, got: %s", protocol) + } + + log.Infof("[GP-SAML] Starting GlobalProtect SAML authentication with gp-saml-gui for gateway=%s", gateway) + + gpSamlPath, err := exec.LookPath("gp-saml-gui") + if err != nil { + return nil, fmt.Errorf("GlobalProtect SAML requires gp-saml-gui (install: pip install gp-saml-gui): %w", err) + } + + args := []string{ + "--gateway", + "--allow-insecure-crypto", + gateway, + } + + cmd := exec.CommandContext(ctx, gpSamlPath, args...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("GP SAML auth: failed to create stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("GP SAML auth: failed to create stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("GP SAML auth: failed to start gp-saml-gui: %w", err) + } + + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + log.Debugf("[GP-SAML] gp-saml-gui: %s", scanner.Text()) + } + }() + + result := &gpSamlAuthResult{Host: gateway} + var allOutput []string + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + allOutput = append(allOutput, line) + log.Infof("[GP-SAML] stdout: %s", line) + + switch { + case strings.HasPrefix(line, "COOKIE="): + result.Cookie = unshellQuote(strings.TrimPrefix(line, "COOKIE=")) + case strings.HasPrefix(line, "HOST="): + result.Host = unshellQuote(strings.TrimPrefix(line, "HOST=")) + case strings.HasPrefix(line, "USER="): + result.User = unshellQuote(strings.TrimPrefix(line, "USER=")) + case strings.HasPrefix(line, "FINGERPRINT="): + result.Fingerprint = unshellQuote(strings.TrimPrefix(line, "FINGERPRINT=")) + default: + parseGPSamlFromCommandLine(line, result) + } + } + + if err := cmd.Wait(); err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("GP SAML auth timed out or was cancelled: %w", ctx.Err()) + } + if result.Cookie == "" { + return nil, fmt.Errorf("GP SAML auth failed: %w (output: %s)", err, strings.Join(allOutput, "\n")) + } + log.Warnf("[GP-SAML] gp-saml-gui exited with error but cookie was captured: %v", err) + } + + if result.Cookie == "" { + return nil, fmt.Errorf("GP SAML auth: no cookie in gp-saml-gui output") + } + + log.Infof("[GP-SAML] Got prelogin-cookie from gp-saml-gui, converting to openconnect cookie via --authenticate") + + // Convert prelogin-cookie to full openconnect cookie format + ocResult, err := convertGPPreloginCookie(ctx, gateway, result.Cookie, result.User) + if err != nil { + return nil, fmt.Errorf("GP SAML auth: failed to convert prelogin-cookie: %w", err) + } + + result.Cookie = ocResult.Cookie + result.Host = ocResult.Host + result.Fingerprint = ocResult.Fingerprint + + log.Infof("[GP-SAML] Authentication successful: user=%s, host=%s, cookie_len=%d, has_fingerprint=%v", + result.User, result.Host, len(result.Cookie), result.Fingerprint != "") + return result, nil +} + +func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*gpSamlAuthResult, error) { + ocPath, err := exec.LookPath("openconnect") + if err != nil { + return nil, fmt.Errorf("openconnect not found: %w", err) + } + + args := []string{ + "--protocol=gp", + "--usergroup=gateway:prelogin-cookie", + "--user=" + user, + "--passwd-on-stdin", + "--allow-insecure-crypto", + "--authenticate", + gateway, + } + + cmd := exec.CommandContext(ctx, ocPath, args...) + cmd.Stdin = strings.NewReader(preloginCookie) + + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("openconnect --authenticate failed: %w\noutput: %s", err, string(output)) + } + + result := &gpSamlAuthResult{} + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "COOKIE="): + result.Cookie = unshellQuote(strings.TrimPrefix(line, "COOKIE=")) + case strings.HasPrefix(line, "HOST="): + result.Host = unshellQuote(strings.TrimPrefix(line, "HOST=")) + case strings.HasPrefix(line, "FINGERPRINT="): + result.Fingerprint = unshellQuote(strings.TrimPrefix(line, "FINGERPRINT=")) + case strings.HasPrefix(line, "CONNECT_URL="): + connectURL := unshellQuote(strings.TrimPrefix(line, "CONNECT_URL=")) + if connectURL != "" && result.Host == "" { + result.Host = connectURL + } + } + } + + if result.Cookie == "" { + return nil, fmt.Errorf("no COOKIE in openconnect --authenticate output: %s", string(output)) + } + + log.Infof("[GP-SAML] openconnect --authenticate: cookie_len=%d, host=%s, fingerprint=%s", + len(result.Cookie), result.Host, result.Fingerprint) + + return result, nil +} + +func unshellQuote(s string) string { + if len(s) >= 2 { + if (s[0] == '\'' && s[len(s)-1] == '\'') || + (s[0] == '"' && s[len(s)-1] == '"') { + return s[1 : len(s)-1] + } + } + return s +} + +func parseGPSamlFromCommandLine(line string, result *gpSamlAuthResult) { + if !strings.Contains(line, "openconnect") { + return + } + + for _, part := range strings.Fields(line) { + switch { + case strings.HasPrefix(part, "--cookie="): + if result.Cookie == "" { + result.Cookie = strings.TrimPrefix(part, "--cookie=") + } + case strings.HasPrefix(part, "--servercert="): + if result.Fingerprint == "" { + result.Fingerprint = strings.TrimPrefix(part, "--servercert=") + } + case strings.HasPrefix(part, "--user="): + if result.User == "" { + result.User = strings.TrimPrefix(part, "--user=") + } + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml_test.go new file mode 100644 index 0000000..5655815 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml_test.go @@ -0,0 +1,169 @@ +package network + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUnshellQuote(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "single quoted", + input: "'hello world'", + expected: "hello world", + }, + { + name: "double quoted", + input: `"hello world"`, + expected: "hello world", + }, + { + name: "unquoted", + input: "hello", + expected: "hello", + }, + { + name: "empty single quotes", + input: "''", + expected: "", + }, + { + name: "empty double quotes", + input: `""`, + expected: "", + }, + { + name: "single quote only", + input: "'", + expected: "'", + }, + { + name: "mismatched quotes", + input: "'hello\"", + expected: "'hello\"", + }, + { + name: "with special chars", + input: "'cookie=abc123&user=john'", + expected: "cookie=abc123&user=john", + }, + { + name: "complex cookie", + input: `'authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100'`, + expected: "authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := unshellQuote(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseGPSamlFromCommandLine(t *testing.T) { + tests := []struct { + name string + line string + initialResult *gpSamlAuthResult + expectedCookie string + expectedUser string + expectedFP string + }{ + { + name: "full openconnect command", + line: "openconnect --protocol=gp --cookie=AUTH123 --servercert=pin-sha256:ABC --user=john", + initialResult: &gpSamlAuthResult{}, + expectedCookie: "AUTH123", + expectedUser: "john", + expectedFP: "pin-sha256:ABC", + }, + { + name: "with equals signs in cookie", + line: "openconnect --cookie=authcookie=xyz123&portal=GATE --user=jane", + initialResult: &gpSamlAuthResult{}, + expectedCookie: "authcookie=xyz123&portal=GATE", + expectedUser: "jane", + expectedFP: "", + }, + { + name: "non-openconnect line", + line: "some other output", + initialResult: &gpSamlAuthResult{}, + expectedCookie: "", + expectedUser: "", + expectedFP: "", + }, + { + name: "preserves existing values", + line: "openconnect --user=newuser", + initialResult: &gpSamlAuthResult{Cookie: "existing", Fingerprint: "existing-fp"}, + expectedCookie: "existing", + expectedUser: "newuser", + expectedFP: "existing-fp", + }, + { + name: "only updates empty fields", + line: "openconnect --cookie=NEW --user=NEW", + initialResult: &gpSamlAuthResult{Cookie: "OLD"}, + expectedCookie: "OLD", + expectedUser: "NEW", + expectedFP: "", + }, + { + name: "real gp-saml-gui output", + line: "openconnect --protocol=gp --user=john.doe@example.com --os=linux-64 --usergroup=gateway:prelogin-cookie --passwd-on-stdin", + initialResult: &gpSamlAuthResult{}, + expectedCookie: "", + expectedUser: "john.doe@example.com", + expectedFP: "", + }, + { + name: "with server cert flag", + line: "openconnect --servercert=pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE= vpn.example.com", + initialResult: &gpSamlAuthResult{}, + expectedCookie: "", + expectedUser: "", + expectedFP: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.initialResult + parseGPSamlFromCommandLine(tt.line, result) + + assert.Equal(t, tt.expectedCookie, result.Cookie, "cookie mismatch") + assert.Equal(t, tt.expectedUser, result.User, "user mismatch") + assert.Equal(t, tt.expectedFP, result.Fingerprint, "fingerprint mismatch") + }) + } +} + +func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) { + // Simulate gp-saml-gui output with command line suggestion + lines := []string{ + "", + "SAML REDIRECT", + "Got SAML Login URL", + "POST to ACS endpoint...", + "Got 'prelogin-cookie': 'FAKE_cookie_12345'", + "openconnect --protocol=gp --user=john.doe@example.com --usergroup=gateway:prelogin-cookie --passwd-on-stdin vpn.example.com", + "", + } + + result := &gpSamlAuthResult{} + for _, line := range lines { + parseGPSamlFromCommandLine(line, result) + } + + assert.Equal(t, "john.doe@example.com", result.User) + assert.Empty(t, result.Cookie, "cookie should not be parsed from command line") + assert.Empty(t, result.Fingerprint) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals.go new file mode 100644 index 0000000..5274ed0 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals.go @@ -0,0 +1,530 @@ +package network + +import ( + "github.com/Wifx/gonetworkmanager/v2" + "github.com/godbus/dbus/v5" +) + +func (b *NetworkManagerBackend) startSignalPump() error { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return err + } + b.dbusConn = conn + + signals := make(chan *dbus.Signal, 256) + b.signals = signals + conn.Signal(signals) + + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ); err != nil { + conn.RemoveSignal(signals) + conn.Close() + return err + } + + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")), + dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"), + dbus.WithMatchMember("NewConnection"), + ); err != nil { + conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + conn.RemoveSignal(signals) + conn.Close() + return err + } + + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")), + dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"), + dbus.WithMatchMember("ConnectionRemoved"), + ); err != nil { + conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")), + dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"), + dbus.WithMatchMember("NewConnection"), + ) + conn.RemoveSignal(signals) + conn.Close() + return err + } + + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)), + dbus.WithMatchInterface(dbusNMInterface), + dbus.WithMatchMember("DeviceAdded"), + ); err != nil { + conn.RemoveSignal(signals) + conn.Close() + return err + } + + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)), + dbus.WithMatchInterface(dbusNMInterface), + dbus.WithMatchMember("DeviceRemoved"), + ); err != nil { + conn.RemoveSignal(signals) + conn.Close() + return err + } + + for _, info := range b.wifiDevices { + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ); err != nil { + conn.RemoveSignal(signals) + conn.Close() + return err + } + } + + for _, info := range b.ethernetDevices { + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ); err != nil { + conn.RemoveSignal(signals) + conn.Close() + return err + } + } + + b.sigWG.Add(1) + go func() { + defer b.sigWG.Done() + for { + select { + case <-b.stopChan: + return + case sig, ok := <-signals: + if !ok { + return + } + if sig == nil { + continue + } + b.handleDBusSignal(sig) + } + } + }() + return nil +} + +func (b *NetworkManagerBackend) stopSignalPump() { + if b.dbusConn == nil { + return + } + + b.dbusConn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + + for _, info := range b.wifiDevices { + b.dbusConn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + } + + for _, info := range b.ethernetDevices { + b.dbusConn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + } + + if b.signals != nil { + b.dbusConn.RemoveSignal(b.signals) + close(b.signals) + } + + b.sigWG.Wait() + + b.dbusConn.Close() +} + +func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) { + if sig.Name == "org.freedesktop.NetworkManager.Settings.NewConnection" || + sig.Name == "org.freedesktop.NetworkManager.Settings.ConnectionRemoved" { + b.ListVPNProfiles() + if b.onStateChange != nil { + b.onStateChange() + } + return + } + + if sig.Name == "org.freedesktop.NetworkManager.DeviceAdded" { + if len(sig.Body) >= 1 { + if devicePath, ok := sig.Body[0].(dbus.ObjectPath); ok { + b.handleDeviceAdded(devicePath) + } + } + return + } + + if sig.Name == "org.freedesktop.NetworkManager.DeviceRemoved" { + if len(sig.Body) >= 1 { + if devicePath, ok := sig.Body[0].(dbus.ObjectPath); ok { + b.handleDeviceRemoved(devicePath) + } + } + return + } + + if len(sig.Body) < 2 { + return + } + + iface, ok := sig.Body[0].(string) + if !ok { + return + } + + changes, ok := sig.Body[1].(map[string]dbus.Variant) + if !ok { + return + } + + switch iface { + case dbusNMInterface: + b.handleNetworkManagerChange(changes) + + case dbusNMDeviceInterface: + b.handleDeviceChange(sig.Path, changes) + + case dbusNMWiredInterface: + b.handleWiredChange(changes) + + case dbusNMWirelessInterface: + b.handleWiFiChange(changes) + + case dbusNMAccessPointInterface: + b.handleAccessPointChange(changes) + } +} + +func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]dbus.Variant) { + var needsUpdate bool + + for key := range changes { + switch key { + case "PrimaryConnection", "State", "ActiveConnections": + needsUpdate = true + case "WirelessEnabled": + nm := b.nmConn.(gonetworkmanager.NetworkManager) + if enabled, err := nm.GetPropertyWirelessEnabled(); err == nil { + b.stateMutex.Lock() + b.state.WiFiEnabled = enabled + b.stateMutex.Unlock() + needsUpdate = true + } + default: + continue + } + } + + if needsUpdate { + b.updatePrimaryConnection() + if _, exists := changes["State"]; exists { + b.updateEthernetState() + b.updateWiFiState() + } + if _, exists := changes["ActiveConnections"]; exists { + b.updateVPNConnectionState() + b.ListActiveVPN() + } + if b.onStateChange != nil { + b.onStateChange() + } + } +} + +func (b *NetworkManagerBackend) handleDeviceChange(devicePath dbus.ObjectPath, changes map[string]dbus.Variant) { + var needsUpdate bool + var stateChanged bool + var managedChanged bool + + for key := range changes { + switch key { + case "State": + stateChanged = true + needsUpdate = true + case "Ip4Config": + needsUpdate = true + case "Managed": + managedChanged = true + default: + continue + } + } + + if managedChanged { + if managedVariant, ok := changes["Managed"]; ok { + if managed, ok := managedVariant.Value().(bool); ok && managed { + b.handleDeviceAdded(devicePath) + return + } + } + } + + if !needsUpdate { + return + } + + b.updateAllEthernetDevices() + b.updateEthernetState() + b.updateAllWiFiDevices() + b.updateWiFiState() + if stateChanged { + b.listEthernetConnections() + b.updatePrimaryConnection() + } + if b.onStateChange != nil { + b.onStateChange() + } +} + +func (b *NetworkManagerBackend) handleWiredChange(changes map[string]dbus.Variant) { + var needsUpdate bool + + for key := range changes { + switch key { + case "Carrier", "Speed", "HwAddress": + needsUpdate = true + default: + continue + } + } + + if !needsUpdate { + return + } + + b.updateAllEthernetDevices() + b.updateEthernetState() + b.updatePrimaryConnection() + if b.onStateChange != nil { + b.onStateChange() + } +} + +func (b *NetworkManagerBackend) handleWiFiChange(changes map[string]dbus.Variant) { + var needsStateUpdate bool + var needsNetworkUpdate bool + + for key := range changes { + switch key { + case "ActiveAccessPoint": + needsStateUpdate = true + needsNetworkUpdate = true + case "AccessPoints": + needsNetworkUpdate = true + default: + continue + } + } + + if needsStateUpdate { + b.updateWiFiState() + } + if needsNetworkUpdate { + b.updateWiFiNetworks() + } + if needsStateUpdate || needsNetworkUpdate { + if b.onStateChange != nil { + b.onStateChange() + } + } +} + +func (b *NetworkManagerBackend) handleAccessPointChange(changes map[string]dbus.Variant) { + _, hasStrength := changes["Strength"] + if !hasStrength { + return + } + + b.stateMutex.RLock() + oldSignal := b.state.WiFiSignal + b.stateMutex.RUnlock() + + b.updateWiFiState() + + b.stateMutex.RLock() + newSignal := b.state.WiFiSignal + b.stateMutex.RUnlock() + + if signalChangeSignificant(oldSignal, newSignal) { + if b.onStateChange != nil { + b.onStateChange() + } + } +} + +func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) { + dev, err := gonetworkmanager.NewDevice(devicePath) + if err != nil { + return + } + + devType, err := dev.GetPropertyDeviceType() + if err != nil { + return + } + + if devType != gonetworkmanager.NmDeviceTypeEthernet && devType != gonetworkmanager.NmDeviceTypeWifi { + return + } + + if b.dbusConn != nil { + b.dbusConn.AddMatchSignal( + dbus.WithMatchObjectPath(devicePath), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + } + + managed, _ := dev.GetPropertyManaged() + if !managed { + return + } + + iface, err := dev.GetPropertyInterface() + if err != nil { + return + } + + switch devType { + case gonetworkmanager.NmDeviceTypeEthernet: + w, err := gonetworkmanager.NewDeviceWired(devicePath) + if err != nil { + return + } + hwAddr, _ := w.GetPropertyHwAddress() + + b.ethernetDevices[iface] = ðernetDeviceInfo{ + device: dev, + wired: w, + name: iface, + hwAddress: hwAddr, + } + + if b.ethernetDevice == nil { + b.ethernetDevice = dev + } + + b.updateAllEthernetDevices() + b.updateEthernetState() + b.listEthernetConnections() + b.updatePrimaryConnection() + + case gonetworkmanager.NmDeviceTypeWifi: + w, err := gonetworkmanager.NewDeviceWireless(devicePath) + if err != nil { + return + } + hwAddr, _ := w.GetPropertyHwAddress() + + b.wifiDevices[iface] = &wifiDeviceInfo{ + device: dev, + wireless: w, + name: iface, + hwAddress: hwAddr, + } + + if b.wifiDevice == nil { + b.wifiDevice = dev + b.wifiDev = w + } + + b.updateAllWiFiDevices() + b.updateWiFiState() + } + + if b.onStateChange != nil { + b.onStateChange() + } +} + +func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath) { + if b.dbusConn != nil { + b.dbusConn.RemoveMatchSignal( + dbus.WithMatchObjectPath(devicePath), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + } + + for iface, info := range b.ethernetDevices { + if info.device.GetPath() == devicePath { + delete(b.ethernetDevices, iface) + + if b.ethernetDevice != nil { + dev := b.ethernetDevice.(gonetworkmanager.Device) + if dev.GetPath() == devicePath { + b.ethernetDevice = nil + for _, remaining := range b.ethernetDevices { + b.ethernetDevice = remaining.device + break + } + } + } + + b.updateAllEthernetDevices() + b.updateEthernetState() + b.listEthernetConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + return + } + } + + for iface, info := range b.wifiDevices { + if info.device.GetPath() == devicePath { + delete(b.wifiDevices, iface) + + if b.wifiDevice != nil { + dev := b.wifiDevice.(gonetworkmanager.Device) + if dev.GetPath() == devicePath { + b.wifiDevice = nil + b.wifiDev = nil + for _, remaining := range b.wifiDevices { + b.wifiDevice = remaining.device + b.wifiDev = remaining.wireless + break + } + } + } + + b.updateAllWiFiDevices() + b.updateWiFiState() + + if b.onStateChange != nil { + b.onStateChange() + } + return + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals_test.go new file mode 100644 index 0000000..6b58684 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals_test.go @@ -0,0 +1,255 @@ +package network + +import ( + "testing" + + mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2" + "github.com/Wifx/gonetworkmanager/v2" + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" +) + +func TestNetworkManagerBackend_HandleDBusSignal_NewConnection(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + sig := &dbus.Signal{ + Name: "org.freedesktop.NetworkManager.Settings.NewConnection", + Body: []any{"/org/freedesktop/NetworkManager/Settings/1"}, + } + + assert.NotPanics(t, func() { + backend.handleDBusSignal(sig) + }) +} + +func TestNetworkManagerBackend_HandleDBusSignal_ConnectionRemoved(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + sig := &dbus.Signal{ + Name: "org.freedesktop.NetworkManager.Settings.ConnectionRemoved", + Body: []any{"/org/freedesktop/NetworkManager/Settings/1"}, + } + + assert.NotPanics(t, func() { + backend.handleDBusSignal(sig) + }) +} + +func TestNetworkManagerBackend_HandleDBusSignal_InvalidBody(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + sig := &dbus.Signal{ + Name: "org.freedesktop.DBus.Properties.PropertiesChanged", + Body: []any{"only-one-element"}, + } + + assert.NotPanics(t, func() { + backend.handleDBusSignal(sig) + }) +} + +func TestNetworkManagerBackend_HandleDBusSignal_InvalidInterface(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + sig := &dbus.Signal{ + Name: "org.freedesktop.DBus.Properties.PropertiesChanged", + Body: []any{123, map[string]dbus.Variant{}}, + } + + assert.NotPanics(t, func() { + backend.handleDBusSignal(sig) + }) +} + +func TestNetworkManagerBackend_HandleDBusSignal_InvalidChanges(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + sig := &dbus.Signal{ + Name: "org.freedesktop.DBus.Properties.PropertiesChanged", + Body: []any{dbusNMInterface, "not-a-map"}, + } + + assert.NotPanics(t, func() { + backend.handleDBusSignal(sig) + }) +} + +func TestNetworkManagerBackend_HandleNetworkManagerChange(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe() + mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil).Maybe() + + changes := map[string]dbus.Variant{ + "PrimaryConnection": dbus.MakeVariant("/"), + "State": dbus.MakeVariant(uint32(70)), + } + + assert.NotPanics(t, func() { + backend.handleNetworkManagerChange(changes) + }) +} + +func TestNetworkManagerBackend_HandleNetworkManagerChange_WirelessEnabled(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyWirelessEnabled().Return(true, nil) + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe() + mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil).Maybe() + + changes := map[string]dbus.Variant{ + "WirelessEnabled": dbus.MakeVariant(true), + } + + assert.NotPanics(t, func() { + backend.handleNetworkManagerChange(changes) + }) +} + +func TestNetworkManagerBackend_HandleNetworkManagerChange_ActiveConnections(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil) + mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil).Maybe() + + changes := map[string]dbus.Variant{ + "ActiveConnections": dbus.MakeVariant([]any{}), + } + + assert.NotPanics(t, func() { + backend.handleNetworkManagerChange(changes) + }) +} + +func TestNetworkManagerBackend_HandleDeviceChange(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe() + mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil).Maybe() + + changes := map[string]dbus.Variant{ + "State": dbus.MakeVariant(uint32(100)), + } + + assert.NotPanics(t, func() { + backend.handleDeviceChange("/org/freedesktop/NetworkManager/Devices/1", changes) + }) +} + +func TestNetworkManagerBackend_HandleDeviceChange_Ip4Config(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + changes := map[string]dbus.Variant{ + "Ip4Config": dbus.MakeVariant("/"), + } + + assert.NotPanics(t, func() { + backend.handleDeviceChange("/org/freedesktop/NetworkManager/Devices/1", changes) + }) +} + +func TestNetworkManagerBackend_HandleWiFiChange_ActiveAccessPoint(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + changes := map[string]dbus.Variant{ + "ActiveAccessPoint": dbus.MakeVariant("/"), + } + + assert.NotPanics(t, func() { + backend.handleWiFiChange(changes) + }) +} + +func TestNetworkManagerBackend_HandleWiFiChange_AccessPoints(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + changes := map[string]dbus.Variant{ + "AccessPoints": dbus.MakeVariant([]any{}), + } + + assert.NotPanics(t, func() { + backend.handleWiFiChange(changes) + }) +} + +func TestNetworkManagerBackend_HandleAccessPointChange_NoStrength(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + changes := map[string]dbus.Variant{ + "SomeOtherProperty": dbus.MakeVariant("value"), + } + + assert.NotPanics(t, func() { + backend.handleAccessPointChange(changes) + }) +} + +func TestNetworkManagerBackend_HandleAccessPointChange_WithStrength(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.stateMutex.Lock() + backend.state.WiFiSignal = 50 + backend.stateMutex.Unlock() + + changes := map[string]dbus.Variant{ + "Strength": dbus.MakeVariant(uint8(80)), + } + + assert.NotPanics(t, func() { + backend.handleAccessPointChange(changes) + }) +} + +func TestNetworkManagerBackend_StopSignalPump_NoConnection(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.dbusConn = nil + assert.NotPanics(t, func() { + backend.stopSignalPump() + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state.go new file mode 100644 index 0000000..ba77f6d --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state.go @@ -0,0 +1,277 @@ +package network + +import ( + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/Wifx/gonetworkmanager/v2" +) + +func (b *NetworkManagerBackend) updatePrimaryConnection() error { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + + activeConns, err := nm.GetPropertyActiveConnections() + if err != nil { + return err + } + + hasActiveVPN := false + for _, activeConn := range activeConns { + connType, err := activeConn.GetPropertyType() + if err != nil { + continue + } + if connType == "vpn" || connType == "wireguard" { + state, _ := activeConn.GetPropertyState() + if state == 2 { + hasActiveVPN = true + break + } + } + } + + if hasActiveVPN { + b.stateMutex.Lock() + b.state.NetworkStatus = StatusVPN + b.stateMutex.Unlock() + return nil + } + + primaryConn, err := nm.GetPropertyPrimaryConnection() + if err != nil { + return err + } + + if primaryConn == nil || primaryConn.GetPath() == "/" { + b.stateMutex.Lock() + b.state.NetworkStatus = StatusDisconnected + b.stateMutex.Unlock() + return nil + } + + connType, err := primaryConn.GetPropertyType() + if err != nil { + return err + } + + b.stateMutex.Lock() + switch connType { + case "802-3-ethernet": + b.state.NetworkStatus = StatusEthernet + case "802-11-wireless": + b.state.NetworkStatus = StatusWiFi + case "vpn", "wireguard": + b.state.NetworkStatus = StatusVPN + default: + b.state.NetworkStatus = StatusDisconnected + } + b.stateMutex.Unlock() + + return nil +} + +func (b *NetworkManagerBackend) updateEthernetState() error { + var connectedDevice string + var connectedIP string + var anyConnected bool + + for name, info := range b.ethernetDevices { + state, err := info.device.GetPropertyState() + if err != nil { + continue + } + + if state == gonetworkmanager.NmDeviceStateActivated { + anyConnected = true + connectedDevice = name + connectedIP = b.getDeviceIP(info.device) + break + } + } + + if !anyConnected && b.ethernetDevice != nil { + dev := b.ethernetDevice.(gonetworkmanager.Device) + iface, _ := dev.GetPropertyInterface() + connectedDevice = iface + } + + b.stateMutex.Lock() + b.state.EthernetDevice = connectedDevice + b.state.EthernetConnected = anyConnected + b.state.EthernetIP = connectedIP + b.stateMutex.Unlock() + + return nil +} + +func (b *NetworkManagerBackend) getDeviceStateReason(dev gonetworkmanager.Device) uint32 { + path := dev.GetPath() + obj := b.dbusConn.Object("org.freedesktop.NetworkManager", path) + + variant, err := obj.GetProperty(dbusNMDeviceInterface + ".StateReason") + if err != nil { + return 0 + } + + if stateReasonStruct, ok := variant.Value().([]any); ok && len(stateReasonStruct) >= 2 { + if reason, ok := stateReasonStruct[1].(uint32); ok { + return reason + } + } + + return 0 +} + +func (b *NetworkManagerBackend) classifyNMStateReason(reason uint32) string { + switch reason { + case NmDeviceStateReasonWrongPassword, + NmDeviceStateReasonSupplicantTimeout, + NmDeviceStateReasonSupplicantFailed, + NmDeviceStateReasonSecretsRequired: + return errdefs.ErrBadCredentials + case NmDeviceStateReasonNoSecrets: + return errdefs.ErrUserCanceled + case NmDeviceStateReasonNoSsid: + return errdefs.ErrNoSuchSSID + case NmDeviceStateReasonDhcpClientFailed, + NmDeviceStateReasonIpConfigUnavailable: + return errdefs.ErrDhcpTimeout + case NmDeviceStateReasonSupplicantDisconnect, + NmDeviceStateReasonCarrier: + return errdefs.ErrAssocTimeout + default: + return errdefs.ErrConnectionFailed + } +} + +func (b *NetworkManagerBackend) updateWiFiState() error { + if b.wifiDevice == nil { + return nil + } + + dev := b.wifiDevice.(gonetworkmanager.Device) + + iface, err := dev.GetPropertyInterface() + if err != nil { + return err + } + + state, err := dev.GetPropertyState() + if err != nil { + return err + } + + connected := state == gonetworkmanager.NmDeviceStateActivated + failed := state == gonetworkmanager.NmDeviceStateFailed + disconnected := state == gonetworkmanager.NmDeviceStateDisconnected + + var ip, ssid, bssid string + var signal uint8 + + if connected { + if err := b.ensureWiFiDevice(); err == nil && b.wifiDev != nil { + w := b.wifiDev.(gonetworkmanager.DeviceWireless) + activeAP, err := w.GetPropertyActiveAccessPoint() + if err == nil && activeAP != nil && activeAP.GetPath() != "/" { + ssid, _ = activeAP.GetPropertySSID() + signal, _ = activeAP.GetPropertyStrength() + bssid, _ = activeAP.GetPropertyHWAddress() + } + } + + ip = b.getDeviceIP(dev) + } + + b.stateMutex.RLock() + wasConnecting := b.state.IsConnecting + connectingSSID := b.state.ConnectingSSID + b.stateMutex.RUnlock() + + var reasonCode string + if wasConnecting && connectingSSID != "" && (failed || (disconnected && !connected)) { + reason := b.getDeviceStateReason(dev) + + if reason == NmDeviceStateReasonNewActivation || reason == 0 { + return nil + } + + log.Warnf("[updateWiFiState] Connection failed: SSID=%s, state=%d, reason=%d", connectingSSID, state, reason) + + reasonCode = b.classifyNMStateReason(reason) + + if reasonCode == errdefs.ErrConnectionFailed { + b.failedMutex.RLock() + if b.lastFailedSSID == connectingSSID { + elapsed := time.Now().Unix() - b.lastFailedTime + if elapsed < 5 { + reasonCode = errdefs.ErrBadCredentials + } + } + b.failedMutex.RUnlock() + } + } + + var forgetSSID string + + b.stateMutex.Lock() + + wasConnecting = b.state.IsConnecting + connectingSSID = b.state.ConnectingSSID + + if wasConnecting && connectingSSID != "" { + switch { + case connected && ssid == connectingSSID: + log.Infof("[updateWiFiState] Connection successful: %s", ssid) + b.state.IsConnecting = false + b.state.ConnectingSSID = "" + b.state.LastError = "" + case failed || (disconnected && !connected): + log.Warnf("[updateWiFiState] Connection failed: SSID=%s, state=%d", connectingSSID, state) + b.state.IsConnecting = false + b.state.ConnectingSSID = "" + b.state.LastError = reasonCode + + if reasonCode == errdefs.ErrUserCanceled { + forgetSSID = connectingSSID + } + + b.failedMutex.Lock() + b.lastFailedSSID = connectingSSID + b.lastFailedTime = time.Now().Unix() + b.failedMutex.Unlock() + } + } + + b.state.WiFiDevice = iface + b.state.WiFiConnected = connected + b.state.WiFiIP = ip + b.state.WiFiSSID = ssid + b.state.WiFiBSSID = bssid + b.state.WiFiSignal = signal + + b.stateMutex.Unlock() + + if forgetSSID != "" { + log.Infof("[updateWiFiState] User cancelled authentication, removing connection profile for %s", forgetSSID) + if err := b.ForgetWiFiNetwork(forgetSSID); err != nil { + log.Warnf("[updateWiFiState] Failed to remove cancelled connection: %v", err) + } + } + + return nil +} + +func (b *NetworkManagerBackend) getDeviceIP(dev gonetworkmanager.Device) string { + ip4Config, err := dev.GetPropertyIP4Config() + if err != nil || ip4Config == nil { + return "" + } + + addresses, err := ip4Config.GetPropertyAddressData() + if err != nil || len(addresses) == 0 { + return "" + } + + return addresses[0].Address +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state_test.go new file mode 100644 index 0000000..86aa48f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state_test.go @@ -0,0 +1,82 @@ +package network + +import ( + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2" + "github.com/Wifx/gonetworkmanager/v2" + "github.com/stretchr/testify/assert" +) + +func TestNetworkManagerBackend_UpdatePrimaryConnection(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil) + mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil) + + err = backend.updatePrimaryConnection() + assert.NoError(t, err) +} + +func TestNetworkManagerBackend_UpdateEthernetState_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.ethernetDevice = nil + err = backend.updateEthernetState() + assert.NoError(t, err) +} + +func TestNetworkManagerBackend_UpdateWiFiState_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.wifiDevice = nil + err = backend.updateWiFiState() + assert.NoError(t, err) +} + +func TestNetworkManagerBackend_ClassifyNMStateReason(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + testCases := []struct { + reason uint32 + expected string + }{ + {NmDeviceStateReasonWrongPassword, errdefs.ErrBadCredentials}, + {NmDeviceStateReasonNoSecrets, errdefs.ErrUserCanceled}, + {NmDeviceStateReasonSupplicantTimeout, errdefs.ErrBadCredentials}, + {NmDeviceStateReasonDhcpClientFailed, errdefs.ErrDhcpTimeout}, + {NmDeviceStateReasonNoSsid, errdefs.ErrNoSuchSSID}, + {999, errdefs.ErrConnectionFailed}, + } + + for _, tc := range testCases { + result := backend.classifyNMStateReason(tc.reason) + assert.Equal(t, tc.expected, result) + } +} + +func TestNetworkManagerBackend_GetDeviceIP_NoConfig(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockDevice := mock_gonetworkmanager.NewMockDevice(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockDevice.EXPECT().GetPropertyIP4Config().Return(nil, nil) + + ip := backend.getDeviceIP(mockDevice) + assert.Empty(t, ip) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_test.go new file mode 100644 index 0000000..1391e4a --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_test.go @@ -0,0 +1,154 @@ +package network + +import ( + "testing" + + mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2" + "github.com/stretchr/testify/assert" +) + +func TestNetworkManagerBackend_New(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + assert.NotNil(t, backend) + assert.Equal(t, "networkmanager", backend.state.Backend) + assert.NotNil(t, backend.stopChan) + assert.NotNil(t, backend.state) +} + +func TestNetworkManagerBackend_GetCurrentState(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.state.NetworkStatus = StatusWiFi + backend.state.WiFiConnected = true + backend.state.WiFiSSID = "TestNetwork" + backend.state.WiFiIP = "192.168.1.100" + backend.state.WiFiNetworks = []WiFiNetwork{ + {SSID: "TestNetwork", Signal: 80, Connected: true}, + } + backend.state.WiredConnections = []WiredConnection{ + {ID: "Wired connection 1", UUID: "test-uuid"}, + } + + state, err := backend.GetCurrentState() + assert.NoError(t, err) + assert.NotNil(t, state) + assert.Equal(t, StatusWiFi, state.NetworkStatus) + assert.True(t, state.WiFiConnected) + assert.Equal(t, "TestNetwork", state.WiFiSSID) + assert.Len(t, state.WiFiNetworks, 1) + assert.Len(t, state.WiredConnections, 1) + + assert.NotSame(t, &backend.state.WiFiNetworks, &state.WiFiNetworks) + assert.NotSame(t, &backend.state.WiredConnections, &state.WiredConnections) +} + +func TestNetworkManagerBackend_SetPromptBroker_Nil(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + err = backend.SetPromptBroker(nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be nil") +} + +func TestNetworkManagerBackend_SubmitCredentials_NoBroker(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.promptBroker = nil + err = backend.SubmitCredentials("token", map[string]string{"password": "test"}, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not initialized") +} + +func TestNetworkManagerBackend_CancelCredentials_NoBroker(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.promptBroker = nil + err = backend.CancelCredentials("token") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not initialized") +} + +func TestNetworkManagerBackend_EnsureWiFiDevice_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.wifiDevice = nil + backend.wifiDev = nil + + err = backend.ensureWiFiDevice() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no WiFi device available") +} + +func TestNetworkManagerBackend_EnsureWiFiDevice_AlreadySet(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.wifiDev = "dummy-device" + + err = backend.ensureWiFiDevice() + assert.NoError(t, err) +} + +func TestNetworkManagerBackend_StartSecretAgent_NoBroker(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.promptBroker = nil + err = backend.startSecretAgent() + assert.Error(t, err) + assert.Contains(t, err.Error(), "prompt broker not set") +} + +func TestNetworkManagerBackend_Close(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + assert.NotPanics(t, func() { + backend.Close() + }) +} + +func TestNetworkManagerBackend_GetPromptBroker(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + broker := backend.GetPromptBroker() + assert.Nil(t, broker) +} + +func TestNetworkManagerBackend_StopMonitoring_NoSignals(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + assert.NotPanics(t, func() { + backend.StopMonitoring() + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn.go new file mode 100644 index 0000000..261fcef --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn.go @@ -0,0 +1,1394 @@ +package network + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/Wifx/gonetworkmanager/v2" + "github.com/godbus/dbus/v5" +) + +func (b *NetworkManagerBackend) ListVPNProfiles() ([]VPNProfile, error) { + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return nil, fmt.Errorf("failed to get connections: %w", err) + } + + profiles := []VPNProfile{} + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + + connMeta, ok := settings["connection"] + if !ok { + continue + } + + connType, _ := connMeta["type"].(string) + if connType != "vpn" && connType != "wireguard" { + continue + } + + connID, _ := connMeta["id"].(string) + connUUID, _ := connMeta["uuid"].(string) + autoconnect, _ := connMeta["autoconnect"].(bool) + + profile := VPNProfile{ + Name: connID, + UUID: connUUID, + Type: connType, + Autoconnect: autoconnect, + } + + if connType == "vpn" { + if vpnSettings, ok := settings["vpn"]; ok { + if svcType, ok := vpnSettings["service-type"].(string); ok { + profile.ServiceType = svcType + } + // Get full data map + if data, ok := vpnSettings["data"].(map[string]string); ok { + profile.Data = data + if remote, ok := data["remote"]; ok { + profile.RemoteHost = remote + } + if username, ok := data["username"]; ok { + profile.Username = username + } + } + } + } + + profiles = append(profiles, profile) + } + + sort.Slice(profiles, func(i, j int) bool { + return strings.ToLower(profiles[i].Name) < strings.ToLower(profiles[j].Name) + }) + + b.stateMutex.Lock() + b.state.VPNProfiles = profiles + b.stateMutex.Unlock() + + return profiles, nil +} + +func (b *NetworkManagerBackend) ListActiveVPN() ([]VPNActive, error) { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + + activeConns, err := nm.GetPropertyActiveConnections() + if err != nil { + return nil, fmt.Errorf("failed to get active connections: %w", err) + } + + active := []VPNActive{} + for _, activeConn := range activeConns { + connType, err := activeConn.GetPropertyType() + if err != nil { + continue + } + + if connType != "vpn" && connType != "wireguard" { + continue + } + + uuid, _ := activeConn.GetPropertyUUID() + id, _ := activeConn.GetPropertyID() + state, _ := activeConn.GetPropertyState() + + var stateStr string + switch state { + case 0: + stateStr = "unknown" + case 1: + stateStr = "activating" + case 2: + stateStr = "activated" + case 3: + stateStr = "deactivating" + case 4: + stateStr = "deactivated" + } + + vpnActive := VPNActive{ + Name: id, + UUID: uuid, + State: stateStr, + Type: connType, + Plugin: "", + } + + // Get VPN device + devices, _ := activeConn.GetPropertyDevices() + if len(devices) > 0 { + if iface, err := devices[0].GetPropertyInterface(); err == nil { + vpnActive.Device = iface + } + } + + // Get VPN IP from IP4Config + if ip4Config, err := activeConn.GetPropertyIP4Config(); err == nil && ip4Config != nil { + if addrData, err := ip4Config.GetPropertyAddressData(); err == nil && len(addrData) > 0 { + vpnActive.IP = addrData[0].Address + } + if gw, err := ip4Config.GetPropertyGateway(); err == nil { + vpnActive.Gateway = gw + } + } + + // Get MTU from device + if len(devices) > 0 { + if mtu, err := devices[0].GetPropertyMtu(); err == nil { + vpnActive.MTU = mtu + } + } + + if connType == "vpn" { + conn, _ := activeConn.GetPropertyConnection() + if conn != nil { + connSettings, err := conn.GetSettings() + if err == nil { + if vpnSettings, ok := connSettings["vpn"]; ok { + if svcType, ok := vpnSettings["service-type"].(string); ok { + vpnActive.Plugin = svcType + } + // Get full data map + if data, ok := vpnSettings["data"].(map[string]string); ok { + vpnActive.Data = data + if remote, ok := data["remote"]; ok { + vpnActive.RemoteHost = remote + } + if username, ok := data["username"]; ok { + vpnActive.Username = username + } + } + } + } + } + } + + active = append(active, vpnActive) + } + + b.stateMutex.Lock() + b.state.VPNActive = active + b.stateMutex.Unlock() + + return active, nil +} + +func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool) error { + if singleActive { + active, err := b.ListActiveVPN() + if err == nil && len(active) > 0 { + alreadyConnected := false + for _, vpn := range active { + if vpn.UUID == uuidOrName || vpn.Name == uuidOrName { + alreadyConnected = true + break + } + } + + if !alreadyConnected { + if err := b.DisconnectAllVPN(); err != nil { + log.Warnf("Failed to disconnect existing VPNs: %v", err) + } + time.Sleep(500 * time.Millisecond) + } else { + return nil + } + } + } + + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + var targetConn gonetworkmanager.Connection + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + + connMeta, ok := settings["connection"] + if !ok { + continue + } + + connType, _ := connMeta["type"].(string) + if connType != "vpn" && connType != "wireguard" { + continue + } + + connID, _ := connMeta["id"].(string) + connUUID, _ := connMeta["uuid"].(string) + + if connUUID == uuidOrName || connID == uuidOrName { + targetConn = conn + break + } + } + + if targetConn == nil { + return fmt.Errorf("VPN connection not found: %s", uuidOrName) + } + + targetSettings, err := targetConn.GetSettings() + if err != nil { + return fmt.Errorf("failed to get connection settings: %w", err) + } + + var targetUUID string + var connName string + if connMeta, ok := targetSettings["connection"]; ok { + if uuid, ok := connMeta["uuid"].(string); ok { + targetUUID = uuid + } + if id, ok := connMeta["id"].(string); ok { + connName = id + } + } + + var vpnServiceType string + var vpnData map[string]string + if vpnSettings, ok := targetSettings["vpn"]; ok { + if svc, ok := vpnSettings["service-type"].(string); ok { + vpnServiceType = svc + } + if data, ok := vpnSettings["data"].(map[string]string); ok { + vpnData = data + } + } + + authAction := detectVPNAuthAction(vpnServiceType, vpnData) + + switch authAction { + case "openvpn_username": + if b.promptBroker == nil { + return fmt.Errorf("OpenVPN password authentication requires interactive prompt") + } + if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil { + return err + } + case "gp_saml": + gateway := vpnData["gateway"] + protocol := vpnData["protocol"] + if protocol != "gp" { + return fmt.Errorf("GlobalProtect SAML authentication only supported for protocol=gp, got: %s", protocol) + } + + log.Infof("[ConnectVPN] GlobalProtect SAML/SSO authentication required for %s (gateway=%s)", connName, gateway) + + samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute) + authResult, err := b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol) + samlCancel() + if err != nil { + errMsg := err.Error() + switch { + case strings.Contains(errMsg, "not installed"): + return fmt.Errorf("gp-saml-gui is not installed (required for GlobalProtect SAML/SSO VPN)") + case strings.Contains(errMsg, "timed out") || strings.Contains(errMsg, "cancelled"): + return fmt.Errorf("GlobalProtect SAML authentication timed out — please try again") + case strings.Contains(errMsg, "no cookie"): + return fmt.Errorf("GlobalProtect SAML login did not complete — browser was closed before authentication finished") + case strings.Contains(errMsg, "convert prelogin-cookie"): + return fmt.Errorf("GlobalProtect VPN authentication succeeded but cookie exchange failed: %w", err) + default: + return fmt.Errorf("GlobalProtect SAML authentication failed: %w", err) + } + } + + b.cachedGPSamlMu.Lock() + b.cachedGPSamlCookie = &cachedGPSamlCookie{ + ConnectionUUID: targetUUID, + Cookie: authResult.Cookie, + Host: authResult.Host, + User: authResult.User, + Fingerprint: authResult.Fingerprint, + } + b.cachedGPSamlMu.Unlock() + + if err := targetConn.ClearSecrets(); err != nil { + log.Warnf("[ConnectVPN] ClearSecrets failed (non-fatal): %v", err) + } else { + log.Infof("[ConnectVPN] Cleared stale stored secrets for %s", connName) + } + + log.Infof("[ConnectVPN] GlobalProtect SAML cookie cached for %s, proceeding with activation", connName) + } + + b.stateMutex.Lock() + b.state.IsConnectingVPN = true + b.state.ConnectingVPNUUID = targetUUID + b.stateMutex.Unlock() + + if b.onStateChange != nil { + b.onStateChange() + } + + nm := b.nmConn.(gonetworkmanager.NetworkManager) + _, err = nm.ActivateConnection(targetConn, nil, nil) + if err != nil { + b.stateMutex.Lock() + b.state.IsConnectingVPN = false + b.state.ConnectingVPNUUID = "" + b.stateMutex.Unlock() + + if b.onStateChange != nil { + b.onStateChange() + } + + return fmt.Errorf("failed to activate VPN: %w", err) + } + + return nil +} + +func detectVPNAuthAction(serviceType string, data map[string]string) string { + if data == nil { + return "" + } + + switch { + case strings.Contains(serviceType, "openconnect"): + protocol := data["protocol"] + if needsExternalBrowserAuth(protocol, data["authtype"], data["username"], data) { + switch protocol { + case "gp": + return "gp_saml" + default: + log.Infof("[VPN] External browser auth detected for protocol '%s' but only GlobalProtect (gp) is currently supported", protocol) + } + } + case strings.Contains(serviceType, "openvpn"): + connType := data["connection-type"] + username := data["username"] + if (connType == "password" || connType == "password-tls") && username == "" { + return "openvpn_username" + } + } + return "" +} + +func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkmanager.Connection, connName, targetUUID, vpnServiceType string) error { + log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + token, err := b.promptBroker.Ask(ctx, PromptRequest{ + Name: connName, + ConnType: "vpn", + VpnService: vpnServiceType, + SettingName: "vpn", + Fields: []string{"username", "password"}, + FieldsInfo: []FieldInfo{{Name: "username", Label: "Username", IsSecret: false}, {Name: "password", Label: "Password", IsSecret: true}}, + Reason: "required", + ConnectionId: connName, + ConnectionUuid: targetUUID, + ConnectionPath: string(targetConn.GetPath()), + }) + if err != nil { + return fmt.Errorf("failed to request credentials: %w", err) + } + + reply, err := b.promptBroker.Wait(ctx, token) + if err != nil { + return fmt.Errorf("credentials prompt failed: %w", err) + } + + if reply.Cancel { + return fmt.Errorf("user cancelled authentication") + } + + username := reply.Secrets["username"] + password := reply.Secrets["password"] + if username == "" { + return nil + } + + connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", targetConn.GetPath()) + var existingSettings map[string]map[string]dbus.Variant + if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil { + return fmt.Errorf("failed to get settings for username save: %w", err) + } + + settings := make(map[string]map[string]dbus.Variant) + if connSection, ok := existingSettings["connection"]; ok { + settings["connection"] = connSection + } + vpn := existingSettings["vpn"] + var data map[string]string + if dataVariant, ok := vpn["data"]; ok { + if dm, ok := dataVariant.Value().(map[string]string); ok { + data = make(map[string]string) + for k, v := range dm { + data[k] = v + } + } else { + data = make(map[string]string) + } + } else { + data = make(map[string]string) + } + data["username"] = username + + vpn["data"] = dbus.MakeVariant(data) + settings["vpn"] = vpn + + var result map[string]dbus.Variant + if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0, + settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil { + return fmt.Errorf("failed to save username: %w", err) + } + log.Infof("[ConnectVPN] Username saved to connection") + + if password != "" { + b.cachedVPNCredsMu.Lock() + b.cachedVPNCreds = &cachedVPNCredentials{ + ConnectionUUID: targetUUID, + Password: password, + SavePassword: reply.Save, + } + b.cachedVPNCredsMu.Unlock() + log.Infof("[ConnectVPN] Cached password for GetSecrets") + } + + return nil +} + +func (b *NetworkManagerBackend) DisconnectVPN(uuidOrName string) error { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + + activeConns, err := nm.GetPropertyActiveConnections() + if err != nil { + return fmt.Errorf("failed to get active connections: %w", err) + } + + log.Debugf("[DisconnectVPN] Looking for VPN: %s", uuidOrName) + + for _, activeConn := range activeConns { + connType, err := activeConn.GetPropertyType() + if err != nil { + continue + } + + if connType != "vpn" && connType != "wireguard" { + continue + } + + uuid, _ := activeConn.GetPropertyUUID() + id, _ := activeConn.GetPropertyID() + state, _ := activeConn.GetPropertyState() + + log.Debugf("[DisconnectVPN] Found active VPN: uuid=%s id=%s state=%d", uuid, id, state) + + if uuid == uuidOrName || id == uuidOrName { + log.Infof("[DisconnectVPN] Deactivating VPN: %s (state=%d)", id, state) + if err := nm.DeactivateConnection(activeConn); err != nil { + return fmt.Errorf("failed to deactivate VPN: %w", err) + } + b.ListActiveVPN() + if b.onStateChange != nil { + b.onStateChange() + } + return nil + } + } + + log.Warnf("[DisconnectVPN] VPN not found in active connections: %s", uuidOrName) + + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("VPN connection not active and cannot access settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("VPN connection not active: %s", uuidOrName) + } + + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + + connMeta, ok := settings["connection"] + if !ok { + continue + } + + connType, _ := connMeta["type"].(string) + if connType != "vpn" && connType != "wireguard" { + continue + } + + connID, _ := connMeta["id"].(string) + connUUID, _ := connMeta["uuid"].(string) + + if connUUID == uuidOrName || connID == uuidOrName { + log.Infof("[DisconnectVPN] VPN connection exists but not active: %s", connID) + return nil + } + } + + return fmt.Errorf("VPN connection not found: %s", uuidOrName) +} + +func (b *NetworkManagerBackend) DisconnectAllVPN() error { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + + activeConns, err := nm.GetPropertyActiveConnections() + if err != nil { + return fmt.Errorf("failed to get active connections: %w", err) + } + + var lastErr error + var disconnected bool + for _, activeConn := range activeConns { + connType, err := activeConn.GetPropertyType() + if err != nil { + continue + } + + if connType != "vpn" && connType != "wireguard" { + continue + } + + if err := nm.DeactivateConnection(activeConn); err != nil { + lastErr = err + log.Warnf("Failed to deactivate VPN connection: %v", err) + } else { + disconnected = true + } + } + + if disconnected { + b.ListActiveVPN() + if b.onStateChange != nil { + b.onStateChange() + } + } + + return lastErr +} + +func (b *NetworkManagerBackend) ClearVPNCredentials(uuidOrName string) error { + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + + connMeta, ok := settings["connection"] + if !ok { + continue + } + + connType, _ := connMeta["type"].(string) + if connType != "vpn" && connType != "wireguard" { + continue + } + + connID, _ := connMeta["id"].(string) + connUUID, _ := connMeta["uuid"].(string) + + if connUUID == uuidOrName || connID == uuidOrName { + if connType == "vpn" { + if vpnSettings, ok := settings["vpn"]; ok { + delete(vpnSettings, "secrets") + + if dataMap, ok := vpnSettings["data"].(map[string]string); ok { + dataMap["password-flags"] = "1" + vpnSettings["data"] = dataMap + } + } + } + + if err := conn.Update(settings); err != nil { + return fmt.Errorf("failed to update connection: %w", err) + } + + if err := conn.ClearSecrets(); err != nil { + log.Warnf("ClearSecrets call failed (may not be critical): %v", err) + } + + log.Infof("Cleared credentials for VPN: %s", connID) + return nil + } + } + + return fmt.Errorf("VPN connection not found: %s", uuidOrName) +} + +func (b *NetworkManagerBackend) updateVPNConnectionState() { + b.stateMutex.RLock() + isConnectingVPN := b.state.IsConnectingVPN + connectingVPNUUID := b.state.ConnectingVPNUUID + b.stateMutex.RUnlock() + + if !isConnectingVPN || connectingVPNUUID == "" { + return + } + + nm := b.nmConn.(gonetworkmanager.NetworkManager) + activeConns, err := nm.GetPropertyActiveConnections() + if err != nil { + return + } + + foundConnection := false + for _, activeConn := range activeConns { + connType, err := activeConn.GetPropertyType() + if err != nil { + continue + } + + if connType != "vpn" && connType != "wireguard" { + continue + } + + connUUID, err := activeConn.GetPropertyUUID() + if err != nil { + continue + } + + state, _ := activeConn.GetPropertyState() + stateReason, _ := activeConn.GetPropertyStateFlags() + + if connUUID == connectingVPNUUID { + foundConnection = true + + switch state { + case 2: + log.Infof("[updateVPNConnectionState] VPN connection successful: %s", connUUID) + b.stateMutex.Lock() + b.state.IsConnectingVPN = false + b.state.ConnectingVPNUUID = "" + b.state.LastError = "" + b.stateMutex.Unlock() + + // Clear cached PKCS11 PIN and SAML cookie on success + b.cachedPKCS11Mu.Lock() + b.cachedPKCS11PIN = nil + b.cachedPKCS11Mu.Unlock() + b.cachedGPSamlMu.Lock() + b.cachedGPSamlCookie = nil + b.cachedGPSamlMu.Unlock() + + b.pendingVPNSaveMu.Lock() + pending := b.pendingVPNSave + b.pendingVPNSave = nil + b.pendingVPNSaveMu.Unlock() + + if pending != nil { + go b.saveVPNCredentials(pending) + } + return + case 4: + log.Warnf("[updateVPNConnectionState] VPN connection failed/deactivated: %s (state=%d, flags=%d)", connUUID, state, stateReason) + b.stateMutex.Lock() + b.state.IsConnectingVPN = false + b.state.ConnectingVPNUUID = "" + b.state.LastError = "VPN connection failed" + b.stateMutex.Unlock() + + // Clear cached PKCS11 PIN and SAML cookie on failure + b.cachedPKCS11Mu.Lock() + b.cachedPKCS11PIN = nil + b.cachedPKCS11Mu.Unlock() + b.cachedGPSamlMu.Lock() + b.cachedGPSamlCookie = nil + b.cachedGPSamlMu.Unlock() + return + } + } + } + + if !foundConnection { + log.Warnf("[updateVPNConnectionState] VPN connection no longer exists: %s", connectingVPNUUID) + b.stateMutex.Lock() + b.state.IsConnectingVPN = false + b.state.ConnectingVPNUUID = "" + b.state.LastError = "VPN connection failed" + b.stateMutex.Unlock() + + // Clear cached PKCS11 PIN and SAML cookie + b.cachedPKCS11Mu.Lock() + b.cachedPKCS11PIN = nil + b.cachedPKCS11Mu.Unlock() + b.cachedGPSamlMu.Lock() + b.cachedGPSamlCookie = nil + b.cachedGPSamlMu.Unlock() + } +} + +func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials) { + log.Infof("[saveVPNCredentials] Saving credentials for %s (username=%v, savePassword=%v)", + creds.ConnectionPath, creds.Username != "", creds.SavePassword) + + connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath(creds.ConnectionPath)) + var existingSettings map[string]map[string]dbus.Variant + if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil { + log.Warnf("[saveVPNCredentials] GetSettings failed: %v", err) + return + } + + settings := make(map[string]map[string]dbus.Variant) + if connSection, ok := existingSettings["connection"]; ok { + settings["connection"] = connSection + } + + vpn, ok := existingSettings["vpn"] + if !ok { + vpn = make(map[string]dbus.Variant) + } + + // Get existing data map + var data map[string]string + if dataVariant, ok := vpn["data"]; ok { + if dm, ok := dataVariant.Value().(map[string]string); ok { + data = make(map[string]string) + for k, v := range dm { + data[k] = v + } + } else { + data = make(map[string]string) + } + } else { + data = make(map[string]string) + } + + // Always save username if provided + if creds.Username != "" { + data["username"] = creds.Username + log.Infof("[saveVPNCredentials] Saving username") + } + + // Save password if requested + if creds.SavePassword { + data["password-flags"] = "0" + secs := make(map[string]string) + secs["password"] = creds.Password + vpn["secrets"] = dbus.MakeVariant(secs) + log.Infof("[saveVPNCredentials] Saving password with password-flags=0") + } + + vpn["data"] = dbus.MakeVariant(data) + settings["vpn"] = vpn + + var result map[string]dbus.Variant + if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0, + settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil { + log.Warnf("[saveVPNCredentials] Update2 failed: %v", err) + } else { + log.Infof("[saveVPNCredentials] Successfully saved credentials") + } +} + +func (b *NetworkManagerBackend) ListVPNPlugins() ([]VPNPlugin, error) { + plugins := []VPNPlugin{} + pluginDirs := []string{ + "/usr/lib/NetworkManager/VPN", + "/usr/lib64/NetworkManager/VPN", + "/etc/NetworkManager/VPN", + } + + seen := make(map[string]bool) + + for _, dir := range pluginDirs { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".name") { + continue + } + + filePath := filepath.Join(dir, entry.Name()) + plugin, err := parseVPNPluginFile(filePath) + if err != nil { + log.Debugf("Failed to parse VPN plugin file %s: %v", filePath, err) + continue + } + + if seen[plugin.ServiceType] { + continue + } + seen[plugin.ServiceType] = true + plugins = append(plugins, *plugin) + } + } + + sort.Slice(plugins, func(i, j int) bool { + return strings.ToLower(plugins[i].Name) < strings.ToLower(plugins[j].Name) + }) + + return plugins, nil +} + +func parseVPNPluginFile(path string) (*VPNPlugin, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + plugin := &VPNPlugin{} + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + switch key { + case "name": + plugin.Name = value + case "service": + plugin.ServiceType = value + case "program": + plugin.Program = value + case "supports": + plugin.Supports = strings.Split(value, ",") + for i := range plugin.Supports { + plugin.Supports[i] = strings.TrimSpace(plugin.Supports[i]) + } + } + } + + if plugin.ServiceType == "" { + return nil, fmt.Errorf("plugin file missing service type") + } + + plugin.FileExtensions = getVPNFileExtensions(plugin.ServiceType) + + return plugin, nil +} + +func getVPNFileExtensions(serviceType string) []string { + switch { + case strings.Contains(serviceType, "openvpn"): + return []string{".ovpn", ".conf"} + case strings.Contains(serviceType, "wireguard"): + return []string{".conf"} + case strings.Contains(serviceType, "vpnc"), strings.Contains(serviceType, "cisco"): + return []string{".pcf", ".conf"} + case strings.Contains(serviceType, "openconnect"): + return []string{".conf"} + case strings.Contains(serviceType, "pptp"): + return []string{".conf"} + case strings.Contains(serviceType, "l2tp"): + return []string{".conf"} + case strings.Contains(serviceType, "strongswan"), strings.Contains(serviceType, "ipsec"): + return []string{".conf", ".sswan"} + default: + return []string{".conf"} + } +} + +func (b *NetworkManagerBackend) ImportVPN(filePath string, name string) (*VPNImportResult, error) { + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return &VPNImportResult{ + Success: false, + Error: fmt.Sprintf("file not found: %s", filePath), + }, nil + } + + ext := strings.ToLower(filepath.Ext(filePath)) + + switch ext { + case ".ovpn", ".conf": + return b.importVPNWithNmcli(filePath, name) + default: + return b.importVPNWithNmcli(filePath, name) + } +} + +func (b *NetworkManagerBackend) importVPNWithNmcli(filePath string, name string) (*VPNImportResult, error) { + vpnTypes := []string{"openvpn", "wireguard", "vpnc", "pptp", "l2tp", "openconnect", "strongswan"} + + var allErrors []error + var outputStr string + for _, vpnType := range vpnTypes { + cmd := exec.Command("nmcli", "connection", "import", "type", vpnType, "file", filePath) + output, err := cmd.CombinedOutput() + if err == nil { + outputStr = string(output) + break + } + allErrors = append(allErrors, fmt.Errorf("%s: %s", vpnType, strings.TrimSpace(string(output)))) + } + + if len(allErrors) == len(vpnTypes) { + return &VPNImportResult{ + Success: false, + Error: errors.Join(allErrors...).Error(), + }, nil + } + var connUUID, connName string + + lines := strings.Split(outputStr, "\n") + for _, line := range lines { + if strings.Contains(line, "successfully added") { + parts := strings.Fields(line) + for i, part := range parts { + if part == "(" && i+1 < len(parts) { + connUUID = strings.TrimSuffix(parts[i+1], ")") + break + } + } + } + } + + if name != "" && connUUID != "" { + renameCmd := exec.Command("nmcli", "connection", "modify", connUUID, "connection.id", name) + if err := renameCmd.Run(); err != nil { + log.Warnf("Failed to rename imported VPN: %v", err) + } else { + connName = name + } + } + + if connUUID == "" { + s := b.settings + if s == nil { + var settingsErr error + s, settingsErr = gonetworkmanager.NewSettings() + if settingsErr == nil { + b.settings = s + } + } + + if s != nil { + settingsMgr := s.(gonetworkmanager.Settings) + connections, _ := settingsMgr.ListConnections() + baseName := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) + + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + connMeta, ok := settings["connection"] + if !ok { + continue + } + connType, _ := connMeta["type"].(string) + if connType != "vpn" && connType != "wireguard" { + continue + } + connID, _ := connMeta["id"].(string) + if strings.Contains(connID, baseName) || (name != "" && connID == name) { + connUUID, _ = connMeta["uuid"].(string) + connName = connID + break + } + } + } + } + + b.ListVPNProfiles() + + if b.onStateChange != nil { + b.onStateChange() + } + + return &VPNImportResult{ + Success: true, + UUID: connUUID, + Name: connName, + }, nil +} + +func (b *NetworkManagerBackend) GetVPNConfig(uuidOrName string) (*VPNConfig, error) { + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return nil, fmt.Errorf("failed to get connections: %w", err) + } + + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + + connMeta, ok := settings["connection"] + if !ok { + continue + } + + connType, _ := connMeta["type"].(string) + if connType != "vpn" && connType != "wireguard" { + continue + } + + connID, _ := connMeta["id"].(string) + connUUID, _ := connMeta["uuid"].(string) + + if connUUID != uuidOrName && connID != uuidOrName { + continue + } + + autoconnect := true + if ac, ok := connMeta["autoconnect"].(bool); ok { + autoconnect = ac + } + + config := &VPNConfig{ + UUID: connUUID, + Name: connID, + Type: connType, + Autoconnect: autoconnect, + Data: make(map[string]string), + } + + if connType == "vpn" { + if vpnSettings, ok := settings["vpn"]; ok { + if svcType, ok := vpnSettings["service-type"].(string); ok { + config.ServiceType = svcType + } + if dataMap, ok := vpnSettings["data"].(map[string]string); ok { + for k, v := range dataMap { + if !strings.Contains(strings.ToLower(k), "password") && + !strings.Contains(strings.ToLower(k), "secret") && + !strings.Contains(strings.ToLower(k), "key") { + config.Data[k] = v + } + } + } + } + } + + return config, nil + } + + return nil, fmt.Errorf("VPN connection not found: %s", uuidOrName) +} + +func (b *NetworkManagerBackend) UpdateVPNConfig(connUUID string, updates map[string]any) error { + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + + connMeta, ok := settings["connection"] + if !ok { + continue + } + + connType, _ := connMeta["type"].(string) + if connType != "vpn" && connType != "wireguard" { + continue + } + + existingUUID, _ := connMeta["uuid"].(string) + if existingUUID != connUUID { + continue + } + + if name, ok := updates["name"].(string); ok && name != "" { + connMeta["id"] = name + } + + if autoconnect, ok := updates["autoconnect"].(bool); ok { + connMeta["autoconnect"] = autoconnect + } + + if data, ok := updates["data"].(map[string]any); ok { + if vpnSettings, ok := settings["vpn"]; ok { + existingData, _ := vpnSettings["data"].(map[string]string) + if existingData == nil { + existingData = make(map[string]string) + } + for k, v := range data { + if strVal, ok := v.(string); ok { + existingData[k] = strVal + } + } + vpnSettings["data"] = existingData + } + } + + if ipv4, ok := settings["ipv4"]; ok { + delete(ipv4, "addresses") + delete(ipv4, "routes") + delete(ipv4, "dns") + } + if ipv6, ok := settings["ipv6"]; ok { + delete(ipv6, "addresses") + delete(ipv6, "routes") + delete(ipv6, "dns") + } + + if err := conn.Update(settings); err != nil { + return fmt.Errorf("failed to update connection: %w", err) + } + + b.ListVPNProfiles() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil + } + + return fmt.Errorf("VPN connection not found: %s", connUUID) +} + +func (b *NetworkManagerBackend) SetVPNCredentials(connUUID string, username string, password string, saveToKeyring bool) error { + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + + connMeta, ok := settings["connection"] + if !ok { + continue + } + + connType, _ := connMeta["type"].(string) + if connType != "vpn" && connType != "wireguard" { + continue + } + + existingUUID, _ := connMeta["uuid"].(string) + if existingUUID != connUUID { + continue + } + + vpnSettings, ok := settings["vpn"] + if !ok { + vpnSettings = make(map[string]any) + settings["vpn"] = vpnSettings + } + + existingData, _ := vpnSettings["data"].(map[string]string) + if existingData == nil { + existingData = make(map[string]string) + } + + if username != "" { + existingData["username"] = username + } + + if saveToKeyring { + existingData["password-flags"] = "0" + } else { + existingData["password-flags"] = "2" + } + + vpnSettings["data"] = existingData + + if password != "" { + secrets := make(map[string]string) + secrets["password"] = password + vpnSettings["secrets"] = secrets + } + + if ipv4, ok := settings["ipv4"]; ok { + delete(ipv4, "addresses") + delete(ipv4, "routes") + delete(ipv4, "dns") + } + if ipv6, ok := settings["ipv6"]; ok { + delete(ipv6, "addresses") + delete(ipv6, "routes") + delete(ipv6, "dns") + } + + if err := conn.Update(settings); err != nil { + return fmt.Errorf("failed to update connection: %w", err) + } + + log.Infof("Updated VPN credentials for %s (save=%v)", connUUID, saveToKeyring) + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil + } + + return fmt.Errorf("VPN connection not found: %s", connUUID) +} + +func (b *NetworkManagerBackend) DeleteVPN(uuidOrName string) error { + active, _ := b.ListActiveVPN() + for _, vpn := range active { + if vpn.UUID == uuidOrName || vpn.Name == uuidOrName { + if err := b.DisconnectVPN(uuidOrName); err != nil { + log.Warnf("Failed to disconnect VPN before deletion: %v", err) + } + time.Sleep(200 * time.Millisecond) + break + } + } + + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + + connMeta, ok := settings["connection"] + if !ok { + continue + } + + connType, _ := connMeta["type"].(string) + if connType != "vpn" && connType != "wireguard" { + continue + } + + connID, _ := connMeta["id"].(string) + connUUID, _ := connMeta["uuid"].(string) + + if connUUID == uuidOrName || connID == uuidOrName { + if err := conn.Delete(); err != nil { + return fmt.Errorf("failed to delete VPN: %w", err) + } + + b.ListVPNProfiles() + + if b.onStateChange != nil { + b.onStateChange() + } + + log.Infof("Deleted VPN connection: %s (%s)", connID, connUUID) + return nil + } + } + + return fmt.Errorf("VPN connection not found: %s", uuidOrName) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn_test.go new file mode 100644 index 0000000..eebd433 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn_test.go @@ -0,0 +1,138 @@ +package network + +import ( + "testing" + + mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2" + "github.com/Wifx/gonetworkmanager/v2" + "github.com/stretchr/testify/assert" +) + +func TestNetworkManagerBackend_ListVPNProfiles(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + backend.settings = mockSettings + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil) + + profiles, err := backend.ListVPNProfiles() + assert.NoError(t, err) + assert.Empty(t, profiles) +} + +func TestNetworkManagerBackend_ListActiveVPN(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil) + + active, err := backend.ListActiveVPN() + assert.NoError(t, err) + assert.Empty(t, active) +} + +func TestNetworkManagerBackend_ConnectVPN_NotFound(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + backend.settings = mockSettings + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil) + + err = backend.ConnectVPN("non-existent-vpn-12345", false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestNetworkManagerBackend_ConnectVPN_SingleActive_NoActiveVPN(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + backend.settings = mockSettings + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil) + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil) + + err = backend.ConnectVPN("non-existent-vpn-12345", true) + assert.Error(t, err) +} + +func TestNetworkManagerBackend_DisconnectVPN_NotActive(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil) + + err = backend.DisconnectVPN("non-existent-vpn-12345") + assert.Error(t, err) +} + +func TestNetworkManagerBackend_DisconnectAllVPN(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil) + + err = backend.DisconnectAllVPN() + assert.NoError(t, err) +} + +func TestNetworkManagerBackend_ClearVPNCredentials_NotFound(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + backend.settings = mockSettings + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil) + + err = backend.ClearVPNCredentials("non-existent-vpn-12345") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestNetworkManagerBackend_UpdateVPNConnectionState_NotConnecting(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.stateMutex.Lock() + backend.state.IsConnectingVPN = false + backend.state.ConnectingVPNUUID = "" + backend.stateMutex.Unlock() + + assert.NotPanics(t, func() { + backend.updateVPNConnectionState() + }) +} + +func TestNetworkManagerBackend_UpdateVPNConnectionState_EmptyUUID(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.stateMutex.Lock() + backend.state.IsConnectingVPN = true + backend.state.ConnectingVPNUUID = "" + backend.stateMutex.Unlock() + + assert.NotPanics(t, func() { + backend.updateVPNConnectionState() + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi.go new file mode 100644 index 0000000..6f593b1 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi.go @@ -0,0 +1,1173 @@ +package network + +import ( + "bytes" + "fmt" + "sort" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/Wifx/gonetworkmanager/v2" +) + +func (b *NetworkManagerBackend) GetWiFiEnabled() (bool, error) { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + return nm.GetPropertyWirelessEnabled() +} + +func (b *NetworkManagerBackend) SetWiFiEnabled(enabled bool) error { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + err := nm.SetPropertyWirelessEnabled(enabled) + if err != nil { + return fmt.Errorf("failed to set WiFi enabled: %w", err) + } + + b.stateMutex.Lock() + b.state.WiFiEnabled = enabled + b.stateMutex.Unlock() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) ScanWiFi() error { + if b.wifiDevice == nil { + return fmt.Errorf("no WiFi device available") + } + + b.stateMutex.RLock() + enabled := b.state.WiFiEnabled + b.stateMutex.RUnlock() + + if !enabled { + return fmt.Errorf("WiFi is disabled") + } + + if err := b.ensureWiFiDevice(); err != nil { + return err + } + + w := b.wifiDev.(gonetworkmanager.DeviceWireless) + err := w.RequestScan() + if err != nil { + return fmt.Errorf("scan request failed: %w", err) + } + + _, err = b.updateWiFiNetworks() + return err +} + +func (b *NetworkManagerBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) { + if b.wifiDevice == nil { + return nil, fmt.Errorf("no WiFi device available") + } + + if err := b.ensureWiFiDevice(); err != nil { + return nil, err + } + wifiDev := b.wifiDev + + w := wifiDev.(gonetworkmanager.DeviceWireless) + apPaths, err := w.GetAccessPoints() + if err != nil { + return nil, fmt.Errorf("failed to get access points: %w", err) + } + + s := b.settings + if s == nil { + s, err = gonetworkmanager.NewSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return nil, fmt.Errorf("failed to get connections: %w", err) + } + + savedSSIDs := make(map[string]bool) + autoconnectMap := make(map[string]bool) + for _, conn := range connections { + connSettings, err := conn.GetSettings() + if err != nil { + continue + } + + if connMeta, ok := connSettings["connection"]; ok { + if connType, ok := connMeta["type"].(string); ok && connType == "802-11-wireless" { + if wifiSettings, ok := connSettings["802-11-wireless"]; ok { + if ssidBytes, ok := wifiSettings["ssid"].([]byte); ok { + savedSSID := string(ssidBytes) + savedSSIDs[savedSSID] = true + autoconnect := true + if ac, ok := connMeta["autoconnect"].(bool); ok { + autoconnect = ac + } + autoconnectMap[savedSSID] = autoconnect + } + } + } + } + } + + b.stateMutex.RLock() + currentSSID := b.state.WiFiSSID + currentBSSID := b.state.WiFiBSSID + b.stateMutex.RUnlock() + + var bands []WiFiNetwork + + for _, ap := range apPaths { + apSSID, err := ap.GetPropertySSID() + if err != nil || apSSID != ssid { + continue + } + + strength, _ := ap.GetPropertyStrength() + flags, _ := ap.GetPropertyFlags() + wpaFlags, _ := ap.GetPropertyWPAFlags() + rsnFlags, _ := ap.GetPropertyRSNFlags() + freq, _ := ap.GetPropertyFrequency() + maxBitrate, _ := ap.GetPropertyMaxBitrate() + bssid, _ := ap.GetPropertyHWAddress() + mode, _ := ap.GetPropertyMode() + + secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) || + wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) || + rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone) + + enterprise := (rsnFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0) || + (wpaFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0) + + var modeStr string + switch mode { + case gonetworkmanager.Nm80211ModeAdhoc: + modeStr = "adhoc" + case gonetworkmanager.Nm80211ModeInfra: + modeStr = "infrastructure" + case gonetworkmanager.Nm80211ModeAp: + modeStr = "ap" + default: + modeStr = "unknown" + } + + channel := frequencyToChannel(freq) + + isConnected := ssid == currentSSID && bssid == currentBSSID + rate := maxBitrate / 1000 + if isConnected { + if devBitrate, err := w.GetPropertyBitrate(); err == nil && devBitrate > 0 { + rate = devBitrate / 1000 + } + } + + network := WiFiNetwork{ + SSID: ssid, + BSSID: bssid, + Signal: strength, + Secured: secured, + Enterprise: enterprise, + Connected: isConnected, + Saved: savedSSIDs[ssid], + Autoconnect: autoconnectMap[ssid], + Frequency: freq, + Mode: modeStr, + Rate: rate, + Channel: channel, + } + + bands = append(bands, network) + } + + if len(bands) == 0 { + return nil, fmt.Errorf("network not found: %s", ssid) + } + + sort.Slice(bands, func(i, j int) bool { + if bands[i].Connected && !bands[j].Connected { + return true + } + if !bands[i].Connected && bands[j].Connected { + return false + } + return bands[i].Signal > bands[j].Signal + }) + + return &NetworkInfoResponse{ + SSID: ssid, + Bands: bands, + }, nil +} + +func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error) { + conn, err := b.findConnection(ssid) + if err != nil { + return "", fmt.Errorf("no saved connection for `%s`: %w", ssid, err) + } + + connSettings, err := conn.GetSettings() + if err != nil { + return "", fmt.Errorf("failed to get settings for `%s`: %w", ssid, err) + } + + secSettings, ok := connSettings["802-11-wireless-security"] + if !ok { + return "", fmt.Errorf("network `%s` has no security settings", ssid) + } + + keyMgmt, ok := secSettings["key-mgmt"].(string) + if !ok { + return "", fmt.Errorf("failed to identify security type of network `%s`", ssid) + } + + var securityType string + switch keyMgmt { + case "none": + authAlg, _ := secSettings["auth-alg"].(string) + switch authAlg { + case "open": + securityType = "nopass" + default: + securityType = "WEP" + } + case "ieee8021x": + securityType = "WEP" + default: + securityType = "WPA" + } + + if securityType != "WPA" { + return "", fmt.Errorf("QR code generation only supports WPA connections, `%s` uses %s", ssid, securityType) + } + + secrets, err := conn.GetSecrets("802-11-wireless-security") + if err != nil { + return "", fmt.Errorf("failed to retrieve connection secrets for `%s`: %w", ssid, err) + } + + secSecrets, ok := secrets["802-11-wireless-security"] + if !ok { + return "", fmt.Errorf("failed to retrieve password for `%s`", ssid) + } + + psk, ok := secSecrets["psk"].(string) + if !ok { + return "", fmt.Errorf("failed to retrieve password for `%s`", ssid) + } + + return FormatWiFiQRString(securityType, ssid, psk), nil +} + +func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error { + devInfo, err := b.getWifiDeviceForConnection(req.Device) + if err != nil { + return err + } + + b.stateMutex.RLock() + alreadyConnected := b.state.WiFiConnected && b.state.WiFiSSID == req.SSID + b.stateMutex.RUnlock() + + if alreadyConnected && !req.Interactive && req.Device == "" { + return nil + } + + b.stateMutex.Lock() + b.state.IsConnecting = true + b.state.ConnectingSSID = req.SSID + b.state.ConnectingDevice = req.Device + b.state.LastError = "" + b.stateMutex.Unlock() + + if b.onStateChange != nil { + b.onStateChange() + } + + nm := b.nmConn.(gonetworkmanager.NetworkManager) + + existingConn, err := b.findConnection(req.SSID) + if err == nil && existingConn != nil { + _, err := nm.ActivateConnection(existingConn, devInfo.device, nil) + if err != nil { + log.Warnf("[ConnectWiFi] Failed to activate existing connection: %v", err) + b.stateMutex.Lock() + b.state.IsConnecting = false + b.state.ConnectingSSID = "" + b.state.ConnectingDevice = "" + b.state.LastError = fmt.Sprintf("failed to activate connection: %v", err) + b.stateMutex.Unlock() + if b.onStateChange != nil { + b.onStateChange() + } + return fmt.Errorf("failed to activate connection: %w", err) + } + + return nil + } + + if err := b.createAndConnectWiFiOnDevice(req, devInfo); err != nil { + log.Warnf("[ConnectWiFi] Failed to create and connect: %v", err) + b.stateMutex.Lock() + b.state.IsConnecting = false + b.state.ConnectingSSID = "" + b.state.ConnectingDevice = "" + b.state.LastError = err.Error() + b.stateMutex.Unlock() + if b.onStateChange != nil { + b.onStateChange() + } + return err + } + + return nil +} + +func (b *NetworkManagerBackend) DisconnectWiFi() error { + if b.wifiDevice == nil { + return fmt.Errorf("no WiFi device available") + } + + dev := b.wifiDevice.(gonetworkmanager.Device) + + err := dev.Disconnect() + if err != nil { + return fmt.Errorf("failed to disconnect: %w", err) + } + + b.updateWiFiState() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error { + conn, err := b.findConnection(ssid) + if err != nil { + return fmt.Errorf("connection not found: %w", err) + } + + b.stateMutex.RLock() + currentSSID := b.state.WiFiSSID + isConnected := b.state.WiFiConnected + b.stateMutex.RUnlock() + + err = conn.Delete() + if err != nil { + return fmt.Errorf("failed to delete connection: %w", err) + } + + if isConnected && currentSSID == ssid { + b.stateMutex.Lock() + b.state.WiFiConnected = false + b.state.WiFiSSID = "" + b.state.WiFiBSSID = "" + b.state.WiFiSignal = 0 + b.state.WiFiIP = "" + b.state.NetworkStatus = StatusDisconnected + b.stateMutex.Unlock() + } + + b.updateWiFiNetworks() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) IsConnectingTo(ssid string) bool { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + return b.state.IsConnecting && b.state.ConnectingSSID == ssid +} + +func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) { + if b.wifiDevice == nil { + return nil, fmt.Errorf("no WiFi device available") + } + + if err := b.ensureWiFiDevice(); err != nil { + return nil, err + } + wifiDev := b.wifiDev + + w := wifiDev.(gonetworkmanager.DeviceWireless) + apPaths, err := w.GetAccessPoints() + if err != nil { + return nil, fmt.Errorf("failed to get access points: %w", err) + } + + s := b.settings + if s == nil { + s, err = gonetworkmanager.NewSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return nil, fmt.Errorf("failed to get connections: %w", err) + } + + savedSSIDs := make(map[string]bool) + autoconnectMap := make(map[string]bool) + hiddenSSIDs := make(map[string]bool) + for _, conn := range connections { + connSettings, err := conn.GetSettings() + if err != nil { + continue + } + + connMeta, ok := connSettings["connection"] + if !ok { + continue + } + + connType, ok := connMeta["type"].(string) + if !ok || connType != "802-11-wireless" { + continue + } + + wifiSettings, ok := connSettings["802-11-wireless"] + if !ok { + continue + } + + ssidBytes, ok := wifiSettings["ssid"].([]byte) + if !ok { + continue + } + + ssid := string(ssidBytes) + savedSSIDs[ssid] = true + autoconnect := true + if ac, ok := connMeta["autoconnect"].(bool); ok { + autoconnect = ac + } + autoconnectMap[ssid] = autoconnect + + if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden { + hiddenSSIDs[ssid] = true + } + } + + b.stateMutex.RLock() + currentSSID := b.state.WiFiSSID + wifiConnected := b.state.WiFiConnected + wifiSignal := b.state.WiFiSignal + wifiBSSID := b.state.WiFiBSSID + b.stateMutex.RUnlock() + + seenSSIDs := make(map[string]*WiFiNetwork) + networks := []WiFiNetwork{} + + for _, ap := range apPaths { + ssid, err := ap.GetPropertySSID() + if err != nil || ssid == "" { + continue + } + + if existing, exists := seenSSIDs[ssid]; exists { + strength, _ := ap.GetPropertyStrength() + if strength > existing.Signal { + existing.Signal = strength + freq, _ := ap.GetPropertyFrequency() + existing.Frequency = freq + bssid, _ := ap.GetPropertyHWAddress() + existing.BSSID = bssid + } + continue + } + + strength, _ := ap.GetPropertyStrength() + flags, _ := ap.GetPropertyFlags() + wpaFlags, _ := ap.GetPropertyWPAFlags() + rsnFlags, _ := ap.GetPropertyRSNFlags() + freq, _ := ap.GetPropertyFrequency() + maxBitrate, _ := ap.GetPropertyMaxBitrate() + bssid, _ := ap.GetPropertyHWAddress() + mode, _ := ap.GetPropertyMode() + + secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) || + wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) || + rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone) + + enterprise := (rsnFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0) || + (wpaFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0) + + var modeStr string + switch mode { + case gonetworkmanager.Nm80211ModeAdhoc: + modeStr = "adhoc" + case gonetworkmanager.Nm80211ModeInfra: + modeStr = "infrastructure" + case gonetworkmanager.Nm80211ModeAp: + modeStr = "ap" + default: + modeStr = "unknown" + } + + channel := frequencyToChannel(freq) + + isConnected := ssid == currentSSID + rate := maxBitrate / 1000 + if isConnected { + if devBitrate, err := w.GetPropertyBitrate(); err == nil && devBitrate > 0 { + rate = devBitrate / 1000 + } + } + + network := WiFiNetwork{ + SSID: ssid, + BSSID: bssid, + Signal: strength, + Secured: secured, + Enterprise: enterprise, + Connected: isConnected, + Saved: savedSSIDs[ssid], + Autoconnect: autoconnectMap[ssid], + Hidden: hiddenSSIDs[ssid], + Frequency: freq, + Mode: modeStr, + Rate: rate, + Channel: channel, + } + + seenSSIDs[ssid] = &network + networks = append(networks, network) + } + + if wifiConnected && currentSSID != "" { + if _, exists := seenSSIDs[currentSSID]; !exists { + hiddenNetwork := WiFiNetwork{ + SSID: currentSSID, + BSSID: wifiBSSID, + Signal: wifiSignal, + Secured: true, + Connected: true, + Saved: savedSSIDs[currentSSID], + Autoconnect: autoconnectMap[currentSSID], + Hidden: true, + Mode: "infrastructure", + } + networks = append(networks, hiddenNetwork) + } + } + + sortWiFiNetworks(networks) + + b.stateMutex.Lock() + b.state.WiFiNetworks = networks + b.stateMutex.Unlock() + + return networks, nil +} + +func (b *NetworkManagerBackend) findConnection(ssid string) (gonetworkmanager.Connection, error) { + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return nil, err + } + b.settings = s + } + + settings := s.(gonetworkmanager.Settings) + connections, err := settings.ListConnections() + if err != nil { + return nil, err + } + + ssidBytes := []byte(ssid) + for _, conn := range connections { + connSettings, err := conn.GetSettings() + if err != nil { + continue + } + + if connMeta, ok := connSettings["connection"]; ok { + if connType, ok := connMeta["type"].(string); ok && connType == "802-11-wireless" { + if wifiSettings, ok := connSettings["802-11-wireless"]; ok { + if candidateSSID, ok := wifiSettings["ssid"].([]byte); ok { + if bytes.Equal(candidateSSID, ssidBytes) { + return conn, nil + } + } + } + } + } + } + + return nil, fmt.Errorf("connection not found") +} + +func (b *NetworkManagerBackend) createAndConnectWiFi(req ConnectionRequest) error { + devInfo, err := b.getWifiDeviceForConnection(req.Device) + if err != nil { + return err + } + return b.createAndConnectWiFiOnDevice(req, devInfo) +} + +func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionRequest, devInfo *wifiDeviceInfo) error { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + dev := devInfo.device + w := devInfo.wireless + + var targetAP gonetworkmanager.AccessPoint + var flags, wpaFlags, rsnFlags uint32 + + if !req.Hidden { + apPaths, err := w.GetAccessPoints() + if err != nil { + return fmt.Errorf("failed to get access points: %w", err) + } + + for _, ap := range apPaths { + ssid, err := ap.GetPropertySSID() + if err != nil || ssid != req.SSID { + continue + } + targetAP = ap + break + } + + if targetAP == nil { + return fmt.Errorf("access point not found: %s", req.SSID) + } + + flags, _ = targetAP.GetPropertyFlags() + wpaFlags, _ = targetAP.GetPropertyWPAFlags() + rsnFlags, _ = targetAP.GetPropertyRSNFlags() + } + + const KeyMgmt8021x = uint32(512) + const KeyMgmtPsk = uint32(256) + const KeyMgmtSae = uint32(1024) + + var isEnterprise, isPsk, isSae, secured bool + + switch { + case req.Hidden: + secured = req.Password != "" || req.Username != "" + isEnterprise = req.Username != "" + isPsk = req.Password != "" && !isEnterprise + default: + isEnterprise = (wpaFlags&KeyMgmt8021x) != 0 || (rsnFlags&KeyMgmt8021x) != 0 + isPsk = (wpaFlags&KeyMgmtPsk) != 0 || (rsnFlags&KeyMgmtPsk) != 0 + isSae = (wpaFlags&KeyMgmtSae) != 0 || (rsnFlags&KeyMgmtSae) != 0 + secured = flags != uint32(gonetworkmanager.Nm80211APFlagsNone) || + wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) || + rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone) + } + + if isEnterprise { + log.Infof("[createAndConnectWiFi] Enterprise network detected (802.1x) - SSID: %s, interactive: %v", + req.SSID, req.Interactive) + } + + settings := make(map[string]map[string]any) + + settings["connection"] = map[string]any{ + "id": req.SSID, + "type": "802-11-wireless", + "autoconnect": true, + } + + settings["ipv4"] = map[string]any{"method": "auto"} + settings["ipv6"] = map[string]any{"method": "auto"} + + if secured { + wifiSettings := map[string]any{ + "ssid": []byte(req.SSID), + "mode": "infrastructure", + "security": "802-11-wireless-security", + } + if req.Hidden { + wifiSettings["hidden"] = true + } + settings["802-11-wireless"] = wifiSettings + + switch { + case isEnterprise || req.Username != "": + settings["802-11-wireless-security"] = map[string]any{ + "key-mgmt": "wpa-eap", + } + + eapMethod := "peap" + if req.EAPMethod != "" { + eapMethod = req.EAPMethod + } + + phase2Auth := "mschapv2" + if req.Phase2Auth != "" { + phase2Auth = req.Phase2Auth + } + + useSystemCACerts := false + if req.UseSystemCACerts != nil { + useSystemCACerts = *req.UseSystemCACerts + } + + x := map[string]any{ + "eap": []string{eapMethod}, + "system-ca-certs": useSystemCACerts, + "password-flags": uint32(0), + } + + switch eapMethod { + case "peap", "ttls": + x["phase2-auth"] = phase2Auth + case "tls": + if req.ClientCertPath != "" { + x["client-cert"] = []byte("file://" + req.ClientCertPath) + } + if req.PrivateKeyPath != "" { + x["private-key"] = []byte("file://" + req.PrivateKeyPath) + } + } + + if req.Username != "" { + x["identity"] = req.Username + } + if req.Password != "" { + x["password"] = req.Password + } + if req.AnonymousIdentity != "" { + x["anonymous-identity"] = req.AnonymousIdentity + } + if req.DomainSuffixMatch != "" { + x["domain-suffix-match"] = req.DomainSuffixMatch + } + if req.CACertPath != "" { + x["ca-cert"] = []byte("file://" + req.CACertPath) + } + + settings["802-1x"] = x + + log.Infof("[createAndConnectWiFi] WPA-EAP settings: eap=%s, phase2-auth=%s, identity=%s, interactive=%v, system-ca-certs=%v, domain-suffix-match=%q", + eapMethod, phase2Auth, req.Username, req.Interactive, useSystemCACerts, req.DomainSuffixMatch) + + case isPsk: + sec := map[string]any{ + "key-mgmt": "wpa-psk", + "psk-flags": uint32(0), + } + if !req.Interactive { + sec["psk"] = req.Password + } + settings["802-11-wireless-security"] = sec + + case isSae: + sec := map[string]any{ + "key-mgmt": "sae", + "pmf": int32(3), + "psk-flags": uint32(0), + } + if !req.Interactive { + sec["psk"] = req.Password + } + settings["802-11-wireless-security"] = sec + + default: + return fmt.Errorf("secured network but not SAE/PSK/802.1X (rsn=0x%x wpa=0x%x)", rsnFlags, wpaFlags) + } + } else { + wifiSettings := map[string]any{ + "ssid": []byte(req.SSID), + "mode": "infrastructure", + } + if req.Hidden { + wifiSettings["hidden"] = true + } + settings["802-11-wireless"] = wifiSettings + } + + if req.Interactive { + s := b.settings + if s == nil { + var settingsErr error + s, settingsErr = gonetworkmanager.NewSettings() + if settingsErr != nil { + return fmt.Errorf("failed to get settings manager: %w", settingsErr) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + conn, err := settingsMgr.AddConnection(settings) + if err != nil { + return fmt.Errorf("failed to add connection: %w", err) + } + + if isEnterprise { + log.Infof("[createAndConnectWiFi] Enterprise connection added, activating (secret agent will be called)") + } + + if req.Hidden { + _, err = nm.ActivateConnection(conn, dev, nil) + } else { + _, err = nm.ActivateWirelessConnection(conn, dev, targetAP) + } + if err != nil { + return fmt.Errorf("failed to activate connection: %w", err) + } + + log.Infof("[createAndConnectWiFi] Connection activation initiated, waiting for NetworkManager state changes...") + } else { + var err error + if req.Hidden { + _, err = nm.AddAndActivateConnection(settings, dev) + } else { + _, err = nm.AddAndActivateWirelessConnection(settings, dev, targetAP) + } + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + log.Infof("[createAndConnectWiFi] Connection activation initiated, waiting for NetworkManager state changes...") + } + + return nil +} + +func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error { + conn, err := b.findConnection(ssid) + if err != nil { + return fmt.Errorf("connection not found: %w", err) + } + + settings, err := conn.GetSettings() + if err != nil { + return fmt.Errorf("failed to get connection settings: %w", err) + } + + if connMeta, ok := settings["connection"]; ok { + connMeta["autoconnect"] = autoconnect + } else { + return fmt.Errorf("connection metadata not found") + } + + if ipv4, ok := settings["ipv4"]; ok { + delete(ipv4, "addresses") + delete(ipv4, "routes") + delete(ipv4, "dns") + } + + if ipv6, ok := settings["ipv6"]; ok { + delete(ipv6, "addresses") + delete(ipv6, "routes") + delete(ipv6, "dns") + } + + err = conn.Update(settings) + if err != nil { + return fmt.Errorf("failed to update connection: %w", err) + } + + b.updateWiFiNetworks() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error { + devInfo, ok := b.wifiDevices[device] + if !ok { + return fmt.Errorf("WiFi device not found: %s", device) + } + + b.stateMutex.RLock() + enabled := b.state.WiFiEnabled + b.stateMutex.RUnlock() + + if !enabled { + return fmt.Errorf("WiFi is disabled") + } + + if err := devInfo.wireless.RequestScan(); err != nil { + return fmt.Errorf("scan request failed: %w", err) + } + + b.updateAllWiFiDevices() + return nil +} + +func (b *NetworkManagerBackend) DisconnectWiFiDevice(device string) error { + devInfo, ok := b.wifiDevices[device] + if !ok { + return fmt.Errorf("WiFi device not found: %s", device) + } + + if err := devInfo.device.Disconnect(); err != nil { + return fmt.Errorf("failed to disconnect: %w", err) + } + + b.updateWiFiState() + b.updateAllWiFiDevices() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) GetWiFiDevices() []WiFiDevice { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + return append([]WiFiDevice(nil), b.state.WiFiDevices...) +} + +func (b *NetworkManagerBackend) updateAllWiFiDevices() { + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return + } + + savedSSIDs := make(map[string]bool) + autoconnectMap := make(map[string]bool) + hiddenSSIDs := make(map[string]bool) + for _, conn := range connections { + connSettings, err := conn.GetSettings() + if err != nil { + continue + } + + connMeta, ok := connSettings["connection"] + if !ok { + continue + } + + connType, ok := connMeta["type"].(string) + if !ok || connType != "802-11-wireless" { + continue + } + + wifiSettings, ok := connSettings["802-11-wireless"] + if !ok { + continue + } + + ssidBytes, ok := wifiSettings["ssid"].([]byte) + if !ok { + continue + } + + ssid := string(ssidBytes) + savedSSIDs[ssid] = true + autoconnect := true + if ac, ok := connMeta["autoconnect"].(bool); ok { + autoconnect = ac + } + autoconnectMap[ssid] = autoconnect + + if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden { + hiddenSSIDs[ssid] = true + } + } + + var devices []WiFiDevice + + for name, devInfo := range b.wifiDevices { + state, _ := devInfo.device.GetPropertyState() + connected := state == gonetworkmanager.NmDeviceStateActivated + + var ssid, bssid, ip string + var signal uint8 + + if connected { + if activeAP, err := devInfo.wireless.GetPropertyActiveAccessPoint(); err == nil && activeAP != nil && activeAP.GetPath() != "/" { + ssid, _ = activeAP.GetPropertySSID() + signal, _ = activeAP.GetPropertyStrength() + bssid, _ = activeAP.GetPropertyHWAddress() + } + ip = b.getDeviceIP(devInfo.device) + } + + stateStr := "disconnected" + switch state { + case gonetworkmanager.NmDeviceStateActivated: + stateStr = "connected" + case gonetworkmanager.NmDeviceStateConfig, gonetworkmanager.NmDeviceStateIpConfig: + stateStr = "connecting" + case gonetworkmanager.NmDeviceStatePrepare: + stateStr = "preparing" + case gonetworkmanager.NmDeviceStateDeactivating: + stateStr = "disconnecting" + } + + apPaths, err := devInfo.wireless.GetAccessPoints() + var networks []WiFiNetwork + if err == nil { + seenSSIDs := make(map[string]*WiFiNetwork) + for _, ap := range apPaths { + apSSID, err := ap.GetPropertySSID() + if err != nil || apSSID == "" { + continue + } + + if existing, exists := seenSSIDs[apSSID]; exists { + strength, _ := ap.GetPropertyStrength() + if strength > existing.Signal { + existing.Signal = strength + freq, _ := ap.GetPropertyFrequency() + existing.Frequency = freq + apBSSID, _ := ap.GetPropertyHWAddress() + existing.BSSID = apBSSID + } + continue + } + + strength, _ := ap.GetPropertyStrength() + flags, _ := ap.GetPropertyFlags() + wpaFlags, _ := ap.GetPropertyWPAFlags() + rsnFlags, _ := ap.GetPropertyRSNFlags() + freq, _ := ap.GetPropertyFrequency() + maxBitrate, _ := ap.GetPropertyMaxBitrate() + apBSSID, _ := ap.GetPropertyHWAddress() + mode, _ := ap.GetPropertyMode() + + secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) || + wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) || + rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone) + + enterprise := (rsnFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0) || + (wpaFlags&uint32(gonetworkmanager.Nm80211APSecKeyMgmt8021X) != 0) + + var modeStr string + switch mode { + case gonetworkmanager.Nm80211ModeAdhoc: + modeStr = "adhoc" + case gonetworkmanager.Nm80211ModeInfra: + modeStr = "infrastructure" + case gonetworkmanager.Nm80211ModeAp: + modeStr = "ap" + default: + modeStr = "unknown" + } + + channel := frequencyToChannel(freq) + + isConnected := connected && apSSID == ssid + rate := maxBitrate / 1000 + if isConnected { + if devBitrate, err := devInfo.wireless.GetPropertyBitrate(); err == nil && devBitrate > 0 { + rate = devBitrate / 1000 + } + } + + network := WiFiNetwork{ + SSID: apSSID, + BSSID: apBSSID, + Signal: strength, + Secured: secured, + Enterprise: enterprise, + Connected: isConnected, + Saved: savedSSIDs[apSSID], + Autoconnect: autoconnectMap[apSSID], + Hidden: hiddenSSIDs[apSSID], + Frequency: freq, + Mode: modeStr, + Rate: rate, + Channel: channel, + Device: name, + } + + seenSSIDs[apSSID] = &network + networks = append(networks, network) + } + + if connected && ssid != "" { + if _, exists := seenSSIDs[ssid]; !exists { + hiddenNetwork := WiFiNetwork{ + SSID: ssid, + BSSID: bssid, + Signal: signal, + Secured: true, + Connected: true, + Saved: savedSSIDs[ssid], + Autoconnect: autoconnectMap[ssid], + Hidden: true, + Mode: "infrastructure", + Device: name, + } + networks = append(networks, hiddenNetwork) + } + } + + sortWiFiNetworks(networks) + } + + devices = append(devices, WiFiDevice{ + Name: name, + HwAddress: devInfo.hwAddress, + State: stateStr, + Connected: connected, + SSID: ssid, + BSSID: bssid, + Signal: signal, + IP: ip, + Networks: networks, + }) + } + + sort.Slice(devices, func(i, j int) bool { + return devices[i].Name < devices[j].Name + }) + + b.stateMutex.Lock() + b.state.WiFiDevices = devices + b.stateMutex.Unlock() +} + +func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (*wifiDeviceInfo, error) { + if deviceName != "" { + devInfo, ok := b.wifiDevices[deviceName] + if !ok { + return nil, fmt.Errorf("WiFi device not found: %s", deviceName) + } + return devInfo, nil + } + + if b.wifiDevice == nil { + return nil, fmt.Errorf("no WiFi device available") + } + + dev := b.wifiDevice.(gonetworkmanager.Device) + iface, _ := dev.GetPropertyInterface() + if devInfo, ok := b.wifiDevices[iface]; ok { + return devInfo, nil + } + + return nil, fmt.Errorf("no WiFi device available") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi_test.go new file mode 100644 index 0000000..79c2c5b --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi_test.go @@ -0,0 +1,202 @@ +package network + +import ( + "testing" + + mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2" + "github.com/stretchr/testify/assert" +) + +func TestNetworkManagerBackend_GetWiFiEnabled(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyWirelessEnabled().Return(true, nil) + + enabled, err := backend.GetWiFiEnabled() + assert.NoError(t, err) + assert.True(t, enabled) +} + +func TestNetworkManagerBackend_SetWiFiEnabled(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().SetPropertyWirelessEnabled(true).Return(nil) + + err = backend.SetWiFiEnabled(true) + assert.NoError(t, err) + + backend.stateMutex.RLock() + assert.True(t, backend.state.WiFiEnabled) + backend.stateMutex.RUnlock() +} + +func TestNetworkManagerBackend_ScanWiFi_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.wifiDevice = nil + err = backend.ScanWiFi() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no WiFi device available") +} + +func TestNetworkManagerBackend_ScanWiFi_Disabled(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockDeviceWireless := mock_gonetworkmanager.NewMockDeviceWireless(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.wifiDevice = mockDeviceWireless + backend.wifiDev = mockDeviceWireless + + backend.stateMutex.Lock() + backend.state.WiFiEnabled = false + backend.stateMutex.Unlock() + + err = backend.ScanWiFi() + assert.Error(t, err) + assert.Contains(t, err.Error(), "WiFi is disabled") +} + +func TestNetworkManagerBackend_GetWiFiNetworkDetails_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.wifiDevice = nil + _, err = backend.GetWiFiNetworkDetails("TestNetwork") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no WiFi device available") +} + +func TestNetworkManagerBackend_ConnectWiFi_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.wifiDevice = nil + req := ConnectionRequest{SSID: "TestNetwork", Password: "password"} + err = backend.ConnectWiFi(req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no WiFi device available") +} + +func TestNetworkManagerBackend_ConnectWiFi_AlreadyConnected(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockDeviceWireless := mock_gonetworkmanager.NewMockDeviceWireless(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.wifiDevice = mockDeviceWireless + backend.wifiDev = mockDeviceWireless + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": { + device: nil, + wireless: mockDeviceWireless, + name: "wlan0", + hwAddress: "00:11:22:33:44:55", + }, + } + + mockDeviceWireless.EXPECT().GetPropertyInterface().Return("wlan0", nil) + + backend.stateMutex.Lock() + backend.state.WiFiConnected = true + backend.state.WiFiSSID = "TestNetwork" + backend.state.WiFiDevice = "wlan0" + backend.stateMutex.Unlock() + + req := ConnectionRequest{SSID: "TestNetwork", Password: "password"} + err = backend.ConnectWiFi(req) + assert.NoError(t, err) +} + +func TestNetworkManagerBackend_DisconnectWiFi_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.wifiDevice = nil + err = backend.DisconnectWiFi() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no WiFi device available") +} + +func TestNetworkManagerBackend_IsConnectingTo(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.stateMutex.Lock() + backend.state.IsConnecting = true + backend.state.ConnectingSSID = "TestNetwork" + backend.stateMutex.Unlock() + + assert.True(t, backend.IsConnectingTo("TestNetwork")) + assert.False(t, backend.IsConnectingTo("OtherNetwork")) +} + +func TestNetworkManagerBackend_IsConnectingTo_NotConnecting(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.stateMutex.Lock() + backend.state.IsConnecting = false + backend.state.ConnectingSSID = "" + backend.stateMutex.Unlock() + + assert.False(t, backend.IsConnectingTo("TestNetwork")) +} + +func TestNetworkManagerBackend_UpdateWiFiNetworks_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.wifiDevice = nil + _, err = backend.updateWiFiNetworks() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no WiFi device available") +} + +func TestNetworkManagerBackend_FindConnection_NoSettings(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.settings = nil + _, err = backend.findConnection("NonExistentNetwork") + assert.Error(t, err) +} + +func TestNetworkManagerBackend_CreateAndConnectWiFi_NoDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.wifiDevice = nil + backend.wifiDev = nil + req := ConnectionRequest{SSID: "TestNetwork", Password: "password"} + err = backend.createAndConnectWiFi(req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no WiFi device available") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/broker.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/broker.go new file mode 100644 index 0000000..5290fb5 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/broker.go @@ -0,0 +1,22 @@ +package network + +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 + Cancel(path string, setting string) 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/network/connection_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/connection_test.go new file mode 100644 index 0000000..7b37731 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/connection_test.go @@ -0,0 +1,109 @@ +package network_test + +import ( + "errors" + "testing" + + mocks_network "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/network" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" + "github.com/stretchr/testify/assert" +) + +func TestConnectionRequest_Validation(t *testing.T) { + t.Run("basic WiFi connection", func(t *testing.T) { + req := network.ConnectionRequest{ + SSID: "TestNetwork", + Password: "testpass123", + } + + assert.NotEmpty(t, req.SSID) + assert.NotEmpty(t, req.Password) + assert.Empty(t, req.Username) + }) + + t.Run("enterprise WiFi connection", func(t *testing.T) { + req := network.ConnectionRequest{ + SSID: "EnterpriseNetwork", + Password: "testpass123", + Username: "testuser", + } + + assert.NotEmpty(t, req.SSID) + assert.NotEmpty(t, req.Password) + assert.NotEmpty(t, req.Username) + }) + + t.Run("open WiFi connection", func(t *testing.T) { + req := network.ConnectionRequest{ + SSID: "OpenNetwork", + } + + assert.NotEmpty(t, req.SSID) + assert.Empty(t, req.Password) + assert.Empty(t, req.Username) + }) +} + +func TestManager_ConnectWiFi_NoDevice(t *testing.T) { + backend := mocks_network.NewMockBackend(t) + req := network.ConnectionRequest{ + SSID: "TestNetwork", + Password: "testpass123", + } + backend.EXPECT().ConnectWiFi(req).Return(errors.New("no WiFi device available")) + + manager := network.NewTestManager(backend, &network.NetworkState{}) + + err := manager.ConnectWiFi(req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no WiFi device available") +} + +func TestManager_DisconnectWiFi_NoDevice(t *testing.T) { + backend := mocks_network.NewMockBackend(t) + backend.EXPECT().DisconnectWiFi().Return(errors.New("no WiFi device available")) + + manager := network.NewTestManager(backend, &network.NetworkState{}) + + err := manager.DisconnectWiFi() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no WiFi device available") +} + +func TestManager_ForgetWiFiNetwork_NotFound(t *testing.T) { + backend := mocks_network.NewMockBackend(t) + backend.EXPECT().ForgetWiFiNetwork("NonExistentNetwork").Return(errors.New("connection not found")) + + manager := network.NewTestManager(backend, &network.NetworkState{}) + + err := manager.ForgetWiFiNetwork("NonExistentNetwork") + assert.Error(t, err) + assert.Contains(t, err.Error(), "connection not found") +} + +func TestManager_ConnectEthernet_NoDevice(t *testing.T) { + backend := mocks_network.NewMockBackend(t) + backend.EXPECT().ConnectEthernet().Return(errors.New("no ethernet device available")) + + manager := network.NewTestManager(backend, &network.NetworkState{}) + + err := manager.ConnectEthernet() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no ethernet device available") +} + +func TestManager_DisconnectEthernet_NoDevice(t *testing.T) { + backend := mocks_network.NewMockBackend(t) + backend.EXPECT().DisconnectEthernet().Return(errors.New("no ethernet device available")) + + manager := network.NewTestManager(backend, &network.NetworkState{}) + + err := manager.DisconnectEthernet() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no ethernet device available") +} + +// Note: More comprehensive tests for connection operations would require +// mocking the NetworkManager D-Bus interfaces, which is beyond the scope +// of these unit tests. The tests above cover the basic error cases and +// validation logic. Integration tests would be needed for full coverage. diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/detect.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/detect.go new file mode 100644 index 0000000..6031e42 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/detect.go @@ -0,0 +1,89 @@ +package network + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +type BackendType int + +const ( + BackendNone BackendType = iota + BackendNetworkManager + BackendIwd + BackendConnMan + BackendNetworkd +) + +func nameHasOwner(bus *dbus.Conn, name string) (bool, error) { + obj := bus.Object("org.freedesktop.DBus", "/org/freedesktop/DBus") + var owned bool + if err := obj.Call("org.freedesktop.DBus.NameHasOwner", 0, name).Store(&owned); err != nil { + return false, err + } + return owned, nil +} + +type DetectResult struct { + Backend BackendType + HasNM bool + HasIwd bool + HasConnMan bool + HasWpaSupp bool + HasNetworkd bool + ChosenReason string +} + +func DetectNetworkStack() (*DetectResult, error) { + bus, err := dbus.ConnectSystemBus() + if err != nil { + return nil, fmt.Errorf("connect system bus: %w", err) + } + defer bus.Close() + + hasNM, _ := nameHasOwner(bus, "org.freedesktop.NetworkManager") + hasIwd, _ := nameHasOwner(bus, "net.connman.iwd") + hasConn, _ := nameHasOwner(bus, "net.connman") + hasWpa, _ := nameHasOwner(bus, "fi.w1.wpa_supplicant1") + hasNetworkd, _ := nameHasOwner(bus, "org.freedesktop.network1") + + res := &DetectResult{ + HasNM: hasNM, + HasIwd: hasIwd, + HasConnMan: hasConn, + HasWpaSupp: hasWpa, + HasNetworkd: hasNetworkd, + } + + switch { + case hasNM: + res.Backend = BackendNetworkManager + if hasIwd { + res.ChosenReason = "NetworkManager present; iwd also running (likely NM's Wi-Fi backend). Using NM API." + } else { + res.ChosenReason = "NetworkManager present. Using NM API." + } + case hasConn && hasIwd: + res.Backend = BackendConnMan + res.ChosenReason = "ConnMan + iwd detected. Use ConnMan API (iwd is its Wi-Fi daemon)." + case hasIwd && hasNetworkd: + res.Backend = BackendNetworkd + res.ChosenReason = "iwd + systemd-networkd detected. Using iwd for Wi-Fi association and networkd for IP/DHCP." + case hasIwd: + res.Backend = BackendIwd + res.ChosenReason = "iwd detected without NM/ConnMan. Using iwd API." + case hasNetworkd: + res.Backend = BackendNetworkd + res.ChosenReason = "systemd-networkd detected (no NM/ConnMan). Using networkd for L3 and wired." + default: + res.Backend = BackendNone + if hasWpa { + res.ChosenReason = "No NM/ConnMan/iwd; wpa_supplicant present. Consider a wpa_supplicant path." + } else { + res.ChosenReason = "No known network manager bus names found." + } + } + + return res, nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/detect_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/detect_test.go new file mode 100644 index 0000000..87e7635 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/detect_test.go @@ -0,0 +1,41 @@ +package network + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBackendType_Constants(t *testing.T) { + assert.Equal(t, BackendType(0), BackendNone) + assert.Equal(t, BackendType(1), BackendNetworkManager) + assert.Equal(t, BackendType(2), BackendIwd) + assert.Equal(t, BackendType(3), BackendConnMan) + assert.Equal(t, BackendType(4), BackendNetworkd) +} + +func TestDetectResult_HasNetworkdField(t *testing.T) { + result := &DetectResult{ + Backend: BackendNetworkd, + HasNetworkd: true, + HasIwd: true, + } + + assert.True(t, result.HasNetworkd) + assert.True(t, result.HasIwd) + assert.Equal(t, BackendNetworkd, result.Backend) +} + +func TestDetectNetworkStack_Integration(t *testing.T) { + result, err := DetectNetworkStack() + + if err != nil && strings.Contains(err.Error(), "connect system bus") { + t.Skipf("system D-Bus unavailable: %v", err) + } + + assert.NoError(t, err) + if assert.NotNil(t, result) { + assert.NotEmpty(t, result.ChosenReason) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/handlers.go new file mode 100644 index 0000000..2b3e75e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/handlers.go @@ -0,0 +1,630 @@ +package network + +import ( + "encoding/json" + "fmt" + "net" + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + switch req.Method { + case "network.getState": + handleGetState(conn, req, manager) + case "network.wifi.scan": + handleScanWiFi(conn, req, manager) + case "network.wifi.networks": + handleGetWiFiNetworks(conn, req, manager) + case "network.wifi.connect": + handleConnectWiFi(conn, req, manager) + case "network.wifi.disconnect": + handleDisconnectWiFi(conn, req, manager) + case "network.wifi.forget": + handleForgetWiFi(conn, req, manager) + case "network.wifi.toggle": + handleToggleWiFi(conn, req, manager) + case "network.wifi.enable": + handleEnableWiFi(conn, req, manager) + case "network.wifi.disable": + handleDisableWiFi(conn, req, manager) + case "network.ethernet.connect.config": + handleConnectEthernetSpecificConfig(conn, req, manager) + case "network.ethernet.connect": + handleConnectEthernet(conn, req, manager) + case "network.ethernet.disconnect": + handleDisconnectEthernet(conn, req, manager) + case "network.preference.set": + handleSetPreference(conn, req, manager) + case "network.info": + handleGetNetworkInfo(conn, req, manager) + case "network.qrcode": + handleGetNetworkQRCode(conn, req, manager) + case "network.delete-qrcode": + handleDeleteQRCode(conn, req, manager) + case "network.ethernet.info": + handleGetWiredNetworkInfo(conn, req, manager) + case "network.subscribe": + handleSubscribe(conn, req, manager) + case "network.credentials.submit": + handleCredentialsSubmit(conn, req, manager) + case "network.credentials.cancel": + handleCredentialsCancel(conn, req, manager) + case "network.vpn.profiles": + handleListVPNProfiles(conn, req, manager) + case "network.vpn.active": + handleListActiveVPN(conn, req, manager) + case "network.vpn.connect": + handleConnectVPN(conn, req, manager) + case "network.vpn.disconnect": + handleDisconnectVPN(conn, req, manager) + case "network.vpn.disconnectAll": + handleDisconnectAllVPN(conn, req, manager) + case "network.vpn.clearCredentials": + handleClearVPNCredentials(conn, req, manager) + case "network.vpn.plugins": + handleListVPNPlugins(conn, req, manager) + case "network.vpn.import": + handleImportVPN(conn, req, manager) + case "network.vpn.getConfig": + handleGetVPNConfig(conn, req, manager) + case "network.vpn.updateConfig": + handleUpdateVPNConfig(conn, req, manager) + case "network.vpn.delete": + handleDeleteVPN(conn, req, manager) + case "network.vpn.setCredentials": + handleSetVPNCredentials(conn, req, manager) + case "network.wifi.setAutoconnect": + handleSetWiFiAutoconnect(conn, req, manager) + default: + models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) + } +} + +func handleCredentialsSubmit(conn net.Conn, req models.Request, manager *Manager) { + token, err := params.String(req.Params, "token") + if err != nil { + log.Warnf("handleCredentialsSubmit: missing or invalid token parameter") + models.RespondError(conn, req.ID, err.Error()) + return + } + + secrets, err := params.StringMap(req.Params, "secrets") + if err != nil { + log.Warnf("handleCredentialsSubmit: missing or invalid secrets parameter") + models.RespondError(conn, req.ID, err.Error()) + return + } + + save := params.BoolOpt(req.Params, "save", true) + + if err := manager.SubmitCredentials(token, secrets, save); err != nil { + log.Warnf("handleCredentialsSubmit: failed to submit credentials: %v", err) + models.RespondError(conn, req.ID, err.Error()) + return + } + + log.Infof("handleCredentialsSubmit: credentials submitted successfully") + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "credentials submitted"}) +} + +func handleCredentialsCancel(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.CancelCredentials(token); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "credentials cancelled"}) +} + +func handleGetState(conn net.Conn, req models.Request, manager *Manager) { + models.Respond(conn, req.ID, manager.GetState()) +} + +func handleScanWiFi(conn net.Conn, req models.Request, manager *Manager) { + device := params.StringOpt(req.Params, "device", "") + var err error + if device != "" { + err = manager.ScanWiFiDevice(device) + } else { + err = manager.ScanWiFi() + } + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "scanning"}) +} + +func handleGetWiFiNetworks(conn net.Conn, req models.Request, manager *Manager) { + models.Respond(conn, req.ID, manager.GetWiFiNetworks()) +} + +func handleConnectWiFi(conn net.Conn, req models.Request, manager *Manager) { + ssid, err := params.String(req.Params, "ssid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + var connReq ConnectionRequest + connReq.SSID = ssid + connReq.Password = params.StringOpt(req.Params, "password", "") + connReq.Username = params.StringOpt(req.Params, "username", "") + connReq.Device = params.StringOpt(req.Params, "device", "") + + if interactive, ok := models.Get[bool](req, "interactive"); ok { + connReq.Interactive = interactive + } else { + state := manager.GetState() + alreadyConnected := state.WiFiConnected && state.WiFiSSID == ssid + + if alreadyConnected && connReq.Device == "" { + connReq.Interactive = false + } else { + networkInfo, err := manager.GetNetworkInfo(ssid) + isSaved := err == nil && networkInfo.Saved + + if isSaved { + connReq.Interactive = false + } else if err == nil && networkInfo.Secured && connReq.Password == "" && connReq.Username == "" { + connReq.Interactive = true + } + } + } + + connReq.AnonymousIdentity = params.StringOpt(req.Params, "anonymousIdentity", "") + connReq.DomainSuffixMatch = params.StringOpt(req.Params, "domainSuffixMatch", "") + connReq.EAPMethod = params.StringOpt(req.Params, "eapMethod", "") + connReq.Phase2Auth = params.StringOpt(req.Params, "phase2Auth", "") + connReq.CACertPath = params.StringOpt(req.Params, "caCertPath", "") + connReq.ClientCertPath = params.StringOpt(req.Params, "clientCertPath", "") + connReq.PrivateKeyPath = params.StringOpt(req.Params, "privateKeyPath", "") + + if useSystemCACerts, ok := models.Get[bool](req, "useSystemCACerts"); ok { + connReq.UseSystemCACerts = &useSystemCACerts + } + + if err := manager.ConnectWiFi(connReq); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connecting"}) +} + +func handleDisconnectWiFi(conn net.Conn, req models.Request, manager *Manager) { + device := params.StringOpt(req.Params, "device", "") + var err error + if device != "" { + err = manager.DisconnectWiFiDevice(device) + } else { + err = manager.DisconnectWiFi() + } + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"}) +} + +func handleForgetWiFi(conn net.Conn, req models.Request, manager *Manager) { + ssid, err := params.String(req.Params, "ssid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.ForgetWiFiNetwork(ssid); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "forgotten"}) +} + +func handleToggleWiFi(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.ToggleWiFi(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + state := manager.GetState() + models.Respond(conn, req.ID, map[string]bool{"enabled": state.WiFiEnabled}) +} + +func handleEnableWiFi(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.EnableWiFi(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, map[string]bool{"enabled": true}) +} + +func handleDisableWiFi(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.DisableWiFi(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, map[string]bool{"enabled": false}) +} + +func handleConnectEthernetSpecificConfig(conn net.Conn, req models.Request, manager *Manager) { + uuid, err := params.String(req.Params, "uuid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + if err := manager.activateConnection(uuid); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connecting"}) +} + +func handleConnectEthernet(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.ConnectEthernet(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connecting"}) +} + +func handleDisconnectEthernet(conn net.Conn, req models.Request, manager *Manager) { + device := params.StringOpt(req.Params, "device", "") + var err error + if device != "" { + err = manager.DisconnectEthernetDevice(device) + } else { + err = manager.DisconnectEthernet() + } + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"}) +} + +func handleSetPreference(conn net.Conn, req models.Request, manager *Manager) { + preference, err := params.String(req.Params, "preference") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetConnectionPreference(ConnectionPreference(preference)); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, map[string]string{"preference": preference}) +} + +func handleGetNetworkInfo(conn net.Conn, req models.Request, manager *Manager) { + ssid, err := params.String(req.Params, "ssid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + network, err := manager.GetNetworkInfoDetailed(ssid) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, network) +} + +func handleGetNetworkQRCode(conn net.Conn, req models.Request, manager *Manager) { + ssid, err := params.String(req.Params, "ssid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + content, err := manager.GetNetworkQRCode(ssid) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, content) +} + +func handleDeleteQRCode(conn net.Conn, req models.Request, _ *Manager) { + path, err := params.String(req.Params, "path") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if !isValidQRCodePath(path) { + models.RespondError(conn, req.ID, "invalid QR code path") + return + } + + if err := os.Remove(path); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "QR code file deleted"}) +} + +func handleGetWiredNetworkInfo(conn net.Conn, req models.Request, manager *Manager) { + uuid, err := params.String(req.Params, "uuid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + network, err := manager.GetWiredNetworkInfoDetailed(uuid) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, network) +} + +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 := NetworkEvent{ + Type: EventStateChanged, + Data: initialState, + } + if err := json.NewEncoder(conn).Encode(models.Response[NetworkEvent]{ + ID: req.ID, + Result: &event, + }); err != nil { + return + } + + for state := range stateChan { + event := NetworkEvent{ + Type: EventStateChanged, + Data: state, + } + if err := json.NewEncoder(conn).Encode(models.Response[NetworkEvent]{ + Result: &event, + }); err != nil { + return + } + } +} + +func handleListVPNProfiles(conn net.Conn, req models.Request, manager *Manager) { + profiles, err := manager.ListVPNProfiles() + if err != nil { + log.Warnf("handleListVPNProfiles: failed to list profiles: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list VPN profiles: %v", err)) + return + } + + models.Respond(conn, req.ID, profiles) +} + +func handleListActiveVPN(conn net.Conn, req models.Request, manager *Manager) { + active, err := manager.ListActiveVPN() + if err != nil { + log.Warnf("handleListActiveVPN: failed to list active VPNs: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list active VPNs: %v", err)) + return + } + + models.Respond(conn, req.ID, active) +} + +func handleConnectVPN(conn net.Conn, req models.Request, manager *Manager) { + uuidOrName, ok := params.StringAlt(req.Params, "uuidOrName", "name", "uuid") + if !ok { + log.Warnf("handleConnectVPN: missing uuidOrName/name/uuid parameter") + models.RespondError(conn, req.ID, "missing 'uuidOrName', 'name', or 'uuid' parameter") + return + } + + singleActive := params.BoolOpt(req.Params, "singleActive", true) + + if err := manager.ConnectVPN(uuidOrName, singleActive); err != nil { + log.Warnf("handleConnectVPN: failed to connect: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to connect VPN: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "VPN connection initiated"}) +} + +func handleDisconnectVPN(conn net.Conn, req models.Request, manager *Manager) { + uuidOrName, ok := params.StringAlt(req.Params, "uuidOrName", "name", "uuid") + if !ok { + log.Warnf("handleDisconnectVPN: missing uuidOrName/name/uuid parameter") + models.RespondError(conn, req.ID, "missing 'uuidOrName', 'name', or 'uuid' parameter") + return + } + + if err := manager.DisconnectVPN(uuidOrName); err != nil { + log.Warnf("handleDisconnectVPN: failed to disconnect: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to disconnect VPN: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "VPN disconnected"}) +} + +func handleDisconnectAllVPN(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.DisconnectAllVPN(); err != nil { + log.Warnf("handleDisconnectAllVPN: failed: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to disconnect all VPNs: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "All VPNs disconnected"}) +} + +func handleClearVPNCredentials(conn net.Conn, req models.Request, manager *Manager) { + uuidOrName, ok := params.StringAlt(req.Params, "uuid", "name", "uuidOrName") + if !ok { + log.Warnf("handleClearVPNCredentials: missing uuidOrName/name/uuid parameter") + models.RespondError(conn, req.ID, "missing uuidOrName/name/uuid parameter") + return + } + + if err := manager.ClearVPNCredentials(uuidOrName); err != nil { + log.Warnf("handleClearVPNCredentials: failed: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to clear VPN credentials: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "VPN credentials cleared"}) +} + +func handleSetWiFiAutoconnect(conn net.Conn, req models.Request, manager *Manager) { + ssid, err := params.String(req.Params, "ssid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + autoconnect, err := params.Bool(req.Params, "autoconnect") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetWiFiAutoconnect(ssid, autoconnect); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to set autoconnect: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "autoconnect updated"}) +} + +func handleListVPNPlugins(conn net.Conn, req models.Request, manager *Manager) { + plugins, err := manager.ListVPNPlugins() + if err != nil { + log.Warnf("handleListVPNPlugins: failed to list plugins: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list VPN plugins: %v", err)) + return + } + + models.Respond(conn, req.ID, plugins) +} + +func handleImportVPN(conn net.Conn, req models.Request, manager *Manager) { + filePath, ok := params.StringAlt(req.Params, "file", "path") + if !ok { + models.RespondError(conn, req.ID, "missing 'file' or 'path' parameter") + return + } + + name := params.StringOpt(req.Params, "name", "") + + result, err := manager.ImportVPN(filePath, name) + if err != nil { + log.Warnf("handleImportVPN: failed to import: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to import VPN: %v", err)) + return + } + + models.Respond(conn, req.ID, result) +} + +func handleGetVPNConfig(conn net.Conn, req models.Request, manager *Manager) { + uuidOrName, ok := params.StringAlt(req.Params, "uuid", "name", "uuidOrName") + if !ok { + models.RespondError(conn, req.ID, "missing 'uuid', 'name', or 'uuidOrName' parameter") + return + } + + config, err := manager.GetVPNConfig(uuidOrName) + if err != nil { + log.Warnf("handleGetVPNConfig: failed to get config: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to get VPN config: %v", err)) + return + } + + models.Respond(conn, req.ID, config) +} + +func handleUpdateVPNConfig(conn net.Conn, req models.Request, manager *Manager) { + connUUID, err := params.String(req.Params, "uuid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + updates := make(map[string]any) + + if name, ok := models.Get[string](req, "name"); ok { + updates["name"] = name + } + if autoconnect, ok := models.Get[bool](req, "autoconnect"); ok { + updates["autoconnect"] = autoconnect + } + if data, ok := models.Get[map[string]any](req, "data"); ok { + updates["data"] = data + } + + if len(updates) == 0 { + models.RespondError(conn, req.ID, "no updates provided") + return + } + + if err := manager.UpdateVPNConfig(connUUID, updates); err != nil { + log.Warnf("handleUpdateVPNConfig: failed to update: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to update VPN config: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "VPN config updated"}) +} + +func handleDeleteVPN(conn net.Conn, req models.Request, manager *Manager) { + uuidOrName, ok := params.StringAlt(req.Params, "uuid", "name", "uuidOrName") + if !ok { + models.RespondError(conn, req.ID, "missing 'uuid', 'name', or 'uuidOrName' parameter") + return + } + + if err := manager.DeleteVPN(uuidOrName); err != nil { + log.Warnf("handleDeleteVPN: failed to delete: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to delete VPN: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "VPN deleted"}) +} + +func handleSetVPNCredentials(conn net.Conn, req models.Request, manager *Manager) { + connUUID, err := params.String(req.Params, "uuid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + username := params.StringOpt(req.Params, "username", "") + password := params.StringOpt(req.Params, "password", "") + save := params.BoolOpt(req.Params, "save", true) + + if err := manager.SetVPNCredentials(connUUID, username, password, save); err != nil { + log.Warnf("handleSetVPNCredentials: failed to set credentials: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to set VPN credentials: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "VPN credentials set"}) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/handlers_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/handlers_test.go new file mode 100644 index 0000000..ea8ae33 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/handlers_test.go @@ -0,0 +1,269 @@ +package network + +import ( + "bytes" + "encoding/json" + "net" + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockNetConn struct { + net.Conn + readBuf *bytes.Buffer + writeBuf *bytes.Buffer + closed bool +} + +func newMockNetConn() *mockNetConn { + return &mockNetConn{ + readBuf: &bytes.Buffer{}, + writeBuf: &bytes.Buffer{}, + } +} + +func (m *mockNetConn) Read(b []byte) (n int, err error) { + return m.readBuf.Read(b) +} + +func (m *mockNetConn) Write(b []byte) (n int, err error) { + return m.writeBuf.Write(b) +} + +func (m *mockNetConn) Close() error { + m.closed = true + return nil +} + +func TestRespondError_Network(t *testing.T) { + conn := newMockNetConn() + models.RespondError(conn, 123, "test error") + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Equal(t, "test error", resp.Error) + assert.Nil(t, resp.Result) +} + +func TestRespond_Network(t *testing.T) { + conn := newMockNetConn() + result := models.SuccessResult{Success: true, Message: "test"} + models.Respond(conn, 123, result) + + var resp models.Response[models.SuccessResult] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.Equal(t, "test", resp.Result.Message) +} + +func TestHandleGetState(t *testing.T) { + manager := &Manager{ + state: &NetworkState{ + NetworkStatus: StatusWiFi, + WiFiSSID: "TestNetwork", + WiFiConnected: true, + }, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "network.getState"} + + handleGetState(conn, req, manager) + + var resp models.Response[NetworkState] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.Equal(t, StatusWiFi, resp.Result.NetworkStatus) + assert.Equal(t, "TestNetwork", resp.Result.WiFiSSID) +} + +func TestHandleGetWiFiNetworks(t *testing.T) { + manager := &Manager{ + state: &NetworkState{ + WiFiNetworks: []WiFiNetwork{ + {SSID: "Network1", Signal: 90}, + {SSID: "Network2", Signal: 80}, + }, + }, + } + + conn := newMockNetConn() + req := models.Request{ID: 123, Method: "network.wifi.networks"} + + handleGetWiFiNetworks(conn, req, manager) + + var resp models.Response[[]WiFiNetwork] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.Len(t, *resp.Result, 2) + assert.Equal(t, "Network1", (*resp.Result)[0].SSID) +} + +func TestHandleConnectWiFi(t *testing.T) { + t.Run("missing ssid parameter", func(t *testing.T) { + manager := &Manager{ + state: &NetworkState{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "network.wifi.connect", + Params: map[string]any{}, + } + + handleConnectWiFi(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "missing or invalid 'ssid' parameter") + }) +} + +func TestHandleSetPreference(t *testing.T) { + t.Run("missing preference parameter", func(t *testing.T) { + manager := &Manager{ + state: &NetworkState{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "network.preference.set", + Params: map[string]any{}, + } + + handleSetPreference(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "missing or invalid 'preference' parameter") + }) +} + +func TestHandleGetNetworkInfo(t *testing.T) { + t.Run("missing ssid parameter", func(t *testing.T) { + manager := &Manager{ + state: &NetworkState{}, + } + + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "network.info", + Params: map[string]any{}, + } + + handleGetNetworkInfo(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "missing or invalid 'ssid' parameter") + }) +} + +func TestHandleRequest(t *testing.T) { + manager := &Manager{ + state: &NetworkState{ + NetworkStatus: StatusWiFi, + }, + } + + t.Run("unknown method", func(t *testing.T) { + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "network.unknown", + } + + HandleRequest(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "unknown method") + }) + + t.Run("valid method - getState", func(t *testing.T) { + conn := newMockNetConn() + req := models.Request{ + ID: 123, + Method: "network.getState", + } + + HandleRequest(conn, req, manager) + + var resp models.Response[NetworkState] + err := json.NewDecoder(conn.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + }) +} + +func TestHandleSubscribe(t *testing.T) { + // This test is complex due to the streaming nature of subscriptions + // Better suited as an integration test + t.Skip("Subscription test requires connection lifecycle management - integration test needed") +} + +func TestManager_Subscribe_Unsubscribe(t *testing.T) { + manager := &Manager{ + state: &NetworkState{}, + } + + t.Run("subscribe creates channel", func(t *testing.T) { + ch := manager.Subscribe("client1") + assert.NotNil(t, ch) + count := 0 + manager.subscribers.Range(func(key string, ch chan NetworkState) bool { + count++ + return true + }) + assert.Equal(t, 1, count) + }) + + t.Run("unsubscribe removes channel", func(t *testing.T) { + manager.Unsubscribe("client1") + count := 0 + manager.subscribers.Range(func(key string, ch chan NetworkState) bool { count++; return true }) + assert.Equal(t, 0, count) + }) + + t.Run("unsubscribe non-existent client is safe", func(t *testing.T) { + assert.NotPanics(t, func() { + manager.Unsubscribe("non-existent") + }) + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/helpers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/helpers.go new file mode 100644 index 0000000..4669f52 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/helpers.go @@ -0,0 +1,53 @@ +package network + +import "sort" + +func frequencyToChannel(freq uint32) uint32 { + if freq >= 2412 && freq <= 2484 { + if freq == 2484 { + return 14 + } + return (freq-2412)/5 + 1 + } + + if freq >= 5170 && freq <= 5825 { + return (freq-5170)/5 + 34 + } + + if freq >= 5955 && freq <= 7115 { + return (freq-5955)/5 + 1 + } + + return 0 +} + +func sortWiFiNetworks(networks []WiFiNetwork) { + sort.Slice(networks, func(i, j int) bool { + if networks[i].Connected && !networks[j].Connected { + return true + } + if !networks[i].Connected && networks[j].Connected { + return false + } + + if networks[i].Saved && !networks[j].Saved { + return true + } + if !networks[i].Saved && networks[j].Saved { + return false + } + + if !networks[i].Secured && networks[j].Secured { + if networks[i].Signal >= 50 { + return true + } + } + if networks[i].Secured && !networks[j].Secured { + if networks[j].Signal >= 50 { + return false + } + } + + return networks[i].Signal > networks[j].Signal + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/manager.go new file mode 100644 index 0000000..5c6657e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/manager.go @@ -0,0 +1,626 @@ +package network + +import ( + "fmt" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/yeqown/go-qrcode/v2" + "github.com/yeqown/go-qrcode/writer/standard" +) + +func NewManager() (*Manager, error) { + detection, err := DetectNetworkStack() + if err != nil { + return nil, fmt.Errorf("failed to detect network stack: %w", err) + } + + log.Infof("Network backend detection: %s", detection.ChosenReason) + + var backend Backend + switch detection.Backend { + case BackendNetworkManager: + nm, err := NewNetworkManagerBackend() + if err != nil { + return nil, fmt.Errorf("failed to create NetworkManager backend: %w", err) + } + backend = nm + + case BackendIwd: + iwd, err := NewIWDBackend() + if err != nil { + return nil, fmt.Errorf("failed to create iwd backend: %w", err) + } + backend = iwd + + case BackendNetworkd: + if detection.HasIwd && !detection.HasNM { + wifi, err := NewIWDBackend() + if err != nil { + return nil, fmt.Errorf("failed to create iwd backend: %w", err) + } + l3, err := NewSystemdNetworkdBackend() + if err != nil { + return nil, fmt.Errorf("failed to create networkd backend: %w", err) + } + hybrid, err := NewHybridIwdNetworkdBackend(wifi, l3) + if err != nil { + return nil, fmt.Errorf("failed to create hybrid backend: %w", err) + } + backend = hybrid + } else { + nd, err := NewSystemdNetworkdBackend() + if err != nil { + return nil, fmt.Errorf("failed to create networkd backend: %w", err) + } + backend = nd + } + + default: + return nil, fmt.Errorf("no supported network backend found: %s", detection.ChosenReason) + } + + m := &Manager{ + backend: backend, + state: &NetworkState{ + NetworkStatus: StatusDisconnected, + Preference: PreferenceAuto, + WiFiNetworks: []WiFiNetwork{}, + }, + stateMutex: sync.RWMutex{}, + + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + + broker := NewSubscriptionBroker(m.broadcastCredentialPrompt) + if err := backend.SetPromptBroker(broker); err != nil { + return nil, fmt.Errorf("failed to set prompt broker: %w", err) + } + + if err := backend.Initialize(); err != nil { + return nil, fmt.Errorf("failed to initialize backend: %w", err) + } + + if err := m.syncStateFromBackend(); err != nil { + return nil, fmt.Errorf("failed to sync initial state: %w", err) + } + + m.notifierWg.Add(1) + go m.notifier() + + if err := backend.StartMonitoring(m.onBackendStateChange); err != nil { + m.Close() + return nil, fmt.Errorf("failed to start monitoring: %w", err) + } + + return m, nil +} + +func (m *Manager) syncStateFromBackend() error { + backendState, err := m.backend.GetCurrentState() + if err != nil { + return err + } + + m.stateMutex.Lock() + m.state.Backend = backendState.Backend + m.state.NetworkStatus = backendState.NetworkStatus + m.state.EthernetIP = backendState.EthernetIP + m.state.EthernetDevice = backendState.EthernetDevice + m.state.EthernetConnected = backendState.EthernetConnected + m.state.EthernetConnectionUuid = backendState.EthernetConnectionUuid + m.state.EthernetDevices = backendState.EthernetDevices + m.state.WiFiIP = backendState.WiFiIP + m.state.WiFiDevice = backendState.WiFiDevice + m.state.WiFiConnected = backendState.WiFiConnected + m.state.WiFiEnabled = backendState.WiFiEnabled + m.state.WiFiSSID = backendState.WiFiSSID + m.state.WiFiBSSID = backendState.WiFiBSSID + m.state.WiFiSignal = backendState.WiFiSignal + m.state.WiFiNetworks = backendState.WiFiNetworks + m.state.WiFiDevices = backendState.WiFiDevices + m.state.WiredConnections = backendState.WiredConnections + m.state.VPNProfiles = backendState.VPNProfiles + m.state.VPNActive = backendState.VPNActive + m.state.IsConnecting = backendState.IsConnecting + m.state.ConnectingSSID = backendState.ConnectingSSID + m.state.ConnectingDevice = backendState.ConnectingDevice + m.state.LastError = backendState.LastError + m.stateMutex.Unlock() + + return nil +} + +func (m *Manager) onBackendStateChange() { + if err := m.syncStateFromBackend(); err != nil { + log.Errorf("failed to sync state from backend: %v", err) + } + m.notifySubscribers() +} + +func signalChangeSignificant(old, new uint8) bool { + if old == 0 || new == 0 { + return true + } + diff := int(new) - int(old) + if diff < 0 { + diff = -diff + } + return diff >= 5 +} + +func (m *Manager) snapshotState() NetworkState { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + s := *m.state + s.WiFiNetworks = append([]WiFiNetwork(nil), m.state.WiFiNetworks...) + s.WiFiDevices = append([]WiFiDevice(nil), m.state.WiFiDevices...) + s.WiredConnections = append([]WiredConnection(nil), m.state.WiredConnections...) + s.EthernetDevices = append([]EthernetDevice(nil), m.state.EthernetDevices...) + s.VPNProfiles = append([]VPNProfile(nil), m.state.VPNProfiles...) + s.VPNActive = append([]VPNActive(nil), m.state.VPNActive...) + return s +} + +func stateChangedMeaningfully(old, new *NetworkState) bool { + if old.NetworkStatus != new.NetworkStatus { + return true + } + if old.Preference != new.Preference { + return true + } + if old.EthernetConnected != new.EthernetConnected { + return true + } + if old.EthernetIP != new.EthernetIP { + return true + } + if old.WiFiConnected != new.WiFiConnected { + return true + } + if old.WiFiEnabled != new.WiFiEnabled { + return true + } + if old.WiFiSSID != new.WiFiSSID { + return true + } + if old.WiFiBSSID != new.WiFiBSSID { + return true + } + if old.WiFiIP != new.WiFiIP { + return true + } + if !signalChangeSignificant(old.WiFiSignal, new.WiFiSignal) { + if old.WiFiSignal != new.WiFiSignal { + return false + } + } else if old.WiFiSignal != new.WiFiSignal { + return true + } + if old.IsConnecting != new.IsConnecting { + return true + } + if old.ConnectingSSID != new.ConnectingSSID { + return true + } + if old.LastError != new.LastError { + return true + } + if len(old.WiFiNetworks) != len(new.WiFiNetworks) { + return true + } + if len(old.WiFiDevices) != len(new.WiFiDevices) { + return true + } + if len(old.WiredConnections) != len(new.WiredConnections) { + return true + } + if len(old.EthernetDevices) != len(new.EthernetDevices) { + return true + } + + for i := range old.WiFiNetworks { + oldNet := &old.WiFiNetworks[i] + newNet := &new.WiFiNetworks[i] + if oldNet.SSID != newNet.SSID { + return true + } + if oldNet.Connected != newNet.Connected { + return true + } + if oldNet.Saved != newNet.Saved { + return true + } + if oldNet.Autoconnect != newNet.Autoconnect { + return true + } + } + + for i := range old.WiredConnections { + oldNet := &old.WiredConnections[i] + newNet := &new.WiredConnections[i] + if oldNet.ID != newNet.ID { + return true + } + if oldNet.IsActive != newNet.IsActive { + return true + } + } + + for i := range old.EthernetDevices { + oldDev := &old.EthernetDevices[i] + newDev := &new.EthernetDevices[i] + if oldDev.Name != newDev.Name { + return true + } + if oldDev.Connected != newDev.Connected { + return true + } + if oldDev.State != newDev.State { + return true + } + if oldDev.IP != newDev.IP { + return true + } + } + + // Check VPN profiles count + if len(old.VPNProfiles) != len(new.VPNProfiles) { + return true + } + + // Check active VPN connections count or state + if len(old.VPNActive) != len(new.VPNActive) { + return true + } + + // Check if any active VPN changed + for i := range old.VPNActive { + oldVPN := &old.VPNActive[i] + newVPN := &new.VPNActive[i] + if oldVPN.UUID != newVPN.UUID { + return true + } + if oldVPN.State != newVPN.State { + return true + } + } + + return false +} + +func (m *Manager) GetState() NetworkState { + return m.snapshotState() +} + +func (m *Manager) Subscribe(id string) chan NetworkState { + ch := make(chan NetworkState, 64) + m.subscribers.Store(id, ch) + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) SubscribeCredentials(id string) chan CredentialPrompt { + ch := make(chan CredentialPrompt, 16) + m.credentialSubscribers.Store(id, ch) + return ch +} + +func (m *Manager) UnsubscribeCredentials(id string) { + if ch, ok := m.credentialSubscribers.LoadAndDelete(id); ok { + close(ch) + } +} + +func (m *Manager) broadcastCredentialPrompt(prompt CredentialPrompt) { + m.credentialSubscribers.Range(func(key string, ch chan CredentialPrompt) bool { + select { + case ch <- prompt: + default: + } + return true + }) +} + +func (m *Manager) notifier() { + defer m.notifierWg.Done() + const minGap = 100 * 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 + } + + currentState := m.snapshotState() + + if m.lastNotifiedState != nil && !stateChangedMeaningfully(m.lastNotifiedState, ¤tState) { + pending = false + continue + } + + m.subscribers.Range(func(key string, ch chan NetworkState) 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) SetPromptBroker(broker PromptBroker) error { + return m.backend.SetPromptBroker(broker) +} + +func (m *Manager) SubmitCredentials(token string, secrets map[string]string, save bool) error { + return m.backend.SubmitCredentials(token, secrets, save) +} + +func (m *Manager) CancelCredentials(token string) error { + return m.backend.CancelCredentials(token) +} + +func (m *Manager) GetPromptBroker() PromptBroker { + return m.backend.GetPromptBroker() +} + +func (m *Manager) Close() { + close(m.stopChan) + m.notifierWg.Wait() + + if m.backend != nil { + m.backend.Close() + } + + m.subscribers.Range(func(key string, ch chan NetworkState) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) +} + +func (m *Manager) ScanWiFi() error { + return m.backend.ScanWiFi() +} + +func (m *Manager) GetWiFiNetworks() []WiFiNetwork { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + networks := make([]WiFiNetwork, len(m.state.WiFiNetworks)) + copy(networks, m.state.WiFiNetworks) + return networks +} + +func (m *Manager) GetNetworkInfo(ssid string) (*WiFiNetwork, error) { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + + for _, network := range m.state.WiFiNetworks { + if network.SSID == ssid { + return &network, nil + } + } + + return nil, fmt.Errorf("network not found: %s", ssid) +} + +func (m *Manager) GetNetworkInfoDetailed(ssid string) (*NetworkInfoResponse, error) { + return m.backend.GetWiFiNetworkDetails(ssid) +} + +func (m *Manager) GetNetworkQRCode(ssid string) ([2]string, error) { + content, err := m.backend.GetWiFiQRCodeContent(ssid) + if err != nil { + return [2]string{}, err + } + + qrc, err := qrcode.New(content) + if err != nil { + return [2]string{}, fmt.Errorf("failed to create QR code for `%s`: %w", ssid, err) + } + + pathThemed, pathNormal := qrCodePaths(ssid) + + wThemed, err := standard.New( + pathThemed, + standard.WithBuiltinImageEncoder(standard.PNG_FORMAT), + standard.WithBgTransparent(), + standard.WithFgColorRGBHex("#ffffff"), + ) + if err != nil { + return [2]string{}, fmt.Errorf("failed to create QR code writer: %w", err) + } + if err := qrc.Save(wThemed); err != nil { + return [2]string{}, fmt.Errorf("failed to save QR code for `%s`: %w", ssid, err) + } + + wNormal, err := standard.New(pathNormal, standard.WithBuiltinImageEncoder(standard.PNG_FORMAT)) + if err != nil { + return [2]string{}, fmt.Errorf("failed to create QR code writer: %w", err) + } + if err := qrc.Save(wNormal); err != nil { + return [2]string{}, fmt.Errorf("failed to save QR code for `%s`: %w", ssid, err) + } + + return [2]string{pathThemed, pathNormal}, nil +} + +func (m *Manager) ToggleWiFi() error { + enabled, err := m.backend.GetWiFiEnabled() + if err != nil { + return fmt.Errorf("failed to get WiFi state: %w", err) + } + + err = m.backend.SetWiFiEnabled(!enabled) + if err != nil { + return fmt.Errorf("failed to toggle WiFi: %w", err) + } + + return nil +} + +func (m *Manager) EnableWiFi() error { + err := m.backend.SetWiFiEnabled(true) + if err != nil { + return fmt.Errorf("failed to enable WiFi: %w", err) + } + + return nil +} + +func (m *Manager) DisableWiFi() error { + err := m.backend.SetWiFiEnabled(false) + if err != nil { + return fmt.Errorf("failed to disable WiFi: %w", err) + } + + return nil +} + +func (m *Manager) ConnectWiFi(req ConnectionRequest) error { + return m.backend.ConnectWiFi(req) +} + +func (m *Manager) DisconnectWiFi() error { + return m.backend.DisconnectWiFi() +} + +func (m *Manager) ForgetWiFiNetwork(ssid string) error { + return m.backend.ForgetWiFiNetwork(ssid) +} + +func (m *Manager) GetWiredConfigs() []WiredConnection { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + configs := make([]WiredConnection, len(m.state.WiredConnections)) + copy(configs, m.state.WiredConnections) + return configs +} + +func (m *Manager) GetWiredNetworkInfoDetailed(uuid string) (*WiredNetworkInfoResponse, error) { + return m.backend.GetWiredNetworkDetails(uuid) +} + +func (m *Manager) ConnectEthernet() error { + return m.backend.ConnectEthernet() +} + +func (m *Manager) DisconnectEthernet() error { + return m.backend.DisconnectEthernet() +} + +func (m *Manager) DisconnectEthernetDevice(device string) error { + return m.backend.DisconnectEthernetDevice(device) +} + +func (m *Manager) GetEthernetDevices() []EthernetDevice { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + devices := make([]EthernetDevice, len(m.state.EthernetDevices)) + copy(devices, m.state.EthernetDevices) + return devices +} + +func (m *Manager) activateConnection(uuid string) error { + return m.backend.ActivateWiredConnection(uuid) +} + +func (m *Manager) ListVPNProfiles() ([]VPNProfile, error) { + return m.backend.ListVPNProfiles() +} + +func (m *Manager) ListActiveVPN() ([]VPNActive, error) { + return m.backend.ListActiveVPN() +} + +func (m *Manager) ConnectVPN(uuidOrName string, singleActive bool) error { + return m.backend.ConnectVPN(uuidOrName, singleActive) +} + +func (m *Manager) DisconnectVPN(uuidOrName string) error { + return m.backend.DisconnectVPN(uuidOrName) +} + +func (m *Manager) DisconnectAllVPN() error { + return m.backend.DisconnectAllVPN() +} + +func (m *Manager) ClearVPNCredentials(uuidOrName string) error { + return m.backend.ClearVPNCredentials(uuidOrName) +} + +func (m *Manager) ListVPNPlugins() ([]VPNPlugin, error) { + return m.backend.ListVPNPlugins() +} + +func (m *Manager) ImportVPN(filePath string, name string) (*VPNImportResult, error) { + return m.backend.ImportVPN(filePath, name) +} + +func (m *Manager) GetVPNConfig(uuidOrName string) (*VPNConfig, error) { + return m.backend.GetVPNConfig(uuidOrName) +} + +func (m *Manager) UpdateVPNConfig(uuid string, updates map[string]any) error { + return m.backend.UpdateVPNConfig(uuid, updates) +} + +func (m *Manager) DeleteVPN(uuidOrName string) error { + return m.backend.DeleteVPN(uuidOrName) +} + +func (m *Manager) SetVPNCredentials(uuid, username, password string, save bool) error { + return m.backend.SetVPNCredentials(uuid, username, password, save) +} + +func (m *Manager) SetWiFiAutoconnect(ssid string, autoconnect bool) error { + return m.backend.SetWiFiAutoconnect(ssid, autoconnect) +} + +func (m *Manager) GetWiFiDevices() []WiFiDevice { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + devices := make([]WiFiDevice, len(m.state.WiFiDevices)) + copy(devices, m.state.WiFiDevices) + return devices +} + +func (m *Manager) ScanWiFiDevice(device string) error { + return m.backend.ScanWiFiDevice(device) +} + +func (m *Manager) DisconnectWiFiDevice(device string) error { + return m.backend.DisconnectWiFiDevice(device) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/manager_test.go new file mode 100644 index 0000000..79545c5 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/manager_test.go @@ -0,0 +1,191 @@ +package network + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestManager_GetState(t *testing.T) { + state := &NetworkState{ + NetworkStatus: StatusWiFi, + WiFiSSID: "TestNetwork", + WiFiConnected: true, + } + + manager := &Manager{ + state: state, + stateMutex: sync.RWMutex{}, + } + + result := manager.GetState() + assert.Equal(t, StatusWiFi, result.NetworkStatus) + assert.Equal(t, "TestNetwork", result.WiFiSSID) + assert.True(t, result.WiFiConnected) +} + +func TestManager_NotifySubscribers(t *testing.T) { + manager := &Manager{ + state: &NetworkState{ + NetworkStatus: StatusWiFi, + }, + stateMutex: sync.RWMutex{}, + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + manager.notifierWg.Add(1) + go manager.notifier() + + ch := make(chan NetworkState, 10) + manager.subscribers.Store("test-client", ch) + + manager.notifySubscribers() + + select { + case state := <-ch: + assert.Equal(t, StatusWiFi, state.NetworkStatus) + case <-time.After(200 * time.Millisecond): + t.Fatal("did not receive state update") + } + + close(manager.stopChan) + manager.notifierWg.Wait() +} + +func TestManager_NotifySubscribers_Debounce(t *testing.T) { + manager := &Manager{ + state: &NetworkState{ + NetworkStatus: StatusWiFi, + }, + stateMutex: sync.RWMutex{}, + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + manager.notifierWg.Add(1) + go manager.notifier() + + ch := make(chan NetworkState, 10) + manager.subscribers.Store("test-client", ch) + + manager.notifySubscribers() + manager.notifySubscribers() + manager.notifySubscribers() + + receivedCount := 0 + timeout := time.After(200 * time.Millisecond) + for { + select { + case <-ch: + receivedCount++ + case <-timeout: + assert.Equal(t, 1, receivedCount, "should receive exactly one debounced update") + close(manager.stopChan) + manager.notifierWg.Wait() + return + } + } +} + +func TestManager_Close(t *testing.T) { + manager := &Manager{ + state: &NetworkState{}, + stateMutex: sync.RWMutex{}, + stopChan: make(chan struct{}), + } + + ch1 := make(chan NetworkState, 1) + ch2 := make(chan NetworkState, 1) + manager.subscribers.Store("client1", ch1) + manager.subscribers.Store("client2", ch2) + + manager.Close() + + select { + case <-manager.stopChan: + case <-time.After(100 * time.Millisecond): + t.Fatal("stopChan not closed") + } + + _, ok1 := <-ch1 + _, ok2 := <-ch2 + assert.False(t, ok1, "ch1 should be closed") + assert.False(t, ok2, "ch2 should be closed") + + count := 0 + manager.subscribers.Range(func(key string, ch chan NetworkState) bool { count++; return true }) + assert.Equal(t, 0, count) +} + +func TestManager_Subscribe(t *testing.T) { + manager := &Manager{ + state: &NetworkState{}, + } + + ch := manager.Subscribe("test-client") + assert.NotNil(t, ch) + assert.Equal(t, 64, cap(ch)) + + _, exists := manager.subscribers.Load("test-client") + assert.True(t, exists) +} + +func TestManager_Unsubscribe(t *testing.T) { + manager := &Manager{ + state: &NetworkState{}, + } + + ch := manager.Subscribe("test-client") + + manager.Unsubscribe("test-client") + + _, ok := <-ch + assert.False(t, ok) + + _, exists := manager.subscribers.Load("test-client") + assert.False(t, exists) +} + +func TestNewManager(t *testing.T) { + t.Run("attempts to create manager", func(t *testing.T) { + manager, err := NewManager() + if err != nil { + assert.Nil(t, manager) + } else { + assert.NotNil(t, manager) + assert.NotNil(t, manager.state) + assert.NotNil(t, manager.subscribers) + assert.NotNil(t, manager.stopChan) + + manager.Close() + } + }) +} + +func TestManager_GetState_ThreadSafe(t *testing.T) { + manager := &Manager{ + state: &NetworkState{ + NetworkStatus: StatusWiFi, + WiFiSSID: "TestNetwork", + }, + stateMutex: sync.RWMutex{}, + } + + done := make(chan bool) + for i := 0; i < 10; i++ { + go func() { + state := manager.GetState() + assert.Equal(t, StatusWiFi, state.NetworkStatus) + done <- true + }() + } + + for i := 0; i < 10; i++ { + select { + case <-done: + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for goroutines") + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/monitor.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/monitor.go new file mode 100644 index 0000000..1ae2e9d --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/monitor.go @@ -0,0 +1 @@ +package network diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/priority.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/priority.go new file mode 100644 index 0000000..8ee7fdf --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/priority.go @@ -0,0 +1,187 @@ +package network + +import ( + "fmt" + "os/exec" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/godbus/dbus/v5" +) + +const ( + priorityHigh = int32(100) + priorityLow = int32(10) + priorityDefault = int32(0) + + metricPreferred = int64(100) + metricNonPreferred = int64(300) + metricDefault = int64(100) +) + +func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error { + switch pref { + case PreferenceWiFi, PreferenceEthernet, PreferenceAuto: + default: + return fmt.Errorf("invalid preference: %s", pref) + } + + m.stateMutex.Lock() + m.state.Preference = pref + m.stateMutex.Unlock() + + if _, ok := m.backend.(*NetworkManagerBackend); !ok { + m.notifySubscribers() + return nil + } + + switch pref { + case PreferenceWiFi: + return m.prioritizeWiFi() + case PreferenceEthernet: + return m.prioritizeEthernet() + case PreferenceAuto: + return m.balancePriorities() + } + + return nil +} + +func (m *Manager) prioritizeWiFi() error { + if err := m.setConnectionPriority("802-11-wireless", priorityHigh, metricPreferred); err != nil { + log.Warnf("Failed to set WiFi priority: %v", err) + } + + if err := m.setConnectionPriority("802-3-ethernet", priorityLow, metricNonPreferred); err != nil { + log.Warnf("Failed to set Ethernet priority: %v", err) + } + + m.reapplyActiveConnections() + m.notifySubscribers() + return nil +} + +func (m *Manager) prioritizeEthernet() error { + if err := m.setConnectionPriority("802-3-ethernet", priorityHigh, metricPreferred); err != nil { + log.Warnf("Failed to set Ethernet priority: %v", err) + } + + if err := m.setConnectionPriority("802-11-wireless", priorityLow, metricNonPreferred); err != nil { + log.Warnf("Failed to set WiFi priority: %v", err) + } + + m.reapplyActiveConnections() + m.notifySubscribers() + return nil +} + +func (m *Manager) balancePriorities() error { + if err := m.setConnectionPriority("802-3-ethernet", priorityDefault, metricDefault); err != nil { + log.Warnf("Failed to reset Ethernet priority: %v", err) + } + + if err := m.setConnectionPriority("802-11-wireless", priorityDefault, metricDefault); err != nil { + log.Warnf("Failed to reset WiFi priority: %v", err) + } + + m.reapplyActiveConnections() + m.notifySubscribers() + return nil +} + +func (m *Manager) reapplyActiveConnections() { + m.stateMutex.RLock() + ethDev := m.state.EthernetDevice + wifiDev := m.state.WiFiDevice + m.stateMutex.RUnlock() + + if ethDev != "" { + exec.Command("nmcli", "dev", "reapply", ethDev).Run() + } + if wifiDev != "" { + exec.Command("nmcli", "dev", "reapply", wifiDev).Run() + } +} + +func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int32, routeMetric int64) error { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return fmt.Errorf("failed to connect to system bus: %w", err) + } + defer conn.Close() + + settingsObj := conn.Object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/Settings") + + var connPaths []dbus.ObjectPath + if err := settingsObj.Call("org.freedesktop.NetworkManager.Settings.ListConnections", 0).Store(&connPaths); err != nil { + return fmt.Errorf("failed to list connections: %w", err) + } + + for _, connPath := range connPaths { + connObj := conn.Object("org.freedesktop.NetworkManager", connPath) + + var settings map[string]map[string]dbus.Variant + if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&settings); err != nil { + continue + } + + connSection, ok := settings["connection"] + if !ok { + continue + } + + typeVariant, ok := connSection["type"] + if !ok { + continue + } + + cType, ok := typeVariant.Value().(string) + if !ok || cType != connType { + continue + } + + connName := "" + if idVariant, ok := connSection["id"]; ok { + connName, _ = idVariant.Value().(string) + } + + if connName == "" { + continue + } + + if err := exec.Command("nmcli", "con", "mod", connName, + "connection.autoconnect-priority", fmt.Sprintf("%d", autoconnectPriority), + "ipv4.route-metric", fmt.Sprintf("%d", routeMetric), + "ipv6.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil { + log.Warnf("Failed to set priority for %s: %v", connName, err) + continue + } + + log.Infof("Updated %v: autoconnect-priority=%d, route-metric=%d", connName, autoconnectPriority, routeMetric) + } + + return nil +} + +func (m *Manager) GetConnectionPreference() ConnectionPreference { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + return m.state.Preference +} + +func (m *Manager) WasRecentlyFailed(ssid string) bool { + nm, ok := m.backend.(*NetworkManagerBackend) + if !ok { + return false + } + + nm.failedMutex.RLock() + defer nm.failedMutex.RUnlock() + + if nm.lastFailedSSID != ssid { + return false + } + + elapsed := time.Now().Unix() - nm.lastFailedTime + return elapsed < 10 +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/priority_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/priority_test.go new file mode 100644 index 0000000..c0c65e3 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/priority_test.go @@ -0,0 +1,50 @@ +package network + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestManager_SetConnectionPreference(t *testing.T) { + t.Run("invalid preference", func(t *testing.T) { + manager := &Manager{ + state: &NetworkState{ + Preference: PreferenceAuto, + }, + } + + err := manager.SetConnectionPreference(ConnectionPreference("invalid")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid preference") + }) +} + +func TestManager_GetConnectionPreference(t *testing.T) { + tests := []struct { + name string + preference ConnectionPreference + }{ + {"auto", PreferenceAuto}, + {"wifi", PreferenceWiFi}, + {"ethernet", PreferenceEthernet}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{ + state: &NetworkState{ + Preference: tt.preference, + }, + } + + result := manager.GetConnectionPreference() + assert.Equal(t, tt.preference, result) + }) + } +} + +// Note: Full testing of priority operations would require mocking NetworkManager +// D-Bus interfaces. The tests above cover the basic logic and error handling. +// Integration tests would be needed for complete coverage of network connection +// priority updates and reactivation. diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/subscription_broker.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/subscription_broker.go new file mode 100644 index 0000000..1b150a2 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/subscription_broker.go @@ -0,0 +1,125 @@ +package network + +import ( + "context" + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type SubscriptionBroker struct { + pending syncmap.Map[string, chan PromptReply] + requests syncmap.Map[string, PromptRequest] + pathSettingToToken syncmap.Map[string, string] + broadcastPrompt func(CredentialPrompt) +} + +func NewSubscriptionBroker(broadcastPrompt func(CredentialPrompt)) PromptBroker { + return &SubscriptionBroker{ + broadcastPrompt: broadcastPrompt, + } +} + +func (b *SubscriptionBroker) Ask(ctx context.Context, req PromptRequest) (string, error) { + pathSettingKey := fmt.Sprintf("%s:%s", req.ConnectionPath, req.SettingName) + + if existingToken, alreadyPending := b.pathSettingToToken.Load(pathSettingKey); alreadyPending { + log.Infof("[SubscriptionBroker] Duplicate prompt for %s, returning existing token", pathSettingKey) + return existingToken, nil + } + + token, err := generateToken() + if err != nil { + return "", err + } + + replyChan := make(chan PromptReply, 1) + b.pending.Store(token, replyChan) + b.requests.Store(token, req) + b.pathSettingToToken.Store(pathSettingKey, token) + + if b.broadcastPrompt != nil { + prompt := CredentialPrompt{ + Token: token, + Name: req.Name, + SSID: req.SSID, + ConnType: req.ConnType, + VpnService: req.VpnService, + Setting: req.SettingName, + Fields: req.Fields, + FieldsInfo: req.FieldsInfo, + Hints: req.Hints, + Reason: req.Reason, + ConnectionId: req.ConnectionId, + ConnectionUuid: req.ConnectionUuid, + } + 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 { + log.Warnf("[SubscriptionBroker] Resolve: unknown or expired token: %s", token) + return fmt.Errorf("unknown or expired token: %s", token) + } + + select { + case replyChan <- reply: + return nil + default: + log.Warnf("[SubscriptionBroker] Resolve: failed to deliver reply for token %s (channel full or closed)", token) + return fmt.Errorf("failed to deliver reply for token: %s", token) + } +} + +func (b *SubscriptionBroker) cleanup(token string) { + if req, exists := b.requests.Load(token); exists { + pathSettingKey := fmt.Sprintf("%s:%s", req.ConnectionPath, req.SettingName) + b.pathSettingToToken.Delete(pathSettingKey) + } + + b.pending.Delete(token) + b.requests.Delete(token) +} + +func (b *SubscriptionBroker) Cancel(path string, setting string) error { + pathSettingKey := fmt.Sprintf("%s:%s", path, setting) + + token, exists := b.pathSettingToToken.Load(pathSettingKey) + if !exists { + log.Infof("[SubscriptionBroker] Cancel: no pending prompt for %s", pathSettingKey) + return nil + } + + log.Infof("[SubscriptionBroker] Cancelling prompt for %s (token=%s)", pathSettingKey, token) + + reply := PromptReply{ + Cancel: true, + } + + return b.Resolve(token, reply) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/testing.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/testing.go new file mode 100644 index 0000000..a6c4a00 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/testing.go @@ -0,0 +1,14 @@ +package network + +// NewTestManager creates a Manager for testing with a provided backend +func NewTestManager(backend Backend, state *NetworkState) *Manager { + if state == nil { + state = &NetworkState{} + } + return &Manager{ + backend: backend, + state: state, + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/types.go new file mode 100644 index 0000000..93448cf --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/types.go @@ -0,0 +1,267 @@ +package network + +import ( + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" + "github.com/godbus/dbus/v5" +) + +type NetworkStatus string + +const ( + StatusDisconnected NetworkStatus = "disconnected" + StatusEthernet NetworkStatus = "ethernet" + StatusWiFi NetworkStatus = "wifi" + StatusVPN NetworkStatus = "vpn" +) + +type ConnectionPreference string + +const ( + PreferenceAuto ConnectionPreference = "auto" + PreferenceWiFi ConnectionPreference = "wifi" + PreferenceEthernet ConnectionPreference = "ethernet" +) + +type WiFiNetwork struct { + SSID string `json:"ssid"` + BSSID string `json:"bssid"` + Signal uint8 `json:"signal"` + Secured bool `json:"secured"` + Enterprise bool `json:"enterprise"` + Connected bool `json:"connected"` + Saved bool `json:"saved"` + Autoconnect bool `json:"autoconnect"` + Hidden bool `json:"hidden"` + Frequency uint32 `json:"frequency"` + Mode string `json:"mode"` + Rate uint32 `json:"rate"` + Channel uint32 `json:"channel"` + Device string `json:"device,omitempty"` +} + +type WiFiDevice struct { + Name string `json:"name"` + HwAddress string `json:"hwAddress"` + State string `json:"state"` + Connected bool `json:"connected"` + SSID string `json:"ssid,omitempty"` + BSSID string `json:"bssid,omitempty"` + Signal uint8 `json:"signal,omitempty"` + IP string `json:"ip,omitempty"` + Networks []WiFiNetwork `json:"networks"` +} + +type EthernetDevice struct { + Name string `json:"name"` + HwAddress string `json:"hwAddress"` + State string `json:"state"` + Connected bool `json:"connected"` + IP string `json:"ip,omitempty"` + Speed uint32 `json:"speed,omitempty"` + Driver string `json:"driver,omitempty"` +} + +type VPNProfile struct { + Name string `json:"name"` + UUID string `json:"uuid"` + Type string `json:"type"` + ServiceType string `json:"serviceType"` + RemoteHost string `json:"remoteHost,omitempty"` + Username string `json:"username,omitempty"` + Autoconnect bool `json:"autoconnect"` + Data map[string]string `json:"data,omitempty"` +} + +type VPNActive struct { + Name string `json:"name"` + UUID string `json:"uuid"` + Device string `json:"device,omitempty"` + State string `json:"state,omitempty"` + Type string `json:"type"` + Plugin string `json:"serviceType"` + IP string `json:"ip,omitempty"` + Gateway string `json:"gateway,omitempty"` + RemoteHost string `json:"remoteHost,omitempty"` + Username string `json:"username,omitempty"` + MTU uint32 `json:"mtu,omitempty"` + Data map[string]string `json:"data,omitempty"` +} + +type VPNState struct { + Profiles []VPNProfile `json:"profiles"` + Active []VPNActive `json:"activeConnections"` +} + +type NetworkState struct { + Backend string `json:"backend"` + NetworkStatus NetworkStatus `json:"networkStatus"` + Preference ConnectionPreference `json:"preference"` + EthernetIP string `json:"ethernetIP"` + EthernetDevice string `json:"ethernetDevice"` + EthernetConnected bool `json:"ethernetConnected"` + EthernetConnectionUuid string `json:"ethernetConnectionUuid"` + EthernetDevices []EthernetDevice `json:"ethernetDevices"` + WiFiIP string `json:"wifiIP"` + WiFiDevice string `json:"wifiDevice"` + WiFiConnected bool `json:"wifiConnected"` + WiFiEnabled bool `json:"wifiEnabled"` + WiFiSSID string `json:"wifiSSID"` + WiFiBSSID string `json:"wifiBSSID"` + WiFiSignal uint8 `json:"wifiSignal"` + WiFiNetworks []WiFiNetwork `json:"wifiNetworks"` + WiFiDevices []WiFiDevice `json:"wifiDevices"` + WiredConnections []WiredConnection `json:"wiredConnections"` + VPNProfiles []VPNProfile `json:"vpnProfiles"` + VPNActive []VPNActive `json:"vpnActive"` + IsConnecting bool `json:"isConnecting"` + ConnectingSSID string `json:"connectingSSID"` + ConnectingDevice string `json:"connectingDevice,omitempty"` + LastError string `json:"lastError"` +} + +type ConnectionRequest struct { + SSID string `json:"ssid"` + Password string `json:"password,omitempty"` + Username string `json:"username,omitempty"` + AnonymousIdentity string `json:"anonymousIdentity,omitempty"` + DomainSuffixMatch string `json:"domainSuffixMatch,omitempty"` + Interactive bool `json:"interactive,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Device string `json:"device,omitempty"` + EAPMethod string `json:"eapMethod,omitempty"` + Phase2Auth string `json:"phase2Auth,omitempty"` + CACertPath string `json:"caCertPath,omitempty"` + ClientCertPath string `json:"clientCertPath,omitempty"` + PrivateKeyPath string `json:"privateKeyPath,omitempty"` + UseSystemCACerts *bool `json:"useSystemCACerts,omitempty"` +} + +type WiredConnection struct { + Path dbus.ObjectPath `json:"path"` + ID string `json:"id"` + UUID string `json:"uuid"` + Type string `json:"type"` + IsActive bool `json:"isActive"` +} + +type PriorityUpdate struct { + Preference ConnectionPreference `json:"preference"` +} + +type Manager struct { + backend Backend + state *NetworkState + stateMutex sync.RWMutex + subscribers syncmap.Map[string, chan NetworkState] + stopChan chan struct{} + dirty chan struct{} + notifierWg sync.WaitGroup + lastNotifiedState *NetworkState + credentialSubscribers syncmap.Map[string, chan CredentialPrompt] +} + +type EventType string + +const ( + EventStateChanged EventType = "state_changed" + EventNetworksUpdated EventType = "networks_updated" + EventConnecting EventType = "connecting" + EventConnected EventType = "connected" + EventDisconnected EventType = "disconnected" + EventError EventType = "error" +) + +type NetworkEvent struct { + Type EventType `json:"type"` + Data NetworkState `json:"data"` +} + +type PromptRequest struct { + Name string `json:"name"` + SSID string `json:"ssid"` + ConnType string `json:"connType"` + VpnService string `json:"vpnService"` + SettingName string `json:"setting"` + Fields []string `json:"fields"` + FieldsInfo []FieldInfo `json:"fieldsInfo"` + Hints []string `json:"hints"` + Reason string `json:"reason"` + ConnectionId string `json:"connectionId"` + ConnectionUuid string `json:"connectionUuid"` + ConnectionPath string `json:"connectionPath"` +} + +type PromptReply struct { + Secrets map[string]string `json:"secrets"` + Save bool `json:"save"` + Cancel bool `json:"cancel"` +} + +type FieldInfo struct { + Name string `json:"name"` + Label string `json:"label"` + IsSecret bool `json:"isSecret"` +} + +type CredentialPrompt struct { + Token string `json:"token"` + Name string `json:"name"` + SSID string `json:"ssid"` + ConnType string `json:"connType"` + VpnService string `json:"vpnService"` + Setting string `json:"setting"` + Fields []string `json:"fields"` + FieldsInfo []FieldInfo `json:"fieldsInfo"` + Hints []string `json:"hints"` + Reason string `json:"reason"` + ConnectionId string `json:"connectionId"` + ConnectionUuid string `json:"connectionUuid"` +} + +type NetworkInfoResponse struct { + SSID string `json:"ssid"` + Bands []WiFiNetwork `json:"bands"` +} + +type WiredNetworkInfoResponse struct { + UUID string `json:"uuid"` + IFace string `json:"iface"` + Driver string `json:"driver"` + HwAddr string `json:"hwAddr"` + Speed string `json:"speed"` + IPv4 WiredIPConfig `json:"IPv4s"` + IPv6 WiredIPConfig `json:"IPv6s"` +} + +type WiredIPConfig struct { + IPs []string `json:"ips"` + Gateway string `json:"gateway"` + DNS string `json:"dns"` +} + +type VPNPlugin struct { + Name string `json:"name"` + ServiceType string `json:"serviceType"` + Program string `json:"program,omitempty"` + Supports []string `json:"supports,omitempty"` + FileExtensions []string `json:"fileExtensions"` +} + +type VPNConfig struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Type string `json:"type"` + ServiceType string `json:"serviceType,omitempty"` + Autoconnect bool `json:"autoconnect"` + Data map[string]string `json:"data,omitempty"` +} + +type VPNImportResult struct { + Success bool `json:"success"` + UUID string `json:"uuid,omitempty"` + Name string `json:"name,omitempty"` + ServiceType string `json:"serviceType,omitempty"` + Error string `json:"error,omitempty"` +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/types_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/types_test.go new file mode 100644 index 0000000..1c3e0bf --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/types_test.go @@ -0,0 +1,178 @@ +package network + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNetworkStatus_Constants(t *testing.T) { + assert.Equal(t, NetworkStatus("disconnected"), StatusDisconnected) + assert.Equal(t, NetworkStatus("ethernet"), StatusEthernet) + assert.Equal(t, NetworkStatus("wifi"), StatusWiFi) +} + +func TestConnectionPreference_Constants(t *testing.T) { + assert.Equal(t, ConnectionPreference("auto"), PreferenceAuto) + assert.Equal(t, ConnectionPreference("wifi"), PreferenceWiFi) + assert.Equal(t, ConnectionPreference("ethernet"), PreferenceEthernet) +} + +func TestEventType_Constants(t *testing.T) { + assert.Equal(t, EventType("state_changed"), EventStateChanged) + assert.Equal(t, EventType("networks_updated"), EventNetworksUpdated) + assert.Equal(t, EventType("connecting"), EventConnecting) + assert.Equal(t, EventType("connected"), EventConnected) + assert.Equal(t, EventType("disconnected"), EventDisconnected) + assert.Equal(t, EventType("error"), EventError) +} + +func TestWiFiNetwork_JSON(t *testing.T) { + network := WiFiNetwork{ + SSID: "TestNetwork", + BSSID: "00:11:22:33:44:55", + Signal: 85, + Secured: true, + Enterprise: false, + Connected: true, + Saved: true, + Frequency: 2437, + Mode: "infrastructure", + Rate: 300, + Channel: 6, + } + + data, err := json.Marshal(network) + require.NoError(t, err) + + var decoded WiFiNetwork + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, network.SSID, decoded.SSID) + assert.Equal(t, network.BSSID, decoded.BSSID) + assert.Equal(t, network.Signal, decoded.Signal) + assert.Equal(t, network.Secured, decoded.Secured) + assert.Equal(t, network.Enterprise, decoded.Enterprise) + assert.Equal(t, network.Connected, decoded.Connected) + assert.Equal(t, network.Saved, decoded.Saved) + assert.Equal(t, network.Frequency, decoded.Frequency) + assert.Equal(t, network.Mode, decoded.Mode) + assert.Equal(t, network.Rate, decoded.Rate) + assert.Equal(t, network.Channel, decoded.Channel) +} + +func TestNetworkState_JSON(t *testing.T) { + state := NetworkState{ + NetworkStatus: StatusWiFi, + Preference: PreferenceAuto, + EthernetIP: "192.168.1.100", + EthernetDevice: "eth0", + EthernetConnected: false, + WiFiIP: "192.168.1.101", + WiFiDevice: "wlan0", + WiFiConnected: true, + WiFiEnabled: true, + WiFiSSID: "TestNetwork", + WiFiBSSID: "00:11:22:33:44:55", + WiFiSignal: 85, + WiFiNetworks: []WiFiNetwork{ + {SSID: "Network1", Signal: 90}, + {SSID: "Network2", Signal: 60}, + }, + IsConnecting: false, + ConnectingSSID: "", + LastError: "", + } + + data, err := json.Marshal(state) + require.NoError(t, err) + + var decoded NetworkState + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, state.NetworkStatus, decoded.NetworkStatus) + assert.Equal(t, state.Preference, decoded.Preference) + assert.Equal(t, state.WiFiIP, decoded.WiFiIP) + assert.Equal(t, state.WiFiSSID, decoded.WiFiSSID) + assert.Equal(t, len(state.WiFiNetworks), len(decoded.WiFiNetworks)) +} + +func TestConnectionRequest_JSON(t *testing.T) { + t.Run("with password", func(t *testing.T) { + req := ConnectionRequest{ + SSID: "TestNetwork", + Password: "testpass123", + } + + data, err := json.Marshal(req) + require.NoError(t, err) + + var decoded ConnectionRequest + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, req.SSID, decoded.SSID) + assert.Equal(t, req.Password, decoded.Password) + assert.Empty(t, decoded.Username) + }) + + t.Run("with username and password (enterprise)", func(t *testing.T) { + req := ConnectionRequest{ + SSID: "EnterpriseNetwork", + Password: "testpass123", + Username: "testuser", + } + + data, err := json.Marshal(req) + require.NoError(t, err) + + var decoded ConnectionRequest + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, req.SSID, decoded.SSID) + assert.Equal(t, req.Password, decoded.Password) + assert.Equal(t, req.Username, decoded.Username) + }) +} + +func TestPriorityUpdate_JSON(t *testing.T) { + update := PriorityUpdate{ + Preference: PreferenceWiFi, + } + + data, err := json.Marshal(update) + require.NoError(t, err) + + var decoded PriorityUpdate + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, update.Preference, decoded.Preference) +} + +func TestNetworkEvent_JSON(t *testing.T) { + event := NetworkEvent{ + Type: EventStateChanged, + Data: NetworkState{ + NetworkStatus: StatusWiFi, + WiFiSSID: "TestNetwork", + WiFiConnected: true, + }, + } + + data, err := json.Marshal(event) + require.NoError(t, err) + + var decoded NetworkEvent + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, event.Type, decoded.Type) + assert.Equal(t, event.Data.NetworkStatus, decoded.Data.NetworkStatus) + assert.Equal(t, event.Data.WiFiSSID, decoded.Data.WiFiSSID) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/wifi_qrcode.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/wifi_qrcode.go new file mode 100644 index 0000000..680b6d1 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/wifi_qrcode.go @@ -0,0 +1,59 @@ +package network + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" +) + +const qrCodeTmpPrefix = "/tmp/dank-wifi-qrcode-" + +func FormatWiFiQRString(securityType, ssid, password string) string { + return fmt.Sprintf("WIFI:T:%s;S:%s;P:%s;;", securityType, ssid, password) +} + +func qrCodePaths(ssid string) (themed, normal string) { + safe := sanitizeSSIDForPath(ssid) + themed = fmt.Sprintf("%s%s-themed.png", qrCodeTmpPrefix, safe) + normal = fmt.Sprintf("%s%s-normal.png", qrCodeTmpPrefix, safe) + return +} + +func isValidQRCodePath(path string) bool { + clean := filepath.Clean(path) + return strings.HasPrefix(clean, qrCodeTmpPrefix) && strings.HasSuffix(clean, ".png") +} + +var safePathChar = regexp.MustCompile(`[^a-zA-Z0-9_-]`) + +func sanitizeSSIDForPath(ssid string) string { + return safePathChar.ReplaceAllString(ssid, "_") +} + +var iwdVerbatimSSID = regexp.MustCompile(`^[a-zA-Z0-9 _-]+$`) + +func iwdConfigPath(ssid string) string { + switch { + case iwdVerbatimSSID.MatchString(ssid): + return fmt.Sprintf("/var/lib/iwd/%s.psk", ssid) + default: + return fmt.Sprintf("/var/lib/iwd/=%x.psk", []byte(ssid)) + } +} + +func parseIWDPassphrase(data string) (string, error) { + inSecurity := false + for _, line := range strings.Split(data, "\n") { + line = strings.TrimSpace(line) + switch { + case line == "[Security]": + inSecurity = true + case strings.HasPrefix(line, "["): + inSecurity = false + case inSecurity && strings.HasPrefix(line, "Passphrase="): + return strings.TrimPrefix(line, "Passphrase="), nil + } + } + return "", fmt.Errorf("no passphrase found in iwd config") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/wifi_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/wifi_test.go new file mode 100644 index 0000000..2256b60 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/wifi_test.go @@ -0,0 +1,148 @@ +package network + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFrequencyToChannel(t *testing.T) { + tests := []struct { + name string + frequency uint32 + channel uint32 + }{ + {"2.4 GHz channel 1", 2412, 1}, + {"2.4 GHz channel 6", 2437, 6}, + {"2.4 GHz channel 11", 2462, 11}, + {"2.4 GHz channel 14", 2484, 14}, + {"5 GHz channel 36", 5180, 36}, + {"5 GHz channel 40", 5200, 40}, + {"5 GHz channel 165", 5825, 165}, + {"6 GHz channel 1", 5955, 1}, + {"6 GHz channel 233", 7115, 233}, + {"Unknown frequency", 1000, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := frequencyToChannel(tt.frequency) + assert.Equal(t, tt.channel, result) + }) + } +} + +func TestSortWiFiNetworks(t *testing.T) { + t.Run("connected network comes first", func(t *testing.T) { + networks := []WiFiNetwork{ + {SSID: "Network1", Signal: 90, Connected: false}, + {SSID: "Network2", Signal: 80, Connected: true}, + {SSID: "Network3", Signal: 70, Connected: false}, + } + + sortWiFiNetworks(networks) + + assert.Equal(t, "Network2", networks[0].SSID) + assert.True(t, networks[0].Connected) + }) + + t.Run("sorts by signal strength", func(t *testing.T) { + networks := []WiFiNetwork{ + {SSID: "Weak", Signal: 40, Secured: true}, + {SSID: "Strong", Signal: 90, Secured: true}, + {SSID: "Medium", Signal: 60, Secured: true}, + } + + sortWiFiNetworks(networks) + + assert.Equal(t, "Strong", networks[0].SSID) + assert.Equal(t, "Medium", networks[1].SSID) + assert.Equal(t, "Weak", networks[2].SSID) + }) + + t.Run("prioritizes open networks with good signal", func(t *testing.T) { + networks := []WiFiNetwork{ + {SSID: "SecureWeak", Signal: 40, Secured: true}, + {SSID: "OpenStrong", Signal: 60, Secured: false}, + {SSID: "SecureStrong", Signal: 90, Secured: true}, + } + + sortWiFiNetworks(networks) + + assert.Equal(t, "OpenStrong", networks[0].SSID) + + openIdx := -1 + weakSecureIdx := -1 + for i, n := range networks { + if n.SSID == "OpenStrong" { + openIdx = i + } + if n.SSID == "SecureWeak" { + weakSecureIdx = i + } + } + assert.Less(t, openIdx, weakSecureIdx, "OpenStrong should come before SecureWeak") + }) + + t.Run("prioritizes saved networks after connected", func(t *testing.T) { + networks := []WiFiNetwork{ + {SSID: "UnsavedStrong", Signal: 95, Saved: false}, + {SSID: "SavedMedium", Signal: 60, Saved: true}, + {SSID: "SavedWeak", Signal: 50, Saved: true}, + {SSID: "UnsavedMedium", Signal: 70, Saved: false}, + } + + sortWiFiNetworks(networks) + + assert.Equal(t, "SavedMedium", networks[0].SSID) + assert.Equal(t, "SavedWeak", networks[1].SSID) + assert.Equal(t, "UnsavedStrong", networks[2].SSID) + assert.Equal(t, "UnsavedMedium", networks[3].SSID) + }) +} + +func TestManager_GetWiFiNetworks(t *testing.T) { + manager := &Manager{ + state: &NetworkState{ + WiFiNetworks: []WiFiNetwork{ + {SSID: "Network1", Signal: 90}, + {SSID: "Network2", Signal: 80}, + }, + }, + } + + networks := manager.GetWiFiNetworks() + + assert.Len(t, networks, 2) + assert.Equal(t, "Network1", networks[0].SSID) + assert.Equal(t, "Network2", networks[1].SSID) + + networks[0].SSID = "Modified" + assert.Equal(t, "Network1", manager.state.WiFiNetworks[0].SSID) +} + +func TestManager_GetNetworkInfo(t *testing.T) { + manager := &Manager{ + state: &NetworkState{ + WiFiNetworks: []WiFiNetwork{ + {SSID: "Network1", Signal: 90, BSSID: "00:11:22:33:44:55"}, + {SSID: "Network2", Signal: 80, BSSID: "AA:BB:CC:DD:EE:FF"}, + }, + }, + } + + t.Run("finds existing network", func(t *testing.T) { + network, err := manager.GetNetworkInfo("Network1") + assert.NoError(t, err) + assert.NotNil(t, network) + assert.Equal(t, "Network1", network.SSID) + assert.Equal(t, uint8(90), network.Signal) + }) + + t.Run("returns error for non-existent network", func(t *testing.T) { + network, err := manager.GetNetworkInfo("NonExistent") + assert.Error(t, err) + assert.Nil(t, network) + assert.Contains(t, err.Error(), "network not found") + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/wired_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/wired_test.go new file mode 100644 index 0000000..5c346e7 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/network/wired_test.go @@ -0,0 +1,51 @@ +package network + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestManager_GetWiredConfigs(t *testing.T) { + manager := &Manager{ + state: &NetworkState{ + EthernetConnected: true, + WiredConnections: []WiredConnection{ + {ID: "Test", IsActive: true}, + }, + }, + } + + configs := manager.GetWiredConfigs() + + assert.Len(t, configs, 1) + assert.Equal(t, "Test", configs[0].ID) +} + +func TestManager_GetEthernetDevices(t *testing.T) { + manager := &Manager{ + state: &NetworkState{ + EthernetDevices: []EthernetDevice{ + {Name: "enp0s3", Connected: true, IP: "192.168.1.100"}, + {Name: "enp0s8", Connected: false}, + }, + }, + } + + devices := manager.GetEthernetDevices() + + assert.Len(t, devices, 2) + assert.Equal(t, "enp0s3", devices[0].Name) + assert.True(t, devices[0].Connected) + assert.Equal(t, "enp0s8", devices[1].Name) + assert.False(t, devices[1].Connected) +} + +func TestManager_GetEthernetDevices_Empty(t *testing.T) { + manager := &Manager{ + state: &NetworkState{}, + } + + devices := manager.GetEthernetDevices() + assert.Empty(t, devices) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/params/params.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/params/params.go new file mode 100644 index 0000000..1f592c6 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/params/params.go @@ -0,0 +1,113 @@ +package params + +import "fmt" + +func Get[T any](params map[string]any, key string) (T, error) { + val, ok := params[key].(T) + if !ok { + var zero T + return zero, fmt.Errorf("missing or invalid '%s' parameter", key) + } + return val, nil +} + +func GetOpt[T any](params map[string]any, key string, def T) T { + if val, ok := params[key].(T); ok { + return val + } + return def +} + +func String(params map[string]any, key string) (string, error) { + return Get[string](params, key) +} + +func StringNonEmpty(params map[string]any, key string) (string, error) { + val, err := Get[string](params, key) + if err != nil || val == "" { + return "", fmt.Errorf("missing or invalid '%s' parameter", key) + } + return val, nil +} + +func StringOpt(params map[string]any, key string, def string) string { + return GetOpt(params, key, def) +} + +func Int(params map[string]any, key string) (int, error) { + val, err := Get[float64](params, key) + if err != nil { + return 0, err + } + return int(val), nil +} + +func IntOpt(params map[string]any, key string, def int) int { + if val, ok := params[key].(float64); ok { + return int(val) + } + return def +} + +func Float(params map[string]any, key string) (float64, error) { + return Get[float64](params, key) +} + +func FloatOpt(params map[string]any, key string, def float64) float64 { + return GetOpt(params, key, def) +} + +func Bool(params map[string]any, key string) (bool, error) { + return Get[bool](params, key) +} + +func BoolOpt(params map[string]any, key string, def bool) bool { + return GetOpt(params, key, def) +} + +func StringMap(params map[string]any, key string) (map[string]string, error) { + rawMap, err := Get[map[string]any](params, key) + if err != nil { + return nil, err + } + result := make(map[string]string) + for k, v := range rawMap { + if str, ok := v.(string); ok { + result[k] = str + } + } + return result, nil +} + +func StringMapOpt(params map[string]any, key string) map[string]string { + rawMap, ok := params[key].(map[string]any) + if !ok { + return nil + } + result := make(map[string]string) + for k, v := range rawMap { + if str, ok := v.(string); ok { + result[k] = str + } + } + return result +} + +func Any(params map[string]any, key string) (any, bool) { + val, ok := params[key] + return val, ok +} + +func AnyMap(params map[string]any, key string) (map[string]any, bool) { + val, ok := params[key].(map[string]any) + return val, ok +} + +func StringAlt(params map[string]any, keys ...string) (string, bool) { + for _, key := range keys { + if val, ok := params[key].(string); ok { + return val, true + } + } + return "", false +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/params/params_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/params/params_test.go new file mode 100644 index 0000000..99847ff --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/params/params_test.go @@ -0,0 +1,154 @@ +package params + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGet(t *testing.T) { + p := map[string]any{"key": "value"} + val, err := Get[string](p, "key") + assert.NoError(t, err) + assert.Equal(t, "value", val) + + _, err = Get[string](p, "missing") + assert.Error(t, err) + + _, err = Get[int](p, "key") + assert.Error(t, err) +} + +func TestGetOpt(t *testing.T) { + p := map[string]any{"key": "value"} + assert.Equal(t, "value", GetOpt(p, "key", "default")) + assert.Equal(t, "default", GetOpt(p, "missing", "default")) +} + +func TestString(t *testing.T) { + p := map[string]any{"s": "hello", "n": 123} + val, err := String(p, "s") + assert.NoError(t, err) + assert.Equal(t, "hello", val) + + _, err = String(p, "n") + assert.Error(t, err) +} + +func TestStringNonEmpty(t *testing.T) { + p := map[string]any{"s": "hello", "empty": ""} + val, err := StringNonEmpty(p, "s") + assert.NoError(t, err) + assert.Equal(t, "hello", val) + + _, err = StringNonEmpty(p, "empty") + assert.Error(t, err) + + _, err = StringNonEmpty(p, "missing") + assert.Error(t, err) +} + +func TestStringOpt(t *testing.T) { + p := map[string]any{"s": "hello"} + assert.Equal(t, "hello", StringOpt(p, "s", "default")) + assert.Equal(t, "default", StringOpt(p, "missing", "default")) +} + +func TestInt(t *testing.T) { + p := map[string]any{"n": float64(42), "s": "str"} + val, err := Int(p, "n") + assert.NoError(t, err) + assert.Equal(t, 42, val) + + _, err = Int(p, "s") + assert.Error(t, err) +} + +func TestIntOpt(t *testing.T) { + p := map[string]any{"n": float64(42)} + assert.Equal(t, 42, IntOpt(p, "n", 0)) + assert.Equal(t, 99, IntOpt(p, "missing", 99)) +} + +func TestFloat(t *testing.T) { + p := map[string]any{"f": 3.14, "s": "str"} + val, err := Float(p, "f") + assert.NoError(t, err) + assert.Equal(t, 3.14, val) + + _, err = Float(p, "s") + assert.Error(t, err) +} + +func TestFloatOpt(t *testing.T) { + p := map[string]any{"f": 3.14} + assert.Equal(t, 3.14, FloatOpt(p, "f", 0)) + assert.Equal(t, 1.0, FloatOpt(p, "missing", 1.0)) +} + +func TestBool(t *testing.T) { + p := map[string]any{"b": true, "s": "str"} + val, err := Bool(p, "b") + assert.NoError(t, err) + assert.True(t, val) + + _, err = Bool(p, "s") + assert.Error(t, err) +} + +func TestBoolOpt(t *testing.T) { + p := map[string]any{"b": true} + assert.True(t, BoolOpt(p, "b", false)) + assert.True(t, BoolOpt(p, "missing", true)) +} + +func TestStringMap(t *testing.T) { + p := map[string]any{ + "m": map[string]any{"a": "1", "b": "2", "c": 3}, + } + val, err := StringMap(p, "m") + assert.NoError(t, err) + assert.Equal(t, map[string]string{"a": "1", "b": "2"}, val) + + _, err = StringMap(p, "missing") + assert.Error(t, err) +} + +func TestStringMapOpt(t *testing.T) { + p := map[string]any{ + "m": map[string]any{"a": "1"}, + } + assert.Equal(t, map[string]string{"a": "1"}, StringMapOpt(p, "m")) + assert.Nil(t, StringMapOpt(p, "missing")) +} + +func TestAny(t *testing.T) { + p := map[string]any{"k": 123} + val, ok := Any(p, "k") + assert.True(t, ok) + assert.Equal(t, 123, val) + + _, ok = Any(p, "missing") + assert.False(t, ok) +} + +func TestAnyMap(t *testing.T) { + inner := map[string]any{"nested": true} + p := map[string]any{"m": inner} + val, ok := AnyMap(p, "m") + assert.True(t, ok) + assert.Equal(t, inner, val) + + _, ok = AnyMap(p, "missing") + assert.False(t, ok) +} + +func TestStringAlt(t *testing.T) { + p := map[string]any{"b": "found"} + val, ok := StringAlt(p, "a", "b", "c") + assert.True(t, ok) + assert.Equal(t, "found", val) + + _, ok = StringAlt(p, "x", "y") + assert.False(t, ok) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/handlers.go new file mode 100644 index 0000000..33f6564 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/handlers.go @@ -0,0 +1,27 @@ +package plugins + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +func HandleRequest(conn net.Conn, req models.Request) { + switch req.Method { + case "plugins.list": + HandleList(conn, req) + case "plugins.listInstalled": + HandleListInstalled(conn, req) + case "plugins.install": + HandleInstall(conn, req) + case "plugins.uninstall": + HandleUninstall(conn, req) + case "plugins.update": + HandleUpdate(conn, req) + case "plugins.search": + HandleSearch(conn, req) + default: + models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/handlers_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/handlers_test.go new file mode 100644 index 0000000..70202e3 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/handlers_test.go @@ -0,0 +1,196 @@ +package plugins + +import ( + "encoding/json" + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/net" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestHandleList(t *testing.T) { + conn := net.NewMockConn(t) + conn.EXPECT().Write(mock.Anything).Return(0, nil).Maybe() + + req := models.Request{ + ID: 123, + Method: "plugins.list", + Params: map[string]any{}, + } + + HandleList(conn, req) +} + +func TestHandleListInstalled(t *testing.T) { + conn := net.NewMockConn(t) + conn.EXPECT().Write(mock.Anything).Return(0, nil).Maybe() + + req := models.Request{ + ID: 123, + Method: "plugins.listInstalled", + Params: map[string]any{}, + } + + HandleListInstalled(conn, req) +} + +func TestHandleInstallMissingName(t *testing.T) { + conn := net.NewMockConn(t) + var written []byte + conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) { + written = b + return len(b), nil + }).Maybe() + + req := models.Request{ + ID: 123, + Method: "plugins.install", + Params: map[string]any{}, + } + + HandleInstall(conn, req) + + var resp models.Response[SuccessResult] + err := json.Unmarshal(written, &resp) + assert.NoError(t, err) + assert.NotEmpty(t, resp.Error) + assert.Contains(t, resp.Error, "missing or invalid 'name' parameter") +} + +func TestHandleInstallInvalidName(t *testing.T) { + conn := net.NewMockConn(t) + var written []byte + conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) { + written = b + return len(b), nil + }).Maybe() + + req := models.Request{ + ID: 123, + Method: "plugins.install", + Params: map[string]any{ + "name": 123, + }, + } + + HandleInstall(conn, req) + + var resp models.Response[SuccessResult] + err := json.Unmarshal(written, &resp) + assert.NoError(t, err) + assert.NotEmpty(t, resp.Error) +} + +func TestHandleUninstallMissingName(t *testing.T) { + conn := net.NewMockConn(t) + var written []byte + conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) { + written = b + return len(b), nil + }).Maybe() + + req := models.Request{ + ID: 123, + Method: "plugins.uninstall", + Params: map[string]any{}, + } + + HandleUninstall(conn, req) + + var resp models.Response[SuccessResult] + err := json.Unmarshal(written, &resp) + assert.NoError(t, err) + assert.NotEmpty(t, resp.Error) +} + +func TestHandleUpdateMissingName(t *testing.T) { + conn := net.NewMockConn(t) + var written []byte + conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) { + written = b + return len(b), nil + }).Maybe() + + req := models.Request{ + ID: 123, + Method: "plugins.update", + Params: map[string]any{}, + } + + HandleUpdate(conn, req) + + var resp models.Response[SuccessResult] + err := json.Unmarshal(written, &resp) + assert.NoError(t, err) + assert.NotEmpty(t, resp.Error) +} + +func TestHandleSearchMissingQuery(t *testing.T) { + conn := net.NewMockConn(t) + var written []byte + conn.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) { + written = b + return len(b), nil + }).Maybe() + + req := models.Request{ + ID: 123, + Method: "plugins.search", + Params: map[string]any{}, + } + + HandleSearch(conn, req) + + var resp models.Response[[]PluginInfo] + err := json.Unmarshal(written, &resp) + assert.NoError(t, err) + assert.NotEmpty(t, resp.Error) +} + +func TestSortPluginInfoByFirstParty(t *testing.T) { + plugins := []PluginInfo{ + {Name: "third-party", Repo: "https://github.com/other/test"}, + {Name: "first-party", Repo: "https://github.com/AvengeMedia/test"}, + } + + SortPluginInfoByFirstParty(plugins) + + assert.Equal(t, "first-party", plugins[0].Name) + assert.Equal(t, "third-party", plugins[1].Name) +} + +func TestPluginInfoJSON(t *testing.T) { + info := PluginInfo{ + Name: "test", + Description: "test description", + Installed: true, + FirstParty: true, + } + + data, err := json.Marshal(info) + assert.NoError(t, err) + + var unmarshaled PluginInfo + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err) + assert.Equal(t, info.Name, unmarshaled.Name) + assert.Equal(t, info.Installed, unmarshaled.Installed) +} + +func TestSuccessResult(t *testing.T) { + result := SuccessResult{ + Success: true, + Message: "test message", + } + + data, err := json.Marshal(result) + assert.NoError(t, err) + + var unmarshaled SuccessResult + err = json.Unmarshal(data, &unmarshaled) + assert.NoError(t, err) + assert.True(t, unmarshaled.Success) + assert.Equal(t, "test message", unmarshaled.Message) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/install.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/install.go new file mode 100644 index 0000000..2c13a0e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/install.go @@ -0,0 +1,69 @@ +package plugins + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +func HandleInstall(conn net.Conn, req models.Request) { + idOrName, ok := models.Get[string](req, "name") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'name' parameter") + return + } + + registry, err := plugins.NewRegistry() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err)) + return + } + + pluginList, err := registry.List() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err)) + return + } + + // First, try to find by ID (preferred method) + var plugin *plugins.Plugin + for _, p := range pluginList { + if p.ID == idOrName { + plugin = &p + break + } + } + + // Fallback to name for backward compatibility + if plugin == nil { + for _, p := range pluginList { + if p.Name == idOrName { + plugin = &p + break + } + } + } + + if plugin == nil { + models.RespondError(conn, req.ID, fmt.Sprintf("plugin not found: %s", idOrName)) + return + } + + manager, err := plugins.NewManager() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err)) + return + } + + if err := manager.Install(*plugin); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to install plugin: %v", err)) + return + } + + models.Respond(conn, req.ID, SuccessResult{ + Success: true, + Message: fmt.Sprintf("plugin installed: %s", plugin.Name), + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/list.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/list.go new file mode 100644 index 0000000..e44d034 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/list.go @@ -0,0 +1,53 @@ +package plugins + +import ( + "fmt" + "net" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +func HandleList(conn net.Conn, req models.Request) { + registry, err := plugins.NewRegistry() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err)) + return + } + + pluginList, err := registry.List() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err)) + return + } + + manager, err := plugins.NewManager() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err)) + return + } + + result := make([]PluginInfo, len(pluginList)) + for i, p := range pluginList { + installed, _ := manager.IsInstalled(p) + result[i] = PluginInfo{ + ID: p.ID, + Name: p.Name, + Category: p.Category, + Author: p.Author, + Description: p.Description, + Repo: p.Repo, + Path: p.Path, + Capabilities: p.Capabilities, + Compositors: p.Compositors, + Dependencies: p.Dependencies, + Installed: installed, + FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"), + Featured: p.Featured, + RequiresDMS: p.RequiresDMS, + } + } + + models.Respond(conn, req.ID, result) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/list_installed.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/list_installed.go new file mode 100644 index 0000000..b771b0e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/list_installed.go @@ -0,0 +1,77 @@ +package plugins + +import ( + "fmt" + "net" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +func HandleListInstalled(conn net.Conn, req models.Request) { + manager, err := plugins.NewManager() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err)) + return + } + + installedNames, err := manager.ListInstalled() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list installed plugins: %v", err)) + return + } + + registry, err := plugins.NewRegistry() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err)) + return + } + + allPlugins, err := registry.List() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err)) + return + } + + pluginMap := make(map[string]plugins.Plugin) + for _, p := range allPlugins { + pluginMap[p.ID] = p + } + + result := make([]PluginInfo, 0, len(installedNames)) + for _, id := range installedNames { + if plugin, ok := pluginMap[id]; ok { + hasUpdate := false + if hasUpdates, err := manager.HasUpdates(id, plugin); err == nil { + hasUpdate = hasUpdates + } + + result = append(result, PluginInfo{ + ID: plugin.ID, + Name: plugin.Name, + Category: plugin.Category, + Author: plugin.Author, + Description: plugin.Description, + Repo: plugin.Repo, + Path: plugin.Path, + Capabilities: plugin.Capabilities, + Compositors: plugin.Compositors, + Dependencies: plugin.Dependencies, + FirstParty: strings.HasPrefix(plugin.Repo, "https://github.com/AvengeMedia"), + HasUpdate: hasUpdate, + RequiresDMS: plugin.RequiresDMS, + }) + } else { + result = append(result, PluginInfo{ + ID: id, + Name: id, + Note: "not in registry", + }) + } + } + + SortPluginInfoByFirstParty(result) + + models.Respond(conn, req.ID, result) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/search.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/search.go new file mode 100644 index 0000000..9dbea6d --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/search.go @@ -0,0 +1,74 @@ +package plugins + +import ( + "fmt" + "net" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +func HandleSearch(conn net.Conn, req models.Request) { + query, ok := models.Get[string](req, "query") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'query' parameter") + return + } + + registry, err := plugins.NewRegistry() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err)) + return + } + + pluginList, err := registry.List() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list plugins: %v", err)) + return + } + + searchResults := plugins.FuzzySearch(query, pluginList) + + if category := models.GetOr(req, "category", ""); category != "" { + searchResults = plugins.FilterByCategory(category, searchResults) + } + + if compositor := models.GetOr(req, "compositor", ""); compositor != "" { + searchResults = plugins.FilterByCompositor(compositor, searchResults) + } + + if capability := models.GetOr(req, "capability", ""); capability != "" { + searchResults = plugins.FilterByCapability(capability, searchResults) + } + + searchResults = plugins.SortByFirstParty(searchResults) + + manager, err := plugins.NewManager() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err)) + return + } + + result := make([]PluginInfo, len(searchResults)) + for i, p := range searchResults { + installed, _ := manager.IsInstalled(p) + result[i] = PluginInfo{ + ID: p.ID, + Name: p.Name, + Category: p.Category, + Author: p.Author, + Description: p.Description, + Repo: p.Repo, + Path: p.Path, + Capabilities: p.Capabilities, + Compositors: p.Compositors, + Dependencies: p.Dependencies, + Installed: installed, + FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"), + RequiresDMS: p.RequiresDMS, + } + } + + models.Respond(conn, req.ID, result) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/types.go new file mode 100644 index 0000000..8290f72 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/types.go @@ -0,0 +1,25 @@ +package plugins + +type PluginInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category,omitempty"` + Author string `json:"author,omitempty"` + Description string `json:"description,omitempty"` + Repo string `json:"repo,omitempty"` + Path string `json:"path,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Compositors []string `json:"compositors,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + Installed bool `json:"installed,omitempty"` + FirstParty bool `json:"firstParty,omitempty"` + Featured bool `json:"featured,omitempty"` + Note string `json:"note,omitempty"` + HasUpdate bool `json:"hasUpdate,omitempty"` + RequiresDMS string `json:"requires_dms,omitempty"` +} + +type SuccessResult struct { + Success bool `json:"success"` + Message string `json:"message"` +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/uninstall.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/uninstall.go new file mode 100644 index 0000000..7a3d698 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/uninstall.go @@ -0,0 +1,66 @@ +package plugins + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +func HandleUninstall(conn net.Conn, req models.Request) { + name, ok := models.Get[string](req, "name") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'name' parameter") + return + } + + manager, err := plugins.NewManager() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err)) + return + } + + // First try to find in registry (by name or ID) + registry, err := plugins.NewRegistry() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err)) + return + } + + pluginList, _ := registry.List() + plugin := plugins.FindByIDOrName(name, pluginList) + + // If found in registry, use that + if plugin != nil { + installed, err := manager.IsInstalled(*plugin) + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if plugin is installed: %v", err)) + return + } + if !installed { + models.RespondError(conn, req.ID, fmt.Sprintf("plugin not installed: %s", name)) + return + } + if err := manager.Uninstall(*plugin); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to uninstall plugin: %v", err)) + return + } + models.Respond(conn, req.ID, SuccessResult{ + Success: true, + Message: fmt.Sprintf("plugin uninstalled: %s", plugin.Name), + }) + return + } + + // Not in registry - try to find and uninstall from installed plugins directly + if err := manager.UninstallByIDOrName(name); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("plugin not found: %s", name)) + return + } + + models.Respond(conn, req.ID, SuccessResult{ + Success: true, + Message: fmt.Sprintf("plugin uninstalled: %s", name), + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/update.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/update.go new file mode 100644 index 0000000..4ea762a --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/update.go @@ -0,0 +1,64 @@ +package plugins + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +func HandleUpdate(conn net.Conn, req models.Request) { + name, ok := models.Get[string](req, "name") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'name' parameter") + return + } + + manager, err := plugins.NewManager() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err)) + return + } + + registry, err := plugins.NewRegistry() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err)) + return + } + + pluginList, _ := registry.List() + plugin := plugins.FindByIDOrName(name, pluginList) + + if plugin != nil { + installed, err := manager.IsInstalled(*plugin) + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if plugin is installed: %v", err)) + return + } + if !installed { + models.RespondError(conn, req.ID, fmt.Sprintf("plugin not installed: %s", name)) + return + } + if err := manager.Update(*plugin); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to update plugin: %v", err)) + return + } + models.Respond(conn, req.ID, SuccessResult{ + Success: true, + Message: fmt.Sprintf("plugin updated: %s", plugin.Name), + }) + return + } + + // Not in registry - try to update from installed plugins directly + if err := manager.UpdateByIDOrName(name); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("plugin not found: %s", name)) + return + } + + models.Respond(conn, req.ID, SuccessResult{ + Success: true, + Message: fmt.Sprintf("plugin updated: %s", name), + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/utils.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/utils.go new file mode 100644 index 0000000..8b89f44 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/plugins/utils.go @@ -0,0 +1,17 @@ +package plugins + +import ( + "sort" + "strings" +) + +func SortPluginInfoByFirstParty(pluginInfos []PluginInfo) { + sort.SliceStable(pluginInfos, func(i, j int) bool { + isFirstPartyI := strings.HasPrefix(pluginInfos[i].Repo, "https://github.com/AvengeMedia") + isFirstPartyJ := strings.HasPrefix(pluginInfos[j].Repo, "https://github.com/AvengeMedia") + if isFirstPartyI != isFirstPartyJ { + return isFirstPartyI + } + return false + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/router.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/router.go new file mode 100644 index 0000000..6b8c10b --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/router.go @@ -0,0 +1,250 @@ +package server + +import ( + "fmt" + "net" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" + serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/location" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" + serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" + serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" +) + +func RouteRequest(conn net.Conn, req models.Request) { + if strings.HasPrefix(req.Method, "network.") { + if networkManager == nil { + models.RespondError(conn, req.ID, "network manager not initialized") + return + } + network.HandleRequest(conn, req, networkManager) + return + } + + if strings.HasPrefix(req.Method, "plugins.") { + serverPlugins.HandleRequest(conn, req) + return + } + + if strings.HasPrefix(req.Method, "themes.") { + serverThemes.HandleRequest(conn, req) + return + } + + if strings.HasPrefix(req.Method, "theme.auto.") { + if themeModeManager == nil { + models.RespondError(conn, req.ID, "theme mode manager not initialized") + return + } + thememode.HandleRequest(conn, req, themeModeManager) + return + } + + if strings.HasPrefix(req.Method, "loginctl.") { + if loginctlManager == nil { + models.RespondError(conn, req.ID, "loginctl manager not initialized") + return + } + loginctl.HandleRequest(conn, req, loginctlManager) + return + } + + if strings.HasPrefix(req.Method, "freedesktop.") { + if freedesktopManager == nil { + models.RespondError(conn, req.ID, "freedesktop manager not initialized") + return + } + freedesktop.HandleRequest(conn, req, freedesktopManager) + return + } + + if strings.HasPrefix(req.Method, "wayland.") { + if waylandManager == nil { + models.RespondError(conn, req.ID, "wayland manager not initialized") + return + } + wayland.HandleRequest(conn, req, waylandManager) + return + } + + if strings.HasPrefix(req.Method, "bluetooth.") { + if bluezManager == nil { + models.RespondError(conn, req.ID, "bluetooth manager not initialized") + return + } + bluez.HandleRequest(conn, req, bluezManager) + return + } + + if strings.HasPrefix(req.Method, "browser.") || strings.HasPrefix(req.Method, "apppicker.") { + if appPickerManager == nil { + models.RespondError(conn, req.ID, "apppicker manager not initialized") + return + } + apppicker.HandleRequest(conn, req, appPickerManager) + return + } + + if strings.HasPrefix(req.Method, "cups.") { + if cupsManager == nil { + models.RespondError(conn, req.ID, "CUPS manager not initialized") + return + } + cups.HandleRequest(conn, req, cupsManager) + return + } + + if strings.HasPrefix(req.Method, "dwl.") { + if dwlManager == nil { + models.RespondError(conn, req.ID, "dwl manager not initialized") + return + } + dwl.HandleRequest(conn, req, dwlManager) + return + } + + if strings.HasPrefix(req.Method, "brightness.") { + if brightnessManager == nil { + models.RespondError(conn, req.ID, "brightness manager not initialized") + return + } + brightness.HandleRequest(conn, req, brightnessManager) + return + } + + if strings.HasPrefix(req.Method, "extworkspace.") { + if extWorkspaceManager == nil { + if extWorkspaceAvailable.Load() { + extWorkspaceInitMutex.Lock() + if extWorkspaceManager == nil { + if err := InitializeExtWorkspaceManager(); err != nil { + extWorkspaceInitMutex.Unlock() + models.RespondError(conn, req.ID, "extworkspace manager not available") + return + } + } + extWorkspaceInitMutex.Unlock() + } else { + models.RespondError(conn, req.ID, "extworkspace manager not initialized") + return + } + } + extworkspace.HandleRequest(conn, req, extWorkspaceManager) + return + } + + if strings.HasPrefix(req.Method, "wlroutput.") { + if wlrOutputManager == nil { + models.RespondError(conn, req.ID, "wlroutput manager not initialized") + return + } + wlroutput.HandleRequest(conn, req, wlrOutputManager) + return + } + + if strings.HasPrefix(req.Method, "evdev.") { + if evdevManager == nil { + models.RespondError(conn, req.ID, "evdev manager not initialized") + return + } + evdev.HandleRequest(conn, req, evdevManager) + return + } + + if strings.HasPrefix(req.Method, "dbus.") { + if dbusManager == nil { + models.RespondError(conn, req.ID, "dbus manager not initialized") + return + } + serverDbus.HandleRequest(conn, req, dbusManager, dbusClientID) + return + } + + if strings.HasPrefix(req.Method, "clipboard.") { + switch req.Method { + case "clipboard.getConfig": + cfg := clipboard.LoadConfig() + models.Respond(conn, req.ID, cfg) + return + case "clipboard.setConfig": + handleClipboardSetConfig(conn, req) + return + } + if clipboardManager == nil { + models.RespondError(conn, req.ID, "clipboard manager not initialized") + return + } + clipboard.HandleRequest(conn, req, clipboardManager) + return + } + + if strings.HasPrefix(req.Method, "location.") { + if locationManager == nil { + models.RespondError(conn, req.ID, "location manager not initialized") + return + } + location.HandleRequest(conn, req, locationManager) + return + } + + switch req.Method { + case "ping": + models.Respond(conn, req.ID, "pong") + case "getServerInfo": + info := getServerInfo() + models.Respond(conn, req.ID, info) + case "subscribe": + handleSubscribe(conn, req) + case "matugen.queue": + handleMatugenQueue(conn, req) + case "matugen.status": + handleMatugenStatus(conn, req) + default: + models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) + } +} + +func handleClipboardSetConfig(conn net.Conn, req models.Request) { + cfg := clipboard.LoadConfig() + + if v, ok := models.Get[float64](req, "maxHistory"); ok { + cfg.MaxHistory = int(v) + } + if v, ok := models.Get[float64](req, "maxEntrySize"); ok { + cfg.MaxEntrySize = int64(v) + } + if v, ok := models.Get[float64](req, "autoClearDays"); ok { + cfg.AutoClearDays = int(v) + } + if v, ok := models.Get[bool](req, "clearAtStartup"); ok { + cfg.ClearAtStartup = v + } + if v, ok := models.Get[bool](req, "disabled"); ok { + cfg.Disabled = v + } + if v, ok := models.Get[float64](req, "maxPinned"); ok { + cfg.MaxPinned = int(v) + } + + if err := clipboard.SaveConfig(cfg); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "config updated"}) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/server.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/server.go new file mode 100644 index 0000000..58acb62 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/server.go @@ -0,0 +1,1758 @@ +package server + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" + serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/location" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +const APIVersion = 24 + +var CLIVersion = "dev" + +type Capabilities struct { + Capabilities []string `json:"capabilities"` +} + +type ServerInfo struct { + APIVersion int `json:"apiVersion"` + CLIVersion string `json:"cliVersion,omitempty"` + Capabilities []string `json:"capabilities"` +} + +type ServiceEvent struct { + Service string `json:"service"` + Data any `json:"data"` +} + +var networkManager *network.Manager +var loginctlManager *loginctl.Manager +var freedesktopManager *freedesktop.Manager +var waylandManager *wayland.Manager +var bluezManager *bluez.Manager +var appPickerManager *apppicker.Manager +var cupsManager *cups.Manager +var dwlManager *dwl.Manager +var extWorkspaceManager *extworkspace.Manager +var brightnessManager *brightness.Manager +var wlrOutputManager *wlroutput.Manager +var evdevManager *evdev.Manager +var clipboardManager *clipboard.Manager +var dbusManager *serverDbus.Manager +var wlContext *wlcontext.SharedContext +var themeModeManager *thememode.Manager +var trayRecoveryManager *trayrecovery.Manager +var locationManager *location.Manager +var geoClientInstance geolocation.Client + +const dbusClientID = "dms-dbus-client" + +var capabilitySubscribers syncmap.Map[string, chan ServerInfo] +var cupsSubscribers syncmap.Map[string, bool] +var cupsSubscriberCount atomic.Int32 +var extWorkspaceAvailable atomic.Bool +var extWorkspaceInitMutex sync.Mutex + +func getSocketDir() string { + if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" { + return runtime + } + + if os.Getuid() == 0 { + if _, err := os.Stat("/run"); err == nil { + return "/run/dankdots" + } + return "/var/run/dankdots" + } + + return os.TempDir() +} + +func GetSocketPath() string { + return filepath.Join(getSocketDir(), fmt.Sprintf("danklinux-%d.sock", os.Getpid())) +} + +func FindSocket() (string, error) { + dir := getSocketDir() + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "danklinux-") && strings.HasSuffix(entry.Name(), ".sock") { + return filepath.Join(dir, entry.Name()), nil + } + } + return "", fmt.Errorf("no dms socket found") +} + +func cleanupStaleSockets() { + dir := getSocketDir() + entries, err := os.ReadDir(dir) + if err != nil { + return + } + + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), "danklinux-") || !strings.HasSuffix(entry.Name(), ".sock") { + continue + } + + pidStr := strings.TrimPrefix(entry.Name(), "danklinux-") + pidStr = strings.TrimSuffix(pidStr, ".sock") + pid, err := strconv.Atoi(pidStr) + if err != nil { + continue + } + + process, err := os.FindProcess(pid) + if err != nil { + socketPath := filepath.Join(dir, entry.Name()) + os.Remove(socketPath) + log.Debugf("Removed stale socket: %s", socketPath) + continue + } + + err = process.Signal(syscall.Signal(0)) + if err != nil { + socketPath := filepath.Join(dir, entry.Name()) + os.Remove(socketPath) + log.Debugf("Removed stale socket: %s", socketPath) + } + } +} + +func InitializeNetworkManager() error { + manager, err := network.NewManager() + if err != nil { + log.Warnf("Failed to initialize network manager: %v", err) + return err + } + + networkManager = manager + + log.Info("Network manager initialized") + return nil +} + +func InitializeLoginctlManager() error { + manager, err := loginctl.NewManager() + if err != nil { + log.Warnf("Failed to initialize loginctl manager: %v", err) + return err + } + + loginctlManager = manager + + log.Info("Loginctl manager initialized") + return nil +} + +func InitializeFreedeskManager() error { + manager, err := freedesktop.NewManager() + if err != nil { + log.Warnf("Failed to initialize freedesktop manager: %v", err) + return err + } + + freedesktopManager = manager + + log.Info("Freedesktop manager initialized") + return nil +} + +func InitializeWaylandManager() error { + log.Info("Attempting to initialize Wayland gamma control...") + + if wlContext == nil { + ctx, err := wlcontext.New() + if err != nil { + log.Errorf("Failed to create shared Wayland context: %v", err) + return err + } + wlContext = ctx + } + + config := wayland.DefaultConfig() + manager, err := wayland.NewManager(wlContext.Display(), config) + if err != nil { + log.Errorf("Failed to initialize wayland manager: %v", err) + return err + } + + waylandManager = manager + + log.Info("Wayland gamma control initialized successfully") + return nil +} + +func InitializeBluezManager() error { + manager, err := bluez.NewManager() + if err != nil { + log.Warnf("Failed to initialize bluez manager: %v", err) + return err + } + + bluezManager = manager + + log.Info("Bluez manager initialized") + return nil +} + +func InitializeAppPickerManager() error { + manager := apppicker.NewManager() + appPickerManager = manager + log.Info("AppPicker manager initialized") + return nil +} + +func InitializeCupsManager() error { + manager, err := cups.NewManager() + if err != nil { + log.Warnf("Failed to initialize cups manager: %v", err) + return err + } + + cupsManager = manager + + log.Info("CUPS manager initialized") + return nil +} + +func InitializeDwlManager() error { + log.Info("Attempting to initialize DWL IPC...") + + if wlContext == nil { + ctx, err := wlcontext.New() + if err != nil { + log.Errorf("Failed to create shared Wayland context: %v", err) + return err + } + wlContext = ctx + } + + manager, err := dwl.NewManager(wlContext.Display()) + if err != nil { + log.Debug("Failed to initialize dwl manager: %v", err) + return err + } + + dwlManager = manager + + log.Info("DWL IPC initialized successfully") + return nil +} + +func InitializeBrightnessManager() error { + manager, err := brightness.NewManager() + if err != nil { + log.Warnf("Failed to initialize brightness manager: %v", err) + return err + } + + brightnessManager = manager + + log.Info("Brightness manager initialized") + return nil +} + +func InitializeExtWorkspaceManager() error { + log.Info("Attempting to initialize ExtWorkspace...") + + if wlContext == nil { + ctx, err := wlcontext.New() + if err != nil { + log.Errorf("Failed to create shared Wayland context: %v", err) + return err + } + wlContext = ctx + } + + manager, err := extworkspace.NewManager(wlContext.Display()) + if err != nil { + log.Debug("Failed to initialize extworkspace manager: %v", err) + return err + } + + extWorkspaceManager = manager + + log.Info("ExtWorkspace initialized successfully") + return nil +} + +func InitializeWlrOutputManager() error { + log.Info("Attempting to initialize WlrOutput management...") + + if wlContext == nil { + ctx, err := wlcontext.New() + if err != nil { + log.Errorf("Failed to create shared Wayland context: %v", err) + return err + } + wlContext = ctx + } + + manager, err := wlroutput.NewManager(wlContext.Display()) + if err != nil { + log.Debug("Failed to initialize wlroutput manager: %v", err) + return err + } + + wlrOutputManager = manager + + log.Info("WlrOutput management initialized successfully") + return nil +} + +func InitializeEvdevManager() error { + manager, err := evdev.InitializeManager() + if err != nil { + log.Warnf("Failed to initialize evdev manager: %v", err) + return err + } + + evdevManager = manager + + log.Info("Evdev manager initialized") + return nil +} + +func InitializeClipboardManager() error { + log.Info("Attempting to initialize clipboard manager...") + + if wlContext == nil { + ctx, err := wlcontext.New() + if err != nil { + log.Errorf("Failed to create shared Wayland context: %v", err) + return err + } + wlContext = ctx + } + + config := clipboard.LoadConfig() + manager, err := clipboard.NewManager(wlContext, config) + if err != nil { + log.Errorf("Failed to initialize clipboard manager: %v", err) + return err + } + + clipboardManager = manager + + log.Info("Clipboard manager initialized successfully") + return nil +} + +func InitializeDbusManager() error { + manager, err := serverDbus.NewManager() + if err != nil { + log.Warnf("Failed to initialize dbus manager: %v", err) + return err + } + + dbusManager = manager + + log.Info("DBus manager initialized") + return nil +} + +func InitializeThemeModeManager() error { + manager := thememode.NewManager() + themeModeManager = manager + + log.Info("Theme mode automation manager initialized") + return nil +} + +func InitializeTrayRecoveryManager() error { + manager, err := trayrecovery.NewManager() + if err != nil { + return err + } + + trayRecoveryManager = manager + + log.Info("TrayRecovery manager initialized") + return nil +} + +func InitializeLocationManager(geoClient geolocation.Client) error { + manager, err := location.NewManager(geoClient) + if err != nil { + log.Warnf("Failed to initialize location manager: %v", err) + return err + } + + locationManager = manager + + log.Info("Location manager initialized") + return nil +} + +func handleConnection(conn net.Conn) { + defer conn.Close() + + caps := getCapabilities() + capsData, _ := json.Marshal(caps) + conn.Write(capsData) + conn.Write([]byte("\n")) + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + line := scanner.Bytes() + + var req models.Request + if err := json.Unmarshal(line, &req); err != nil { + log.Warnf("handleConnection: Failed to unmarshal JSON: %v, line: %s", err, string(line)) + models.RespondError(conn, 0, "invalid json") + continue + } + + go RouteRequest(conn, req) + } +} + +func getCapabilities() Capabilities { + caps := []string{"plugins"} + + if networkManager != nil { + caps = append(caps, "network") + } + + if loginctlManager != nil { + caps = append(caps, "loginctl") + } + + if freedesktopManager != nil { + caps = append(caps, "freedesktop") + } + + if waylandManager != nil { + caps = append(caps, "gamma") + } + + if bluezManager != nil { + caps = append(caps, "bluetooth") + } + + if appPickerManager != nil { + caps = append(caps, "browser") + } + + if cupsManager != nil { + caps = append(caps, "cups") + } + + if dwlManager != nil { + caps = append(caps, "dwl") + } + + if extWorkspaceAvailable.Load() { + caps = append(caps, "extworkspace") + } + + if brightnessManager != nil { + caps = append(caps, "brightness") + } + + if wlrOutputManager != nil { + caps = append(caps, "wlroutput") + } + + if evdevManager != nil { + caps = append(caps, "evdev") + } + + if clipboardManager != nil { + caps = append(caps, "clipboard") + } + + if themeModeManager != nil { + caps = append(caps, "theme.auto") + } + + if dbusManager != nil { + caps = append(caps, "dbus") + } + + return Capabilities{Capabilities: caps} +} + +func getServerInfo() ServerInfo { + caps := []string{"plugins"} + + if networkManager != nil { + caps = append(caps, "network") + } + + if loginctlManager != nil { + caps = append(caps, "loginctl") + } + + if freedesktopManager != nil { + caps = append(caps, "freedesktop") + } + + if waylandManager != nil { + caps = append(caps, "gamma") + } + + if bluezManager != nil { + caps = append(caps, "bluetooth") + } + + if appPickerManager != nil { + caps = append(caps, "browser") + } + + if cupsManager != nil { + caps = append(caps, "cups") + } + + if dwlManager != nil { + caps = append(caps, "dwl") + } + + if extWorkspaceAvailable.Load() { + caps = append(caps, "extworkspace") + } + + if brightnessManager != nil { + caps = append(caps, "brightness") + } + + if wlrOutputManager != nil { + caps = append(caps, "wlroutput") + } + + if evdevManager != nil { + caps = append(caps, "evdev") + } + + if clipboardManager != nil { + caps = append(caps, "clipboard") + } + + if themeModeManager != nil { + caps = append(caps, "theme.auto") + } + + if locationManager != nil { + caps = append(caps, "location") + } + + if dbusManager != nil { + caps = append(caps, "dbus") + } + + return ServerInfo{ + APIVersion: APIVersion, + CLIVersion: CLIVersion, + Capabilities: caps, + } +} + +func notifyCapabilityChange() { + info := getServerInfo() + capabilitySubscribers.Range(func(key string, ch chan ServerInfo) bool { + select { + case ch <- info: + default: + } + return true + }) +} + +func handleSubscribe(conn net.Conn, req models.Request) { + clientID := fmt.Sprintf("meta-client-%p", conn) + + var services []string + if servicesParam, ok := models.Get[[]any](req, "services"); ok { + for _, s := range servicesParam { + if str, ok := s.(string); ok { + services = append(services, str) + } + } + } + + if len(services) == 0 { + services = []string{"all"} + } + + subscribeAll := false + for _, s := range services { + if s == "all" { + subscribeAll = true + break + } + } + + var wg sync.WaitGroup + eventChan := make(chan ServiceEvent, 256) + stopChan := make(chan struct{}) + + capChan := make(chan ServerInfo, 64) + capabilitySubscribers.Store(clientID+"-capabilities", capChan) + + wg.Add(1) + go func() { + defer wg.Done() + defer capabilitySubscribers.Delete(clientID + "-capabilities") + + for { + select { + case info, ok := <-capChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "server", Data: info}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + + shouldSubscribe := func(service string) bool { + if subscribeAll { + return true + } + for _, s := range services { + if s == service { + return true + } + } + return false + } + + if shouldSubscribe("network") && networkManager != nil { + wg.Add(1) + netChan := networkManager.Subscribe(clientID + "-network") + go func() { + defer wg.Done() + defer networkManager.Unsubscribe(clientID + "-network") + + initialState := networkManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "network", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-netChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "network", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("network.credentials") && networkManager != nil { + wg.Add(1) + credChan := networkManager.SubscribeCredentials(clientID + "-credentials") + go func() { + defer wg.Done() + defer networkManager.UnsubscribeCredentials(clientID + "-credentials") + + for { + select { + case prompt, ok := <-credChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "network.credentials", Data: prompt}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("loginctl") && loginctlManager != nil { + wg.Add(1) + loginChan := loginctlManager.Subscribe(clientID + "-loginctl") + go func() { + defer wg.Done() + defer loginctlManager.Unsubscribe(clientID + "-loginctl") + + initialState := loginctlManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "loginctl", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-loginChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "loginctl", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("freedesktop") && freedesktopManager != nil { + wg.Add(1) + freedesktopChan := freedesktopManager.Subscribe(clientID + "-freedesktop") + go func() { + defer wg.Done() + defer freedesktopManager.Unsubscribe(clientID + "-freedesktop") + + initialState := freedesktopManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "freedesktop", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-freedesktopChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "freedesktop", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("freedesktop.screensaver") && freedesktopManager != nil { + wg.Add(1) + screensaverChan := freedesktopManager.SubscribeScreensaver(clientID + "-screensaver") + go func() { + defer wg.Done() + defer freedesktopManager.UnsubscribeScreensaver(clientID + "-screensaver") + + initialState := freedesktopManager.GetScreensaverState() + select { + case eventChan <- ServiceEvent{Service: "freedesktop.screensaver", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-screensaverChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "freedesktop.screensaver", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("gamma") && waylandManager != nil { + wg.Add(1) + waylandChan := waylandManager.Subscribe(clientID + "-gamma") + go func() { + defer wg.Done() + defer waylandManager.Unsubscribe(clientID + "-gamma") + + initialState := waylandManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "gamma", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-waylandChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "gamma", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("theme.auto") && themeModeManager != nil { + wg.Add(1) + themeAutoChan := themeModeManager.Subscribe(clientID + "-theme-auto") + go func() { + defer wg.Done() + defer themeModeManager.Unsubscribe(clientID + "-theme-auto") + + initialState := themeModeManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "theme.auto", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-themeAutoChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "theme.auto", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("bluetooth") && bluezManager != nil { + wg.Add(1) + bluezChan := bluezManager.Subscribe(clientID + "-bluetooth") + go func() { + defer wg.Done() + defer bluezManager.Unsubscribe(clientID + "-bluetooth") + + initialState := bluezManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "bluetooth", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-bluezChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "bluetooth", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("bluetooth.pairing") && bluezManager != nil { + wg.Add(1) + pairingChan := bluezManager.SubscribePairing(clientID + "-pairing") + go func() { + defer wg.Done() + defer bluezManager.UnsubscribePairing(clientID + "-pairing") + + for { + select { + case prompt, ok := <-pairingChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "bluetooth.pairing", Data: prompt}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("browser") && appPickerManager != nil { + wg.Add(1) + appPickerChan := appPickerManager.Subscribe(clientID + "-browser") + go func() { + defer wg.Done() + defer appPickerManager.Unsubscribe(clientID + "-browser") + + for { + select { + case event, ok := <-appPickerChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "browser.open_requested", Data: event}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("cups") { + cupsSubscribers.Store(clientID+"-cups", true) + count := cupsSubscriberCount.Add(1) + + if count == 1 { + if err := InitializeCupsManager(); err != nil { + log.Warnf("Failed to initialize CUPS manager for subscription: %v", err) + } else { + notifyCapabilityChange() + } + } + + if cupsManager != nil { + wg.Add(1) + cupsChan := cupsManager.Subscribe(clientID + "-cups") + go func() { + defer wg.Done() + defer func() { + cupsManager.Unsubscribe(clientID + "-cups") + cupsSubscribers.Delete(clientID + "-cups") + count := cupsSubscriberCount.Add(-1) + + if count == 0 { + log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager") + if cupsManager != nil { + cupsManager.Close() + cupsManager = nil + notifyCapabilityChange() + } + } + }() + + initialState := cupsManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "cups", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-cupsChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "cups", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + } + + if shouldSubscribe("dwl") && dwlManager != nil { + wg.Add(1) + dwlChan := dwlManager.Subscribe(clientID + "-dwl") + go func() { + defer wg.Done() + defer dwlManager.Unsubscribe(clientID + "-dwl") + + initialState := dwlManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "dwl", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-dwlChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "dwl", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("extworkspace") { + if extWorkspaceManager == nil && extWorkspaceAvailable.Load() { + extWorkspaceInitMutex.Lock() + if extWorkspaceManager == nil { + if err := InitializeExtWorkspaceManager(); err != nil { + log.Warnf("Failed to initialize ExtWorkspace manager for subscription: %v", err) + } + } + extWorkspaceInitMutex.Unlock() + } + + if extWorkspaceManager != nil { + wg.Add(1) + extWorkspaceChan := extWorkspaceManager.Subscribe(clientID + "-extworkspace") + go func() { + defer wg.Done() + defer extWorkspaceManager.Unsubscribe(clientID + "-extworkspace") + + initialState := extWorkspaceManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "extworkspace", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-extWorkspaceChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "extworkspace", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + } + + if shouldSubscribe("brightness") && brightnessManager != nil { + wg.Add(2) + brightnessStateChan := brightnessManager.Subscribe(clientID + "-brightness-state") + brightnessUpdateChan := brightnessManager.SubscribeUpdates(clientID + "-brightness-updates") + + go func() { + defer wg.Done() + defer brightnessManager.Unsubscribe(clientID + "-brightness-state") + + initialState := brightnessManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "brightness", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-brightnessStateChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "brightness", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + + go func() { + defer wg.Done() + defer brightnessManager.UnsubscribeUpdates(clientID + "-brightness-updates") + + for { + select { + case update, ok := <-brightnessUpdateChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "brightness.update", Data: update}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("wlroutput") && wlrOutputManager != nil { + wg.Add(1) + wlrOutputChan := wlrOutputManager.Subscribe(clientID + "-wlroutput") + go func() { + defer wg.Done() + defer wlrOutputManager.Unsubscribe(clientID + "-wlroutput") + + initialState := wlrOutputManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "wlroutput", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-wlrOutputChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "wlroutput", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("evdev") && evdevManager != nil { + wg.Add(1) + evdevChan := evdevManager.Subscribe(clientID + "-evdev") + go func() { + defer wg.Done() + defer evdevManager.Unsubscribe(clientID + "-evdev") + + initialState := evdevManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "evdev", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-evdevChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "evdev", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("clipboard") && clipboardManager != nil { + wg.Add(1) + clipboardChan := clipboardManager.Subscribe(clientID + "-clipboard") + go func() { + defer wg.Done() + defer clipboardManager.Unsubscribe(clientID + "-clipboard") + + initialState := clipboardManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "clipboard", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-clipboardChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "clipboard", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + if shouldSubscribe("dbus") && dbusManager != nil { + wg.Add(1) + dbusChan := dbusManager.SubscribeSignals(dbusClientID) + go func() { + defer wg.Done() + defer dbusManager.UnsubscribeSignals(dbusClientID) + + for { + select { + case event, ok := <-dbusChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "dbus", Data: event}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + + go func() { + wg.Wait() + close(eventChan) + }() + + info := getServerInfo() + if err := json.NewEncoder(conn).Encode(models.Response[ServiceEvent]{ + ID: req.ID, + Result: &ServiceEvent{Service: "server", Data: info}, + }); err != nil { + close(stopChan) + return + } + + for event := range eventChan { + if err := json.NewEncoder(conn).Encode(models.Response[ServiceEvent]{ + ID: req.ID, + Result: &event, + }); err != nil { + close(stopChan) + return + } + } +} + +func cleanupManagers() { + if networkManager != nil { + networkManager.Close() + } + if loginctlManager != nil { + loginctlManager.Close() + } + if freedesktopManager != nil { + freedesktopManager.Close() + } + if waylandManager != nil { + waylandManager.Close() + } + if bluezManager != nil { + bluezManager.Close() + } + if appPickerManager != nil { + appPickerManager.Close() + } + if cupsManager != nil { + cupsManager.Close() + } + if dwlManager != nil { + dwlManager.Close() + } + if extWorkspaceManager != nil { + extWorkspaceManager.Close() + } + if brightnessManager != nil { + brightnessManager.Close() + } + if wlrOutputManager != nil { + wlrOutputManager.Close() + } + if evdevManager != nil { + evdevManager.Close() + } + if clipboardManager != nil { + clipboardManager.Close() + } + if dbusManager != nil { + dbusManager.Close() + } + if themeModeManager != nil { + themeModeManager.Close() + } + if trayRecoveryManager != nil { + trayRecoveryManager.Close() + } + if wlContext != nil { + wlContext.Close() + } + if locationManager != nil { + locationManager.Close() + } + if geoClientInstance != nil { + geoClientInstance.Close() + } +} + +func Start(printDocs bool) error { + cleanupStaleSockets() + + socketPath := GetSocketPath() + os.Remove(socketPath) + + listener, err := net.Listen("unix", socketPath) + if err != nil { + return err + } + defer listener.Close() + defer cleanupManagers() + + log.Infof("DMS API Server listening on: %s", socketPath) + log.Infof("API Version: %d", APIVersion) + log.Info("Protocol: JSON over Unix socket") + log.Info("Request format: {\"id\": <any>, \"method\": \"...\", \"params\": {...}}") + log.Info("Response format: {\"id\": <any>, \"result\": {...}} or {\"id\": <any>, \"error\": \"...\"}") + log.Info("") + if printDocs { + log.Info("Available methods:") + log.Info(" ping - Test connection") + log.Info(" getServerInfo - Get server info (API version and capabilities)") + log.Info(" subscribe - Subscribe to multiple services (params: services [default: all])") + log.Info("Plugins:") + log.Info(" plugins.list - List all plugins") + log.Info(" plugins.listInstalled - List installed plugins") + log.Info(" plugins.install - Install plugin (params: name)") + log.Info(" plugins.uninstall - Uninstall plugin (params: name)") + log.Info(" plugins.update - Update plugin (params: name)") + log.Info(" plugins.search - Search plugins (params: query, category?, compositor?, capability?)") + log.Info("Network:") + log.Info(" network.getState - Get current network state") + log.Info(" network.wifi.scan - Scan for WiFi networks (params: device?)") + log.Info(" network.wifi.networks - Get WiFi network list") + log.Info(" network.wifi.connect - Connect to WiFi (params: ssid, password?, username?, device?, eapMethod?, phase2Auth?, caCertPath?, clientCertPath?, privateKeyPath?, useSystemCACerts?)") + log.Info(" network.wifi.disconnect - Disconnect WiFi (params: device?)") + log.Info(" network.wifi.forget - Forget network (params: ssid)") + log.Info(" network.wifi.toggle - Toggle WiFi radio") + log.Info(" network.wifi.enable - Enable WiFi") + log.Info(" network.wifi.disable - Disable WiFi") + log.Info(" network.wifi.setAutoconnect - Set network autoconnect (params: ssid, autoconnect)") + log.Info(" network.ethernet.connect - Connect Ethernet") + log.Info(" network.ethernet.connect.config - Connect Ethernet to a specific configuration") + log.Info(" network.ethernet.disconnect - Disconnect Ethernet") + log.Info(" network.vpn.profiles - List VPN profiles") + log.Info(" network.vpn.active - List active VPN connections") + log.Info(" network.vpn.connect - Connect VPN (params: uuidOrName|name|uuid, singleActive?)") + log.Info(" network.vpn.disconnect - Disconnect VPN (params: uuidOrName|name|uuid)") + log.Info(" network.vpn.disconnectAll - Disconnect all VPNs") + log.Info(" network.vpn.clearCredentials - Clear saved VPN credentials (params: uuidOrName|name|uuid)") + log.Info(" network.vpn.plugins - List available VPN plugins") + log.Info(" network.vpn.import - Import VPN from file (params: file|path, name?)") + log.Info(" network.vpn.getConfig - Get VPN configuration (params: uuid|name|uuidOrName)") + log.Info(" network.vpn.updateConfig - Update VPN configuration (params: uuid, name?, autoconnect?, data?)") + log.Info(" network.vpn.delete - Delete VPN connection (params: uuid|name|uuidOrName)") + log.Info(" network.preference.set - Set preference (params: preference [auto|wifi|ethernet])") + log.Info(" network.info - Get network info (params: ssid)") + log.Info(" network.credentials.submit - Submit credentials for prompt (params: token, secrets, save?)") + log.Info(" network.credentials.cancel - Cancel credential prompt (params: token)") + log.Info(" network.subscribe - Subscribe to network state changes (streaming)") + log.Info("Loginctl:") + log.Info(" loginctl.getState - Get current session state") + log.Info(" loginctl.lock - Lock session") + log.Info(" loginctl.unlock - Unlock session") + log.Info(" loginctl.activate - Activate session") + log.Info(" loginctl.setIdleHint - Set idle hint (params: idle)") + log.Info(" loginctl.setLockBeforeSuspend - Set lock before suspend (params: enabled)") + log.Info(" loginctl.setSleepInhibitorEnabled - Enable/disable sleep inhibitor (params: enabled)") + log.Info(" loginctl.lockerReady - Signal locker UI is ready (releases sleep inhibitor)") + log.Info(" loginctl.terminate - Terminate session") + log.Info(" loginctl.subscribe - Subscribe to session state changes (streaming)") + log.Info("Freedesktop:") + log.Info(" freedesktop.getState - Get accounts & settings state") + log.Info(" freedesktop.accounts.setIconFile - Set profile icon (params: path)") + log.Info(" freedesktop.accounts.setRealName - Set real name (params: name)") + log.Info(" freedesktop.accounts.setEmail - Set email (params: email)") + log.Info(" freedesktop.accounts.setLanguage - Set language (params: language)") + log.Info(" freedesktop.accounts.setLocation - Set location (params: location)") + log.Info(" freedesktop.accounts.getUserIconFile - Get user icon (params: username)") + log.Info(" freedesktop.settings.getColorScheme - Get color scheme") + log.Info(" freedesktop.settings.setIconTheme - Set icon theme (params: iconTheme)") + log.Info("Wayland:") + log.Info(" wayland.gamma.getState - Get current gamma control state") + log.Info(" wayland.gamma.setTemperature - Set temperature range (params: low, high)") + log.Info(" wayland.gamma.setLocation - Set location (params: latitude, longitude)") + log.Info(" wayland.gamma.setManualTimes - Set manual times (params: sunrise, sunset)") + log.Info(" wayland.gamma.setGamma - Set gamma value (params: gamma)") + log.Info(" wayland.gamma.setEnabled - Enable/disable gamma control (params: enabled)") + log.Info(" wayland.gamma.subscribe - Subscribe to gamma state changes (streaming)") + log.Info("Theme automation:") + log.Info(" theme.auto.getState - Get current theme automation state") + log.Info(" theme.auto.setEnabled - Enable/disable theme automation (params: enabled)") + log.Info(" theme.auto.setMode - Set automation mode (params: mode [time|location])") + log.Info(" theme.auto.setSchedule - Set time schedule (params: startHour, startMinute, endHour, endMinute)") + log.Info(" theme.auto.setLocation - Set location (params: latitude, longitude)") + log.Info(" theme.auto.setUseIPLocation - Use IP location (params: use)") + log.Info(" theme.auto.trigger - Trigger immediate re-evaluation") + log.Info(" theme.auto.subscribe - Subscribe to theme automation state changes (streaming)") + log.Info("Bluetooth:") + log.Info(" bluetooth.getState - Get current bluetooth state") + log.Info(" bluetooth.startDiscovery - Start device discovery") + log.Info(" bluetooth.stopDiscovery - Stop device discovery") + log.Info(" bluetooth.setPowered - Set adapter power state (params: powered)") + log.Info(" bluetooth.pair - Pair with device (params: device)") + log.Info(" bluetooth.connect - Connect to device (params: device)") + log.Info(" bluetooth.disconnect - Disconnect from device (params: device)") + log.Info(" bluetooth.remove - Remove/unpair device (params: device)") + log.Info(" bluetooth.trust - Trust device (params: device)") + log.Info(" bluetooth.untrust - Untrust device (params: device)") + log.Info(" bluetooth.pairing.submit - Submit pairing response (params: token, secrets?, accept?)") + log.Info(" bluetooth.pairing.cancel - Cancel pairing prompt (params: token)") + log.Info(" bluetooth.subscribe - Subscribe to bluetooth state changes (streaming)") + log.Info("CUPS:") + log.Info(" cups.getPrinters - Get printers list") + log.Info(" cups.getJobs - Get non-completed jobs list (params: printerName)") + log.Info(" cups.pausePrinter - Pause printer (params: printerName)") + log.Info(" cups.resumePrinter - Resume printer (params: printerName)") + log.Info(" cups.cancelJob - Cancel job (params: printerName, jobID)") + log.Info(" cups.purgeJobs - Cancel all jobs (params: printerName)") + log.Info("DWL:") + log.Info(" dwl.getState - Get current dwl state (tags, windows, layouts, keyboard)") + log.Info(" dwl.setTags - Set active tags (params: output, tagmask, toggleTagset)") + log.Info(" dwl.setClientTags - Set focused client tags (params: output, andTags, xorTags)") + log.Info(" dwl.setLayout - Set layout (params: output, index)") + log.Info(" dwl.subscribe - Subscribe to dwl state changes (streaming)") + log.Info(" Output state includes:") + log.Info(" - tags : Tag states (active, clients, focused)") + log.Info(" - layoutSymbol : Current layout name") + log.Info(" - title : Focused window title") + log.Info(" - appId : Focused window app ID") + log.Info(" - kbLayout : Current keyboard layout") + log.Info(" - keymode : Current keybind mode") + log.Info("ExtWorkspace:") + log.Info(" extworkspace.getState - Get current workspace state (groups, workspaces)") + log.Info(" extworkspace.activateWorkspace - Activate workspace (params: groupID, workspaceID)") + log.Info(" extworkspace.deactivateWorkspace - Deactivate workspace (params: groupID, workspaceID)") + log.Info(" extworkspace.removeWorkspace - Remove workspace (params: groupID, workspaceID)") + log.Info(" extworkspace.createWorkspace - Create workspace (params: groupID, name)") + log.Info(" extworkspace.subscribe - Subscribe to workspace state changes (streaming)") + log.Info("Brightness:") + log.Info(" brightness.getState - Get current brightness state for all devices") + log.Info(" brightness.setBrightness - Set device brightness (params: device, percent)") + log.Info(" brightness.increment - Increment device brightness (params: device, step?)") + log.Info(" brightness.decrement - Decrement device brightness (params: device, step?)") + log.Info(" brightness.rescan - Rescan for brightness devices (e.g., after plugging in monitor)") + log.Info(" brightness.subscribe - Subscribe to brightness state changes (streaming)") + log.Info(" Subscription events:") + log.Info(" - brightness : Full device list (on rescan, DDC discovery, device changes)") + log.Info(" - brightness.update: Single device update (on brightness change for efficiency)") + log.Info("WlrOutput:") + log.Info(" wlroutput.getState - Get current output configuration state") + log.Info(" wlroutput.applyConfiguration - Apply output configuration (params: heads)") + log.Info(" wlroutput.testConfiguration - Test output configuration without applying (params: heads)") + log.Info(" wlroutput.subscribe - Subscribe to output state changes (streaming)") + log.Info(" Head configuration params:") + log.Info(" - name : Output name (required)") + log.Info(" - enabled : Enable/disable output (required)") + log.Info(" - modeId : Mode ID from available modes (optional)") + log.Info(" - customMode : Custom mode {width, height, refresh} (optional)") + log.Info(" - position : Position {x, y} (optional)") + log.Info(" - transform : Transform value (optional)") + log.Info(" - scale : Scale value (optional)") + log.Info(" - adaptiveSync : Adaptive sync state (optional)") + log.Info("Evdev:") + log.Info(" evdev.getState - Get current evdev state (caps lock)") + log.Info(" evdev.subscribe - Subscribe to evdev state changes (streaming)") + log.Info("Clipboard:") + log.Info(" clipboard.getState - Get clipboard state (enabled, history, current)") + log.Info(" clipboard.getHistory - Get clipboard history with previews") + log.Info(" clipboard.getEntry - Get full entry by ID (params: id)") + log.Info(" clipboard.deleteEntry - Delete entry by ID (params: id)") + log.Info(" clipboard.clearHistory - Clear all clipboard history") + log.Info(" clipboard.copy - Copy text to clipboard (params: text)") + log.Info(" clipboard.paste - Get current clipboard text") + log.Info(" clipboard.search - Search history (params: query?, mimeType?, isImage?, limit?, offset?, before?, after?)") + log.Info(" clipboard.getConfig - Get clipboard configuration") + log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)") + log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)") + log.Info("Location:") + log.Info(" location.getState - Get current location state") + log.Info(" location.subscribe - Subscribe to location changes (streaming)") + log.Info("") + } + log.Info("Initializing managers...") + log.Info("") + + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + if err := InitializeNetworkManager(); err != nil { + log.Warnf("Network manager unavailable: %v", err) + } else { + notifyCapabilityChange() + return + } + + for range ticker.C { + if networkManager != nil { + return + } + if err := InitializeNetworkManager(); err == nil { + log.Info("Network manager initialized") + notifyCapabilityChange() + return + } + } + }() + + loginctlReady := make(chan struct{}) + freedesktopReady := make(chan struct{}) + + go func() { + defer close(loginctlReady) + if err := InitializeLoginctlManager(); err != nil { + log.Warnf("Loginctl manager unavailable: %v", err) + } else { + notifyCapabilityChange() + } + }() + + go func() { + defer close(freedesktopReady) + if err := InitializeFreedeskManager(); err != nil { + log.Warnf("Freedesktop manager unavailable: %v", err) + } else if freedesktopManager != nil { + freedesktopManager.NotifySubscribers() + notifyCapabilityChange() + } + }() + + // Bridge loginctl lock state to the freedesktop/gnome screensaver + // ActiveChanged signal so apps like Bitwarden can detect screen lock. + go func() { + <-loginctlReady + <-freedesktopReady + + if loginctlManager == nil || freedesktopManager == nil { + return + } + + ch := loginctlManager.Subscribe("dms-lock-bridge") + defer loginctlManager.Unsubscribe("dms-lock-bridge") + + initial := loginctlManager.GetState() + lastLocked := initial.Locked + freedesktopManager.SetScreenLockActive(lastLocked) + + for state := range ch { + if state.Locked != lastLocked { + lastLocked = state.Locked + freedesktopManager.SetScreenLockActive(lastLocked) + } + } + }() + + if err := InitializeWaylandManager(); err != nil { + log.Warnf("Wayland manager unavailable: %v", err) + } + + if err := InitializeThemeModeManager(); err != nil { + log.Warnf("Theme mode manager unavailable: %v", err) + } else { + notifyCapabilityChange() + go func() { + <-loginctlReady + if loginctlManager == nil { + return + } + themeModeManager.WatchLoginctl(loginctlManager) + }() + } + + go func() { + <-loginctlReady + if loginctlManager == nil { + return + } + if err := InitializeTrayRecoveryManager(); err != nil { + log.Warnf("TrayRecovery manager unavailable: %v", err) + } else { + trayRecoveryManager.WatchLoginctl(loginctlManager) + } + }() + + go func() { + geoClient := geolocation.NewClient() + geoClientInstance = geoClient + + if waylandManager != nil { + waylandManager.SetGeoClient(geoClient) + } + if themeModeManager != nil { + themeModeManager.SetGeoClient(geoClient) + } + + if err := InitializeLocationManager(geoClient); err != nil { + log.Warnf("Location manager unavailable: %v", err) + } else { + notifyCapabilityChange() + } + }() + + go func() { + if err := InitializeBluezManager(); err != nil { + log.Warnf("Bluez manager unavailable: %v", err) + } else { + notifyCapabilityChange() + } + }() + + if err := InitializeAppPickerManager(); err != nil { + log.Debugf("AppPicker manager unavailable: %v", err) + } + + if err := InitializeDwlManager(); err != nil { + log.Debugf("DWL manager unavailable: %v", err) + } + + if extworkspace.CheckCapability() { + extWorkspaceAvailable.Store(true) + log.Info("ExtWorkspace capability detected and will be available on subscription") + } else { + log.Debug("ExtWorkspace capability not available") + extWorkspaceAvailable.Store(false) + } + + if err := InitializeWlrOutputManager(); err != nil { + log.Debugf("WlrOutput manager unavailable: %v", err) + } + + fatalErrChan := make(chan error, 1) + if wlrOutputManager != nil { + go func() { + err := <-wlrOutputManager.FatalError() + fatalErrChan <- fmt.Errorf("WlrOutput fatal error: %w", err) + }() + } + if wlContext != nil { + go func() { + err := <-wlContext.FatalError() + fatalErrChan <- fmt.Errorf("wayland context fatal error: %w", err) + }() + } + + go func() { + if err := InitializeBrightnessManager(); err != nil { + log.Warnf("Brightness manager unavailable: %v", err) + } else { + notifyCapabilityChange() + } + }() + + go func() { + if err := InitializeEvdevManager(); err != nil { + log.Debugf("Evdev manager unavailable: %v", err) + } else { + notifyCapabilityChange() + } + }() + + go func() { + if err := InitializeClipboardManager(); err != nil { + log.Warnf("Clipboard manager unavailable: %v", err) + } + if wlContext != nil { + wlContext.Start() + log.Info("Wayland event dispatcher started") + } + }() + + go func() { + if err := InitializeDbusManager(); err != nil { + log.Warnf("DBus manager unavailable: %v", err) + } else { + notifyCapabilityChange() + } + }() + + log.Info("") + log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities) + + listenerErrChan := make(chan error, 1) + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + listenerErrChan <- err + return + } + go handleConnection(conn) + } + }() + + select { + case err := <-listenerErrChan: + return err + case err := <-fatalErrChan: + return err + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/server_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/server_test.go new file mode 100644 index 0000000..404f624 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/server_test.go @@ -0,0 +1,180 @@ +package server + +import ( + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetSocketDir(t *testing.T) { + tests := []struct { + name string + xdgRuntimeDir string + uid int + expectedSubstr string + }{ + { + name: "uses XDG_RUNTIME_DIR when set", + xdgRuntimeDir: "/run/user/1000", + expectedSubstr: "/run/user/1000", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.xdgRuntimeDir != "" { + t.Setenv("XDG_RUNTIME_DIR", tt.xdgRuntimeDir) + } + + result := getSocketDir() + assert.Contains(t, result, tt.expectedSubstr) + }) + } +} + +func TestGetSocketPath(t *testing.T) { + path := GetSocketPath() + assert.Contains(t, path, "danklinux-") + assert.Contains(t, path, ".sock") + assert.Contains(t, path, fmt.Sprintf("%d", os.Getpid())) +} + +func TestGetCapabilities(t *testing.T) { + originalNetworkManager := networkManager + defer func() { networkManager = originalNetworkManager }() + + t.Run("capabilities without network manager", func(t *testing.T) { + networkManager = nil + caps := getCapabilities() + assert.Contains(t, caps.Capabilities, "plugins") + assert.NotContains(t, caps.Capabilities, "network") + }) + + t.Run("capabilities with network manager", func(t *testing.T) { + networkManager = &network.Manager{} + caps := getCapabilities() + assert.Contains(t, caps.Capabilities, "plugins") + assert.Contains(t, caps.Capabilities, "network") + }) +} + +type mockConn struct { + net.Conn + written []byte +} + +func (m *mockConn) Write(b []byte) (n int, err error) { + m.written = append(m.written, b...) + return len(b), nil +} + +func (m *mockConn) Close() error { + return nil +} + +func TestRespondError(t *testing.T) { + conn := &mockConn{} + models.RespondError(conn, 123, "test error") + + var resp models.Response[any] + err := json.Unmarshal(conn.written, &resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Equal(t, "test error", resp.Error) + assert.Nil(t, resp.Result) +} + +func TestRespond(t *testing.T) { + conn := &mockConn{} + result := map[string]string{"foo": "bar"} + models.Respond(conn, 123, result) + + var resp models.Response[map[string]string] + err := json.Unmarshal(conn.written, &resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.Equal(t, "bar", (*resp.Result)["foo"]) +} + +func TestRequest_JSON(t *testing.T) { + jsonStr := `{"id":123,"method":"test.method","params":{"key":"value"}}` + var req models.Request + err := json.Unmarshal([]byte(jsonStr), &req) + require.NoError(t, err) + + assert.Equal(t, 123, req.ID) + assert.Equal(t, "test.method", req.Method) + assert.Equal(t, "value", req.Params["key"]) +} + +func TestResponse_JSON(t *testing.T) { + t.Run("success response", func(t *testing.T) { + result := "success" + resp := models.Response[string]{ + ID: 123, + Result: &result, + } + + data, err := json.Marshal(resp) + require.NoError(t, err) + + var decoded models.Response[string] + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, 123, decoded.ID) + assert.Equal(t, "success", *decoded.Result) + assert.Empty(t, decoded.Error) + }) + + t.Run("error response", func(t *testing.T) { + resp := models.Response[any]{ + ID: 123, + Error: "test error", + } + + data, err := json.Marshal(resp) + require.NoError(t, err) + + var decoded models.Response[any] + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, 123, decoded.ID) + assert.Equal(t, "test error", decoded.Error) + assert.Nil(t, decoded.Result) + }) +} + +func TestCleanupStaleSockets(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", tempDir) + + staleSocket := filepath.Join(tempDir, "danklinux-4194305.sock") + err := os.WriteFile(staleSocket, []byte{}, 0o600) + require.NoError(t, err) + + activeSocket := filepath.Join(tempDir, fmt.Sprintf("danklinux-%d.sock", os.Getpid())) + err = os.WriteFile(activeSocket, []byte{}, 0o600) + require.NoError(t, err) + + cleanupStaleSockets() + + _, err = os.Stat(staleSocket) + assert.True(t, os.IsNotExist(err)) + + _, err = os.Stat(activeSocket) + assert.NoError(t, err) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/thememode/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/thememode/handlers.go new file mode 100644 index 0000000..6891875 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/thememode/handlers.go @@ -0,0 +1,154 @@ +package thememode + +import ( + "encoding/json" + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + if manager == nil { + models.RespondError(conn, req.ID, "theme mode manager not initialized") + return + } + + switch req.Method { + case "theme.auto.getState": + handleGetState(conn, req, manager) + case "theme.auto.setEnabled": + handleSetEnabled(conn, req, manager) + case "theme.auto.setMode": + handleSetMode(conn, req, manager) + case "theme.auto.setSchedule": + handleSetSchedule(conn, req, manager) + case "theme.auto.setLocation": + handleSetLocation(conn, req, manager) + case "theme.auto.setUseIPLocation": + handleSetUseIPLocation(conn, req, manager) + case "theme.auto.trigger": + handleTrigger(conn, req, manager) + case "theme.auto.subscribe": + handleSubscribe(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 handleSetEnabled(conn net.Conn, req models.Request, manager *Manager) { + enabled, err := params.Bool(req.Params, "enabled") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + manager.SetEnabled(enabled) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto enabled set"}) +} + +func handleSetMode(conn net.Conn, req models.Request, manager *Manager) { + mode, err := params.String(req.Params, "mode") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if mode != "time" && mode != "location" { + models.RespondError(conn, req.ID, "invalid mode") + return + } + + manager.SetMode(mode) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto mode set"}) +} + +func handleSetSchedule(conn net.Conn, req models.Request, manager *Manager) { + startHour, err := params.Int(req.Params, "startHour") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + startMinute, err := params.Int(req.Params, "startMinute") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + endHour, err := params.Int(req.Params, "endHour") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + endMinute, err := params.Int(req.Params, "endMinute") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.ValidateSchedule(startHour, startMinute, endHour, endMinute); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + manager.SetSchedule(startHour, startMinute, endHour, endMinute) + models.Respond(conn, req.ID, manager.GetState()) +} + +func handleSetLocation(conn net.Conn, req models.Request, manager *Manager) { + lat, err := params.Float(req.Params, "latitude") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + lon, err := params.Float(req.Params, "longitude") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + manager.SetLocation(lat, lon) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto location set"}) +} + +func handleSetUseIPLocation(conn net.Conn, req models.Request, manager *Manager) { + use, err := params.Bool(req.Params, "use") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + manager.SetUseIPLocation(use) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto IP location set"}) +} + +func handleTrigger(conn net.Conn, req models.Request, manager *Manager) { + manager.TriggerUpdate() + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto update triggered"}) +} + +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() + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + ID: req.ID, + Result: &initialState, + }); err != nil { + return + } + + for state := range stateChan { + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + Result: &state, + }); err != nil { + return + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/thememode/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/thememode/manager.go new file mode 100644 index 0000000..f7644ad --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/thememode/manager.go @@ -0,0 +1,462 @@ +package thememode + +import ( + "errors" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +const ( + defaultStartHour = 18 + defaultStartMinute = 0 + defaultEndHour = 6 + defaultEndMinute = 0 + defaultElevationTwilight = -6.0 + defaultElevationDaylight = 3.0 +) + +type Manager struct { + config Config + configMutex sync.RWMutex + + state *State + stateMutex sync.RWMutex + + subscribers syncmap.Map[string, chan State] + + locationMutex sync.RWMutex + cachedIPLat *float64 + cachedIPLon *float64 + + geoClient geolocation.Client + + stopChan chan struct{} + updateTrigger chan struct{} + wg sync.WaitGroup +} + +func NewManager() *Manager { + m := &Manager{ + config: Config{ + Enabled: false, + Mode: "time", + StartHour: defaultStartHour, + StartMinute: defaultStartMinute, + EndHour: defaultEndHour, + EndMinute: defaultEndMinute, + ElevationTwilight: defaultElevationTwilight, + ElevationDaylight: defaultElevationDaylight, + }, + stopChan: make(chan struct{}), + updateTrigger: make(chan struct{}, 1), + } + + m.updateState(time.Now()) + + m.wg.Add(1) + go m.schedulerLoop() + + return m +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + if m.state == nil { + return State{Config: m.getConfig()} + } + stateCopy := *m.state + return stateCopy +} + +func (m *Manager) Subscribe(id string) chan State { + ch := make(chan State, 64) + m.subscribers.Store(id, ch) + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) SetEnabled(enabled bool) { + m.configMutex.Lock() + if m.config.Enabled == enabled { + m.configMutex.Unlock() + return + } + m.config.Enabled = enabled + m.configMutex.Unlock() + m.TriggerUpdate() +} + +func (m *Manager) SetMode(mode string) { + m.configMutex.Lock() + if m.config.Mode == mode { + m.configMutex.Unlock() + return + } + m.config.Mode = mode + m.configMutex.Unlock() + m.TriggerUpdate() +} + +func (m *Manager) SetSchedule(startHour, startMinute, endHour, endMinute int) { + m.configMutex.Lock() + changed := m.config.StartHour != startHour || + m.config.StartMinute != startMinute || + m.config.EndHour != endHour || + m.config.EndMinute != endMinute + if !changed { + m.configMutex.Unlock() + return + } + m.config.StartHour = startHour + m.config.StartMinute = startMinute + m.config.EndHour = endHour + m.config.EndMinute = endMinute + m.configMutex.Unlock() + m.TriggerUpdate() +} + +func (m *Manager) SetLocation(lat, lon float64) { + m.configMutex.Lock() + if m.config.Latitude != nil && m.config.Longitude != nil && + *m.config.Latitude == lat && *m.config.Longitude == lon && !m.config.UseIPLocation { + m.configMutex.Unlock() + return + } + m.config.Latitude = &lat + m.config.Longitude = &lon + m.config.UseIPLocation = false + m.configMutex.Unlock() + + m.locationMutex.Lock() + m.cachedIPLat = nil + m.cachedIPLon = nil + m.locationMutex.Unlock() + + m.TriggerUpdate() +} + +func (m *Manager) SetUseIPLocation(use bool) { + m.configMutex.Lock() + if m.config.UseIPLocation == use { + m.configMutex.Unlock() + return + } + m.config.UseIPLocation = use + if use { + m.config.Latitude = nil + m.config.Longitude = nil + } + m.configMutex.Unlock() + + if use { + m.locationMutex.Lock() + m.cachedIPLat = nil + m.cachedIPLon = nil + m.locationMutex.Unlock() + } + + m.TriggerUpdate() +} + +func (m *Manager) TriggerUpdate() { + select { + case m.updateTrigger <- struct{}{}: + default: + } +} + +func (m *Manager) Close() { + select { + case <-m.stopChan: + return + default: + close(m.stopChan) + } + m.wg.Wait() + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) +} + +func (m *Manager) WatchLoginctl(lm *loginctl.Manager) { + ch := lm.Subscribe("thememode") + m.wg.Add(1) + go func() { + defer m.wg.Done() + defer lm.Unsubscribe("thememode") + for { + select { + case <-m.stopChan: + return + case state, ok := <-ch: + if !ok { + return + } + if state.PreparingForSleep { + continue + } + m.TriggerUpdate() + } + } + }() +} + +func (m *Manager) schedulerLoop() { + defer m.wg.Done() + + var timer *time.Timer + for { + config := m.getConfig() + now := time.Now() + var isLight bool + var next time.Time + if config.Enabled { + isLight, next = m.computeSchedule(now, config) + } else { + m.stateMutex.RLock() + if m.state != nil { + isLight = m.state.IsLight + } + m.stateMutex.RUnlock() + next = now.Add(24 * time.Hour) + } + + m.updateStateWithValues(config, isLight, next) + + waitDur := time.Until(next) + if !config.Enabled { + waitDur = 24 * time.Hour + } + if waitDur < time.Second { + waitDur = time.Second + } + + if timer != nil { + timer.Stop() + } + timer = time.NewTimer(waitDur) + + select { + case <-m.stopChan: + timer.Stop() + return + case <-m.updateTrigger: + timer.Stop() + continue + case <-timer.C: + continue + } + } +} + +func (m *Manager) updateState(now time.Time) { + config := m.getConfig() + var isLight bool + var next time.Time + if config.Enabled { + isLight, next = m.computeSchedule(now, config) + } else { + m.stateMutex.RLock() + if m.state != nil { + isLight = m.state.IsLight + } + m.stateMutex.RUnlock() + next = now.Add(24 * time.Hour) + } + m.updateStateWithValues(config, isLight, next) +} + +func (m *Manager) updateStateWithValues(config Config, isLight bool, next time.Time) { + newState := State{ + Config: config, + IsLight: isLight, + NextTransition: next, + } + + m.stateMutex.Lock() + if m.state != nil && statesEqual(m.state, &newState) { + m.stateMutex.Unlock() + return + } + m.state = &newState + m.stateMutex.Unlock() + + m.notifySubscribers() +} + +func (m *Manager) notifySubscribers() { + state := m.GetState() + m.subscribers.Range(func(key string, ch chan State) bool { + select { + case ch <- state: + default: + } + return true + }) +} + +func (m *Manager) getConfig() Config { + m.configMutex.RLock() + defer m.configMutex.RUnlock() + return m.config +} + +func (m *Manager) SetGeoClient(client geolocation.Client) { + m.geoClient = client +} + +func (m *Manager) getLocation(config Config) (*float64, *float64) { + if config.Latitude != nil && config.Longitude != nil { + return config.Latitude, config.Longitude + } + if !config.UseIPLocation { + return nil, nil + } + if m.geoClient == nil { + return nil, nil + } + + m.locationMutex.RLock() + if m.cachedIPLat != nil && m.cachedIPLon != nil { + lat, lon := m.cachedIPLat, m.cachedIPLon + m.locationMutex.RUnlock() + return lat, lon + } + m.locationMutex.RUnlock() + + location, err := m.geoClient.GetLocation() + if err != nil { + return nil, nil + } + + m.locationMutex.Lock() + m.cachedIPLat = &location.Latitude + m.cachedIPLon = &location.Longitude + m.locationMutex.Unlock() + + return m.cachedIPLat, m.cachedIPLon +} + +func statesEqual(a, b *State) bool { + if a == nil || b == nil { + return a == b + } + if a.IsLight != b.IsLight || !a.NextTransition.Equal(b.NextTransition) { + return false + } + return a.Config == b.Config +} + +func (m *Manager) computeSchedule(now time.Time, config Config) (bool, time.Time) { + switch config.Mode { + case "location": + return m.computeLocationSchedule(now, config) + default: + return computeTimeSchedule(now, config) + } +} + +func computeTimeSchedule(now time.Time, config Config) (bool, time.Time) { + startMinutes := config.StartHour*60 + config.StartMinute + endMinutes := config.EndHour*60 + config.EndMinute + currentMinutes := now.Hour()*60 + now.Minute() + + startTime := time.Date(now.Year(), now.Month(), now.Day(), config.StartHour, config.StartMinute, 0, 0, now.Location()) + endTime := time.Date(now.Year(), now.Month(), now.Day(), config.EndHour, config.EndMinute, 0, 0, now.Location()) + + if startMinutes == endMinutes { + next := startTime + if !next.After(now) { + next = next.Add(24 * time.Hour) + } + return true, next + } + + if startMinutes < endMinutes { + if currentMinutes < startMinutes { + return true, startTime + } + if currentMinutes >= endMinutes { + return true, startTime.Add(24 * time.Hour) + } + return false, endTime + } + + if currentMinutes >= startMinutes { + return false, endTime.Add(24 * time.Hour) + } + if currentMinutes < endMinutes { + return false, endTime + } + return true, startTime +} + +func (m *Manager) computeLocationSchedule(now time.Time, config Config) (bool, time.Time) { + lat, lon := m.getLocation(config) + if lat == nil || lon == nil { + currentIsLight := false + m.stateMutex.RLock() + if m.state != nil { + currentIsLight = m.state.IsLight + } + m.stateMutex.RUnlock() + return currentIsLight, now.Add(10 * time.Minute) + } + + times, cond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight) + switch cond { + case wayland.SunMidnightSun: + return true, startOfNextDay(now) + case wayland.SunPolarNight: + return false, startOfNextDay(now) + } + + if now.Before(times.Sunrise) { + return false, times.Sunrise + } + if now.Before(times.Sunset) { + return true, times.Sunset + } + + nextDay := startOfNextDay(now) + nextTimes, nextCond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, nextDay, config.ElevationTwilight, config.ElevationDaylight) + switch nextCond { + case wayland.SunMidnightSun: + return true, startOfNextDay(nextDay) + case wayland.SunPolarNight: + return false, startOfNextDay(nextDay) + } + + return false, nextTimes.Sunrise +} + +func startOfNextDay(t time.Time) time.Time { + next := t.Add(24 * time.Hour) + return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location()) +} + +func validateHourMinute(hour, minute int) bool { + return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 +} + +func (m *Manager) ValidateSchedule(startHour, startMinute, endHour, endMinute int) error { + if !validateHourMinute(startHour, startMinute) || !validateHourMinute(endHour, endMinute) { + return errInvalidTime + } + return nil +} + +var errInvalidTime = errors.New("invalid schedule time") diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/thememode/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/thememode/types.go new file mode 100644 index 0000000..72e6468 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/thememode/types.go @@ -0,0 +1,23 @@ +package thememode + +import "time" + +type Config struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + StartHour int `json:"startHour"` + StartMinute int `json:"startMinute"` + EndHour int `json:"endHour"` + EndMinute int `json:"endMinute"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + UseIPLocation bool `json:"useIPLocation"` + ElevationTwilight float64 `json:"elevationTwilight"` + ElevationDaylight float64 `json:"elevationDaylight"` +} + +type State struct { + Config Config `json:"config"` + IsLight bool `json:"isLight"` + NextTransition time.Time `json:"nextTransition"` +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/handlers.go new file mode 100644 index 0000000..b3b308a --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/handlers.go @@ -0,0 +1,27 @@ +package themes + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +func HandleRequest(conn net.Conn, req models.Request) { + switch req.Method { + case "themes.list": + HandleList(conn, req) + case "themes.listInstalled": + HandleListInstalled(conn, req) + case "themes.install": + HandleInstall(conn, req) + case "themes.uninstall": + HandleUninstall(conn, req) + case "themes.update": + HandleUpdate(conn, req) + case "themes.search": + HandleSearch(conn, req) + default: + models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/install.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/install.go new file mode 100644 index 0000000..26d22ba --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/install.go @@ -0,0 +1,52 @@ +package themes + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/themes" +) + +func HandleInstall(conn net.Conn, req models.Request) { + idOrName, ok := models.Get[string](req, "name") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'name' parameter") + return + } + + registry, err := themes.NewRegistry() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err)) + return + } + + themeList, err := registry.List() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list themes: %v", err)) + return + } + + theme := themes.FindByIDOrName(idOrName, themeList) + if theme == nil { + models.RespondError(conn, req.ID, fmt.Sprintf("theme not found: %s", idOrName)) + return + } + + manager, err := themes.NewManager() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err)) + return + } + + registryThemeDir := registry.GetThemeDir(theme.SourceDir) + if err := manager.Install(*theme, registryThemeDir); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to install theme: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{ + Success: true, + Message: fmt.Sprintf("theme installed: %s", theme.Name), + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/list.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/list.go new file mode 100644 index 0000000..9564be1 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/list.go @@ -0,0 +1,54 @@ +package themes + +import ( + "fmt" + "net" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/themes" +) + +func HandleList(conn net.Conn, req models.Request) { + registry, err := themes.NewRegistry() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err)) + return + } + + themeList, err := registry.List() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list themes: %v", err)) + return + } + + manager, err := themes.NewManager() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err)) + return + } + + result := make([]ThemeInfo, len(themeList)) + for i, t := range themeList { + installed, _ := manager.IsInstalled(t) + info := ThemeInfo{ + ID: t.ID, + Name: t.Name, + Version: t.Version, + Author: t.Author, + Description: t.Description, + PreviewPath: t.PreviewPath, + SourceDir: t.SourceDir, + Installed: installed, + FirstParty: isFirstParty(t.Author), + } + addVariantsInfo(&info, t.Variants) + result[i] = info + } + + models.Respond(conn, req.ID, result) +} + +func isFirstParty(author string) bool { + return strings.EqualFold(author, "Avenge Media") || strings.EqualFold(author, "AvengeMedia") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/list_installed.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/list_installed.go new file mode 100644 index 0000000..49be81b --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/list_installed.go @@ -0,0 +1,149 @@ +package themes + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/themes" +) + +func addVariantsInfo(info *ThemeInfo, variants *themes.ThemeVariants) { + if variants == nil { + return + } + + if variants.Type == "multi" { + if len(variants.Flavors) == 0 && len(variants.Accents) == 0 { + return + } + info.HasVariants = true + info.Variants = &VariantsInfo{ + Type: "multi", + Flavors: make([]FlavorInfo, len(variants.Flavors)), + Accents: make([]AccentInfo, len(variants.Accents)), + } + if variants.Defaults != nil { + info.Variants.Defaults = &MultiDefaults{ + Dark: variants.Defaults.Dark, + Light: variants.Defaults.Light, + } + } + for i, f := range variants.Flavors { + mode := "" + switch { + case f.Dark.Primary != "" && f.Light.Primary != "": + mode = "both" + case f.Dark.Primary != "": + mode = "dark" + case f.Light.Primary != "": + mode = "light" + default: + if f.Dark.Surface != "" { + mode = "dark" + } else if f.Light.Surface != "" { + mode = "light" + } + } + info.Variants.Flavors[i] = FlavorInfo{ID: f.ID, Name: f.Name, Mode: mode} + } + for i, a := range variants.Accents { + color := "" + if colors, ok := a.FlavorColors["mocha"]; ok && colors.Primary != "" { + color = colors.Primary + } else if colors, ok := a.FlavorColors["latte"]; ok && colors.Primary != "" { + color = colors.Primary + } else { + for _, c := range a.FlavorColors { + if c.Primary != "" { + color = c.Primary + break + } + } + } + info.Variants.Accents[i] = AccentInfo{ID: a.ID, Name: a.Name, Color: color} + } + return + } + + if len(variants.Options) == 0 { + return + } + info.HasVariants = true + info.Variants = &VariantsInfo{ + Default: variants.Default, + Options: make([]VariantInfo, len(variants.Options)), + } + for i, v := range variants.Options { + info.Variants.Options[i] = VariantInfo{ID: v.ID, Name: v.Name} + } +} + +func HandleListInstalled(conn net.Conn, req models.Request) { + manager, err := themes.NewManager() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err)) + return + } + + installedIDs, err := manager.ListInstalled() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list installed themes: %v", err)) + return + } + + themeMap := make(map[string]themes.Theme) + if registry, err := themes.NewRegistry(); err == nil { + if allThemes, err := registry.List(); err == nil { + for _, t := range allThemes { + themeMap[t.ID] = t + } + } + } + + result := make([]ThemeInfo, 0, len(installedIDs)) + for _, id := range installedIDs { + if theme, ok := themeMap[id]; ok { + hasUpdate := false + if hasUpdates, err := manager.HasUpdates(id, theme); err == nil { + hasUpdate = hasUpdates + } + + info := ThemeInfo{ + ID: theme.ID, + Name: theme.Name, + Version: theme.Version, + Author: theme.Author, + Description: theme.Description, + SourceDir: id, + FirstParty: isFirstParty(theme.Author), + HasUpdate: hasUpdate, + } + addVariantsInfo(&info, theme.Variants) + result = append(result, info) + } else { + installed, err := manager.GetInstalledTheme(id) + if err != nil { + result = append(result, ThemeInfo{ + ID: id, + Name: id, + SourceDir: id, + }) + continue + } + info := ThemeInfo{ + ID: installed.ID, + Name: installed.Name, + Version: installed.Version, + Author: installed.Author, + Description: installed.Description, + SourceDir: id, + FirstParty: isFirstParty(installed.Author), + } + addVariantsInfo(&info, installed.Variants) + result = append(result, info) + } + } + + models.Respond(conn, req.ID, result) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/search.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/search.go new file mode 100644 index 0000000..2823fe0 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/search.go @@ -0,0 +1,53 @@ +package themes + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/themes" +) + +func HandleSearch(conn net.Conn, req models.Request) { + query, ok := models.Get[string](req, "query") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'query' parameter") + return + } + + registry, err := themes.NewRegistry() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err)) + return + } + + themeList, err := registry.List() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list themes: %v", err)) + return + } + + searchResults := themes.FuzzySearch(query, themeList) + + manager, err := themes.NewManager() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err)) + return + } + + result := make([]ThemeInfo, len(searchResults)) + for i, t := range searchResults { + installed, _ := manager.IsInstalled(t) + result[i] = ThemeInfo{ + ID: t.ID, + Name: t.Name, + Version: t.Version, + Author: t.Author, + Description: t.Description, + Installed: installed, + FirstParty: isFirstParty(t.Author), + } + } + + models.Respond(conn, req.ID, result) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/types.go new file mode 100644 index 0000000..0ad19b4 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/types.go @@ -0,0 +1,47 @@ +package themes + +type VariantInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type FlavorInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Mode string `json:"mode,omitempty"` +} + +type AccentInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color,omitempty"` +} + +type MultiDefaults struct { + Dark map[string]string `json:"dark,omitempty"` + Light map[string]string `json:"light,omitempty"` +} + +type VariantsInfo struct { + Type string `json:"type,omitempty"` + Default string `json:"default,omitempty"` + Defaults *MultiDefaults `json:"defaults,omitempty"` + Options []VariantInfo `json:"options,omitempty"` + Flavors []FlavorInfo `json:"flavors,omitempty"` + Accents []AccentInfo `json:"accents,omitempty"` +} + +type ThemeInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author,omitempty"` + Description string `json:"description,omitempty"` + PreviewPath string `json:"previewPath,omitempty"` + SourceDir string `json:"sourceDir,omitempty"` + Installed bool `json:"installed,omitempty"` + FirstParty bool `json:"firstParty,omitempty"` + HasUpdate bool `json:"hasUpdate,omitempty"` + HasVariants bool `json:"hasVariants,omitempty"` + Variants *VariantsInfo `json:"variants,omitempty"` +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/uninstall.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/uninstall.go new file mode 100644 index 0000000..9b453e4 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/uninstall.go @@ -0,0 +1,63 @@ +package themes + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/themes" +) + +func HandleUninstall(conn net.Conn, req models.Request) { + idOrName, ok := models.Get[string](req, "name") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'name' parameter") + return + } + + manager, err := themes.NewManager() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err)) + return + } + + registry, err := themes.NewRegistry() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err)) + return + } + + themeList, _ := registry.List() + theme := themes.FindByIDOrName(idOrName, themeList) + + if theme != nil { + installed, err := manager.IsInstalled(*theme) + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if theme is installed: %v", err)) + return + } + if !installed { + models.RespondError(conn, req.ID, fmt.Sprintf("theme not installed: %s", idOrName)) + return + } + if err := manager.Uninstall(*theme); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to uninstall theme: %v", err)) + return + } + models.Respond(conn, req.ID, models.SuccessResult{ + Success: true, + Message: fmt.Sprintf("theme uninstalled: %s", theme.Name), + }) + return + } + + if err := manager.UninstallByID(idOrName); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("theme not found: %s", idOrName)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{ + Success: true, + Message: fmt.Sprintf("theme uninstalled: %s", idOrName), + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/update.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/update.go new file mode 100644 index 0000000..fb9f81f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/themes/update.go @@ -0,0 +1,57 @@ +package themes + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/themes" +) + +func HandleUpdate(conn net.Conn, req models.Request) { + idOrName, ok := models.Get[string](req, "name") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'name' parameter") + return + } + + manager, err := themes.NewManager() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err)) + return + } + + registry, err := themes.NewRegistry() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err)) + return + } + + themeList, _ := registry.List() + theme := themes.FindByIDOrName(idOrName, themeList) + + if theme == nil { + models.RespondError(conn, req.ID, fmt.Sprintf("theme not found in registry: %s", idOrName)) + return + } + + installed, err := manager.IsInstalled(*theme) + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if theme is installed: %v", err)) + return + } + if !installed { + models.RespondError(conn, req.ID, fmt.Sprintf("theme not installed: %s", idOrName)) + return + } + + if err := manager.Update(*theme); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to update theme: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{ + Success: true, + Message: fmt.Sprintf("theme updated: %s", theme.Name), + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/trayrecovery/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/trayrecovery/manager.go new file mode 100644 index 0000000..3610f1b --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/trayrecovery/manager.go @@ -0,0 +1,93 @@ +package trayrecovery + +import ( + "fmt" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" + "github.com/godbus/dbus/v5" +) + +const resumeDelay = 3 * time.Second + +type Manager struct { + conn *dbus.Conn + stopChan chan struct{} + wg sync.WaitGroup +} + +func NewManager() (*Manager, error) { + conn, err := dbus.ConnectSessionBus() + if err != nil { + return nil, fmt.Errorf("failed to connect to session bus: %w", err) + } + + m := &Manager{ + conn: conn, + stopChan: make(chan struct{}), + } + + // Run a startup scan after a delay — covers the case where the process + // was killed during suspend and restarted by systemd (Type=dbus). + // The fresh process never sees the PrepareForSleep true→false transition, + // so the loginctl watcher alone is not enough. + go m.scheduleRecovery() + + return m, nil +} + +// WatchLoginctl subscribes to loginctl session state changes and triggers +// tray recovery after resume from suspend (PrepareForSleep false transition). +// This handles the case where the process survives suspend. +func (m *Manager) WatchLoginctl(lm *loginctl.Manager) { + ch := lm.Subscribe("tray-recovery") + m.wg.Add(1) + go func() { + defer m.wg.Done() + defer lm.Unsubscribe("tray-recovery") + + wasSleeping := false + for { + select { + case <-m.stopChan: + return + case state, ok := <-ch: + if !ok { + return + } + if state.PreparingForSleep { + wasSleeping = true + continue + } + if wasSleeping { + wasSleeping = false + go m.scheduleRecovery() + } + } + } + }() +} + +func (m *Manager) scheduleRecovery() { + select { + case <-time.After(resumeDelay): + m.recoverTrayItems() + case <-m.stopChan: + } +} + +func (m *Manager) Close() { + select { + case <-m.stopChan: + return + default: + close(m.stopChan) + } + m.wg.Wait() + if m.conn != nil { + m.conn.Close() + } + log.Info("TrayRecovery manager closed") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/trayrecovery/recovery.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/trayrecovery/recovery.go new file mode 100644 index 0000000..c7b98fb --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/trayrecovery/recovery.go @@ -0,0 +1,262 @@ +package trayrecovery + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/godbus/dbus/v5" +) + +const ( + sniWatcherDest = "org.kde.StatusNotifierWatcher" + sniWatcherPath = "/StatusNotifierWatcher" + sniWatcherIface = "org.kde.StatusNotifierWatcher" + sniItemIface = "org.kde.StatusNotifierItem" + dbusIface = "org.freedesktop.DBus" + propsIface = "org.freedesktop.DBus.Properties" + probeTimeout = 300 * time.Millisecond + connProbeTimeout = 150 * time.Millisecond + batchSize = 30 +) + +var excludedPrefixes = []string{ + "org.freedesktop.", + "org.gnome.", + "org.kde.StatusNotifier", + "com.canonical.AppMenu", + "org.mpris.", + "org.pipewire.", + "org.pulseaudio", + "fi.epitaph", + "quickshell", + "org.kde.quickshell", +} + +func (m *Manager) recoverTrayItems() { + registeredItems := m.getRegisteredItems() + allNames := m.getDBusNames() + if allNames == nil { + return + } + + registeredConnIDs := m.buildRegisteredConnIDs(registeredItems) + + count := len(registeredItems) + log.Infof("TrayRecoveryService: scanning DBus for unregistered SNI items (%d already registered)...", count) + + m.scanWellKnownNames(allNames, registeredItems, registeredConnIDs) + m.scanConnectionIDs(allNames, registeredItems, registeredConnIDs) +} + +func (m *Manager) getRegisteredItems() []string { + obj := m.conn.Object(sniWatcherDest, sniWatcherPath) + variant, err := obj.GetProperty(sniWatcherIface + ".RegisteredStatusNotifierItems") + if err != nil { + log.Warnf("TrayRecoveryService: failed to get registered items: %v", err) + return nil + } + + switch v := variant.Value().(type) { + case []string: + return v + case []any: + items := make([]string, 0, len(v)) + for _, elem := range v { + if s, ok := elem.(string); ok { + items = append(items, s) + } + } + return items + } + return nil +} + +func (m *Manager) getDBusNames() []string { + var names []string + err := m.conn.BusObject().Call(dbusIface+".ListNames", 0).Store(&names) + if err != nil { + log.Warnf("TrayRecoveryService: failed to list bus names: %v", err) + return nil + } + return names +} + +func (m *Manager) getNameOwner(name string) string { + var owner string + err := m.conn.BusObject().Call(dbusIface+".GetNameOwner", 0, name).Store(&owner) + if err != nil { + return "" + } + return owner +} + +// buildRegisteredConnIDs resolves every registered SNI item (well-known name +// or :1.xxx connection ID) to a canonical connection ID. This prevents +// duplicates in both directions. +func (m *Manager) buildRegisteredConnIDs(registeredItems []string) map[string]bool { + connIDs := make(map[string]bool, len(registeredItems)) + for _, item := range registeredItems { + name := extractName(item) + if strings.HasPrefix(name, ":1.") { + connIDs[name] = true + } else { + owner := m.getNameOwner(name) + if owner != "" { + connIDs[owner] = true + } + } + } + return connIDs +} + +// scanWellKnownNames probes well-known names (e.g. DinoX, nm-applet) for +// unregistered SNI items and re-registers them. +func (m *Manager) scanWellKnownNames(allNames []string, registeredItems []string, registeredConnIDs map[string]bool) { + registeredRaw := strings.Join(registeredItems, "\n") + + for _, name := range allNames { + if strings.HasPrefix(name, ":") { + continue + } + + if strings.Contains(registeredRaw, name) { + continue + } + + // Skip if this name's connection ID is already in the registered set + // (handles the case where the app registered via connection ID instead) + connForName := m.getNameOwner(name) + if connForName != "" && registeredConnIDs[connForName] { + continue + } + + if isExcludedName(name) { + continue + } + + short := shortName(name) + objectPaths := []string{ + "/StatusNotifierItem", + "/org/ayatana/NotificationItem/" + short, + } + + for _, objPath := range objectPaths { + if m.probeSNI(name, objPath, probeTimeout) { + m.registerSNI(name) + // Update set so the connection-ID section won't double-register this app + if connForName != "" { + registeredConnIDs[connForName] = true + } + break + } + } + } +} + +// scanConnectionIDs probes all :1.xxx connections in parallel for unregistered +// SNI items (e.g. Vesktop, Electron apps). Most non-SNI connections return an +// error instantly, so this is fast. +func (m *Manager) scanConnectionIDs(allNames []string, registeredItems []string, registeredConnIDs map[string]bool) { + registeredRaw := strings.Join(registeredItems, "\n") + registeredLower := strings.ToLower(registeredRaw) + + var wg sync.WaitGroup + sem := make(chan struct{}, batchSize) + + for _, name := range allNames { + if !strings.HasPrefix(name, ":1.") { + continue + } + if registeredConnIDs[name] { + continue + } + + sem <- struct{}{} + wg.Add(1) + go func(conn string) { + defer wg.Done() + defer func() { <-sem }() + + sniID := m.getSNIId(conn, connProbeTimeout) + if sniID == "" { + return + } + + // Skip if an item with the same Id is already registered (case-insensitive) + if strings.Contains(registeredLower, strings.ToLower(sniID)) { + return + } + + m.registerSNI(conn) + log.Infof("TrayRecovery: re-registered %s (Id: %s)", conn, sniID) + }(name) + } + wg.Wait() +} + +func (m *Manager) probeSNI(dest, path string, timeout time.Duration) bool { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + obj := m.conn.Object(dest, dbus.ObjectPath(path)) + var props map[string]dbus.Variant + err := obj.CallWithContext(ctx, propsIface+".GetAll", 0, sniItemIface).Store(&props) + if err != nil { + return false + } + + _, hasID := props["Id"] + return hasID +} + +func (m *Manager) getSNIId(dest string, timeout time.Duration) string { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + obj := m.conn.Object(dest, "/StatusNotifierItem") + var variant dbus.Variant + err := obj.CallWithContext(ctx, propsIface+".Get", 0, sniItemIface, "Id").Store(&variant) + if err != nil { + return "" + } + + id, _ := variant.Value().(string) + return id +} + +func (m *Manager) registerSNI(name string) { + obj := m.conn.Object(sniWatcherDest, sniWatcherPath) + call := obj.Call(sniWatcherIface+".RegisterStatusNotifierItem", 0, name) + if call.Err != nil { + log.Warnf("TrayRecovery: failed to register %s: %v", name, call.Err) + return + } + log.Infof("TrayRecovery: re-registered %s", name) +} + +func extractName(item string) string { + if idx := strings.IndexByte(item, '/'); idx != -1 { + return item[:idx] + } + return item +} + +func shortName(name string) string { + parts := strings.Split(name, ".") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return name +} + +func isExcludedName(name string) bool { + for _, prefix := range excludedPrefixes { + if strings.HasPrefix(name, prefix) { + return true + } + } + return false +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go new file mode 100644 index 0000000..1fff03f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go @@ -0,0 +1,167 @@ +package wayland + +import ( + "math" +) + +type GammaRamp struct { + Red []uint16 + Green []uint16 + Blue []uint16 +} + +type rgb struct { + r, g, b float64 +} + +type xyz struct { + x, y, z float64 +} + +func illuminantD(temp int) (float64, float64, bool) { + var x float64 + switch { + case temp >= 2500 && temp <= 7000: + t := float64(temp) + x = 0.244063 + 0.09911e3/t + 2.9678e6/(t*t) - 4.6070e9/(t*t*t) + case temp > 7000 && temp <= 25000: + t := float64(temp) + x = 0.237040 + 0.24748e3/t + 1.9018e6/(t*t) - 2.0064e9/(t*t*t) + default: + return 0, 0, false + } + y := -3*(x*x) + 2.870*x - 0.275 + return x, y, true +} + +func planckianLocus(temp int) (float64, float64, bool) { + var x, y float64 + switch { + case temp >= 1667 && temp <= 4000: + t := float64(temp) + x = -0.2661239e9/(t*t*t) - 0.2343589e6/(t*t) + 0.8776956e3/t + 0.179910 + if temp <= 2222 { + y = -1.1064814*(x*x*x) - 1.34811020*(x*x) + 2.18555832*x - 0.20219683 + } else { + y = -0.9549476*(x*x*x) - 1.37418593*(x*x) + 2.09137015*x - 0.16748867 + } + case temp > 4000 && temp < 25000: + t := float64(temp) + x = -3.0258469e9/(t*t*t) + 2.1070379e6/(t*t) + 0.2226347e3/t + 0.240390 + y = 3.0817580*(x*x*x) - 5.87338670*(x*x) + 3.75112997*x - 0.37001483 + default: + return 0, 0, false + } + return x, y, true +} + +func srgbGamma(value, gamma float64) float64 { + if value <= 0.0031308 { + return 12.92 * value + } + return math.Pow(1.055*value, 1.0/gamma) - 0.055 +} + +func clamp01(v float64) float64 { + switch { + case v > 1.0: + return 1.0 + case v < 0.0: + return 0.0 + default: + return v + } +} + +func xyzToSRGB(c xyz) rgb { + return rgb{ + r: srgbGamma(clamp01(3.2404542*c.x-1.5371385*c.y-0.4985314*c.z), 2.2), + g: srgbGamma(clamp01(-0.9692660*c.x+1.8760108*c.y+0.0415560*c.z), 2.2), + b: srgbGamma(clamp01(0.0556434*c.x-0.2040259*c.y+1.0572252*c.z), 2.2), + } +} + +func normalizeRGB(c *rgb) { + maxw := math.Max(c.r, math.Max(c.g, c.b)) + if maxw > 0 { + c.r /= maxw + c.g /= maxw + c.b /= maxw + } +} + +func calcWhitepoint(temp int) rgb { + if temp == 6500 { + return rgb{r: 1.0, g: 1.0, b: 1.0} + } + + var wp xyz + + switch { + case temp >= 25000: + x, y, _ := illuminantD(25000) + wp.x = x + wp.y = y + case temp >= 4000: + x, y, _ := illuminantD(temp) + wp.x = x + wp.y = y + case temp >= 2500: + x1, y1, _ := illuminantD(temp) + x2, y2, _ := planckianLocus(temp) + factor := float64(4000-temp) / 1500.0 + sineFactor := (math.Cos(math.Pi*factor) + 1.0) / 2.0 + wp.x = x1*sineFactor + x2*(1.0-sineFactor) + wp.y = y1*sineFactor + y2*(1.0-sineFactor) + default: + t := temp + if t < 1667 { + t = 1667 + } + x, y, _ := planckianLocus(t) + wp.x = x + wp.y = y + } + + wp.z = 1.0 - wp.x - wp.y + + wpRGB := xyzToSRGB(wp) + normalizeRGB(&wpRGB) + return wpRGB +} + +func GenerateGammaRamp(size uint32, temp int, gamma float64) GammaRamp { + ramp := GammaRamp{ + Red: make([]uint16, size), + Green: make([]uint16, size), + Blue: make([]uint16, size), + } + + wp := calcWhitepoint(temp) + + for i := uint32(0); i < size; i++ { + val := float64(i) / float64(size-1) + ramp.Red[i] = uint16(clamp01(math.Pow(val*wp.r, 1.0/gamma)) * 65535.0) + ramp.Green[i] = uint16(clamp01(math.Pow(val*wp.g, 1.0/gamma)) * 65535.0) + ramp.Blue[i] = uint16(clamp01(math.Pow(val*wp.b, 1.0/gamma)) * 65535.0) + } + + return ramp +} + +func GenerateIdentityRamp(size uint32) GammaRamp { + ramp := GammaRamp{ + Red: make([]uint16, size), + Green: make([]uint16, size), + Blue: make([]uint16, size), + } + + for i := uint32(0); i < size; i++ { + val := uint16((float64(i) / float64(size-1)) * 65535.0) + ramp.Red[i] = val + ramp.Green[i] = val + ramp.Blue[i] = val + } + + return ramp +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go new file mode 100644 index 0000000..0bf8e71 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go @@ -0,0 +1,120 @@ +package wayland + +import ( + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +func TestGenerateGammaRamp(t *testing.T) { + tests := []struct { + name string + size uint32 + temp int + gamma float64 + }{ + {"small_warm", 16, 6500, 1.0}, + {"small_cool", 16, 4000, 1.0}, + {"large_warm", 256, 6500, 1.0}, + {"large_cool", 256, 4000, 1.0}, + {"custom_gamma", 64, 5500, 1.2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ramp := GenerateGammaRamp(tt.size, tt.temp, tt.gamma) + + if len(ramp.Red) != int(tt.size) { + t.Errorf("expected %d red values, got %d", tt.size, len(ramp.Red)) + } + if len(ramp.Green) != int(tt.size) { + t.Errorf("expected %d green values, got %d", tt.size, len(ramp.Green)) + } + if len(ramp.Blue) != int(tt.size) { + t.Errorf("expected %d blue values, got %d", tt.size, len(ramp.Blue)) + } + + if ramp.Red[0] != 0 || ramp.Green[0] != 0 || ramp.Blue[0] != 0 { + t.Errorf("first values should be 0, got R:%d G:%d B:%d", + ramp.Red[0], ramp.Green[0], ramp.Blue[0]) + } + + lastIdx := tt.size - 1 + if ramp.Red[lastIdx] == 0 || ramp.Green[lastIdx] == 0 || ramp.Blue[lastIdx] == 0 { + t.Errorf("last values should be non-zero, got R:%d G:%d B:%d", + ramp.Red[lastIdx], ramp.Green[lastIdx], ramp.Blue[lastIdx]) + } + + for i := uint32(1); i < tt.size; i++ { + if ramp.Red[i] < ramp.Red[i-1] { + t.Errorf("red ramp not monotonic at index %d", i) + } + } + }) + } +} + +func TestCalcWhitepoint(t *testing.T) { + tests := []struct { + name string + temp int + }{ + {"very_warm", 6500}, + {"neutral", 5500}, + {"cool", 4000}, + {"very_cool", 3000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wp := calcWhitepoint(tt.temp) + + if wp.r < 0 || wp.r > 1 { + t.Errorf("red out of range: %f", wp.r) + } + if wp.g < 0 || wp.g > 1 { + t.Errorf("green out of range: %f", wp.g) + } + if wp.b < 0 || wp.b > 1 { + t.Errorf("blue out of range: %f", wp.b) + } + }) + } +} + +func TestWhitepointProgression(t *testing.T) { + temps := []int{3000, 4000, 5000, 6000, 6500} + + var prevBlue float64 + for i, temp := range temps { + wp := calcWhitepoint(temp) + if i > 0 && wp.b < prevBlue { + t.Errorf("blue should increase with temperature, %d->%d: %f->%f", + temps[i-1], temp, prevBlue, wp.b) + } + prevBlue = wp.b + } +} + +func TestClamp(t *testing.T) { + tests := []struct { + val float64 + min float64 + max float64 + expected float64 + }{ + {5, 0, 10, 5}, + {-5, 0, 10, 0}, + {15, 0, 10, 10}, + {0, 0, 10, 0}, + {10, 0, 10, 10}, + } + + for _, tt := range tests { + result := utils.Clamp(tt.val, tt.min, tt.max) + if result != tt.expected { + t.Errorf("clamp(%f, %f, %f) = %f, want %f", + tt.val, tt.min, tt.max, result, tt.expected) + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go new file mode 100644 index 0000000..ba99b5f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go @@ -0,0 +1,50 @@ +package wayland + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +type ipAPIResponse struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + City string `json:"city"` +} + +func FetchIPLocation() (*float64, *float64, error) { + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Get("http://ip-api.com/json/") + if err != nil { + return nil, nil, fmt.Errorf("failed to fetch IP location: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("ip-api.com returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response: %w", err) + } + + var data ipAPIResponse + if err := json.Unmarshal(body, &data); err != nil { + return nil, nil, fmt.Errorf("failed to parse response: %w", err) + } + + if data.Lat == 0 && data.Lon == 0 { + return nil, nil, fmt.Errorf("missing location data in response") + } + + log.Infof("Fetched IP-based location: %s (%.4f, %.4f)", data.City, data.Lat, data.Lon) + return &data.Lat, &data.Lon, nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go new file mode 100644 index 0000000..76b08b5 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go @@ -0,0 +1,182 @@ +package wayland + +import ( + "encoding/json" + "fmt" + "net" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + if manager == nil { + models.RespondError(conn, req.ID, "wayland manager not initialized") + return + } + + switch req.Method { + case "wayland.gamma.getState": + handleGetState(conn, req, manager) + case "wayland.gamma.setTemperature": + handleSetTemperature(conn, req, manager) + case "wayland.gamma.setLocation": + handleSetLocation(conn, req, manager) + case "wayland.gamma.setManualTimes": + handleSetManualTimes(conn, req, manager) + case "wayland.gamma.setUseIPLocation": + handleSetUseIPLocation(conn, req, manager) + case "wayland.gamma.setGamma": + handleSetGamma(conn, req, manager) + case "wayland.gamma.setEnabled": + handleSetEnabled(conn, req, manager) + case "wayland.gamma.subscribe": + handleSubscribe(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 handleSetTemperature(conn net.Conn, req models.Request, manager *Manager) { + var lowTemp, highTemp int + + if temp, ok := models.Get[float64](req, "temp"); ok { + lowTemp = int(temp) + highTemp = int(temp) + } else { + low, err := params.Float(req.Params, "low") + if err != nil { + models.RespondError(conn, req.ID, "missing temperature parameters (provide 'temp' or both 'low' and 'high')") + return + } + high, err := params.Float(req.Params, "high") + if err != nil { + models.RespondError(conn, req.ID, "missing temperature parameters (provide 'temp' or both 'low' and 'high')") + return + } + lowTemp = int(low) + highTemp = int(high) + } + + if err := manager.SetTemperature(lowTemp, highTemp); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "temperature set"}) +} + +func handleSetLocation(conn net.Conn, req models.Request, manager *Manager) { + lat, err := params.Float(req.Params, "latitude") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + lon, err := params.Float(req.Params, "longitude") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetLocation(lat, lon); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "location set"}) +} + +func handleSetManualTimes(conn net.Conn, req models.Request, manager *Manager) { + sunriseStr, sunriseOK := models.Get[string](req, "sunrise") + sunsetStr, sunsetOK := models.Get[string](req, "sunset") + + if !sunriseOK || !sunsetOK || sunriseStr == "" || sunsetStr == "" { + manager.ClearManualTimes() + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "manual times cleared"}) + return + } + + sunrise, err := time.Parse("15:04", sunriseStr) + if err != nil { + models.RespondError(conn, req.ID, "invalid sunrise format (use HH:MM)") + return + } + + sunset, err := time.Parse("15:04", sunsetStr) + if err != nil { + models.RespondError(conn, req.ID, "invalid sunset format (use HH:MM)") + return + } + + if err := manager.SetManualTimes(sunrise, sunset); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "manual times set"}) +} + +func handleSetUseIPLocation(conn net.Conn, req models.Request, manager *Manager) { + use, err := params.Bool(req.Params, "use") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + manager.SetUseIPLocation(use) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "IP location preference set"}) +} + +func handleSetGamma(conn net.Conn, req models.Request, manager *Manager) { + gamma, err := params.Float(req.Params, "gamma") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetGamma(gamma); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "gamma set"}) +} + +func handleSetEnabled(conn net.Conn, req models.Request, manager *Manager) { + enabled, err := params.Bool(req.Params, "enabled") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + manager.SetEnabled(enabled) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "enabled state set"}) +} + +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() + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + ID: req.ID, + Result: &initialState, + }); err != nil { + return + } + + for state := range stateChan { + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + Result: &state, + }); err != nil { + return + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go new file mode 100644 index 0000000..3540e3d --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go @@ -0,0 +1,1256 @@ +package wayland + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "slices" + "syscall" + "time" + + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + "github.com/godbus/dbus/v5" + "golang.org/x/sys/unix" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control" +) + +const animKelvinStep = 25 + +func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + if config.ElevationTwilight == 0 { + config.ElevationTwilight = -6.0 + } + if config.ElevationDaylight == 0 { + config.ElevationDaylight = 3.0 + } + + m := &Manager{ + config: config, + display: display, + ctx: display.Context(), + cmdq: make(chan cmd, 128), + stopChan: make(chan struct{}), + updateTrigger: make(chan struct{}, 1), + dirty: make(chan struct{}, 1), + dbusSignal: make(chan *dbus.Signal, 16), + } + + if err := m.setupRegistry(); err != nil { + return nil, err + } + + if err := m.setupDBusMonitor(); err != nil { + log.Warnf("Failed to setup D-Bus monitoring: %v", err) + } + + m.alive = true + m.recalcSchedule(time.Now()) + m.updateStateFromSchedule() + + m.notifierWg.Add(1) + go m.notifier() + + m.wg.Add(1) + go m.schedulerLoop() + + if m.dbusConn != nil { + m.wg.Add(1) + go m.dbusMonitor() + } + + m.wg.Add(1) + go m.waylandActor() + + if config.Enabled { + m.post(func() { + log.Info("Gamma control enabled at startup") + gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) + m.availOutputsMu.RLock() + outs := slices.Clone(m.availableOutputs) + m.availOutputsMu.RUnlock() + if err := m.setupOutputControls(outs, gammaMgr); err != nil { + log.Errorf("Failed to initialize gamma controls: %v", err) + return + } + m.controlsInitialized = true + }) + } + + return m, nil +} + +func (m *Manager) post(fn func()) { + select { + case m.cmdq <- cmd{fn: fn}: + default: + log.Warn("Actor command queue full") + } +} + +func (m *Manager) waylandActor() { + defer m.wg.Done() + for { + select { + case <-m.stopChan: + return + case c := <-m.cmdq: + c.fn() + } + } +} + +func (m *Manager) anyOutputReady() bool { + anyReady := false + m.outputs.Range(func(_ uint32, out *outputState) bool { + if out.rampSize > 0 && !out.failed { + anyReady = true + return false // stop iteration + } + return true + }) + return anyReady +} + +func (m *Manager) setupDBusMonitor() error { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return fmt.Errorf("system bus: %w", err) + } + + matchRule := "type='signal',interface='org.freedesktop.login1.Manager',member='PrepareForSleep',path='/org/freedesktop/login1'" + if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil { + conn.Close() + return fmt.Errorf("add match: %w", err) + } + + conn.Signal(m.dbusSignal) + m.dbusConn = conn + return nil +} + +func (m *Manager) setupRegistry() error { + registry, err := m.display.GetRegistry() + if err != nil { + return fmt.Errorf("get registry: %w", err) + } + m.registry = registry + + outputs := make([]*wlclient.Output, 0) + outputNames := make(map[uint32]string) + var gammaMgr *wlr_gamma_control.ZwlrGammaControlManagerV1 + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case wlr_gamma_control.ZwlrGammaControlManagerV1InterfaceName: + manager := wlr_gamma_control.NewZwlrGammaControlManagerV1(m.ctx) + version := e.Version + if version > 1 { + version = 1 + } + if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil { + gammaMgr = manager + } + case "wl_output": + output := wlclient.NewOutput(m.ctx) + version := e.Version + if version > 4 { + version = 4 + } + if err := registry.Bind(e.Name, e.Interface, version, output); err != nil { + return + } + outputID := output.ID() + output.SetNameHandler(func(ev wlclient.OutputNameEvent) { + outputNames[outputID] = ev.Name + }) + if gammaMgr != nil { + outputs = append(outputs, output) + m.addAvailableOutput(output) + } + m.outputRegNames.Store(outputID, e.Name) + + m.configMutex.RLock() + enabled := m.config.Enabled + m.configMutex.RUnlock() + + if enabled && m.controlsInitialized { + m.post(func() { + if err := m.addOutputControl(output); err != nil { + log.Warnf("Failed to add output control: %v", err) + } + }) + } + } + }) + + registry.SetGlobalRemoveHandler(func(e wlclient.RegistryGlobalRemoveEvent) { + m.post(func() { + var foundID uint32 + var foundOut *outputState + m.outputs.Range(func(id uint32, out *outputState) bool { + if out.registryName == e.Name { + foundID = id + foundOut = out + return false + } + return true + }) + if foundOut == nil { + return + } + if foundOut.gammaControl != nil { + foundOut.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy() + foundOut.gammaControl = nil + } + m.removeAvailableOutput(foundOut.output) + if foundOut.output != nil && !foundOut.output.IsZombie() { + _ = foundOut.output.Release() + } + m.outputs.Delete(foundID) + + hasOutputs := false + m.outputs.Range(func(_ uint32, _ *outputState) bool { + hasOutputs = true + return false + }) + if !hasOutputs { + m.controlsInitialized = false + } + }) + }) + + if err := m.display.Roundtrip(); err != nil { + return fmt.Errorf("roundtrip 1: %w", err) + } + if err := m.display.Roundtrip(); err != nil { + return fmt.Errorf("roundtrip 2: %w", err) + } + + if gammaMgr == nil { + return errdefs.ErrNoGammaControl + } + if len(outputs) == 0 { + return fmt.Errorf("no outputs") + } + + physicalOutputs := make([]*wlclient.Output, 0, len(outputs)) + for _, output := range outputs { + name := outputNames[output.ID()] + if len(name) >= 9 && name[:9] == "HEADLESS-" { + continue + } + physicalOutputs = append(physicalOutputs, output) + } + + m.gammaControl = gammaMgr + m.availableOutputs = physicalOutputs + return nil +} + +func (m *Manager) setupOutputControls(outputs []*wlclient.Output, manager *wlr_gamma_control.ZwlrGammaControlManagerV1) error { + for _, output := range outputs { + control, err := manager.GetGammaControl(output) + if err != nil { + continue + } + outputID := output.ID() + registryName, _ := m.outputRegNames.Load(outputID) + outState := &outputState{ + id: outputID, + registryName: registryName, + output: output, + gammaControl: control, + } + m.setupControlHandlers(outState, control) + m.outputs.Store(outputID, outState) + } + return nil +} + +func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_control.ZwlrGammaControlV1) { + outputID := state.id + + control.SetGammaSizeHandler(func(e wlr_gamma_control.ZwlrGammaControlV1GammaSizeEvent) { + size := e.Size + m.post(func() { + if out, ok := m.outputs.Load(outputID); ok { + out.rampSize = size + out.failed = false + out.retryCount = 0 + } + m.lastAppliedTemp = 0 + m.applyCurrentTemp("gamma_size") + }) + }) + + control.SetFailedHandler(func(_ wlr_gamma_control.ZwlrGammaControlV1FailedEvent) { + m.post(func() { + out, ok := m.outputs.Load(outputID) + if !ok { + return + } + if ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && ctrl != nil && !ctrl.IsZombie() { + ctrl.Destroy() + } + out.gammaControl = nil + out.failed = true + out.rampSize = 0 + out.retryCount++ + out.lastFailTime = time.Now() + + if !m.outputStillValid(out) { + return + } + + backoff := time.Duration(300<<uint(min(out.retryCount-1, 4))) * time.Millisecond + time.AfterFunc(backoff, func() { + m.post(func() { + if !m.outputStillValid(out) { + return + } + if _, stillTracked := m.outputs.Load(outputID); !stillTracked { + return + } + m.recreateOutputControl(out) + }) + }) + }) + }) +} + +func (m *Manager) addAvailableOutput(o *wlclient.Output) { + if o == nil { + return + } + m.availOutputsMu.Lock() + defer m.availOutputsMu.Unlock() + if slices.Contains(m.availableOutputs, o) { + return + } + m.availableOutputs = append(m.availableOutputs, o) +} + +func (m *Manager) removeAvailableOutput(o *wlclient.Output) { + if o == nil { + return + } + m.availOutputsMu.Lock() + defer m.availOutputsMu.Unlock() + m.availableOutputs = slices.DeleteFunc(m.availableOutputs, func(existing *wlclient.Output) bool { + return existing == o + }) +} + +func (m *Manager) outputStillValid(out *outputState) bool { + switch { + case out == nil: + return false + case out.output == nil: + return false + case out.output.IsZombie(): + return false + } + m.availOutputsMu.RLock() + defer m.availOutputsMu.RUnlock() + return slices.Contains(m.availableOutputs, out.output) +} + +func isConnectionDeadErr(err error) bool { + switch { + case err == nil: + return false + case errors.Is(err, syscall.EPIPE): + return true + case errors.Is(err, syscall.ECONNRESET): + return true + case errors.Is(err, syscall.EBADF): + return true + case errors.Is(err, io.EOF): + return true + } + return false +} + +func (m *Manager) addOutputControl(output *wlclient.Output) error { + switch { + case m.connectionDead.Load(): + return nil + case output == nil || output.IsZombie(): + return nil + } + + outputID := output.ID() + gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) + + control, err := gammaMgr.GetGammaControl(output) + if err != nil { + if isConnectionDeadErr(err) { + m.markConnectionDead(err) + } + return err + } + + registryName, _ := m.outputRegNames.Load(outputID) + outState := &outputState{ + id: outputID, + registryName: registryName, + output: output, + gammaControl: control, + } + m.setupControlHandlers(outState, control) + m.outputs.Store(outputID, outState) + return nil +} + +func (m *Manager) recreateOutputControl(out *outputState) error { + m.configMutex.RLock() + enabled := m.config.Enabled + m.configMutex.RUnlock() + + switch { + case m.connectionDead.Load(): + return nil + case !enabled || !m.controlsInitialized: + return nil + case out.isVirtual: + return nil + case out.retryCount >= 10: + return nil + case !m.outputStillValid(out): + return nil + } + if _, ok := m.outputs.Load(out.id); !ok { + return nil + } + + gammaMgr, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) + if !ok { + return fmt.Errorf("no gamma manager") + } + + if existing, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && existing != nil && !existing.IsZombie() { + existing.Destroy() + out.gammaControl = nil + } + + control, err := gammaMgr.GetGammaControl(out.output) + if err != nil { + if isConnectionDeadErr(err) { + m.markConnectionDead(err) + } + return err + } + + m.setupControlHandlers(out, control) + out.gammaControl = control + out.failed = false + return nil +} + +func (m *Manager) markConnectionDead(err error) { + if m.connectionDead.Swap(true) { + return + } + log.Errorf("gamma: wayland connection appears dead (%v); pausing gamma operations", err) +} + +func (m *Manager) recalcSchedule(now time.Time) { + m.configMutex.RLock() + config := m.config + m.configMutex.RUnlock() + + m.scheduleMutex.Lock() + defer m.scheduleMutex.Unlock() + + dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + alreadyValid := !m.schedule.times.Sunrise.IsZero() + if m.schedule.calcDay.Equal(dayStart) && alreadyValid { + return + } + + var times SunTimes + var cond SunCondition + + if config.ManualSunrise != nil && config.ManualSunset != nil { + dur := time.Hour + if config.ManualDuration != nil { + dur = *config.ManualDuration + } + sunrise := time.Date(now.Year(), now.Month(), now.Day(), + config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, now.Location()) + sunset := time.Date(now.Year(), now.Month(), now.Day(), + config.ManualSunset.Hour(), config.ManualSunset.Minute(), config.ManualSunset.Second(), 0, now.Location()) + times = SunTimes{ + Dawn: sunrise.Add(-dur), + Sunrise: sunrise, + Sunset: sunset, + Night: sunset.Add(dur), + } + cond = SunNormal + } else { + lat, lon := m.getLocation() + if lat == nil || lon == nil { + m.gammaState = StateStatic + return + } + times, cond = CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight) + } + + m.schedule.calcDay = dayStart + m.schedule.times = times + m.schedule.condition = cond + + switch cond { + case SunNormal: + m.gammaState = StateNormal + tempDiff := config.HighTemp - config.LowTemp + if tempDiff > 0 { + dawnDur := times.Sunrise.Sub(times.Dawn) + nightDur := times.Night.Sub(times.Sunset) + m.schedule.dawnStepTime = time.Duration(max(1, int(dawnDur.Seconds())*animKelvinStep/tempDiff)) * time.Second + m.schedule.nightStepTime = time.Duration(max(1, int(nightDur.Seconds())*animKelvinStep/tempDiff)) * time.Second + } + case SunMidnightSun: + m.gammaState = StateStatic + case SunPolarNight: + m.gammaState = StateStatic + } +} + +func (m *Manager) SetGeoClient(client geolocation.Client) { + m.geoClient = client +} + +func (m *Manager) getLocation() (*float64, *float64) { + m.configMutex.RLock() + config := m.config + m.configMutex.RUnlock() + + if config.Latitude != nil && config.Longitude != nil { + return config.Latitude, config.Longitude + } + if !config.UseIPLocation { + return nil, nil + } + if m.geoClient == nil { + return nil, nil + } + + m.locationMutex.RLock() + if m.cachedIPLat != nil && m.cachedIPLon != nil { + lat, lon := m.cachedIPLat, m.cachedIPLon + m.locationMutex.RUnlock() + return lat, lon + } + m.locationMutex.RUnlock() + + location, err := m.geoClient.GetLocation() + if err != nil { + return nil, nil + } + + m.locationMutex.Lock() + m.cachedIPLat = &location.Latitude + m.cachedIPLon = &location.Longitude + m.locationMutex.Unlock() + return m.cachedIPLat, m.cachedIPLon +} + +func (m *Manager) hasValidSchedule() bool { + m.scheduleMutex.RLock() + defer m.scheduleMutex.RUnlock() + return !m.schedule.times.Sunrise.IsZero() +} + +func (m *Manager) getSunPosition(now time.Time) float64 { + m.scheduleMutex.RLock() + sched := m.schedule + state := m.gammaState + m.scheduleMutex.RUnlock() + + if sched.times.Sunrise.IsZero() { + return 1.0 + } + + switch state { + case StateStatic: + if sched.condition == SunMidnightSun { + return 1.0 + } + return 0.0 + case StateNormal: + return m.getSunPositionNormal(now, sched.times) + } + return 1.0 +} + +func (m *Manager) getSunPositionNormal(now time.Time, times SunTimes) float64 { + if now.Before(times.Dawn) { + return 0.0 + } + if now.Before(times.Sunrise) { + return interpolate(now, times.Dawn, times.Sunrise) + } + if now.Before(times.Sunset) { + return 1.0 + } + if now.Before(times.Night) { + return interpolate(now, times.Night, times.Sunset) + } + return 0.0 +} + +func interpolate(now time.Time, start, stop time.Time) float64 { + if start.Equal(stop) { + return 1.0 + } + pos := float64(now.Sub(start)) / float64(stop.Sub(start)) + switch { + case pos > 1.0: + return 1.0 + case pos < 0.0: + return 0.0 + default: + return pos + } +} + +func (m *Manager) getTempFromPosition(pos float64) int { + m.configMutex.RLock() + low, high := m.config.LowTemp, m.config.HighTemp + m.configMutex.RUnlock() + return low + int(float64(high-low)*pos) +} + +func (m *Manager) getNextDeadline(now time.Time) time.Time { + m.scheduleMutex.RLock() + sched := m.schedule + state := m.gammaState + m.scheduleMutex.RUnlock() + + switch state { + case StateStatic: + return m.tomorrow(now) + case StateNormal: + return m.getDeadlineNormal(now, sched) + default: + return m.tomorrow(now) + } +} + +func (m *Manager) getDeadlineNormal(now time.Time, sched sunSchedule) time.Time { + times := sched.times + switch { + case now.Before(times.Dawn): + return times.Dawn + case now.Before(times.Sunrise): + return now.Add(sched.dawnStepTime) + case now.Before(times.Sunset): + return times.Sunset + case now.Before(times.Night): + return now.Add(sched.nightStepTime) + default: + return m.tomorrowDawn(now) + } +} + +func (m *Manager) tomorrowDawn(now time.Time) time.Time { + tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) + + m.configMutex.RLock() + config := m.config + m.configMutex.RUnlock() + + if config.ManualSunrise != nil { + dur := time.Hour + if config.ManualDuration != nil { + dur = *config.ManualDuration + } + return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), + config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, tomorrow.Location()).Add(-dur) + } + + lat, lon := m.getLocation() + if lat == nil || lon == nil { + return tomorrow + } + + times, cond := CalculateSunTimesWithTwilight(*lat, *lon, tomorrow, config.ElevationTwilight, config.ElevationDaylight) + if cond != SunNormal { + return tomorrow + } + return times.Dawn +} + +func (m *Manager) tomorrow(now time.Time) time.Time { + return time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) +} + +func (m *Manager) schedulerLoop() { + defer m.wg.Done() + + m.configMutex.RLock() + enabled := m.config.Enabled + m.configMutex.RUnlock() + + if enabled { + m.post(func() { m.applyCurrentTemp("startup") }) + } + + var timer *time.Timer + for { + m.configMutex.RLock() + enabled := m.config.Enabled + m.configMutex.RUnlock() + + now := time.Now() + m.recalcSchedule(now) + + var waitDur time.Duration + if enabled { + deadline := m.getNextDeadline(now) + waitDur = time.Until(deadline) + if waitDur < time.Second { + waitDur = time.Second + } + } else { + waitDur = 24 * time.Hour + } + + if timer != nil { + timer.Stop() + } + timer = time.NewTimer(waitDur) + + select { + case <-m.stopChan: + timer.Stop() + return + case <-m.updateTrigger: + timer.Stop() + m.scheduleMutex.Lock() + m.schedule.calcDay = time.Time{} + m.scheduleMutex.Unlock() + m.recalcSchedule(time.Now()) + m.updateStateFromSchedule() + m.configMutex.RLock() + enabled := m.config.Enabled + m.configMutex.RUnlock() + if enabled { + m.post(func() { m.applyCurrentTemp("updateTrigger") }) + } + case <-timer.C: + m.configMutex.RLock() + enabled := m.config.Enabled + m.configMutex.RUnlock() + if enabled { + m.post(func() { m.applyCurrentTemp("timer") }) + } + } + } +} + +func (m *Manager) applyCurrentTemp(_ string) { + if !m.controlsInitialized || !m.anyOutputReady() { + return + } + + // Ensure schedule is up-to-date (handles display wake after overnight sleep) + m.recalcSchedule(time.Now()) + + m.configMutex.RLock() + low, high := m.config.LowTemp, m.config.HighTemp + m.configMutex.RUnlock() + + if low == high { + m.applyGamma(low) + m.updateStateFromSchedule() + return + } + + if !m.hasValidSchedule() { + m.updateStateFromSchedule() + return + } + + now := time.Now() + pos := m.getSunPosition(now) + temp := m.getTempFromPosition(pos) + + m.applyGamma(temp) + m.updateStateFromSchedule() +} + +func (m *Manager) applyGamma(temp int) { + m.configMutex.RLock() + gamma := m.config.Gamma + m.configMutex.RUnlock() + + switch { + case m.connectionDead.Load(): + return + case !m.controlsInitialized: + return + case m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma: + return + } + + var outs []*outputState + m.outputs.Range(func(_ uint32, out *outputState) bool { + outs = append(outs, out) + return true + }) + if len(outs) == 0 { + return + } + + type job struct { + out *outputState + data []byte + } + var jobs []job + + for _, out := range outs { + switch { + case out.failed: + continue + case out.rampSize == 0: + continue + case out.gammaControl == nil: + continue + case !m.outputStillValid(out): + continue + } + ramp := GenerateGammaRamp(out.rampSize, temp, gamma) + buf := bytes.NewBuffer(make([]byte, 0, int(out.rampSize)*6)) + for _, v := range ramp.Red { + binary.Write(buf, binary.LittleEndian, v) + } + for _, v := range ramp.Green { + binary.Write(buf, binary.LittleEndian, v) + } + for _, v := range ramp.Blue { + binary.Write(buf, binary.LittleEndian, v) + } + jobs = append(jobs, job{out: out, data: buf.Bytes()}) + } + + for _, j := range jobs { + err := m.setGammaBytes(j.out, j.data) + if err == nil { + continue + } + log.Warnf("gamma: failed to set output %d: %v", j.out.id, err) + j.out.failed = true + j.out.rampSize = 0 + if isConnectionDeadErr(err) { + m.markConnectionDead(err) + return + } + } + + m.lastAppliedTemp = temp + m.lastAppliedGamma = gamma +} + +func (m *Manager) setGammaBytes(out *outputState, data []byte) error { + if out.gammaControl == nil { + return fmt.Errorf("no gamma control") + } + ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1) + if !ok || ctrl == nil || ctrl.IsZombie() { + return fmt.Errorf("gamma control invalid") + } + + fd, err := MemfdCreate("gamma-ramp", 0) + if err != nil { + return err + } + defer syscall.Close(fd) + + if err := syscall.Ftruncate(fd, int64(len(data))); err != nil { + return err + } + + dupFd, err := syscall.Dup(fd) + if err != nil { + return err + } + f := os.NewFile(uintptr(dupFd), "gamma") + defer f.Close() + + if _, err := f.Write(data); err != nil { + return err + } + syscall.Seek(fd, 0, 0) + + return ctrl.SetGamma(fd) +} + +func (m *Manager) updateStateFromSchedule() { + now := time.Now() + + m.configMutex.RLock() + config := m.config + m.configMutex.RUnlock() + + m.scheduleMutex.RLock() + times := m.schedule.times + m.scheduleMutex.RUnlock() + + var pos float64 + var temp int + var isDay bool + var deadline time.Time + + if times.Sunrise.IsZero() { + pos = 1.0 + temp = config.HighTemp + isDay = true + deadline = m.tomorrow(now) + } else { + pos = m.getSunPosition(now) + temp = m.getTempFromPosition(pos) + deadline = m.getNextDeadline(now) + isDay = now.After(times.Sunrise) && now.Before(times.Sunset) + } + + newState := State{ + Config: config, + CurrentTemp: temp, + NextTransition: deadline, + SunriseTime: times.Sunrise, + SunsetTime: times.Sunset, + DawnTime: times.Dawn, + NightTime: times.Night, + IsDay: isDay, + SunPosition: pos, + } + + m.stateMutex.Lock() + m.state = &newState + m.stateMutex.Unlock() + + m.notifySubscribers() +} + +func (m *Manager) notifier() { + defer m.notifierWg.Done() + const minGap = 100 * 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 + } + currentState := m.GetState() + if m.lastNotified != nil && !stateChanged(m.lastNotified, ¤tState) { + pending = false + continue + } + m.subscribers.Range(func(_ string, ch chan State) bool { + select { + case ch <- currentState: + default: + } + return true + }) + stateCopy := currentState + m.lastNotified = &stateCopy + pending = false + } + } +} + +func (m *Manager) dbusMonitor() { + defer m.wg.Done() + for { + select { + case <-m.stopChan: + return + case sig := <-m.dbusSignal: + if sig == nil { + continue + } + m.handleDBusSignal(sig) + } + } +} + +func (m *Manager) handleDBusSignal(sig *dbus.Signal) { + switch { + case sig.Name != "org.freedesktop.login1.Manager.PrepareForSleep": + return + case len(sig.Body) == 0: + return + } + preparing, ok := sig.Body[0].(bool) + if !ok || preparing { + return + } + m.configMutex.RLock() + enabled := m.config.Enabled + m.configMutex.RUnlock() + if !enabled { + return + } + time.AfterFunc(500*time.Millisecond, func() { + m.post(m.handleResume) + }) +} + +func (m *Manager) handleResume() { + m.configMutex.RLock() + stillEnabled := m.config.Enabled + m.configMutex.RUnlock() + + switch { + case !stillEnabled: + return + case !m.controlsInitialized: + return + case m.connectionDead.Load(): + return + } + + // Compositors (Niri, Hyprland, wlroots-based) re-apply the cached gamma + // ramp to DRM on resume; gamma_control objects stay valid. We just need + // to force a resend so the schedule catches up with the current time of + // day — the original #1235 regression was caused by lastAppliedTemp + // matching and the send being skipped. + m.recalcSchedule(time.Now()) + m.lastAppliedTemp = 0 + m.applyCurrentTemp("resume") +} + +func (m *Manager) triggerUpdate() { + select { + case m.updateTrigger <- struct{}{}: + default: + } +} + +func (m *Manager) SetConfig(config Config) error { + if err := config.Validate(); err != nil { + return err + } + m.configMutex.Lock() + m.config = config + m.configMutex.Unlock() + m.triggerUpdate() + return nil +} + +func (m *Manager) SetTemperature(low, high int) error { + m.configMutex.Lock() + if m.config.LowTemp == low && m.config.HighTemp == high { + m.configMutex.Unlock() + return nil + } + m.config.LowTemp = low + m.config.HighTemp = high + err := m.config.Validate() + m.configMutex.Unlock() + if err != nil { + return err + } + m.triggerUpdate() + return nil +} + +func (m *Manager) SetLocation(lat, lon float64) error { + m.configMutex.Lock() + if m.config.Latitude != nil && m.config.Longitude != nil && + *m.config.Latitude == lat && *m.config.Longitude == lon && !m.config.UseIPLocation { + m.configMutex.Unlock() + return nil + } + m.config.Latitude = &lat + m.config.Longitude = &lon + m.config.UseIPLocation = false + err := m.config.Validate() + m.configMutex.Unlock() + if err != nil { + return err + } + m.triggerUpdate() + return nil +} + +func (m *Manager) SetUseIPLocation(use bool) { + m.configMutex.Lock() + if m.config.UseIPLocation == use { + m.configMutex.Unlock() + return + } + m.config.UseIPLocation = use + if use { + m.config.Latitude = nil + m.config.Longitude = nil + } + m.configMutex.Unlock() + + if use { + m.locationMutex.Lock() + m.cachedIPLat = nil + m.cachedIPLon = nil + m.locationMutex.Unlock() + } + m.triggerUpdate() +} + +func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error { + m.configMutex.Lock() + if m.config.ManualSunrise != nil && m.config.ManualSunset != nil && + m.config.ManualSunrise.Hour() == sunrise.Hour() && m.config.ManualSunrise.Minute() == sunrise.Minute() && + m.config.ManualSunset.Hour() == sunset.Hour() && m.config.ManualSunset.Minute() == sunset.Minute() { + m.configMutex.Unlock() + return nil + } + m.config.ManualSunrise = &sunrise + m.config.ManualSunset = &sunset + err := m.config.Validate() + m.configMutex.Unlock() + if err != nil { + return err + } + m.triggerUpdate() + return nil +} + +func (m *Manager) ClearManualTimes() { + m.configMutex.Lock() + if m.config.ManualSunrise == nil && m.config.ManualSunset == nil { + m.configMutex.Unlock() + return + } + m.config.ManualSunrise = nil + m.config.ManualSunset = nil + m.configMutex.Unlock() + m.triggerUpdate() +} + +func (m *Manager) SetGamma(gamma float64) error { + m.configMutex.Lock() + if m.config.Gamma == gamma { + m.configMutex.Unlock() + return nil + } + m.config.Gamma = gamma + err := m.config.Validate() + m.configMutex.Unlock() + if err != nil { + return err + } + m.triggerUpdate() + return nil +} + +func (m *Manager) SetEnabled(enabled bool) { + m.configMutex.Lock() + wasEnabled := m.config.Enabled + if wasEnabled == enabled { + m.configMutex.Unlock() + return + } + m.config.Enabled = enabled + highTemp := m.config.HighTemp + m.configMutex.Unlock() + + switch { + case enabled && !m.controlsInitialized: + m.post(func() { + gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) + m.availOutputsMu.RLock() + outs := slices.Clone(m.availableOutputs) + m.availOutputsMu.RUnlock() + if err := m.setupOutputControls(outs, gammaMgr); err != nil { + log.Errorf("gamma: failed to create controls: %v", err) + return + } + m.controlsInitialized = true + m.triggerUpdate() + }) + case enabled && !wasEnabled: + m.triggerUpdate() + case !enabled && m.controlsInitialized: + m.post(func() { + m.outputs.Range(func(id uint32, out *outputState) bool { + if out.gammaControl != nil { + out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy() + } + return true + }) + m.outputs.Range(func(key uint32, _ *outputState) bool { + m.outputs.Delete(key) + return true + }) + m.controlsInitialized = false + }) + _ = highTemp + } +} + +func (m *Manager) Close() { + close(m.stopChan) + m.wg.Wait() + m.notifierWg.Wait() + + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + + m.outputs.Range(func(_ uint32, out *outputState) bool { + if ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok { + ctrl.Destroy() + } + return true + }) + m.outputs.Range(func(key uint32, _ *outputState) bool { + m.outputs.Delete(key) + return true + }) + + if manager, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1); ok { + manager.Destroy() + } + + if m.dbusConn != nil { + m.dbusConn.RemoveSignal(m.dbusSignal) + m.dbusConn.Close() + } +} + +func MemfdCreate(name string, flags int) (int, error) { + fd, err := unix.MemfdCreate(name, flags) + if err != nil { + return -1, err + } + return fd, nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go new file mode 100644 index 0000000..8c00129 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go @@ -0,0 +1,414 @@ +package wayland + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient" +) + +func TestManager_ActorSerializesOutputStateAccess(t *testing.T) { + m := &Manager{ + cmdq: make(chan cmd, 8192), + stopChan: make(chan struct{}), + } + + m.wg.Add(1) + go m.waylandActor() + + state := &outputState{ + id: 1, + registryName: 100, + rampSize: 256, + } + m.outputs.Store(state.id, state) + + var wg sync.WaitGroup + const goroutines = 50 + const iterations = 100 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.post(func() { + if out, ok := m.outputs.Load(state.id); ok { + out.rampSize = uint32(j) + out.failed = j%2 == 0 + out.retryCount = j + out.lastFailTime = time.Now() + } + }) + } + }(i) + } + + wg.Wait() + + done := make(chan struct{}) + m.post(func() { close(done) }) + <-done + + close(m.stopChan) + m.wg.Wait() +} + +func TestManager_ConcurrentSubscriberAccess(t *testing.T) { + m := &Manager{ + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + updateTrigger: make(chan struct{}, 1), + } + + var wg sync.WaitGroup + const goroutines = 20 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + subID := string(rune('a' + id)) + ch := m.Subscribe(subID) + assert.NotNil(t, ch) + time.Sleep(time.Millisecond) + m.Unsubscribe(subID) + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentGetState(t *testing.T) { + m := &Manager{ + state: &State{ + CurrentTemp: 5000, + IsDay: true, + }, + } + + var wg sync.WaitGroup + const goroutines = 50 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + s := m.GetState() + assert.GreaterOrEqual(t, s.CurrentTemp, 0) + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.stateMutex.Lock() + m.state = &State{ + CurrentTemp: 4000 + i*100, + IsDay: j%2 == 0, + } + m.stateMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentConfigAccess(t *testing.T) { + m := &Manager{ + config: DefaultConfig(), + } + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.configMutex.RLock() + _ = m.config.LowTemp + _ = m.config.HighTemp + _ = m.config.Enabled + m.configMutex.RUnlock() + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.configMutex.Lock() + m.config.LowTemp = 3000 + j + m.config.HighTemp = 7000 - j + m.config.Enabled = j%2 == 0 + m.configMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_SyncmapOutputsConcurrentAccess(t *testing.T) { + m := &Manager{} + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 50 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + key := uint32(id) + + for j := 0; j < iterations; j++ { + state := &outputState{ + id: key, + rampSize: uint32(j), + failed: j%2 == 0, + } + m.outputs.Store(key, state) + + if loaded, ok := m.outputs.Load(key); ok { + assert.Equal(t, key, loaded.id) + } + + m.outputs.Range(func(k uint32, v *outputState) bool { + _ = v.rampSize + _ = v.failed + return true + }) + } + + m.outputs.Delete(key) + }(i) + } + + wg.Wait() +} + +func TestManager_LocationCacheConcurrentAccess(t *testing.T) { + m := &Manager{} + + var wg sync.WaitGroup + const goroutines = 20 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.locationMutex.RLock() + _ = m.cachedIPLat + _ = m.cachedIPLon + m.locationMutex.RUnlock() + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + lat := float64(40 + i) + lon := float64(-74 + j) + m.locationMutex.Lock() + m.cachedIPLat = &lat + m.cachedIPLon = &lon + m.locationMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ScheduleConcurrentAccess(t *testing.T) { + now := time.Now() + m := &Manager{ + schedule: sunSchedule{ + times: SunTimes{ + Dawn: now, + Sunrise: now.Add(time.Hour), + Sunset: now.Add(12 * time.Hour), + Night: now.Add(13 * time.Hour), + }, + }, + } + + var wg sync.WaitGroup + const goroutines = 20 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.scheduleMutex.RLock() + _ = m.schedule.times.Dawn + _ = m.schedule.times.Sunrise + _ = m.schedule.times.Sunset + _ = m.schedule.condition + m.scheduleMutex.RUnlock() + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.scheduleMutex.Lock() + m.schedule.times.Dawn = time.Now() + m.schedule.times.Sunrise = time.Now().Add(time.Hour) + m.schedule.condition = SunNormal + m.scheduleMutex.Unlock() + } + }() + } + + wg.Wait() +} + +func TestInterpolate_EdgeCases(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + now time.Time + start time.Time + stop time.Time + expected float64 + }{ + { + name: "same start and stop", + now: now, + start: now, + stop: now, + expected: 1.0, + }, + { + name: "now before start", + now: now, + start: now.Add(time.Hour), + stop: now.Add(2 * time.Hour), + expected: 0.0, + }, + { + name: "now after stop", + now: now.Add(3 * time.Hour), + start: now, + stop: now.Add(time.Hour), + expected: 1.0, + }, + { + name: "now at midpoint", + now: now.Add(30 * time.Minute), + start: now, + stop: now.Add(time.Hour), + expected: 0.5, + }, + { + name: "now equals start", + now: now, + start: now, + stop: now.Add(time.Hour), + expected: 0.0, + }, + { + name: "now equals stop", + now: now.Add(time.Hour), + start: now, + stop: now.Add(time.Hour), + expected: 1.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := interpolate(tt.now, tt.start, tt.stop) + assert.InDelta(t, tt.expected, result, 0.01) + }) + } +} + +func TestGenerateGammaRamp_ZeroSize(t *testing.T) { + ramp := GenerateGammaRamp(0, 5000, 1.0) + assert.Empty(t, ramp.Red) + assert.Empty(t, ramp.Green) + assert.Empty(t, ramp.Blue) +} + +func TestGenerateGammaRamp_ValidSizes(t *testing.T) { + sizes := []uint32{1, 256, 1024} + temps := []int{1000, 4000, 6500, 10000} + gammas := []float64{0.5, 1.0, 2.0} + + for _, size := range sizes { + for _, temp := range temps { + for _, gamma := range gammas { + ramp := GenerateGammaRamp(size, temp, gamma) + assert.Len(t, ramp.Red, int(size)) + assert.Len(t, ramp.Green, int(size)) + assert.Len(t, ramp.Blue, int(size)) + } + } + } +} + +func TestNotifySubscribers_NonBlocking(t *testing.T) { + m := &Manager{ + dirty: make(chan struct{}, 1), + } + + for i := 0; i < 10; i++ { + m.notifySubscribers() + } + + assert.Len(t, m.dirty, 1) +} + +func TestNewManager_GetRegistryError(t *testing.T) { + mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) + + mockDisplay.EXPECT().Context().Return(nil) + mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry")) + + config := DefaultConfig() + _, err := NewManager(mockDisplay, config) + assert.Error(t, err) + assert.Contains(t, err.Error(), "get registry") +} + +func TestNewManager_InvalidConfig(t *testing.T) { + mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) + + config := Config{ + LowTemp: 500, + HighTemp: 6500, + Gamma: 1.0, + } + + _, err := NewManager(mockDisplay, config) + assert.Error(t, err) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go new file mode 100644 index 0000000..c978a8e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go @@ -0,0 +1,122 @@ +package wayland + +import ( + "math" + "time" +) + +const ( + degToRad = math.Pi / 180.0 + radToDeg = 180.0 / math.Pi +) + +type SunCondition int + +const ( + SunNormal SunCondition = iota + SunMidnightSun + SunPolarNight +) + +type SunTimes struct { + Dawn time.Time + Sunrise time.Time + Sunset time.Time + Night time.Time +} + +func daysInYear(year int) int { + if (year%4 == 0 && year%100 != 0) || year%400 == 0 { + return 366 + } + return 365 +} + +func dateOrbitAngle(t time.Time) float64 { + return 2 * math.Pi / float64(daysInYear(t.Year())) * float64(t.YearDay()-1) +} + +func equationOfTime(orbitAngle float64) float64 { + return 4 * (0.000075 + + 0.001868*math.Cos(orbitAngle) - + 0.032077*math.Sin(orbitAngle) - + 0.014615*math.Cos(2*orbitAngle) - + 0.040849*math.Sin(2*orbitAngle)) +} + +func sunDeclination(orbitAngle float64) float64 { + return 0.006918 - + 0.399912*math.Cos(orbitAngle) + + 0.070257*math.Sin(orbitAngle) - + 0.006758*math.Cos(2*orbitAngle) + + 0.000907*math.Sin(2*orbitAngle) - + 0.002697*math.Cos(3*orbitAngle) + + 0.00148*math.Sin(3*orbitAngle) +} + +func sunHourAngle(latRad, declination, targetSunRad float64) float64 { + return math.Acos(math.Cos(targetSunRad)/ + math.Cos(latRad)*math.Cos(declination) - + math.Tan(latRad)*math.Tan(declination)) +} + +func hourAngleToSeconds(hourAngle, eqtime float64) float64 { + return radToDeg * (4.0*math.Pi - 4*hourAngle - eqtime) * 60 +} + +func sunCondition(latRad, declination float64) SunCondition { + signLat := latRad >= 0 + signDecl := declination >= 0 + if signLat == signDecl { + return SunMidnightSun + } + return SunPolarNight +} + +func CalculateSunTimesWithTwilight(lat, lon float64, date time.Time, elevTwilight, elevDaylight float64) (SunTimes, SunCondition) { + latRad := lat * degToRad + elevTwilightRad := (90.833 - elevTwilight) * degToRad + elevDaylightRad := (90.833 - elevDaylight) * degToRad + + utc := date.UTC() + orbitAngle := dateOrbitAngle(utc) + decl := sunDeclination(orbitAngle) + eqtime := equationOfTime(orbitAngle) + + haTwilight := sunHourAngle(latRad, decl, elevTwilightRad) + haDaylight := sunHourAngle(latRad, decl, elevDaylightRad) + + if math.IsNaN(haTwilight) || math.IsNaN(haDaylight) { + cond := sunCondition(latRad, decl) + return SunTimes{}, cond + } + + dayStart := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC) + lonOffset := time.Duration(-lon*4) * time.Minute + + dawnSecs := hourAngleToSeconds(math.Abs(haTwilight), eqtime) + sunriseSecs := hourAngleToSeconds(math.Abs(haDaylight), eqtime) + sunsetSecs := hourAngleToSeconds(-math.Abs(haDaylight), eqtime) + nightSecs := hourAngleToSeconds(-math.Abs(haTwilight), eqtime) + + return SunTimes{ + Dawn: dayStart.Add(time.Duration(dawnSecs)*time.Second + lonOffset).In(date.Location()), + Sunrise: dayStart.Add(time.Duration(sunriseSecs)*time.Second + lonOffset).In(date.Location()), + Sunset: dayStart.Add(time.Duration(sunsetSecs)*time.Second + lonOffset).In(date.Location()), + Night: dayStart.Add(time.Duration(nightSecs)*time.Second + lonOffset).In(date.Location()), + }, SunNormal +} + +func CalculateSunTimes(lat, lon float64, date time.Time) SunTimes { + times, cond := CalculateSunTimesWithTwilight(lat, lon, date, -6.0, 3.0) + switch cond { + case SunMidnightSun: + dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + dayEnd := dayStart.Add(24*time.Hour - time.Second) + return SunTimes{Dawn: dayStart, Sunrise: dayStart, Sunset: dayEnd, Night: dayEnd} + case SunPolarNight: + dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + return SunTimes{Dawn: dayStart, Sunrise: dayStart, Sunset: dayStart, Night: dayStart} + } + return times +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go new file mode 100644 index 0000000..b2815d9 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go @@ -0,0 +1,387 @@ +package wayland + +import ( + "math" + "testing" + "time" +) + +func calculateTemperature(config Config, now time.Time) int { + if !config.Enabled { + return config.HighTemp + } + + var sunrise, sunset time.Time + + if config.ManualSunrise != nil && config.ManualSunset != nil { + year, month, day := now.Date() + loc := now.Location() + + sunrise = time.Date(year, month, day, + config.ManualSunrise.Hour(), + config.ManualSunrise.Minute(), + config.ManualSunrise.Second(), 0, loc) + sunset = time.Date(year, month, day, + config.ManualSunset.Hour(), + config.ManualSunset.Minute(), + config.ManualSunset.Second(), 0, loc) + + if sunset.Before(sunrise) { + sunset = sunset.Add(24 * time.Hour) + } + } else if config.UseIPLocation { + lat, lon, err := FetchIPLocation() + if err != nil { + return config.HighTemp + } + times := CalculateSunTimes(*lat, *lon, now) + sunrise = times.Sunrise + sunset = times.Sunset + } else if config.Latitude != nil && config.Longitude != nil { + times := CalculateSunTimes(*config.Latitude, *config.Longitude, now) + sunrise = times.Sunrise + sunset = times.Sunset + } else { + return config.HighTemp + } + + if now.Before(sunrise) || now.After(sunset) { + return config.LowTemp + } + return config.HighTemp +} + +func calculateNextTransition(config Config, now time.Time) time.Time { + if !config.Enabled { + return now.Add(24 * time.Hour) + } + + var sunrise, sunset time.Time + + if config.ManualSunrise != nil && config.ManualSunset != nil { + year, month, day := now.Date() + loc := now.Location() + + sunrise = time.Date(year, month, day, + config.ManualSunrise.Hour(), + config.ManualSunrise.Minute(), + config.ManualSunrise.Second(), 0, loc) + sunset = time.Date(year, month, day, + config.ManualSunset.Hour(), + config.ManualSunset.Minute(), + config.ManualSunset.Second(), 0, loc) + + if sunset.Before(sunrise) { + sunset = sunset.Add(24 * time.Hour) + } + } else if config.UseIPLocation { + lat, lon, err := FetchIPLocation() + if err != nil { + return now.Add(24 * time.Hour) + } + times := CalculateSunTimes(*lat, *lon, now) + sunrise = times.Sunrise + sunset = times.Sunset + } else if config.Latitude != nil && config.Longitude != nil { + times := CalculateSunTimes(*config.Latitude, *config.Longitude, now) + sunrise = times.Sunrise + sunset = times.Sunset + } else { + return now.Add(24 * time.Hour) + } + + if now.Before(sunrise) { + return sunrise + } + if now.Before(sunset) { + return sunset + } + + if config.ManualSunrise != nil && config.ManualSunset != nil { + year, month, day := now.Add(24 * time.Hour).Date() + loc := now.Location() + nextSunrise := time.Date(year, month, day, + config.ManualSunrise.Hour(), + config.ManualSunrise.Minute(), + config.ManualSunrise.Second(), 0, loc) + return nextSunrise + } + + if config.UseIPLocation { + lat, lon, err := FetchIPLocation() + if err != nil { + return now.Add(24 * time.Hour) + } + nextDayTimes := CalculateSunTimes(*lat, *lon, now.Add(24*time.Hour)) + return nextDayTimes.Sunrise + } + + if config.Latitude != nil && config.Longitude != nil { + nextDayTimes := CalculateSunTimes(*config.Latitude, *config.Longitude, now.Add(24*time.Hour)) + return nextDayTimes.Sunrise + } + + return now.Add(24 * time.Hour) +} + +func TestCalculateSunTimes(t *testing.T) { + tests := []struct { + name string + lat float64 + lon float64 + date time.Time + checkFunc func(*testing.T, SunTimes) + }{ + { + name: "new_york_summer", + lat: 40.7128, + lon: -74.0060, + date: time.Date(2024, 6, 21, 12, 0, 0, 0, time.Local), + checkFunc: func(t *testing.T, times SunTimes) { + if times.Sunrise.Hour() < 4 || times.Sunrise.Hour() > 6 { + t.Logf("sunrise: %v", times.Sunrise) + } + if times.Sunset.Hour() < 19 || times.Sunset.Hour() > 21 { + t.Logf("sunset: %v", times.Sunset) + } + if !times.Sunset.After(times.Sunrise) { + t.Error("sunset should be after sunrise") + } + }, + }, + { + name: "london_winter", + lat: 51.5074, + lon: -0.1278, + date: time.Date(2024, 12, 21, 12, 0, 0, 0, time.UTC), + checkFunc: func(t *testing.T, times SunTimes) { + if times.Sunrise.Hour() < 7 || times.Sunrise.Hour() > 9 { + t.Errorf("unexpected sunrise hour: %d", times.Sunrise.Hour()) + } + if times.Sunset.Hour() < 15 || times.Sunset.Hour() > 17 { + t.Errorf("unexpected sunset hour: %d", times.Sunset.Hour()) + } + }, + }, + { + name: "equator_equinox", + lat: 0.0, + lon: 0.0, + date: time.Date(2024, 3, 20, 12, 0, 0, 0, time.UTC), + checkFunc: func(t *testing.T, times SunTimes) { + if times.Sunrise.Hour() < 5 || times.Sunrise.Hour() > 7 { + t.Errorf("unexpected sunrise hour: %d", times.Sunrise.Hour()) + } + if times.Sunset.Hour() < 17 || times.Sunset.Hour() > 19 { + t.Errorf("unexpected sunset hour: %d", times.Sunset.Hour()) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + times := CalculateSunTimes(tt.lat, tt.lon, tt.date) + tt.checkFunc(t, times) + }) + } +} + +func TestCalculateTemperature(t *testing.T) { + lat := 40.7128 + lon := -74.0060 + date := time.Date(2024, 6, 21, 0, 0, 0, 0, time.Local) + + config := Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: &lat, + Longitude: &lon, + Enabled: true, + } + + times := CalculateSunTimes(lat, lon, date) + + tests := []struct { + name string + timeFunc func() time.Time + wantTemp int + }{ + { + name: "midnight", + timeFunc: func() time.Time { return times.Sunrise.Add(-4 * time.Hour) }, + wantTemp: 4000, + }, + { + name: "sunrise", + timeFunc: func() time.Time { return times.Sunrise }, + wantTemp: 6500, + }, + { + name: "noon", + timeFunc: func() time.Time { return times.Sunrise.Add(6 * time.Hour) }, + wantTemp: 6500, + }, + { + name: "after_sunset_transition", + timeFunc: func() time.Time { return times.Sunset.Add(2 * time.Hour) }, + wantTemp: 4000, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temp := calculateTemperature(config, tt.timeFunc()) + + if math.Abs(float64(temp-tt.wantTemp)) > 500 { + t.Errorf("temperature = %d, want approximately %d", temp, tt.wantTemp) + } + }) + } +} + +func TestCalculateTemperatureManualTimes(t *testing.T) { + sunrise := time.Date(0, 1, 1, 6, 30, 0, 0, time.Local) + sunset := time.Date(0, 1, 1, 18, 30, 0, 0, time.Local) + + config := Config{ + LowTemp: 4000, + HighTemp: 6500, + ManualSunrise: &sunrise, + ManualSunset: &sunset, + Enabled: true, + } + + tests := []struct { + name string + time time.Time + want int + }{ + {"before_sunrise", time.Date(2024, 1, 1, 3, 0, 0, 0, time.Local), 4000}, + {"at_sunrise", time.Date(2024, 1, 1, 6, 30, 0, 0, time.Local), 6500}, + {"midday", time.Date(2024, 1, 1, 12, 0, 0, 0, time.Local), 6500}, + {"at_sunset", time.Date(2024, 1, 1, 18, 30, 0, 0, time.Local), 6500}, + {"after_sunset", time.Date(2024, 1, 1, 22, 0, 0, 0, time.Local), 4000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temp := calculateTemperature(config, tt.time) + if math.Abs(float64(temp-tt.want)) > 500 { + t.Errorf("temperature = %d, want approximately %d", temp, tt.want) + } + }) + } +} + +func TestCalculateTemperatureDisabled(t *testing.T) { + lat := 40.7128 + lon := -74.0060 + + config := Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: &lat, + Longitude: &lon, + Enabled: false, + } + + temp := calculateTemperature(config, time.Now()) + if temp != 6500 { + t.Errorf("disabled should return high temp, got %d", temp) + } +} + +func TestCalculateNextTransition(t *testing.T) { + lat := 40.7128 + lon := -74.0060 + date := time.Date(2024, 6, 21, 0, 0, 0, 0, time.Local) + + config := Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: &lat, + Longitude: &lon, + Enabled: true, + } + + times := CalculateSunTimes(lat, lon, date) + + tests := []struct { + name string + now time.Time + checkFunc func(*testing.T, time.Time) + }{ + { + name: "before_sunrise", + now: times.Sunrise.Add(-2 * time.Hour), + checkFunc: func(t *testing.T, next time.Time) { + if !next.Equal(times.Sunrise) && !next.After(times.Sunrise.Add(-1*time.Minute)) { + t.Error("next transition should be at or near sunrise") + } + }, + }, + { + name: "after_sunrise", + now: times.Sunrise.Add(2 * time.Hour), + checkFunc: func(t *testing.T, next time.Time) { + if !next.After(times.Sunrise) { + t.Error("next transition should be after sunrise") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + next := calculateNextTransition(config, tt.now) + tt.checkFunc(t, next) + }) + } +} + +func TestSunTimesWithTwilight(t *testing.T) { + lat := 40.7128 + lon := -74.0060 + date := time.Date(2024, 6, 21, 12, 0, 0, 0, time.Local) + + times, cond := CalculateSunTimesWithTwilight(lat, lon, date, -6.0, 3.0) + + if cond != SunNormal { + t.Errorf("expected SunNormal, got %v", cond) + } + if !times.Dawn.Before(times.Sunrise) { + t.Error("dawn should be before sunrise") + } + if !times.Sunrise.Before(times.Sunset) { + t.Error("sunrise should be before sunset") + } + if !times.Sunset.Before(times.Night) { + t.Error("sunset should be before night") + } +} + +func TestSunConditions(t *testing.T) { + tests := []struct { + name string + lat float64 + date time.Time + expected SunCondition + }{ + { + name: "normal_conditions", + lat: 40.0, + date: time.Date(2024, 6, 21, 12, 0, 0, 0, time.UTC), + expected: SunNormal, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, cond := CalculateSunTimesWithTwilight(tt.lat, 0, tt.date, -6.0, 3.0) + if cond != tt.expected { + t.Errorf("expected condition %v, got %v", tt.expected, cond) + } + }) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types.go new file mode 100644 index 0000000..31bac15 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types.go @@ -0,0 +1,216 @@ +package wayland + +import ( + "math" + "sync" + "sync/atomic" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" + "github.com/godbus/dbus/v5" +) + +type GammaState int + +const ( + StateNormal GammaState = iota + StateTransition + StateStatic +) + +type Config struct { + Outputs []string + LowTemp int + HighTemp int + Latitude *float64 + Longitude *float64 + UseIPLocation bool + ManualSunrise *time.Time + ManualSunset *time.Time + ManualDuration *time.Duration + Gamma float64 + Enabled bool + ElevationTwilight float64 + ElevationDaylight float64 +} + +type State struct { + Config Config `json:"config"` + CurrentTemp int `json:"currentTemp"` + NextTransition time.Time `json:"nextTransition"` + SunriseTime time.Time `json:"sunriseTime"` + SunsetTime time.Time `json:"sunsetTime"` + DawnTime time.Time `json:"dawnTime"` + NightTime time.Time `json:"nightTime"` + IsDay bool `json:"isDay"` + SunPosition float64 `json:"sunPosition"` +} + +type cmd struct { + fn func() +} + +type sunSchedule struct { + times SunTimes + condition SunCondition + dawnStepTime time.Duration + nightStepTime time.Duration + calcDay time.Time +} + +type Manager struct { + config Config + configMutex sync.RWMutex + state *State + stateMutex sync.RWMutex + + display wlclient.WaylandDisplay + ctx *wlclient.Context + registry *wlclient.Registry + gammaControl any + availableOutputs []*wlclient.Output + availOutputsMu sync.RWMutex + outputRegNames syncmap.Map[uint32, uint32] + outputs syncmap.Map[uint32, *outputState] + controlsInitialized bool + connectionDead atomic.Bool + + cmdq chan cmd + alive bool + + stopChan chan struct{} + updateTrigger chan struct{} + wg sync.WaitGroup + + schedule sunSchedule + scheduleMutex sync.RWMutex + gammaState GammaState + + cachedIPLat *float64 + cachedIPLon *float64 + locationMutex sync.RWMutex + + subscribers syncmap.Map[string, chan State] + dirty chan struct{} + notifierWg sync.WaitGroup + lastNotified *State + + dbusConn *dbus.Conn + dbusSignal chan *dbus.Signal + + geoClient geolocation.Client + + lastAppliedTemp int + lastAppliedGamma float64 +} + +type outputState struct { + id uint32 + registryName uint32 + output *wlclient.Output + gammaControl any + rampSize uint32 + failed bool + isVirtual bool + retryCount int + lastFailTime time.Time +} + +func DefaultConfig() Config { + return Config{ + Outputs: []string{}, + LowTemp: 4000, + HighTemp: 6500, + Gamma: 1.0, + Enabled: false, + ElevationTwilight: -6.0, + ElevationDaylight: 3.0, + } +} + +func (c *Config) Validate() error { + if c.LowTemp < 1000 || c.LowTemp > 10000 { + return errdefs.ErrInvalidTemperature + } + if c.HighTemp < 1000 || c.HighTemp > 10000 { + return errdefs.ErrInvalidTemperature + } + if c.LowTemp > c.HighTemp { + return errdefs.ErrInvalidTemperature + } + if c.Gamma <= 0 || c.Gamma > 10 { + return errdefs.ErrInvalidGamma + } + if c.Latitude != nil && (math.Abs(*c.Latitude) > 90) { + return errdefs.ErrInvalidLocation + } + if c.Longitude != nil && (math.Abs(*c.Longitude) > 180) { + return errdefs.ErrInvalidLocation + } + if (c.Latitude != nil) != (c.Longitude != nil) { + return errdefs.ErrInvalidLocation + } + if (c.ManualSunrise != nil) != (c.ManualSunset != nil) { + return errdefs.ErrInvalidManualTimes + } + return nil +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + if m.state == nil { + return State{} + } + return *m.state +} + +func (m *Manager) Subscribe(id string) chan State { + ch := make(chan State, 64) + m.subscribers.Store(id, ch) + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) notifySubscribers() { + select { + case m.dirty <- struct{}{}: + default: + } +} + +func stateChanged(old, new *State) bool { + if old == nil || new == nil { + return true + } + if old.CurrentTemp != new.CurrentTemp { + return true + } + if old.IsDay != new.IsDay { + return true + } + if !old.NextTransition.Equal(new.NextTransition) { + return true + } + if !old.SunriseTime.Equal(new.SunriseTime) { + return true + } + if !old.SunsetTime.Equal(new.SunsetTime) { + return true + } + if old.Config.Enabled != new.Config.Enabled { + return true + } + if old.SunPosition != new.SunPosition { + return true + } + return false +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go new file mode 100644 index 0000000..f5bbb42 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go @@ -0,0 +1,330 @@ +package wayland + +import ( + "testing" + "time" +) + +func TestConfigValidate(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + }{ + { + name: "valid_default", + config: DefaultConfig(), + wantErr: false, + }, + { + name: "valid_with_location", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: floatPtr(40.7128), + Longitude: floatPtr(-74.0060), + Gamma: 1.0, + Enabled: true, + }, + wantErr: false, + }, + { + name: "valid_manual_times", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + ManualSunrise: timePtr(time.Date(0, 1, 1, 6, 30, 0, 0, time.Local)), + ManualSunset: timePtr(time.Date(0, 1, 1, 18, 30, 0, 0, time.Local)), + Gamma: 1.0, + Enabled: true, + }, + wantErr: false, + }, + { + name: "invalid_low_temp_too_low", + config: Config{ + LowTemp: 500, + HighTemp: 6500, + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_low_temp_too_high", + config: Config{ + LowTemp: 15000, + HighTemp: 20000, + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_high_temp_too_low", + config: Config{ + LowTemp: 4000, + HighTemp: 500, + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "valid_temps_equal", + config: Config{ + LowTemp: 5000, + HighTemp: 5000, + Gamma: 1.0, + }, + wantErr: false, + }, + { + name: "invalid_temps_reversed", + config: Config{ + LowTemp: 6500, + HighTemp: 4000, + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_gamma_zero", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Gamma: 0, + }, + wantErr: true, + }, + { + name: "invalid_gamma_negative", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Gamma: -1.0, + }, + wantErr: true, + }, + { + name: "invalid_gamma_too_high", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Gamma: 15.0, + }, + wantErr: true, + }, + { + name: "invalid_latitude_too_high", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: floatPtr(100), + Longitude: floatPtr(0), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_latitude_too_low", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: floatPtr(-100), + Longitude: floatPtr(0), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_longitude_too_high", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: floatPtr(40), + Longitude: floatPtr(200), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_longitude_too_low", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: floatPtr(40), + Longitude: floatPtr(-200), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_latitude_without_longitude", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: floatPtr(40), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_longitude_without_latitude", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Longitude: floatPtr(-74), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_sunrise_without_sunset", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + ManualSunrise: timePtr(time.Date(0, 1, 1, 6, 30, 0, 0, time.Local)), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_sunset_without_sunrise", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + ManualSunset: timePtr(time.Date(0, 1, 1, 18, 30, 0, 0, time.Local)), + Gamma: 1.0, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestDefaultConfig(t *testing.T) { + config := DefaultConfig() + + if config.LowTemp != 4000 { + t.Errorf("default low temp = %d, want 4000", config.LowTemp) + } + if config.HighTemp != 6500 { + t.Errorf("default high temp = %d, want 6500", config.HighTemp) + } + if config.Gamma != 1.0 { + t.Errorf("default gamma = %f, want 1.0", config.Gamma) + } + if config.Enabled { + t.Error("default should be disabled") + } + if config.Latitude != nil { + t.Error("default should not have latitude") + } + if config.Longitude != nil { + t.Error("default should not have longitude") + } +} + +func TestStateChanged(t *testing.T) { + baseState := &State{ + CurrentTemp: 5000, + NextTransition: time.Now(), + SunriseTime: time.Now().Add(6 * time.Hour), + SunsetTime: time.Now().Add(18 * time.Hour), + IsDay: true, + Config: DefaultConfig(), + } + + tests := []struct { + name string + old *State + new *State + wantChanged bool + }{ + { + name: "nil_old", + old: nil, + new: baseState, + wantChanged: true, + }, + { + name: "nil_new", + old: baseState, + new: nil, + wantChanged: true, + }, + { + name: "same_state", + old: baseState, + new: baseState, + wantChanged: false, + }, + { + name: "temp_changed", + old: baseState, + new: &State{ + CurrentTemp: 6000, + NextTransition: baseState.NextTransition, + SunriseTime: baseState.SunriseTime, + SunsetTime: baseState.SunsetTime, + IsDay: baseState.IsDay, + Config: baseState.Config, + }, + wantChanged: true, + }, + { + name: "is_day_changed", + old: baseState, + new: &State{ + CurrentTemp: baseState.CurrentTemp, + NextTransition: baseState.NextTransition, + SunriseTime: baseState.SunriseTime, + SunsetTime: baseState.SunsetTime, + IsDay: false, + Config: baseState.Config, + }, + wantChanged: true, + }, + { + name: "enabled_changed", + old: baseState, + new: &State{ + CurrentTemp: baseState.CurrentTemp, + NextTransition: baseState.NextTransition, + SunriseTime: baseState.SunriseTime, + SunsetTime: baseState.SunsetTime, + IsDay: baseState.IsDay, + Config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Gamma: 1.0, + Enabled: true, + }, + }, + wantChanged: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + changed := stateChanged(tt.old, tt.new) + if changed != tt.wantChanged { + t.Errorf("stateChanged() = %v, want %v", changed, tt.wantChanged) + } + }) + } +} + +func floatPtr(f float64) *float64 { + return &f +} + +func timePtr(t time.Time) *time.Time { + return &t +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlcontext/context.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlcontext/context.go new file mode 100644 index 0000000..9bbb30a --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlcontext/context.go @@ -0,0 +1,200 @@ +package wlcontext + +import ( + "fmt" + "golang.org/x/sys/unix" + "os" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type WaylandContext interface { + Display() *wlclient.Display + Post(fn func()) + FatalError() <-chan error + Start() + Close() +} + +var _ WaylandContext = (*SharedContext)(nil) + +type SharedContext struct { + display *wlclient.Display + stopChan chan struct{} + fatalError chan error + cmdQueue chan func() + wakeR int + wakeW int + wg sync.WaitGroup + mu sync.Mutex + started bool +} + +func New() (*SharedContext, error) { + display, err := wlclient.Connect("") + if err != nil { + return nil, fmt.Errorf("%w: %v", errdefs.ErrNoWaylandDisplay, err) + } + + fds := make([]int, 2) + if err := unix.Pipe(fds); err != nil { + display.Context().Close() + return nil, fmt.Errorf("failed to create wake pipe: %w", err) + } + if err := unix.SetNonblock(fds[0], true); err != nil { + unix.Close(fds[0]) + unix.Close(fds[1]) + display.Context().Close() + return nil, fmt.Errorf("failed to set wake pipe nonblock: %w", err) + } + if err := unix.SetNonblock(fds[1], true); err != nil { + unix.Close(fds[0]) + unix.Close(fds[1]) + display.Context().Close() + return nil, fmt.Errorf("failed to set wake pipe nonblock: %w", err) + } + + sc := &SharedContext{ + display: display, + stopChan: make(chan struct{}), + fatalError: make(chan error, 1), + cmdQueue: make(chan func(), 256), + wakeR: fds[0], + wakeW: fds[1], + started: false, + } + + return sc, nil +} + +func (sc *SharedContext) Start() { + sc.mu.Lock() + defer sc.mu.Unlock() + + if sc.started { + return + } + + sc.started = true + sc.wg.Add(1) + go sc.eventDispatcher() +} + +func (sc *SharedContext) Display() *wlclient.Display { + return sc.display +} + +func (sc *SharedContext) Post(fn func()) { + select { + case sc.cmdQueue <- fn: + if _, err := unix.Write(sc.wakeW, []byte{1}); err != nil && err != unix.EAGAIN { + log.Errorf("wake pipe write error: %v", err) + } + default: + } +} + +func (sc *SharedContext) FatalError() <-chan error { + return sc.fatalError +} + +func (sc *SharedContext) eventDispatcher() { + defer sc.wg.Done() + defer func() { + if r := recover(); r != nil { + err := fmt.Errorf("FATAL: Wayland event dispatcher panic: %v", r) + log.Error(err) + select { + case sc.fatalError <- err: + default: + } + } + }() + + ctx := sc.display.Context() + wlFd := ctx.Fd() + + pollFds := []unix.PollFd{ + {Fd: int32(wlFd), Events: unix.POLLIN}, + {Fd: int32(sc.wakeR), Events: unix.POLLIN}, + } + + consecutiveErrors := 0 + const maxConsecutiveErrors = 20 + + for { + sc.drainCmdQueue() + + select { + case <-sc.stopChan: + return + default: + } + + _, err := unix.Poll(pollFds, -1) + switch { + case err == unix.EINTR: + continue + case err != nil: + log.Errorf("Poll error: %v", err) + return + } + + if pollFds[1].Revents&unix.POLLIN != 0 { + var buf [64]byte + if _, err := unix.Read(sc.wakeR, buf[:]); err != nil && err != unix.EAGAIN { + log.Errorf("wake pipe read error: %v", err) + } + } + + if pollFds[0].Revents&unix.POLLIN == 0 { + continue + } + + if err := ctx.Dispatch(); err != nil && !os.IsTimeout(err) { + consecutiveErrors++ + log.Warnf("Wayland connection error (%d/%d): %v", consecutiveErrors, maxConsecutiveErrors, err) + + if consecutiveErrors >= maxConsecutiveErrors { + log.Errorf("Fatal: Wayland connection unrecoverable after %d attempts. Exiting dispatcher.", maxConsecutiveErrors) + return + } + + time.Sleep(100 * time.Millisecond * time.Duration(consecutiveErrors)) + continue + } + + consecutiveErrors = 0 + } +} + +func (sc *SharedContext) drainCmdQueue() { + for { + select { + case fn := <-sc.cmdQueue: + fn() + default: + return + } + } +} + +func (sc *SharedContext) Close() { + close(sc.stopChan) + if _, err := unix.Write(sc.wakeW, []byte{1}); err != nil && err != unix.EAGAIN { + log.Errorf("wake pipe write error on close: %v", err) + } + sc.wg.Wait() + + unix.Close(sc.wakeR) + unix.Close(sc.wakeW) + + if sc.display == nil { + return + } + sc.display.Context().Close() +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlcontext/context_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlcontext/context_test.go new file mode 100644 index 0000000..11ecfbf --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlcontext/context_test.go @@ -0,0 +1,128 @@ +package wlcontext + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/sys/unix" +) + +func newTestSharedContext(t *testing.T, queueSize int) *SharedContext { + t.Helper() + fds := make([]int, 2) + if err := unix.Pipe(fds); err != nil { + t.Fatalf("failed to create test pipe: %v", err) + } + t.Cleanup(func() { + unix.Close(fds[0]) + unix.Close(fds[1]) + }) + return &SharedContext{ + cmdQueue: make(chan func(), queueSize), + stopChan: make(chan struct{}), + wakeR: fds[0], + wakeW: fds[1], + } +} + +func TestSharedContext_ConcurrentPostNonBlocking(t *testing.T) { + sc := newTestSharedContext(t, 256) + + var wg sync.WaitGroup + const goroutines = 100 + const iterations = 50 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + sc.Post(func() { + _ = id + j + }) + } + }(i) + } + + wg.Wait() +} + +func TestSharedContext_PostQueueFull(t *testing.T) { + sc := newTestSharedContext(t, 2) + + sc.Post(func() {}) + sc.Post(func() {}) + sc.Post(func() {}) + sc.Post(func() {}) + + assert.Len(t, sc.cmdQueue, 2) +} + +func TestSharedContext_StartMultipleTimes(t *testing.T) { + sc := newTestSharedContext(t, 256) + sc.started = true + + var wg sync.WaitGroup + const goroutines = 10 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + sc.Start() + }() + } + + wg.Wait() + + assert.True(t, sc.started) +} + +func TestSharedContext_DrainCmdQueue(t *testing.T) { + sc := newTestSharedContext(t, 256) + + counter := 0 + for i := 0; i < 10; i++ { + sc.cmdQueue <- func() { + counter++ + } + } + + sc.drainCmdQueue() + + assert.Equal(t, 10, counter) + assert.Len(t, sc.cmdQueue, 0) +} + +func TestSharedContext_DrainCmdQueueEmpty(t *testing.T) { + sc := newTestSharedContext(t, 256) + + sc.drainCmdQueue() + + assert.Len(t, sc.cmdQueue, 0) +} + +func TestSharedContext_ConcurrentDrainAndPost(t *testing.T) { + sc := newTestSharedContext(t, 256) + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + sc.Post(func() {}) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + sc.drainCmdQueue() + } + }() + + wg.Wait() +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/handlers.go new file mode 100644 index 0000000..c885e13 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/handlers.go @@ -0,0 +1,282 @@ +package wlroutput + +import ( + "encoding/json" + "fmt" + "net" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +type HeadConfig struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + ModeID *uint32 `json:"modeId,omitempty"` + CustomMode *struct { + Width int32 `json:"width"` + Height int32 `json:"height"` + Refresh int32 `json:"refresh"` + } `json:"customMode,omitempty"` + Position *struct{ X, Y int32 } `json:"position,omitempty"` + Transform *int32 `json:"transform,omitempty"` + Scale *float64 `json:"scale,omitempty"` + AdaptiveSync *uint32 `json:"adaptiveSync,omitempty"` +} + +type ConfigurationRequest struct { + Heads []HeadConfig `json:"heads"` + Test bool `json:"test"` +} + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + if manager == nil { + models.RespondError(conn, req.ID, "wlroutput manager not initialized") + return + } + + switch req.Method { + case "wlroutput.getState": + handleGetState(conn, req, manager) + case "wlroutput.applyConfiguration": + handleApplyConfiguration(conn, req, manager, false) + case "wlroutput.testConfiguration": + handleApplyConfiguration(conn, req, manager, true) + case "wlroutput.subscribe": + handleSubscribe(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 handleApplyConfiguration(conn net.Conn, req models.Request, manager *Manager, test bool) { + headsParam, ok := models.Get[any](req, "heads") + if !ok { + models.RespondError(conn, req.ID, "missing 'heads' parameter") + return + } + + headsJSON, err := json.Marshal(headsParam) + if err != nil { + models.RespondError(conn, req.ID, "invalid 'heads' parameter format") + return + } + + var heads []HeadConfig + if err := json.Unmarshal(headsJSON, &heads); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("invalid heads configuration: %v", err)) + return + } + + if err := manager.ApplyConfiguration(heads, test); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + msg := "configuration applied" + if test { + msg = "configuration test succeeded" + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: msg}) +} + +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() + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + ID: req.ID, + Result: &initialState, + }); err != nil { + return + } + + for state := range stateChan { + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + Result: &state, + }); err != nil { + return + } + } +} + +func (m *Manager) ApplyConfiguration(heads []HeadConfig, test bool) error { + if m.manager == nil { + return fmt.Errorf("output manager not initialized") + } + + resultChan := make(chan error, 1) + + m.post(func() { + m.wlMutex.Lock() + defer m.wlMutex.Unlock() + + config, err := m.manager.CreateConfiguration(m.serial) + if err != nil { + resultChan <- fmt.Errorf("failed to create configuration: %w", err) + return + } + + responded := false + + config.SetSucceededHandler(func(e wlr_output_management.ZwlrOutputConfigurationV1SucceededEvent) { + if responded { + return + } + responded = true + log.Info("WlrOutput: configuration succeeded") + config.Destroy() + resultChan <- nil + }) + + config.SetFailedHandler(func(e wlr_output_management.ZwlrOutputConfigurationV1FailedEvent) { + if responded { + return + } + responded = true + log.Warn("WlrOutput: configuration failed") + config.Destroy() + resultChan <- fmt.Errorf("compositor rejected configuration") + }) + + config.SetCancelledHandler(func(e wlr_output_management.ZwlrOutputConfigurationV1CancelledEvent) { + if responded { + return + } + responded = true + log.Warn("WlrOutput: configuration cancelled") + config.Destroy() + resultChan <- fmt.Errorf("configuration cancelled (outdated serial)") + }) + + time.AfterFunc(time.Second, func() { + m.post(func() { + if responded { + return + } + responded = true + config.Destroy() + resultChan <- fmt.Errorf("timeout waiting for configuration response") + }) + }) + + headsByName := make(map[string]*headState) + m.heads.Range(func(key uint32, head *headState) bool { + if !head.finished { + headsByName[head.name] = head + } + return true + }) + + for _, headCfg := range heads { + head, exists := headsByName[headCfg.Name] + if !exists { + config.Destroy() + resultChan <- fmt.Errorf("head not found: %s", headCfg.Name) + return + } + + if !headCfg.Enabled { + if err := config.DisableHead(head.handle); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to disable head %s: %w", headCfg.Name, err) + return + } + continue + } + + headConfig, err := config.EnableHead(head.handle) + if err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to enable head %s: %w", headCfg.Name, err) + return + } + + if headCfg.ModeID != nil { + mode, exists := m.modes.Load(*headCfg.ModeID) + + if !exists { + config.Destroy() + resultChan <- fmt.Errorf("mode not found: %d", *headCfg.ModeID) + return + } + + if err := headConfig.SetMode(mode.handle); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to set mode for %s: %w", headCfg.Name, err) + return + } + } else if headCfg.CustomMode != nil { + if err := headConfig.SetCustomMode( + headCfg.CustomMode.Width, + headCfg.CustomMode.Height, + headCfg.CustomMode.Refresh, + ); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to set custom mode for %s: %w", headCfg.Name, err) + return + } + } + + if headCfg.Position != nil { + if err := headConfig.SetPosition(headCfg.Position.X, headCfg.Position.Y); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to set position for %s: %w", headCfg.Name, err) + return + } + } + + if headCfg.Transform != nil { + if err := headConfig.SetTransform(*headCfg.Transform); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to set transform for %s: %w", headCfg.Name, err) + return + } + } + + if headCfg.Scale != nil { + if err := headConfig.SetScale(*headCfg.Scale); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to set scale for %s: %w", headCfg.Name, err) + return + } + } + + if headCfg.AdaptiveSync != nil { + if err := headConfig.SetAdaptiveSync(*headCfg.AdaptiveSync); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to set adaptive sync for %s: %w", headCfg.Name, err) + return + } + } + } + + var applyErr error + if test { + applyErr = config.Test() + } else { + applyErr = config.Apply() + } + + if applyErr != nil { + responded = true + config.Destroy() + action := "apply" + if test { + action = "test" + } + resultChan <- fmt.Errorf("failed to %s configuration: %w", action, applyErr) + return + } + }) + + return <-resultChan +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager.go new file mode 100644 index 0000000..cbd89a3 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager.go @@ -0,0 +1,492 @@ +package wlroutput + +import ( + "fmt" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +func NewManager(display wlclient.WaylandDisplay) (*Manager, error) { + m := &Manager{ + display: display, + ctx: display.Context(), + cmdq: make(chan cmd, 512), + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + fatalError: make(chan error, 1), + } + + m.wg.Add(1) + go m.waylandActor() + + if err := m.setupRegistry(); err != nil { + close(m.stopChan) + m.wg.Wait() + return nil, err + } + + m.updateState() + + m.notifierWg.Add(1) + go m.notifier() + + return m, nil +} + +func (m *Manager) post(fn func()) { + select { + case m.cmdq <- cmd{fn: fn}: + default: + log.Warn("WlrOutput actor command queue full, dropping command") + } +} + +func (m *Manager) waylandActor() { + defer m.wg.Done() + defer func() { + if r := recover(); r != nil { + err := fmt.Errorf("waylandActor panic: %v", r) + log.Errorf("WlrOutput: %v", err) + + select { + case m.fatalError <- err: + default: + } + + select { + case <-m.stopChan: + default: + close(m.stopChan) + } + } + }() + + for { + select { + case <-m.stopChan: + return + case c := <-m.cmdq: + func() { + defer func() { + if r := recover(); r != nil { + log.Errorf("WlrOutput: command execution panic: %v", r) + } + }() + c.fn() + }() + } + } +} + +func (m *Manager) setupRegistry() error { + log.Info("WlrOutput: starting registry setup") + + registry, err := m.display.GetRegistry() + if err != nil { + return fmt.Errorf("failed to get registry: %w", err) + } + m.registry = registry + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + if e.Interface == wlr_output_management.ZwlrOutputManagerV1InterfaceName { + log.Infof("WlrOutput: found %s", wlr_output_management.ZwlrOutputManagerV1InterfaceName) + manager := wlr_output_management.NewZwlrOutputManagerV1(m.ctx) + version := e.Version + if version > 4 { + version = 4 + } + + manager.SetHeadHandler(func(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) { + m.handleHead(e) + }) + + manager.SetDoneHandler(func(e wlr_output_management.ZwlrOutputManagerV1DoneEvent) { + log.Debugf("WlrOutput: done event received (serial=%d)", e.Serial) + m.serial = e.Serial + m.post(func() { + m.updateState() + }) + }) + + manager.SetFinishedHandler(func(e wlr_output_management.ZwlrOutputManagerV1FinishedEvent) { + log.Info("WlrOutput: finished event received") + }) + + if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil { + m.manager = manager + log.Info("WlrOutput: manager bound successfully") + } else { + log.Errorf("WlrOutput: failed to bind manager: %v", err) + } + } + }) + + log.Info("WlrOutput: registry setup complete (events will be processed async)") + return nil +} + +func (m *Manager) handleHead(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) { + handle := e.Head + headID := handle.ID() + + log.Debugf("WlrOutput: New head (id=%d)", headID) + + head := &headState{ + id: headID, + handle: handle, + modeIDs: make([]uint32, 0), + } + + m.heads.Store(headID, head) + + handle.SetNameHandler(func(e wlr_output_management.ZwlrOutputHeadV1NameEvent) { + log.Debugf("WlrOutput: Head %d name: %s", headID, e.Name) + head.name = e.Name + head.ready = true + m.post(func() { + m.updateState() + }) + }) + + handle.SetDescriptionHandler(func(e wlr_output_management.ZwlrOutputHeadV1DescriptionEvent) { + log.Debugf("WlrOutput: Head %d description: %s", headID, e.Description) + head.description = e.Description + m.post(func() { + m.updateState() + }) + }) + + handle.SetPhysicalSizeHandler(func(e wlr_output_management.ZwlrOutputHeadV1PhysicalSizeEvent) { + log.Debugf("WlrOutput: Head %d physical size: %dx%d", headID, e.Width, e.Height) + head.physicalWidth = e.Width + head.physicalHeight = e.Height + m.post(func() { + m.updateState() + }) + }) + + handle.SetModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModeEvent) { + m.handleMode(headID, e) + }) + + handle.SetEnabledHandler(func(e wlr_output_management.ZwlrOutputHeadV1EnabledEvent) { + log.Debugf("WlrOutput: Head %d enabled: %d", headID, e.Enabled) + head.enabled = e.Enabled != 0 + m.post(func() { + m.updateState() + }) + }) + + handle.SetCurrentModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1CurrentModeEvent) { + modeID := e.Mode.ID() + log.Debugf("WlrOutput: Head %d current mode: %d", headID, modeID) + head.currentModeID = modeID + m.post(func() { + m.updateState() + }) + }) + + handle.SetPositionHandler(func(e wlr_output_management.ZwlrOutputHeadV1PositionEvent) { + log.Debugf("WlrOutput: Head %d position: %d,%d", headID, e.X, e.Y) + head.x = e.X + head.y = e.Y + m.post(func() { + m.updateState() + }) + }) + + handle.SetTransformHandler(func(e wlr_output_management.ZwlrOutputHeadV1TransformEvent) { + log.Debugf("WlrOutput: Head %d transform: %d", headID, e.Transform) + head.transform = e.Transform + m.post(func() { + m.updateState() + }) + }) + + handle.SetScaleHandler(func(e wlr_output_management.ZwlrOutputHeadV1ScaleEvent) { + log.Debugf("WlrOutput: Head %d scale: %f", headID, e.Scale) + head.scale = e.Scale + m.post(func() { + m.updateState() + }) + }) + + handle.SetMakeHandler(func(e wlr_output_management.ZwlrOutputHeadV1MakeEvent) { + log.Debugf("WlrOutput: Head %d make: %s", headID, e.Make) + head.make = e.Make + m.post(func() { + m.updateState() + }) + }) + + handle.SetModelHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModelEvent) { + log.Debugf("WlrOutput: Head %d model: %s", headID, e.Model) + head.model = e.Model + m.post(func() { + m.updateState() + }) + }) + + handle.SetSerialNumberHandler(func(e wlr_output_management.ZwlrOutputHeadV1SerialNumberEvent) { + log.Debugf("WlrOutput: Head %d serial: %s", headID, e.SerialNumber) + head.serialNumber = e.SerialNumber + m.post(func() { + m.updateState() + }) + }) + + handle.SetAdaptiveSyncHandler(func(e wlr_output_management.ZwlrOutputHeadV1AdaptiveSyncEvent) { + log.Debugf("WlrOutput: Head %d adaptive sync: %d", headID, e.State) + head.adaptiveSync = e.State + head.adaptiveSyncSupported = true + m.post(func() { + m.updateState() + }) + }) + + handle.SetFinishedHandler(func(e wlr_output_management.ZwlrOutputHeadV1FinishedEvent) { + log.Debugf("WlrOutput: Head %d finished", headID) + head.finished = true + + m.heads.Delete(headID) + + m.wlMutex.Lock() + handle.Release() + m.wlMutex.Unlock() + + m.post(func() { + m.updateState() + }) + }) +} + +func (m *Manager) handleMode(headID uint32, e wlr_output_management.ZwlrOutputHeadV1ModeEvent) { + handle := e.Mode + modeID := handle.ID() + + log.Debugf("WlrOutput: Head %d new mode (id=%d)", headID, modeID) + + mode := &modeState{ + id: modeID, + handle: handle, + } + + m.modes.Store(modeID, mode) + + if head, ok := m.heads.Load(headID); ok { + head.modeIDs = append(head.modeIDs, modeID) + m.heads.Store(headID, head) + } + + handle.SetSizeHandler(func(e wlr_output_management.ZwlrOutputModeV1SizeEvent) { + log.Debugf("WlrOutput: Mode %d size: %dx%d", modeID, e.Width, e.Height) + mode.width = e.Width + mode.height = e.Height + m.post(func() { + m.updateState() + }) + }) + + handle.SetRefreshHandler(func(e wlr_output_management.ZwlrOutputModeV1RefreshEvent) { + log.Debugf("WlrOutput: Mode %d refresh: %d", modeID, e.Refresh) + mode.refresh = e.Refresh + m.post(func() { + m.updateState() + }) + }) + + handle.SetPreferredHandler(func(e wlr_output_management.ZwlrOutputModeV1PreferredEvent) { + log.Debugf("WlrOutput: Mode %d preferred", modeID) + mode.preferred = true + m.post(func() { + m.updateState() + }) + }) + + handle.SetFinishedHandler(func(e wlr_output_management.ZwlrOutputModeV1FinishedEvent) { + log.Debugf("WlrOutput: Mode %d finished", modeID) + mode.finished = true + + m.modes.Delete(modeID) + + m.wlMutex.Lock() + handle.Release() + m.wlMutex.Unlock() + + m.post(func() { + m.updateState() + }) + }) +} + +func (m *Manager) updateState() { + outputs := make([]Output, 0) + + m.heads.Range(func(key uint32, head *headState) bool { + if head.finished { + return true + } + + if !head.ready { + return true + } + + modes := make([]OutputMode, 0) + var currentMode *OutputMode + + for _, modeID := range head.modeIDs { + mode, exists := m.modes.Load(modeID) + if !exists { + continue + } + if mode.finished { + continue + } + + outMode := OutputMode{ + Width: mode.width, + Height: mode.height, + Refresh: mode.refresh, + Preferred: mode.preferred, + ID: modeID, + } + modes = append(modes, outMode) + + if modeID == head.currentModeID { + currentMode = &outMode + } + } + + output := Output{ + Name: head.name, + Description: head.description, + Make: head.make, + Model: head.model, + SerialNumber: head.serialNumber, + PhysicalWidth: head.physicalWidth, + PhysicalHeight: head.physicalHeight, + Enabled: head.enabled, + X: head.x, + Y: head.y, + Transform: head.transform, + Scale: head.scale, + CurrentMode: currentMode, + Modes: modes, + AdaptiveSync: head.adaptiveSync, + AdaptiveSyncSupported: head.adaptiveSyncSupported, + ID: head.id, + } + outputs = append(outputs, output) + return true + }) + + newState := State{ + Outputs: outputs, + Serial: m.serial, + } + + m.stateMutex.Lock() + m.state = &newState + m.stateMutex.Unlock() + + m.notifySubscribers() +} + +func (m *Manager) notifier() { + defer m.notifierWg.Done() + defer func() { + if r := recover(); r != nil { + err := fmt.Errorf("notifier panic: %v", r) + log.Errorf("WlrOutput: %v", err) + + select { + case m.fatalError <- err: + default: + } + + select { + case <-m.stopChan: + default: + close(m.stopChan) + } + } + }() + + const minGap = 100 * 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 + } + + currentState := m.GetState() + + if m.lastNotified != nil && !stateChanged(m.lastNotified, ¤tState) { + pending = false + continue + } + + m.subscribers.Range(func(key string, ch chan State) bool { + select { + case ch <- currentState: + default: + log.Warn("WlrOutput: subscriber channel full, dropping update") + } + return true + }) + + stateCopy := currentState + m.lastNotified = &stateCopy + pending = false + } + } +} + +func (m *Manager) Close() { + close(m.stopChan) + m.wg.Wait() + m.notifierWg.Wait() + + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + + m.modes.Range(func(key uint32, mode *modeState) bool { + if mode.handle != nil { + mode.handle.Release() + } + m.modes.Delete(key) + return true + }) + + m.heads.Range(func(key uint32, head *headState) bool { + if head.handle != nil { + head.handle.Release() + } + m.heads.Delete(key) + return true + }) + + if m.manager != nil { + m.manager.Stop() + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager_test.go new file mode 100644 index 0000000..e90c70c --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager_test.go @@ -0,0 +1,414 @@ +package wlroutput + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient" +) + +func TestStateChanged_BothNil(t *testing.T) { + assert.True(t, stateChanged(nil, nil)) +} + +func TestStateChanged_OneNil(t *testing.T) { + s := &State{Serial: 1} + assert.True(t, stateChanged(s, nil)) + assert.True(t, stateChanged(nil, s)) +} + +func TestStateChanged_SerialDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{}} + b := &State{Serial: 2, Outputs: []Output{}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputCountDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}}} + b := &State{Serial: 1, Outputs: []Output{}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputNameDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: true}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "HDMI-A-1", Enabled: true}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputEnabledDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: true}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: false}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputPositionDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", X: 0, Y: 0}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", X: 1920, Y: 0}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputTransformDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Transform: 0}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Transform: 1}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputScaleDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Scale: 1.0}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Scale: 2.0}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputAdaptiveSyncDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", AdaptiveSync: 0}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", AdaptiveSync: 1}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_CurrentModeNilVsNonNil(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: &OutputMode{Width: 1920}}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_CurrentModeDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{ + Name: "eDP-1", + CurrentMode: &OutputMode{Width: 1920, Height: 1080, Refresh: 60000}, + }}} + b := &State{Serial: 1, Outputs: []Output{{ + Name: "eDP-1", + CurrentMode: &OutputMode{Width: 2560, Height: 1440, Refresh: 60000}, + }}} + assert.True(t, stateChanged(a, b)) + + b.Outputs[0].CurrentMode.Width = 1920 + b.Outputs[0].CurrentMode.Height = 1080 + b.Outputs[0].CurrentMode.Refresh = 144000 + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_ModesLengthDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Modes: []OutputMode{{Width: 1920}}}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Modes: []OutputMode{{Width: 1920}, {Width: 1280}}}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_Equal(t *testing.T) { + mode := OutputMode{Width: 1920, Height: 1080, Refresh: 60000, Preferred: true} + a := &State{ + Serial: 5, + Outputs: []Output{{ + Name: "eDP-1", + Description: "Built-in display", + Make: "BOE", + Model: "0x0ABC", + SerialNumber: "12345", + PhysicalWidth: 309, + PhysicalHeight: 174, + Enabled: true, + X: 0, + Y: 0, + Transform: 0, + Scale: 1.0, + CurrentMode: &mode, + Modes: []OutputMode{mode}, + AdaptiveSync: 0, + }}, + } + b := &State{ + Serial: 5, + Outputs: []Output{{ + Name: "eDP-1", + Description: "Built-in display", + Make: "BOE", + Model: "0x0ABC", + SerialNumber: "12345", + PhysicalWidth: 309, + PhysicalHeight: 174, + Enabled: true, + X: 0, + Y: 0, + Transform: 0, + Scale: 1.0, + CurrentMode: &mode, + Modes: []OutputMode{mode}, + AdaptiveSync: 0, + }}, + } + assert.False(t, stateChanged(a, b)) +} + +func TestManager_ConcurrentGetState(t *testing.T) { + m := &Manager{ + state: &State{ + Serial: 1, + Outputs: []Output{{Name: "eDP-1", Enabled: true}}, + }, + } + + var wg sync.WaitGroup + const goroutines = 50 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + s := m.GetState() + _ = s.Serial + _ = s.Outputs + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.stateMutex.Lock() + m.state = &State{ + Serial: uint32(j), + Outputs: []Output{{Name: "eDP-1", Scale: float64(j % 3)}}, + } + m.stateMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentSubscriberAccess(t *testing.T) { + m := &Manager{ + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + + var wg sync.WaitGroup + const goroutines = 20 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + subID := string(rune('a' + id)) + ch := m.Subscribe(subID) + assert.NotNil(t, ch) + time.Sleep(time.Millisecond) + m.Unsubscribe(subID) + }(i) + } + + wg.Wait() +} + +func TestManager_SyncmapHeadsConcurrentAccess(t *testing.T) { + m := &Manager{} + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 50 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + key := uint32(id) + + for j := 0; j < iterations; j++ { + state := &headState{ + id: key, + name: "test-head", + enabled: j%2 == 0, + scale: float64(j % 3), + modeIDs: []uint32{uint32(j)}, + } + m.heads.Store(key, state) + + if loaded, ok := m.heads.Load(key); ok { + assert.Equal(t, key, loaded.id) + } + + m.heads.Range(func(k uint32, v *headState) bool { + _ = v.name + _ = v.enabled + return true + }) + } + + m.heads.Delete(key) + }(i) + } + + wg.Wait() +} + +func TestManager_SyncmapModesConcurrentAccess(t *testing.T) { + m := &Manager{} + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 50 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + key := uint32(id) + + for j := 0; j < iterations; j++ { + state := &modeState{ + id: key, + width: int32(1920 + j), + height: int32(1080 + j), + refresh: 60000, + preferred: j == 0, + } + m.modes.Store(key, state) + + if loaded, ok := m.modes.Load(key); ok { + assert.Equal(t, key, loaded.id) + } + + m.modes.Range(func(k uint32, v *modeState) bool { + _ = v.width + _ = v.height + return true + }) + } + + m.modes.Delete(key) + }(i) + } + + wg.Wait() +} + +func TestManager_NotifySubscribersNonBlocking(t *testing.T) { + m := &Manager{ + dirty: make(chan struct{}, 1), + } + + for i := 0; i < 10; i++ { + m.notifySubscribers() + } + + assert.Len(t, m.dirty, 1) +} + +func TestManager_PostQueueFull(t *testing.T) { + m := &Manager{ + cmdq: make(chan cmd, 2), + stopChan: make(chan struct{}), + } + + m.post(func() {}) + m.post(func() {}) + m.post(func() {}) + m.post(func() {}) + + assert.Len(t, m.cmdq, 2) +} + +func TestManager_GetStateNilState(t *testing.T) { + m := &Manager{} + + s := m.GetState() + assert.NotNil(t, s.Outputs) + assert.Equal(t, uint32(0), s.Serial) +} + +func TestManager_FatalErrorChannel(t *testing.T) { + m := &Manager{ + fatalError: make(chan error, 1), + } + + ch := m.FatalError() + assert.NotNil(t, ch) + + m.fatalError <- assert.AnError + err := <-ch + assert.Error(t, err) +} + +func TestOutputMode_Fields(t *testing.T) { + mode := OutputMode{ + Width: 1920, + Height: 1080, + Refresh: 60000, + Preferred: true, + ID: 42, + } + + assert.Equal(t, int32(1920), mode.Width) + assert.Equal(t, int32(1080), mode.Height) + assert.Equal(t, int32(60000), mode.Refresh) + assert.True(t, mode.Preferred) + assert.Equal(t, uint32(42), mode.ID) +} + +func TestOutput_Fields(t *testing.T) { + out := Output{ + Name: "eDP-1", + Description: "Built-in display", + Make: "BOE", + Model: "0x0ABC", + SerialNumber: "12345", + PhysicalWidth: 309, + PhysicalHeight: 174, + Enabled: true, + X: 0, + Y: 0, + Transform: 0, + Scale: 1.5, + AdaptiveSync: 1, + ID: 1, + } + + assert.Equal(t, "eDP-1", out.Name) + assert.Equal(t, "Built-in display", out.Description) + assert.True(t, out.Enabled) + assert.Equal(t, float64(1.5), out.Scale) + assert.Equal(t, uint32(1), out.AdaptiveSync) +} + +func TestHeadState_ModeIDsSlice(t *testing.T) { + head := &headState{ + id: 1, + modeIDs: make([]uint32, 0), + } + + head.modeIDs = append(head.modeIDs, 1, 2, 3) + assert.Len(t, head.modeIDs, 3) + assert.Equal(t, uint32(1), head.modeIDs[0]) +} + +func TestStateChanged_BothCurrentModeNil(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}} + assert.False(t, stateChanged(a, b)) +} + +func TestStateChanged_IndexOutOfBounds(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}, {Name: "HDMI-A-1"}}} + assert.True(t, stateChanged(a, b)) +} + +func TestNewManager_GetRegistryError(t *testing.T) { + mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) + + mockDisplay.EXPECT().Context().Return(nil) + mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry")) + + _, err := NewManager(mockDisplay) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get registry") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/types.go new file mode 100644 index 0000000..9c79b1a --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/types.go @@ -0,0 +1,192 @@ +package wlroutput + +import ( + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type OutputMode struct { + Width int32 `json:"width"` + Height int32 `json:"height"` + Refresh int32 `json:"refresh"` + Preferred bool `json:"preferred"` + ID uint32 `json:"id"` +} + +type Output struct { + Name string `json:"name"` + Description string `json:"description"` + Make string `json:"make"` + Model string `json:"model"` + SerialNumber string `json:"serialNumber"` + PhysicalWidth int32 `json:"physicalWidth"` + PhysicalHeight int32 `json:"physicalHeight"` + Enabled bool `json:"enabled"` + X int32 `json:"x"` + Y int32 `json:"y"` + Transform int32 `json:"transform"` + Scale float64 `json:"scale"` + CurrentMode *OutputMode `json:"currentMode"` + Modes []OutputMode `json:"modes"` + AdaptiveSync uint32 `json:"adaptiveSync"` + AdaptiveSyncSupported bool `json:"adaptiveSyncSupported"` + ID uint32 `json:"id"` +} + +type State struct { + Outputs []Output `json:"outputs"` + Serial uint32 `json:"serial"` +} + +type cmd struct { + fn func() +} + +type Manager struct { + display wlclient.WaylandDisplay + ctx *wlclient.Context + registry *wlclient.Registry + manager *wlr_output_management.ZwlrOutputManagerV1 + + heads syncmap.Map[uint32, *headState] + modes syncmap.Map[uint32, *modeState] + + serial uint32 + + wlMutex sync.Mutex + cmdq chan cmd + stopChan chan struct{} + wg sync.WaitGroup + + subscribers syncmap.Map[string, chan State] + dirty chan struct{} + notifierWg sync.WaitGroup + lastNotified *State + + stateMutex sync.RWMutex + state *State + + fatalError chan error +} + +type headState struct { + id uint32 + handle *wlr_output_management.ZwlrOutputHeadV1 + name string + description string + make string + model string + serialNumber string + physicalWidth int32 + physicalHeight int32 + enabled bool + x int32 + y int32 + transform int32 + scale float64 + currentModeID uint32 + modeIDs []uint32 + adaptiveSync uint32 + adaptiveSyncSupported bool + finished bool + ready bool +} + +type modeState struct { + id uint32 + handle *wlr_output_management.ZwlrOutputModeV1 + width int32 + height int32 + refresh int32 + preferred bool + finished bool +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + if m.state == nil { + return State{ + Outputs: []Output{}, + Serial: 0, + } + } + stateCopy := *m.state + return stateCopy +} + +func (m *Manager) Subscribe(id string) chan State { + ch := make(chan State, 64) + + m.subscribers.Store(id, ch) + + return ch +} + +func (m *Manager) Unsubscribe(id string) { + + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + + } + +} + +func (m *Manager) notifySubscribers() { + select { + case m.dirty <- struct{}{}: + default: + } +} + +func (m *Manager) FatalError() <-chan error { + return m.fatalError +} + +func stateChanged(old, new *State) bool { + if old == nil || new == nil { + return true + } + if old.Serial != new.Serial { + return true + } + if len(old.Outputs) != len(new.Outputs) { + return true + } + for i := range new.Outputs { + if i >= len(old.Outputs) { + return true + } + oldOut := &old.Outputs[i] + newOut := &new.Outputs[i] + if oldOut.Name != newOut.Name || oldOut.Enabled != newOut.Enabled { + return true + } + if oldOut.X != newOut.X || oldOut.Y != newOut.Y { + return true + } + if oldOut.Transform != newOut.Transform || oldOut.Scale != newOut.Scale { + return true + } + if oldOut.AdaptiveSync != newOut.AdaptiveSync || oldOut.AdaptiveSyncSupported != newOut.AdaptiveSyncSupported { + return true + } + if (oldOut.CurrentMode == nil) != (newOut.CurrentMode == nil) { + return true + } + if oldOut.CurrentMode != nil && newOut.CurrentMode != nil { + if oldOut.CurrentMode.Width != newOut.CurrentMode.Width || + oldOut.CurrentMode.Height != newOut.CurrentMode.Height || + oldOut.CurrentMode.Refresh != newOut.CurrentMode.Refresh { + return true + } + } + if len(oldOut.Modes) != len(newOut.Modes) { + return true + } + } + return false +} |