From 7bea6b4a62dcb10e73cd91c43b8dd4c5fcf99ab5 Mon Sep 17 00:00:00 2001 From: Sunner Date: Sat, 28 Feb 2026 04:29:08 +0100 Subject: [PATCH] Add GeoClue2 integration as alternative to IP location (#1856) * feat: switch auto location in weather widget to use GeoClue2 instead of simple IP check * nix: enable GeoClue2 service by default * lint: fix line endings * fix: fall back to IP location if GeoClue is not available --- core/.mockery.yml | 6 + core/internal/geolocation/client.go | 14 ++ core/internal/geolocation/client_geoclue.go | 236 ++++++++++++++++++ core/internal/geolocation/client_ip.go | 90 +++++++ core/internal/geolocation/types.go | 15 ++ .../internal/mocks/geolocation/mock_Client.go | 203 +++++++++++++++ core/internal/server/location/handlers.go | 61 +++++ core/internal/server/location/manager.go | 174 +++++++++++++ core/internal/server/location/types.go | 28 +++ core/internal/server/router.go | 10 + core/internal/server/server.go | 47 +++- core/internal/server/thememode/manager.go | 14 +- core/internal/server/wayland/manager.go | 13 +- core/internal/server/wayland/manager_test.go | 7 +- core/internal/server/wayland/types.go | 3 + distro/nix/nixos.nix | 1 + quickshell/Services/DMSService.qml | 9 +- quickshell/Services/LocationService.qml | 51 ++++ quickshell/Services/WeatherService.qml | 63 +---- 19 files changed, 974 insertions(+), 71 deletions(-) create mode 100644 core/internal/geolocation/client.go create mode 100644 core/internal/geolocation/client_geoclue.go create mode 100644 core/internal/geolocation/client_ip.go create mode 100644 core/internal/geolocation/types.go create mode 100644 core/internal/mocks/geolocation/mock_Client.go create mode 100644 core/internal/server/location/handlers.go create mode 100644 core/internal/server/location/manager.go create mode 100644 core/internal/server/location/types.go create mode 100644 quickshell/Services/LocationService.qml diff --git a/core/.mockery.yml b/core/.mockery.yml index d02c9613..f84f82c0 100644 --- a/core/.mockery.yml +++ b/core/.mockery.yml @@ -28,6 +28,12 @@ packages: outpkg: mocks_brightness interfaces: DBusConn: + github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation: + config: + dir: "internal/mocks/geolocation" + outpkg: mocks_geolocation + interfaces: + Client: github.com/AvengeMedia/DankMaterialShell/core/internal/server/network: config: dir: "internal/mocks/network" diff --git a/core/internal/geolocation/client.go b/core/internal/geolocation/client.go new file mode 100644 index 00000000..a37ebd44 --- /dev/null +++ b/core/internal/geolocation/client.go @@ -0,0 +1,14 @@ +package geolocation + +import "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + +func NewClient() Client { + if geoclueClient, err := newGeoClueClient(); err != nil { + log.Warnf("Failed to initialize GeoClue2 client: %v", err) + } else { + return geoclueClient + } + + log.Info("Falling back to IP location") + return newIpClient() +} diff --git a/core/internal/geolocation/client_geoclue.go b/core/internal/geolocation/client_geoclue.go new file mode 100644 index 00000000..3d3bb64e --- /dev/null +++ b/core/internal/geolocation/client_geoclue.go @@ -0,0 +1,236 @@ +package geolocation + +import ( + "fmt" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" + "github.com/godbus/dbus/v5" +) + +const ( + dbusGeoClueService = "org.freedesktop.GeoClue2" + dbusGeoCluePath = "/org/freedesktop/GeoClue2" + dbusGeoClueInterface = dbusGeoClueService + + dbusGeoClueManagerPath = dbusGeoCluePath + "/Manager" + dbusGeoClueManagerInterface = dbusGeoClueInterface + ".Manager" + dbusGeoClueManagerGetClient = dbusGeoClueManagerInterface + ".GetClient" + + dbusGeoClueClientInterface = dbusGeoClueInterface + ".Client" + dbusGeoClueClientDesktopId = dbusGeoClueClientInterface + ".DesktopId" + dbusGeoClueClientTimeThreshold = dbusGeoClueClientInterface + ".TimeThreshold" + dbusGeoClueClientTimeStart = dbusGeoClueClientInterface + ".Start" + dbusGeoClueClientTimeStop = dbusGeoClueClientInterface + ".Stop" + dbusGeoClueClientLocationUpdated = dbusGeoClueClientInterface + ".LocationUpdated" + + dbusGeoClueLocationInterface = dbusGeoClueInterface + ".Location" + dbusGeoClueLocationLatitude = dbusGeoClueLocationInterface + ".Latitude" + dbusGeoClueLocationLongitude = dbusGeoClueLocationInterface + ".Longitude" +) + +type GeoClueClient struct { + currLocation *Location + locationMutex sync.RWMutex + + dbusConn *dbus.Conn + clientPath dbus.ObjectPath + signals chan *dbus.Signal + + stopChan chan struct{} + sigWG sync.WaitGroup + + subscribers syncmap.Map[string, chan Location] +} + +func newGeoClueClient() (*GeoClueClient, error) { + dbusConn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, fmt.Errorf("system bus connection failed: %w", err) + } + + c := &GeoClueClient{ + dbusConn: dbusConn, + stopChan: make(chan struct{}), + signals: make(chan *dbus.Signal, 256), + + currLocation: &Location{ + Latitude: 0.0, + Longitude: 0.0, + }, + } + + if err := c.setupClient(); err != nil { + dbusConn.Close() + return nil, err + } + + if err := c.startSignalPump(); err != nil { + return nil, err + } + + return c, nil +} + +func (c *GeoClueClient) Close() { + close(c.stopChan) + + c.sigWG.Wait() + + if c.signals != nil { + c.dbusConn.RemoveSignal(c.signals) + close(c.signals) + } + + c.subscribers.Range(func(key string, ch chan Location) bool { + close(ch) + c.subscribers.Delete(key) + return true + }) + + if c.dbusConn != nil { + c.dbusConn.Close() + } +} + +func (c *GeoClueClient) Subscribe(id string) chan Location { + ch := make(chan Location, 64) + c.subscribers.Store(id, ch) + return ch +} + +func (c *GeoClueClient) Unsubscribe(id string) { + if ch, ok := c.subscribers.LoadAndDelete(id); ok { + close(ch) + } +} + +func (c *GeoClueClient) setupClient() error { + managerObj := c.dbusConn.Object(dbusGeoClueService, dbusGeoClueManagerPath) + + if err := managerObj.Call(dbusGeoClueManagerGetClient, 0).Store(&c.clientPath); err != nil { + return fmt.Errorf("failed to create GeoClue2 client: %w", err) + } + + clientObj := c.dbusConn.Object(dbusGeoClueService, c.clientPath) + if err := clientObj.SetProperty(dbusGeoClueClientDesktopId, "dms"); err != nil { + return fmt.Errorf("failed to set desktop ID: %w", err) + } + + if err := clientObj.SetProperty(dbusGeoClueClientTimeThreshold, uint(10)); err != nil { + return fmt.Errorf("failed to set time threshold: %w", err) + } + + return nil +} + +func (c *GeoClueClient) startSignalPump() error { + c.dbusConn.Signal(c.signals) + + if err := c.dbusConn.AddMatchSignal( + dbus.WithMatchObjectPath(c.clientPath), + dbus.WithMatchInterface(dbusGeoClueClientInterface), + dbus.WithMatchSender(dbusGeoClueClientLocationUpdated), + ); err != nil { + return err + } + + c.sigWG.Add(1) + go func() { + defer c.sigWG.Done() + + clientObj := c.dbusConn.Object(dbusGeoClueService, c.clientPath) + clientObj.Call(dbusGeoClueClientTimeStart, 0) + defer clientObj.Call(dbusGeoClueClientTimeStop, 0) + + for { + select { + case <-c.stopChan: + return + case sig, ok := <-c.signals: + if !ok { + return + } + if sig == nil { + continue + } + + c.handleSignal(sig) + } + } + }() + + return nil +} + +func (c *GeoClueClient) handleSignal(sig *dbus.Signal) { + switch sig.Name { + case dbusGeoClueClientLocationUpdated: + if len(sig.Body) != 2 { + return + } + + newLocationPath, ok := sig.Body[1].(dbus.ObjectPath) + if !ok { + return + } + + if err := c.handleLocationUpdated(newLocationPath); err != nil { + log.Warn("GeoClue: Failed to handle location update: %v", err) + return + } + } +} + +func (c *GeoClueClient) handleLocationUpdated(path dbus.ObjectPath) error { + locationObj := c.dbusConn.Object(dbusGeoClueService, path) + + lat, err := locationObj.GetProperty(dbusGeoClueLocationLatitude) + if err != nil { + return err + } + + long, err := locationObj.GetProperty(dbusGeoClueLocationLongitude) + if err != nil { + return err + } + + c.locationMutex.Lock() + c.currLocation.Latitude = dbusutil.AsOr(lat, 0.0) + c.currLocation.Longitude = dbusutil.AsOr(long, 0.0) + c.locationMutex.Unlock() + + c.notifySubscribers() + return nil +} + +func (c *GeoClueClient) notifySubscribers() { + currentLocation, err := c.GetLocation() + if err != nil { + return + } + + c.subscribers.Range(func(key string, ch chan Location) bool { + select { + case ch <- currentLocation: + default: + log.Warn("GeoClue: subscriber channel full, dropping update") + } + return true + }) +} + +func (c *GeoClueClient) GetLocation() (Location, error) { + c.locationMutex.RLock() + defer c.locationMutex.RUnlock() + if c.currLocation == nil { + return Location{ + Latitude: 0.0, + Longitude: 0.0, + }, nil + } + stateCopy := *c.currLocation + return stateCopy, nil +} diff --git a/core/internal/geolocation/client_ip.go b/core/internal/geolocation/client_ip.go new file mode 100644 index 00000000..2736a2db --- /dev/null +++ b/core/internal/geolocation/client_ip.go @@ -0,0 +1,90 @@ +package geolocation + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +type IpClient struct { + currLocation *Location +} + +type ipAPIResponse struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + City string `json:"city"` +} + +func newIpClient() *IpClient { + return &IpClient{ + currLocation: &Location{ + Latitude: 0.0, + Longitude: 0.0, + }, + } +} + +func (c *IpClient) Subscribe(id string) chan Location { + ch := make(chan Location, 1) + if location, err := c.GetLocation(); err != nil { + ch <- location + } else { + close(ch) + } + + return ch +} + +func (c *IpClient) Unsubscribe(id string) { + // Stub +} + +func (c *IpClient) Close() { + // Stub +} + +func (c *IpClient) GetLocation() (Location, error) { + client := &http.Client{ + Timeout: 10 * time.Second, + } + + result := Location{ + Latitude: 0.0, + Longitude: 0.0, + } + + resp, err := client.Get("http://ip-api.com/json/") + if err != nil { + return result, fmt.Errorf("failed to fetch IP location: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return result, fmt.Errorf("ip-api.com returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return result, fmt.Errorf("failed to read response: %w", err) + } + + var data ipAPIResponse + if err := json.Unmarshal(body, &data); err != nil { + return result, fmt.Errorf("failed to parse response: %w", err) + } + + if data.Lat == 0 && data.Lon == 0 { + return result, fmt.Errorf("missing location data in response") + } + + log.Infof("Fetched IP-based location: %s (%.4f, %.4f)", data.City, data.Lat, data.Lon) + result.Latitude = data.Lat + result.Longitude = data.Lon + + return result, nil +} diff --git a/core/internal/geolocation/types.go b/core/internal/geolocation/types.go new file mode 100644 index 00000000..bf71041f --- /dev/null +++ b/core/internal/geolocation/types.go @@ -0,0 +1,15 @@ +package geolocation + +type Location struct { + Latitude float64 + Longitude float64 +} + +type Client interface { + GetLocation() (Location, error) + + Subscribe(id string) chan Location + Unsubscribe(id string) + + Close() +} diff --git a/core/internal/mocks/geolocation/mock_Client.go b/core/internal/mocks/geolocation/mock_Client.go new file mode 100644 index 00000000..bf097cb8 --- /dev/null +++ b/core/internal/mocks/geolocation/mock_Client.go @@ -0,0 +1,203 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks_geolocation + +import ( + geolocation "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation" + mock "github.com/stretchr/testify/mock" +) + +// MockClient is an autogenerated mock type for the Client type +type MockClient struct { + mock.Mock +} + +type MockClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockClient) EXPECT() *MockClient_Expecter { + return &MockClient_Expecter{mock: &_m.Mock} +} + +// Close provides a mock function with no fields +func (_m *MockClient) Close() { + _m.Called() +} + +// MockClient_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' +type MockClient_Close_Call struct { + *mock.Call +} + +// Close is a helper method to define mock.On call +func (_e *MockClient_Expecter) Close() *MockClient_Close_Call { + return &MockClient_Close_Call{Call: _e.mock.On("Close")} +} + +func (_c *MockClient_Close_Call) Run(run func()) *MockClient_Close_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockClient_Close_Call) Return() *MockClient_Close_Call { + _c.Call.Return() + return _c +} + +func (_c *MockClient_Close_Call) RunAndReturn(run func()) *MockClient_Close_Call { + _c.Run(run) + return _c +} + +// GetLocation provides a mock function with no fields +func (_m *MockClient) GetLocation() (geolocation.Location, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetLocation") + } + + var r0 geolocation.Location + var r1 error + if rf, ok := ret.Get(0).(func() (geolocation.Location, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() geolocation.Location); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(geolocation.Location) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetLocation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLocation' +type MockClient_GetLocation_Call struct { + *mock.Call +} + +// GetLocation is a helper method to define mock.On call +func (_e *MockClient_Expecter) GetLocation() *MockClient_GetLocation_Call { + return &MockClient_GetLocation_Call{Call: _e.mock.On("GetLocation")} +} + +func (_c *MockClient_GetLocation_Call) Run(run func()) *MockClient_GetLocation_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockClient_GetLocation_Call) Return(_a0 geolocation.Location, _a1 error) *MockClient_GetLocation_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetLocation_Call) RunAndReturn(run func() (geolocation.Location, error)) *MockClient_GetLocation_Call { + _c.Call.Return(run) + return _c +} + +// Subscribe provides a mock function with given fields: id +func (_m *MockClient) Subscribe(id string) chan geolocation.Location { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for Subscribe") + } + + var r0 chan geolocation.Location + if rf, ok := ret.Get(0).(func(string) chan geolocation.Location); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(chan geolocation.Location) + } + } + + return r0 +} + +// MockClient_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe' +type MockClient_Subscribe_Call struct { + *mock.Call +} + +// Subscribe is a helper method to define mock.On call +// - id string +func (_e *MockClient_Expecter) Subscribe(id interface{}) *MockClient_Subscribe_Call { + return &MockClient_Subscribe_Call{Call: _e.mock.On("Subscribe", id)} +} + +func (_c *MockClient_Subscribe_Call) Run(run func(id string)) *MockClient_Subscribe_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockClient_Subscribe_Call) Return(_a0 chan geolocation.Location) *MockClient_Subscribe_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockClient_Subscribe_Call) RunAndReturn(run func(string) chan geolocation.Location) *MockClient_Subscribe_Call { + _c.Call.Return(run) + return _c +} + +// Unsubscribe provides a mock function with given fields: id +func (_m *MockClient) Unsubscribe(id string) { + _m.Called(id) +} + +// MockClient_Unsubscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Unsubscribe' +type MockClient_Unsubscribe_Call struct { + *mock.Call +} + +// Unsubscribe is a helper method to define mock.On call +// - id string +func (_e *MockClient_Expecter) Unsubscribe(id interface{}) *MockClient_Unsubscribe_Call { + return &MockClient_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe", id)} +} + +func (_c *MockClient_Unsubscribe_Call) Run(run func(id string)) *MockClient_Unsubscribe_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockClient_Unsubscribe_Call) Return() *MockClient_Unsubscribe_Call { + _c.Call.Return() + return _c +} + +func (_c *MockClient_Unsubscribe_Call) RunAndReturn(run func(string)) *MockClient_Unsubscribe_Call { + _c.Run(run) + return _c +} + +// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockClient { + mock := &MockClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/internal/server/location/handlers.go b/core/internal/server/location/handlers.go new file mode 100644 index 00000000..c9d556c4 --- /dev/null +++ b/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/core/internal/server/location/manager.go b/core/internal/server/location/manager.go new file mode 100644 index 00000000..8866c1d0 --- /dev/null +++ b/core/internal/server/location/manager.go @@ -0,0 +1,174 @@ +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{}), + + 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/core/internal/server/location/types.go b/core/internal/server/location/types.go new file mode 100644 index 00000000..1f3d5180 --- /dev/null +++ b/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/core/internal/server/router.go b/core/internal/server/router.go index 2d37f695..6b8c10b0 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -15,6 +15,7 @@ import ( "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" @@ -192,6 +193,15 @@ func RouteRequest(conn net.Conn, req models.Request) { 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") diff --git a/core/internal/server/server.go b/core/internal/server/server.go index 8d838f5f..72a2c7ea 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -14,6 +14,7 @@ import ( "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" @@ -25,6 +26,7 @@ import ( "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" @@ -70,6 +72,7 @@ var clipboardManager *clipboard.Manager var dbusManager *serverDbus.Manager var wlContext *wlcontext.SharedContext var themeModeManager *thememode.Manager +var locationManager *location.Manager const dbusClientID = "dms-dbus-client" @@ -188,7 +191,7 @@ func InitializeFreedeskManager() error { return nil } -func InitializeWaylandManager() error { +func InitializeWaylandManager(geoClient geolocation.Client) error { log.Info("Attempting to initialize Wayland gamma control...") if wlContext == nil { @@ -201,7 +204,7 @@ func InitializeWaylandManager() error { } config := wayland.DefaultConfig() - manager, err := wayland.NewManager(wlContext.Display(), config) + manager, err := wayland.NewManager(wlContext.Display(), geoClient, config) if err != nil { log.Errorf("Failed to initialize wayland manager: %v", err) return err @@ -382,14 +385,27 @@ func InitializeDbusManager() error { return nil } -func InitializeThemeModeManager() error { - manager := thememode.NewManager() +func InitializeThemeModeManager(geoClient geolocation.Client) error { + manager := thememode.NewManager(geoClient) themeModeManager = manager log.Info("Theme mode automation 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() @@ -537,6 +553,10 @@ func getServerInfo() ServerInfo { caps = append(caps, "theme.auto") } + if locationManager != nil { + caps = append(caps, "location") + } + if dbusManager != nil { caps = append(caps, "dbus") } @@ -1307,6 +1327,9 @@ func cleanupManagers() { if wlContext != nil { wlContext.Close() } + if locationManager != nil { + locationManager.Close() + } } func Start(printDocs bool) error { @@ -1488,6 +1511,9 @@ func Start(printDocs bool) error { 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...") @@ -1519,6 +1545,9 @@ func Start(printDocs bool) error { loginctlReady := make(chan struct{}) freedesktopReady := make(chan struct{}) + geoClient := geolocation.NewClient() + defer geoClient.Close() + go func() { defer close(loginctlReady) if err := InitializeLoginctlManager(); err != nil { @@ -1563,7 +1592,7 @@ func Start(printDocs bool) error { } }() - if err := InitializeWaylandManager(); err != nil { + if err := InitializeWaylandManager(geoClient); err != nil { log.Warnf("Wayland manager unavailable: %v", err) } @@ -1595,7 +1624,7 @@ func Start(printDocs bool) error { log.Debugf("WlrOutput manager unavailable: %v", err) } - if err := InitializeThemeModeManager(); err != nil { + if err := InitializeThemeModeManager(geoClient); err != nil { log.Warnf("Theme mode manager unavailable: %v", err) } else { notifyCapabilityChange() @@ -1608,6 +1637,12 @@ func Start(printDocs bool) error { }() } + if err := InitializeLocationManager(geoClient); err != nil { + log.Warnf("Location manager unavailable: %v", err) + } else { + notifyCapabilityChange() + } + fatalErrChan := make(chan error, 1) if wlrOutputManager != nil { go func() { diff --git a/core/internal/server/thememode/manager.go b/core/internal/server/thememode/manager.go index 41dfdd53..1b0c7d63 100644 --- a/core/internal/server/thememode/manager.go +++ b/core/internal/server/thememode/manager.go @@ -5,6 +5,7 @@ import ( "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" @@ -32,12 +33,14 @@ type Manager struct { cachedIPLat *float64 cachedIPLon *float64 + geoClient geolocation.Client + stopChan chan struct{} updateTrigger chan struct{} wg sync.WaitGroup } -func NewManager() *Manager { +func NewManager(geoClient geolocation.Client) *Manager { m := &Manager{ config: Config{ Enabled: false, @@ -51,6 +54,7 @@ func NewManager() *Manager { }, stopChan: make(chan struct{}), updateTrigger: make(chan struct{}, 1), + geoClient: geoClient, } m.updateState(time.Now()) @@ -327,17 +331,17 @@ func (m *Manager) getLocation(config Config) (*float64, *float64) { } m.locationMutex.RUnlock() - lat, lon, err := wayland.FetchIPLocation() + location, err := m.geoClient.GetLocation() if err != nil { return nil, nil } m.locationMutex.Lock() - m.cachedIPLat = lat - m.cachedIPLon = lon + m.cachedIPLat = &location.Latitude + m.cachedIPLon = &location.Longitude m.locationMutex.Unlock() - return lat, lon + return m.cachedIPLat, m.cachedIPLon } func statesEqual(a, b *State) bool { diff --git a/core/internal/server/wayland/manager.go b/core/internal/server/wayland/manager.go index fe4bd173..8b9e433a 100644 --- a/core/internal/server/wayland/manager.go +++ b/core/internal/server/wayland/manager.go @@ -13,13 +13,14 @@ import ( "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) { +func NewManager(display wlclient.WaylandDisplay, geoClient geolocation.Client, config Config) (*Manager, error) { if err := config.Validate(); err != nil { return nil, err } @@ -40,6 +41,7 @@ func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error updateTrigger: make(chan struct{}, 1), dirty: make(chan struct{}, 1), dbusSignal: make(chan *dbus.Signal, 16), + geoClient: geoClient, } if err := m.setupRegistry(); err != nil { @@ -437,15 +439,16 @@ func (m *Manager) getLocation() (*float64, *float64) { } m.locationMutex.RUnlock() - lat, lon, err := FetchIPLocation() + location, err := m.geoClient.GetLocation() if err != nil { return nil, nil } + m.locationMutex.Lock() - m.cachedIPLat = lat - m.cachedIPLon = lon + m.cachedIPLat = &location.Latitude + m.cachedIPLon = &location.Longitude m.locationMutex.Unlock() - return lat, lon + return m.cachedIPLat, m.cachedIPLon } return nil, nil } diff --git a/core/internal/server/wayland/manager_test.go b/core/internal/server/wayland/manager_test.go index 8c001292..03473f1d 100644 --- a/core/internal/server/wayland/manager_test.go +++ b/core/internal/server/wayland/manager_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" + mocks_geolocation "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/geolocation" mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient" ) @@ -390,18 +391,20 @@ func TestNotifySubscribers_NonBlocking(t *testing.T) { func TestNewManager_GetRegistryError(t *testing.T) { mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) + mockGeoclient := mocks_geolocation.NewMockClient(t) mockDisplay.EXPECT().Context().Return(nil) mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry")) config := DefaultConfig() - _, err := NewManager(mockDisplay, config) + _, err := NewManager(mockDisplay, mockGeoclient, config) assert.Error(t, err) assert.Contains(t, err.Error(), "get registry") } func TestNewManager_InvalidConfig(t *testing.T) { mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) + mockGeoclient := mocks_geolocation.NewMockClient(t) config := Config{ LowTemp: 500, @@ -409,6 +412,6 @@ func TestNewManager_InvalidConfig(t *testing.T) { Gamma: 1.0, } - _, err := NewManager(mockDisplay, config) + _, err := NewManager(mockDisplay, mockGeoclient, config) assert.Error(t, err) } diff --git a/core/internal/server/wayland/types.go b/core/internal/server/wayland/types.go index 44861bdd..d624ca80 100644 --- a/core/internal/server/wayland/types.go +++ b/core/internal/server/wayland/types.go @@ -6,6 +6,7 @@ import ( "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" @@ -97,6 +98,8 @@ type Manager struct { dbusConn *dbus.Conn dbusSignal chan *dbus.Signal + geoClient geolocation.Client + lastAppliedTemp int lastAppliedGamma float64 } diff --git a/distro/nix/nixos.nix b/distro/nix/nixos.nix index 5303ac8c..0596efda 100644 --- a/distro/nix/nixos.nix +++ b/distro/nix/nixos.nix @@ -50,6 +50,7 @@ in services.power-profiles-daemon.enable = lib.mkDefault true; services.accounts-daemon.enable = lib.mkDefault true; + services.geoclue2.enable = lib.mkDefault true; security.polkit.enable = lib.mkDefault true; }; } diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index 237866a7..217b96a0 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -61,12 +61,13 @@ Singleton { signal appPickerRequested(var data) signal screensaverStateUpdate(var data) signal clipboardStateUpdate(var data) + signal locationStateUpdate(var data) property bool capsLockState: false property bool screensaverInhibited: false property var screensaverInhibitors: [] - property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus", "clipboard"] + property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus", "clipboard", "location"] Component.onCompleted: { if (socketPath && socketPath.length > 0) { @@ -284,7 +285,7 @@ Singleton { function removeSubscription(service) { if (activeSubscriptions.includes("all")) { - const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "extworkspace", "browser"]; + const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "extworkspace", "browser", "location"]; const filtered = allServices.filter(s => s !== service); subscribe(filtered); } else { @@ -306,7 +307,7 @@ Singleton { excludeServices = [excludeServices]; } - const allServices = ["network", "loginctl", "freedesktop", "gamma", "theme.auto", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser", "dbus"]; + const allServices = ["network", "loginctl", "freedesktop", "gamma", "theme.auto", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser", "dbus", "location"]; const filtered = allServices.filter(s => !excludeServices.includes(s)); subscribe(filtered); } @@ -395,6 +396,8 @@ Singleton { dbusSignalReceived(data.subscriptionId || "", data); } else if (service === "clipboard") { clipboardStateUpdate(data); + } else if (service === "location") { + locationStateUpdate(data); } } diff --git a/quickshell/Services/LocationService.qml b/quickshell/Services/LocationService.qml new file mode 100644 index 00000000..41345f40 --- /dev/null +++ b/quickshell/Services/LocationService.qml @@ -0,0 +1,51 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common + +Singleton { + id: root + + readonly property bool locationAvailable: DMSService.isConnected && (DMSService.capabilities.length === 0 || DMSService.capabilities.includes("location")) + + property var latitude: 0.0 + property var longitude: 0.0 + + signal locationChanged(var data) + + Component.onCompleted: { + console.info("LocationService: Initializing..."); + getState(); + } + + Connections { + target: DMSService + + function onLocationStateUpdate(data) { + if (locationAvailable) { + handleStateUpdate(data); + } + } + } + + function handleStateUpdate(data) { + root.latitude = data.latitude; + root.longitude = data.longitude; + + root.locationChanged(data) + } + + function getState() { + if (!locationAvailable) + return; + + DMSService.sendRequest("location.getState", null, response => { + if (response.result) { + handleStateUpdate(response.result); + } + }); + } +} diff --git a/quickshell/Services/WeatherService.qml b/quickshell/Services/WeatherService.qml index 2c2f4802..c76ffd3f 100644 --- a/quickshell/Services/WeatherService.qml +++ b/quickshell/Services/WeatherService.qml @@ -480,7 +480,7 @@ Singleton { const cityName = SessionData.isGreeterMode ? GreetdSettings.weatherLocation : SettingsData.weatherLocation; if (useAuto) { - getLocationFromIP(); + getLocationFromService(); return; } @@ -511,8 +511,8 @@ Singleton { cityGeocodeFetcher.running = true; } - function getLocationFromIP() { - ipLocationFetcher.running = true; + function getLocationFromService() { + getLocationFromCoords(LocationService.latitude, LocationService.longitude); } function fetchWeather() { @@ -583,53 +583,6 @@ Singleton { } } - Process { - id: ipLocationFetcher - command: lowPriorityCmd.concat(curlBaseCmd).concat(["http://ip-api.com/json/"]) - running: false - - stdout: StdioCollector { - onStreamFinished: { - const raw = text.trim(); - if (!raw || raw[0] !== "{") { - root.handleWeatherFailure(); - return; - } - - try { - const data = JSON.parse(raw); - - if (data.status === "fail") { - throw new Error("IP location lookup failed"); - } - - const lat = parseFloat(data.lat); - const lon = parseFloat(data.lon); - const city = data.city; - - if (!city || isNaN(lat) || isNaN(lon)) { - throw new Error("Missing or invalid location data"); - } - - root.location = { - city: city, - latitude: lat, - longitude: lon - }; - fetchWeather(); - } catch (e) { - root.handleWeatherFailure(); - } - } - } - - onExited: exitCode => { - if (exitCode !== 0) { - root.handleWeatherFailure(); - } - } - } - Process { id: reverseGeocodeFetcher running: false @@ -872,6 +825,16 @@ Singleton { } } + Connections { + target: LocationService + + function onLocationChanged(data) { + if (SettingsData.useAutoLocation) { + root.getLocationFromCoords(data.latitude, data.longitude) + } + } + } + Component.onCompleted: { SettingsData.weatherCoordinatesChanged.connect(() => { root.location = null;