mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-04-03 20:32:07 -04:00
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
This commit is contained in:
@@ -28,6 +28,12 @@ packages:
|
|||||||
outpkg: mocks_brightness
|
outpkg: mocks_brightness
|
||||||
interfaces:
|
interfaces:
|
||||||
DBusConn:
|
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:
|
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
|
||||||
config:
|
config:
|
||||||
dir: "internal/mocks/network"
|
dir: "internal/mocks/network"
|
||||||
|
|||||||
14
core/internal/geolocation/client.go
Normal file
14
core/internal/geolocation/client.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
236
core/internal/geolocation/client_geoclue.go
Normal file
236
core/internal/geolocation/client_geoclue.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
90
core/internal/geolocation/client_ip.go
Normal file
90
core/internal/geolocation/client_ip.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
15
core/internal/geolocation/types.go
Normal file
15
core/internal/geolocation/types.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
203
core/internal/mocks/geolocation/mock_Client.go
Normal file
203
core/internal/mocks/geolocation/mock_Client.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
61
core/internal/server/location/handlers.go
Normal file
61
core/internal/server/location/handlers.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
core/internal/server/location/manager.go
Normal file
174
core/internal/server/location/manager.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
28
core/internal/server/location/types.go
Normal file
28
core/internal/server/location/types.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
"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/loginctl"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||||
@@ -192,6 +193,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
|||||||
return
|
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 {
|
switch req.Method {
|
||||||
case "ping":
|
case "ping":
|
||||||
models.Respond(conn, req.ID, "pong")
|
models.Respond(conn, req.ID, "pong")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
|
"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/evdev"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
"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/loginctl"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||||
@@ -70,6 +72,7 @@ var clipboardManager *clipboard.Manager
|
|||||||
var dbusManager *serverDbus.Manager
|
var dbusManager *serverDbus.Manager
|
||||||
var wlContext *wlcontext.SharedContext
|
var wlContext *wlcontext.SharedContext
|
||||||
var themeModeManager *thememode.Manager
|
var themeModeManager *thememode.Manager
|
||||||
|
var locationManager *location.Manager
|
||||||
|
|
||||||
const dbusClientID = "dms-dbus-client"
|
const dbusClientID = "dms-dbus-client"
|
||||||
|
|
||||||
@@ -188,7 +191,7 @@ func InitializeFreedeskManager() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitializeWaylandManager() error {
|
func InitializeWaylandManager(geoClient geolocation.Client) error {
|
||||||
log.Info("Attempting to initialize Wayland gamma control...")
|
log.Info("Attempting to initialize Wayland gamma control...")
|
||||||
|
|
||||||
if wlContext == nil {
|
if wlContext == nil {
|
||||||
@@ -201,7 +204,7 @@ func InitializeWaylandManager() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config := wayland.DefaultConfig()
|
config := wayland.DefaultConfig()
|
||||||
manager, err := wayland.NewManager(wlContext.Display(), config)
|
manager, err := wayland.NewManager(wlContext.Display(), geoClient, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Failed to initialize wayland manager: %v", err)
|
log.Errorf("Failed to initialize wayland manager: %v", err)
|
||||||
return err
|
return err
|
||||||
@@ -382,14 +385,27 @@ func InitializeDbusManager() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitializeThemeModeManager() error {
|
func InitializeThemeModeManager(geoClient geolocation.Client) error {
|
||||||
manager := thememode.NewManager()
|
manager := thememode.NewManager(geoClient)
|
||||||
themeModeManager = manager
|
themeModeManager = manager
|
||||||
|
|
||||||
log.Info("Theme mode automation manager initialized")
|
log.Info("Theme mode automation manager initialized")
|
||||||
return nil
|
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) {
|
func handleConnection(conn net.Conn) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -537,6 +553,10 @@ func getServerInfo() ServerInfo {
|
|||||||
caps = append(caps, "theme.auto")
|
caps = append(caps, "theme.auto")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if locationManager != nil {
|
||||||
|
caps = append(caps, "location")
|
||||||
|
}
|
||||||
|
|
||||||
if dbusManager != nil {
|
if dbusManager != nil {
|
||||||
caps = append(caps, "dbus")
|
caps = append(caps, "dbus")
|
||||||
}
|
}
|
||||||
@@ -1307,6 +1327,9 @@ func cleanupManagers() {
|
|||||||
if wlContext != nil {
|
if wlContext != nil {
|
||||||
wlContext.Close()
|
wlContext.Close()
|
||||||
}
|
}
|
||||||
|
if locationManager != nil {
|
||||||
|
locationManager.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(printDocs bool) error {
|
func Start(printDocs bool) error {
|
||||||
@@ -1488,6 +1511,9 @@ func Start(printDocs bool) error {
|
|||||||
log.Info(" clipboard.getConfig - Get clipboard configuration")
|
log.Info(" clipboard.getConfig - Get clipboard configuration")
|
||||||
log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)")
|
log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)")
|
||||||
log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)")
|
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("")
|
||||||
}
|
}
|
||||||
log.Info("Initializing managers...")
|
log.Info("Initializing managers...")
|
||||||
@@ -1519,6 +1545,9 @@ func Start(printDocs bool) error {
|
|||||||
loginctlReady := make(chan struct{})
|
loginctlReady := make(chan struct{})
|
||||||
freedesktopReady := make(chan struct{})
|
freedesktopReady := make(chan struct{})
|
||||||
|
|
||||||
|
geoClient := geolocation.NewClient()
|
||||||
|
defer geoClient.Close()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer close(loginctlReady)
|
defer close(loginctlReady)
|
||||||
if err := InitializeLoginctlManager(); err != nil {
|
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)
|
log.Warnf("Wayland manager unavailable: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1595,7 +1624,7 @@ func Start(printDocs bool) error {
|
|||||||
log.Debugf("WlrOutput manager unavailable: %v", err)
|
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)
|
log.Warnf("Theme mode manager unavailable: %v", err)
|
||||||
} else {
|
} else {
|
||||||
notifyCapabilityChange()
|
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)
|
fatalErrChan := make(chan error, 1)
|
||||||
if wlrOutputManager != nil {
|
if wlrOutputManager != nil {
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||||
@@ -32,12 +33,14 @@ type Manager struct {
|
|||||||
cachedIPLat *float64
|
cachedIPLat *float64
|
||||||
cachedIPLon *float64
|
cachedIPLon *float64
|
||||||
|
|
||||||
|
geoClient geolocation.Client
|
||||||
|
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
updateTrigger chan struct{}
|
updateTrigger chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager() *Manager {
|
func NewManager(geoClient geolocation.Client) *Manager {
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
config: Config{
|
config: Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
@@ -51,6 +54,7 @@ func NewManager() *Manager {
|
|||||||
},
|
},
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
updateTrigger: make(chan struct{}, 1),
|
updateTrigger: make(chan struct{}, 1),
|
||||||
|
geoClient: geoClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
m.updateState(time.Now())
|
m.updateState(time.Now())
|
||||||
@@ -327,17 +331,17 @@ func (m *Manager) getLocation(config Config) (*float64, *float64) {
|
|||||||
}
|
}
|
||||||
m.locationMutex.RUnlock()
|
m.locationMutex.RUnlock()
|
||||||
|
|
||||||
lat, lon, err := wayland.FetchIPLocation()
|
location, err := m.geoClient.GetLocation()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
m.locationMutex.Lock()
|
m.locationMutex.Lock()
|
||||||
m.cachedIPLat = lat
|
m.cachedIPLat = &location.Latitude
|
||||||
m.cachedIPLon = lon
|
m.cachedIPLon = &location.Longitude
|
||||||
m.locationMutex.Unlock()
|
m.locationMutex.Unlock()
|
||||||
|
|
||||||
return lat, lon
|
return m.cachedIPLat, m.cachedIPLon
|
||||||
}
|
}
|
||||||
|
|
||||||
func statesEqual(a, b *State) bool {
|
func statesEqual(a, b *State) bool {
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ import (
|
|||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
"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/log"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control"
|
||||||
)
|
)
|
||||||
|
|
||||||
const animKelvinStep = 25
|
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 {
|
if err := config.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -40,6 +41,7 @@ func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error
|
|||||||
updateTrigger: make(chan struct{}, 1),
|
updateTrigger: make(chan struct{}, 1),
|
||||||
dirty: make(chan struct{}, 1),
|
dirty: make(chan struct{}, 1),
|
||||||
dbusSignal: make(chan *dbus.Signal, 16),
|
dbusSignal: make(chan *dbus.Signal, 16),
|
||||||
|
geoClient: geoClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.setupRegistry(); err != nil {
|
if err := m.setupRegistry(); err != nil {
|
||||||
@@ -437,15 +439,16 @@ func (m *Manager) getLocation() (*float64, *float64) {
|
|||||||
}
|
}
|
||||||
m.locationMutex.RUnlock()
|
m.locationMutex.RUnlock()
|
||||||
|
|
||||||
lat, lon, err := FetchIPLocation()
|
location, err := m.geoClient.GetLocation()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
m.locationMutex.Lock()
|
m.locationMutex.Lock()
|
||||||
m.cachedIPLat = lat
|
m.cachedIPLat = &location.Latitude
|
||||||
m.cachedIPLon = lon
|
m.cachedIPLon = &location.Longitude
|
||||||
m.locationMutex.Unlock()
|
m.locationMutex.Unlock()
|
||||||
return lat, lon
|
return m.cachedIPLat, m.cachedIPLon
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"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"
|
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) {
|
func TestNewManager_GetRegistryError(t *testing.T) {
|
||||||
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
|
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
|
||||||
|
mockGeoclient := mocks_geolocation.NewMockClient(t)
|
||||||
|
|
||||||
mockDisplay.EXPECT().Context().Return(nil)
|
mockDisplay.EXPECT().Context().Return(nil)
|
||||||
mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
|
mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
|
||||||
|
|
||||||
config := DefaultConfig()
|
config := DefaultConfig()
|
||||||
_, err := NewManager(mockDisplay, config)
|
_, err := NewManager(mockDisplay, mockGeoclient, config)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "get registry")
|
assert.Contains(t, err.Error(), "get registry")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewManager_InvalidConfig(t *testing.T) {
|
func TestNewManager_InvalidConfig(t *testing.T) {
|
||||||
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
|
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
|
||||||
|
mockGeoclient := mocks_geolocation.NewMockClient(t)
|
||||||
|
|
||||||
config := Config{
|
config := Config{
|
||||||
LowTemp: 500,
|
LowTemp: 500,
|
||||||
@@ -409,6 +412,6 @@ func TestNewManager_InvalidConfig(t *testing.T) {
|
|||||||
Gamma: 1.0,
|
Gamma: 1.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := NewManager(mockDisplay, config)
|
_, err := NewManager(mockDisplay, mockGeoclient, config)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
"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"
|
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
@@ -97,6 +98,8 @@ type Manager struct {
|
|||||||
dbusConn *dbus.Conn
|
dbusConn *dbus.Conn
|
||||||
dbusSignal chan *dbus.Signal
|
dbusSignal chan *dbus.Signal
|
||||||
|
|
||||||
|
geoClient geolocation.Client
|
||||||
|
|
||||||
lastAppliedTemp int
|
lastAppliedTemp int
|
||||||
lastAppliedGamma float64
|
lastAppliedGamma float64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ in
|
|||||||
|
|
||||||
services.power-profiles-daemon.enable = lib.mkDefault true;
|
services.power-profiles-daemon.enable = lib.mkDefault true;
|
||||||
services.accounts-daemon.enable = lib.mkDefault true;
|
services.accounts-daemon.enable = lib.mkDefault true;
|
||||||
|
services.geoclue2.enable = lib.mkDefault true;
|
||||||
security.polkit.enable = lib.mkDefault true;
|
security.polkit.enable = lib.mkDefault true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,12 +61,13 @@ Singleton {
|
|||||||
signal appPickerRequested(var data)
|
signal appPickerRequested(var data)
|
||||||
signal screensaverStateUpdate(var data)
|
signal screensaverStateUpdate(var data)
|
||||||
signal clipboardStateUpdate(var data)
|
signal clipboardStateUpdate(var data)
|
||||||
|
signal locationStateUpdate(var data)
|
||||||
|
|
||||||
property bool capsLockState: false
|
property bool capsLockState: false
|
||||||
property bool screensaverInhibited: false
|
property bool screensaverInhibited: false
|
||||||
property var screensaverInhibitors: []
|
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: {
|
Component.onCompleted: {
|
||||||
if (socketPath && socketPath.length > 0) {
|
if (socketPath && socketPath.length > 0) {
|
||||||
@@ -284,7 +285,7 @@ Singleton {
|
|||||||
|
|
||||||
function removeSubscription(service) {
|
function removeSubscription(service) {
|
||||||
if (activeSubscriptions.includes("all")) {
|
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);
|
const filtered = allServices.filter(s => s !== service);
|
||||||
subscribe(filtered);
|
subscribe(filtered);
|
||||||
} else {
|
} else {
|
||||||
@@ -306,7 +307,7 @@ Singleton {
|
|||||||
excludeServices = [excludeServices];
|
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));
|
const filtered = allServices.filter(s => !excludeServices.includes(s));
|
||||||
subscribe(filtered);
|
subscribe(filtered);
|
||||||
}
|
}
|
||||||
@@ -395,6 +396,8 @@ Singleton {
|
|||||||
dbusSignalReceived(data.subscriptionId || "", data);
|
dbusSignalReceived(data.subscriptionId || "", data);
|
||||||
} else if (service === "clipboard") {
|
} else if (service === "clipboard") {
|
||||||
clipboardStateUpdate(data);
|
clipboardStateUpdate(data);
|
||||||
|
} else if (service === "location") {
|
||||||
|
locationStateUpdate(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
quickshell/Services/LocationService.qml
Normal file
51
quickshell/Services/LocationService.qml
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -480,7 +480,7 @@ Singleton {
|
|||||||
const cityName = SessionData.isGreeterMode ? GreetdSettings.weatherLocation : SettingsData.weatherLocation;
|
const cityName = SessionData.isGreeterMode ? GreetdSettings.weatherLocation : SettingsData.weatherLocation;
|
||||||
|
|
||||||
if (useAuto) {
|
if (useAuto) {
|
||||||
getLocationFromIP();
|
getLocationFromService();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,8 +511,8 @@ Singleton {
|
|||||||
cityGeocodeFetcher.running = true;
|
cityGeocodeFetcher.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLocationFromIP() {
|
function getLocationFromService() {
|
||||||
ipLocationFetcher.running = true;
|
getLocationFromCoords(LocationService.latitude, LocationService.longitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchWeather() {
|
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 {
|
Process {
|
||||||
id: reverseGeocodeFetcher
|
id: reverseGeocodeFetcher
|
||||||
running: false
|
running: false
|
||||||
@@ -872,6 +825,16 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: LocationService
|
||||||
|
|
||||||
|
function onLocationChanged(data) {
|
||||||
|
if (SettingsData.useAutoLocation) {
|
||||||
|
root.getLocationFromCoords(data.latitude, data.longitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
SettingsData.weatherCoordinatesChanged.connect(() => {
|
SettingsData.weatherCoordinatesChanged.connect(() => {
|
||||||
root.location = null;
|
root.location = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user