diff --git a/core/internal/mocks/network/mock_Backend.go b/core/internal/mocks/network/mock_Backend.go index da31780d1..5c32106b4 100644 --- a/core/internal/mocks/network/mock_Backend.go +++ b/core/internal/mocks/network/mock_Backend.go @@ -36,6 +36,57 @@ func (_m *MockBackend) EXPECT() *MockBackend_Expecter { return &MockBackend_Expecter{mock: &_m.Mock} } +// ActivateCellularConnection provides a mock function for the type MockBackend +func (_mock *MockBackend) ActivateCellularConnection(uuid string) error { + ret := _mock.Called(uuid) + + if len(ret) == 0 { + panic("no return value specified for ActivateCellularConnection") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(uuid) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockBackend_ActivateCellularConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ActivateCellularConnection' +type MockBackend_ActivateCellularConnection_Call struct { + *mock.Call +} + +// ActivateCellularConnection is a helper method to define mock.On call +// - uuid string +func (_e *MockBackend_Expecter) ActivateCellularConnection(uuid any) *MockBackend_ActivateCellularConnection_Call { + return &MockBackend_ActivateCellularConnection_Call{Call: _e.mock.On("ActivateCellularConnection", uuid)} +} + +func (_c *MockBackend_ActivateCellularConnection_Call) Run(run func(uuid string)) *MockBackend_ActivateCellularConnection_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockBackend_ActivateCellularConnection_Call) Return(err error) *MockBackend_ActivateCellularConnection_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockBackend_ActivateCellularConnection_Call) RunAndReturn(run func(uuid string) error) *MockBackend_ActivateCellularConnection_Call { + _c.Call.Return(run) + return _c +} + // ActivateWiredConnection provides a mock function for the type MockBackend func (_mock *MockBackend) ActivateWiredConnection(uuid string) error { ret := _mock.Called(uuid) @@ -222,6 +273,50 @@ func (_c *MockBackend_Close_Call) RunAndReturn(run func()) *MockBackend_Close_Ca return _c } +// ConnectCellular provides a mock function for the type MockBackend +func (_mock *MockBackend) ConnectCellular() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for ConnectCellular") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockBackend_ConnectCellular_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConnectCellular' +type MockBackend_ConnectCellular_Call struct { + *mock.Call +} + +// ConnectCellular is a helper method to define mock.On call +func (_e *MockBackend_Expecter) ConnectCellular() *MockBackend_ConnectCellular_Call { + return &MockBackend_ConnectCellular_Call{Call: _e.mock.On("ConnectCellular")} +} + +func (_c *MockBackend_ConnectCellular_Call) Run(run func()) *MockBackend_ConnectCellular_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_ConnectCellular_Call) Return(err error) *MockBackend_ConnectCellular_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockBackend_ConnectCellular_Call) RunAndReturn(run func() error) *MockBackend_ConnectCellular_Call { + _c.Call.Return(run) + return _c +} + // ConnectEthernet provides a mock function for the type MockBackend func (_mock *MockBackend) ConnectEthernet() error { ret := _mock.Called() @@ -469,6 +564,101 @@ func (_c *MockBackend_DisconnectAllVPN_Call) RunAndReturn(run func() error) *Moc return _c } +// DisconnectCellular provides a mock function for the type MockBackend +func (_mock *MockBackend) DisconnectCellular() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for DisconnectCellular") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockBackend_DisconnectCellular_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisconnectCellular' +type MockBackend_DisconnectCellular_Call struct { + *mock.Call +} + +// DisconnectCellular is a helper method to define mock.On call +func (_e *MockBackend_Expecter) DisconnectCellular() *MockBackend_DisconnectCellular_Call { + return &MockBackend_DisconnectCellular_Call{Call: _e.mock.On("DisconnectCellular")} +} + +func (_c *MockBackend_DisconnectCellular_Call) Run(run func()) *MockBackend_DisconnectCellular_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_DisconnectCellular_Call) Return(err error) *MockBackend_DisconnectCellular_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockBackend_DisconnectCellular_Call) RunAndReturn(run func() error) *MockBackend_DisconnectCellular_Call { + _c.Call.Return(run) + return _c +} + +// DisconnectCellularDevice provides a mock function for the type MockBackend +func (_mock *MockBackend) DisconnectCellularDevice(device string) error { + ret := _mock.Called(device) + + if len(ret) == 0 { + panic("no return value specified for DisconnectCellularDevice") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(device) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockBackend_DisconnectCellularDevice_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisconnectCellularDevice' +type MockBackend_DisconnectCellularDevice_Call struct { + *mock.Call +} + +// DisconnectCellularDevice is a helper method to define mock.On call +// - device string +func (_e *MockBackend_Expecter) DisconnectCellularDevice(device any) *MockBackend_DisconnectCellularDevice_Call { + return &MockBackend_DisconnectCellularDevice_Call{Call: _e.mock.On("DisconnectCellularDevice", device)} +} + +func (_c *MockBackend_DisconnectCellularDevice_Call) Run(run func(device string)) *MockBackend_DisconnectCellularDevice_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockBackend_DisconnectCellularDevice_Call) Return(err error) *MockBackend_DisconnectCellularDevice_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockBackend_DisconnectCellularDevice_Call) RunAndReturn(run func(device string) error) *MockBackend_DisconnectCellularDevice_Call { + _c.Call.Return(run) + return _c +} + // DisconnectEthernet provides a mock function for the type MockBackend func (_mock *MockBackend) DisconnectEthernet() error { ret := _mock.Called() @@ -761,6 +951,160 @@ func (_c *MockBackend_ForgetWiFiNetwork_Call) RunAndReturn(run func(ssid string) return _c } +// GetCellularConnections provides a mock function for the type MockBackend +func (_mock *MockBackend) GetCellularConnections() ([]network.WiredConnection, error) { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetCellularConnections") + } + + var r0 []network.WiredConnection + var r1 error + if returnFunc, ok := ret.Get(0).(func() ([]network.WiredConnection, error)); ok { + return returnFunc() + } + if returnFunc, ok := ret.Get(0).(func() []network.WiredConnection); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]network.WiredConnection) + } + } + if returnFunc, ok := ret.Get(1).(func() error); ok { + r1 = returnFunc() + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_GetCellularConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCellularConnections' +type MockBackend_GetCellularConnections_Call struct { + *mock.Call +} + +// GetCellularConnections is a helper method to define mock.On call +func (_e *MockBackend_Expecter) GetCellularConnections() *MockBackend_GetCellularConnections_Call { + return &MockBackend_GetCellularConnections_Call{Call: _e.mock.On("GetCellularConnections")} +} + +func (_c *MockBackend_GetCellularConnections_Call) Run(run func()) *MockBackend_GetCellularConnections_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_GetCellularConnections_Call) Return(wiredConnections []network.WiredConnection, err error) *MockBackend_GetCellularConnections_Call { + _c.Call.Return(wiredConnections, err) + return _c +} + +func (_c *MockBackend_GetCellularConnections_Call) RunAndReturn(run func() ([]network.WiredConnection, error)) *MockBackend_GetCellularConnections_Call { + _c.Call.Return(run) + return _c +} + +// GetCellularDevices provides a mock function for the type MockBackend +func (_mock *MockBackend) GetCellularDevices() []network.CellularDevice { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetCellularDevices") + } + + var r0 []network.CellularDevice + if returnFunc, ok := ret.Get(0).(func() []network.CellularDevice); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]network.CellularDevice) + } + } + return r0 +} + +// MockBackend_GetCellularDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCellularDevices' +type MockBackend_GetCellularDevices_Call struct { + *mock.Call +} + +// GetCellularDevices is a helper method to define mock.On call +func (_e *MockBackend_Expecter) GetCellularDevices() *MockBackend_GetCellularDevices_Call { + return &MockBackend_GetCellularDevices_Call{Call: _e.mock.On("GetCellularDevices")} +} + +func (_c *MockBackend_GetCellularDevices_Call) Run(run func()) *MockBackend_GetCellularDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_GetCellularDevices_Call) Return(cellularDevices []network.CellularDevice) *MockBackend_GetCellularDevices_Call { + _c.Call.Return(cellularDevices) + return _c +} + +func (_c *MockBackend_GetCellularDevices_Call) RunAndReturn(run func() []network.CellularDevice) *MockBackend_GetCellularDevices_Call { + _c.Call.Return(run) + return _c +} + +// GetCellularEnabled provides a mock function for the type MockBackend +func (_mock *MockBackend) GetCellularEnabled() (bool, error) { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetCellularEnabled") + } + + var r0 bool + var r1 error + if returnFunc, ok := ret.Get(0).(func() (bool, error)); ok { + return returnFunc() + } + if returnFunc, ok := ret.Get(0).(func() bool); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(bool) + } + if returnFunc, ok := ret.Get(1).(func() error); ok { + r1 = returnFunc() + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockBackend_GetCellularEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCellularEnabled' +type MockBackend_GetCellularEnabled_Call struct { + *mock.Call +} + +// GetCellularEnabled is a helper method to define mock.On call +func (_e *MockBackend_Expecter) GetCellularEnabled() *MockBackend_GetCellularEnabled_Call { + return &MockBackend_GetCellularEnabled_Call{Call: _e.mock.On("GetCellularEnabled")} +} + +func (_c *MockBackend_GetCellularEnabled_Call) Run(run func()) *MockBackend_GetCellularEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_GetCellularEnabled_Call) Return(b bool, err error) *MockBackend_GetCellularEnabled_Call { + _c.Call.Return(b, err) + return _c +} + +func (_c *MockBackend_GetCellularEnabled_Call) RunAndReturn(run func() (bool, error)) *MockBackend_GetCellularEnabled_Call { + _c.Call.Return(run) + return _c +} + // GetCurrentState provides a mock function for the type MockBackend func (_mock *MockBackend) GetCurrentState() (*network.BackendState, error) { ret := _mock.Called() @@ -1680,6 +2024,57 @@ func (_c *MockBackend_ScanWiFiDevice_Call) RunAndReturn(run func(device string) return _c } +// SetCellularEnabled provides a mock function for the type MockBackend +func (_mock *MockBackend) SetCellularEnabled(enabled bool) error { + ret := _mock.Called(enabled) + + if len(ret) == 0 { + panic("no return value specified for SetCellularEnabled") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(bool) error); ok { + r0 = returnFunc(enabled) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockBackend_SetCellularEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetCellularEnabled' +type MockBackend_SetCellularEnabled_Call struct { + *mock.Call +} + +// SetCellularEnabled is a helper method to define mock.On call +// - enabled bool +func (_e *MockBackend_Expecter) SetCellularEnabled(enabled any) *MockBackend_SetCellularEnabled_Call { + return &MockBackend_SetCellularEnabled_Call{Call: _e.mock.On("SetCellularEnabled", enabled)} +} + +func (_c *MockBackend_SetCellularEnabled_Call) Run(run func(enabled bool)) *MockBackend_SetCellularEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 bool + if args[0] != nil { + arg0 = args[0].(bool) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockBackend_SetCellularEnabled_Call) Return(err error) *MockBackend_SetCellularEnabled_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockBackend_SetCellularEnabled_Call) RunAndReturn(run func(enabled bool) error) *MockBackend_SetCellularEnabled_Call { + _c.Call.Return(run) + return _c +} + // SetPromptBroker provides a mock function for the type MockBackend func (_mock *MockBackend) SetPromptBroker(broker network.PromptBroker) error { ret := _mock.Called(broker) diff --git a/core/internal/server/network/backend.go b/core/internal/server/network/backend.go index 19ec78836..ec4de2e86 100644 --- a/core/internal/server/network/backend.go +++ b/core/internal/server/network/backend.go @@ -7,6 +7,9 @@ type Backend interface { GetWiFiEnabled() (bool, error) SetWiFiEnabled(enabled bool) error + GetCellularEnabled() (bool, error) + SetCellularEnabled(enabled bool) error + ScanWiFi() error ScanWiFiDevice(device string) error GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) @@ -27,6 +30,13 @@ type Backend interface { DisconnectEthernetDevice(device string) error ActivateWiredConnection(uuid string) error + GetCellularDevices() []CellularDevice + GetCellularConnections() ([]WiredConnection, error) + ConnectCellular() error + DisconnectCellular() error + DisconnectCellularDevice(device string) error + ActivateCellularConnection(uuid string) error + ListVPNProfiles() ([]VPNProfile, error) ListActiveVPN() ([]VPNActive, error) ConnectVPN(uuidOrName string, singleActive bool) error @@ -59,42 +69,50 @@ type HotspotBackend interface { } type BackendState struct { - Backend string - NetworkStatus NetworkStatus - EthernetIP string - EthernetDevice string - EthernetConnected bool - EthernetConnectionUuid string - EthernetDevices []EthernetDevice - WiFiIP string - WiFiDevice string - WiFiConnected bool - WiFiEnabled bool - WiFiSSID string - WiFiBSSID string - WiFiSignal uint8 - WiFiNetworks []WiFiNetwork - SavedWiFiNetworks []WiFiNetwork - WiFiDevices []WiFiDevice - HotspotAvailable bool - HotspotConfigured bool - HotspotEnabled bool - HotspotActivating bool - HotspotSecured bool - HotspotSSID string - HotspotDevice string - HotspotBand string - HotspotLastError string - WiredConnections []WiredConnection - VPNProfiles []VPNProfile - VPNActive []VPNActive - IsConnecting bool - ConnectingSSID string - ConnectingDevice string - ConnectingPreExisting bool - IsConnectingVPN bool - ConnectingVPNUUID string - LastError string - VPNError string - VPNErrorUuid string + Backend string + NetworkStatus NetworkStatus + EthernetIP string + EthernetDevice string + EthernetConnected bool + EthernetConnectionUuid string + EthernetDevices []EthernetDevice + CellularIP string + CellularDevice string + CellularConnected bool + CellularEnabled bool + CellularHardwareEnabled bool + CellularConnectionUuid string + CellularDevices []CellularDevice + CellularConnections []WiredConnection + WiFiIP string + WiFiDevice string + WiFiConnected bool + WiFiEnabled bool + WiFiSSID string + WiFiBSSID string + WiFiSignal uint8 + WiFiNetworks []WiFiNetwork + SavedWiFiNetworks []WiFiNetwork + WiFiDevices []WiFiDevice + HotspotAvailable bool + HotspotConfigured bool + HotspotEnabled bool + HotspotActivating bool + HotspotSecured bool + HotspotSSID string + HotspotDevice string + HotspotBand string + HotspotLastError string + WiredConnections []WiredConnection + VPNProfiles []VPNProfile + VPNActive []VPNActive + IsConnecting bool + ConnectingSSID string + ConnectingDevice string + ConnectingPreExisting bool + IsConnectingVPN bool + ConnectingVPNUUID string + LastError string + VPNError string + VPNErrorUuid string } diff --git a/core/internal/server/network/backend_hybrid_iwd_networkd.go b/core/internal/server/network/backend_hybrid_iwd_networkd.go index ce883ba26..d59790139 100644 --- a/core/internal/server/network/backend_hybrid_iwd_networkd.go +++ b/core/internal/server/network/backend_hybrid_iwd_networkd.go @@ -164,6 +164,38 @@ func (b *HybridIwdNetworkdBackend) ActivateWiredConnection(uuid string) error { return b.l3.ActivateWiredConnection(uuid) } +func (b *HybridIwdNetworkdBackend) GetCellularDevices() []CellularDevice { + return []CellularDevice{} +} + +func (b *HybridIwdNetworkdBackend) GetCellularEnabled() (bool, error) { + return false, fmt.Errorf("cellular radio control not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) SetCellularEnabled(enabled bool) error { + return fmt.Errorf("cellular radio control not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) GetCellularConnections() ([]WiredConnection, error) { + return nil, fmt.Errorf("cellular connections not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) ConnectCellular() error { + return fmt.Errorf("cellular connections not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) DisconnectCellular() error { + return fmt.Errorf("cellular connections not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) DisconnectCellularDevice(device string) error { + return fmt.Errorf("cellular connections not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) ActivateCellularConnection(uuid string) error { + return fmt.Errorf("cellular connections not supported in hybrid mode") +} + func (b *HybridIwdNetworkdBackend) ListVPNProfiles() ([]VPNProfile, error) { return []VPNProfile{}, nil } diff --git a/core/internal/server/network/backend_iwd_unimplemented.go b/core/internal/server/network/backend_iwd_unimplemented.go index f5a8b033a..05c7cf7a1 100644 --- a/core/internal/server/network/backend_iwd_unimplemented.go +++ b/core/internal/server/network/backend_iwd_unimplemented.go @@ -33,6 +33,38 @@ func (b *IWDBackend) ActivateWiredConnection(uuid string) error { return fmt.Errorf("wired connections not supported by iwd") } +func (b *IWDBackend) GetCellularDevices() []CellularDevice { + return []CellularDevice{} +} + +func (b *IWDBackend) GetCellularEnabled() (bool, error) { + return false, fmt.Errorf("cellular radio control not supported by iwd") +} + +func (b *IWDBackend) SetCellularEnabled(enabled bool) error { + return fmt.Errorf("cellular radio control not supported by iwd") +} + +func (b *IWDBackend) GetCellularConnections() ([]WiredConnection, error) { + return nil, fmt.Errorf("cellular connections not supported by iwd") +} + +func (b *IWDBackend) ConnectCellular() error { + return fmt.Errorf("cellular connections not supported by iwd") +} + +func (b *IWDBackend) DisconnectCellular() error { + return fmt.Errorf("cellular connections not supported by iwd") +} + +func (b *IWDBackend) DisconnectCellularDevice(device string) error { + return fmt.Errorf("cellular connections not supported by iwd") +} + +func (b *IWDBackend) ActivateCellularConnection(uuid string) error { + return fmt.Errorf("cellular connections not supported by iwd") +} + func (b *IWDBackend) ListVPNProfiles() ([]VPNProfile, error) { return nil, fmt.Errorf("VPN not supported by iwd backend") } diff --git a/core/internal/server/network/backend_networkd_unimplemented.go b/core/internal/server/network/backend_networkd_unimplemented.go index 695c3c5f3..3e9bc50e7 100644 --- a/core/internal/server/network/backend_networkd_unimplemented.go +++ b/core/internal/server/network/backend_networkd_unimplemented.go @@ -97,3 +97,35 @@ func (b *SystemdNetworkdBackend) DisconnectWiFiDevice(device string) error { func (b *SystemdNetworkdBackend) GetWiFiDevices() []WiFiDevice { return nil } + +func (b *SystemdNetworkdBackend) GetCellularDevices() []CellularDevice { + return []CellularDevice{} +} + +func (b *SystemdNetworkdBackend) GetCellularEnabled() (bool, error) { + return false, fmt.Errorf("cellular radio control not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) SetCellularEnabled(enabled bool) error { + return fmt.Errorf("cellular radio control not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) GetCellularConnections() ([]WiredConnection, error) { + return nil, fmt.Errorf("cellular connections not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ConnectCellular() error { + return fmt.Errorf("cellular connections not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) DisconnectCellular() error { + return fmt.Errorf("cellular connections not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) DisconnectCellularDevice(device string) error { + return fmt.Errorf("cellular connections not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ActivateCellularConnection(uuid string) error { + return fmt.Errorf("cellular connections not supported by networkd backend") +} diff --git a/core/internal/server/network/backend_networkmanager.go b/core/internal/server/network/backend_networkmanager.go index e54bc8e32..4e5db417a 100644 --- a/core/internal/server/network/backend_networkmanager.go +++ b/core/internal/server/network/backend_networkmanager.go @@ -49,16 +49,26 @@ type ethernetDeviceInfo struct { hwAddress string } +type cellularDeviceInfo struct { + device gonetworkmanager.Device + generic gonetworkmanager.DeviceGeneric + name string + hwAddress string + description string +} + type NetworkManagerBackend struct { nmConn any ethernetDevice any ethernetDevices map[string]*ethernetDeviceInfo + cellularDevice any + cellularDevices map[string]*cellularDeviceInfo wifiDevice any settings any wifiDev any wifiDevices map[string]*wifiDeviceInfo - // devMutex guards ethernetDevices/wifiDevices (written by the signal pump, + // devMutex guards ethernetDevices/wifiDevices/cellularDevices (written by the signal pump, // read by request handlers). Not reentrant — never hold it across calls // into other backend methods. devMutex sync.RWMutex @@ -152,6 +162,7 @@ func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*Netwo nmConn: nm, stopChan: make(chan struct{}), ethernetDevices: make(map[string]*ethernetDeviceInfo), + cellularDevices: make(map[string]*cellularDeviceInfo), wifiDevices: make(map[string]*wifiDeviceInfo), state: &BackendState{ Backend: "networkmanager", @@ -212,6 +223,36 @@ func (b *NetworkManagerBackend) Initialize() error { return fmt.Errorf("failed to get wired configurations: %w", err) } + case gonetworkmanager.NmDeviceTypeModem: + if managed, _ := dev.GetPropertyManaged(); !managed { + continue + } + iface, err := dev.GetPropertyInterface() + if err != nil { + continue + } + g, _ := gonetworkmanager.NewDeviceGeneric(dev.GetPath()) + hwAddr := "" + description := "Mobile broadband" + if g != nil { + hwAddr, _ = g.GetPropertyHwAddress() + if desc, err := g.GetPropertyTypeDescription(); err == nil && desc != "" { + description = desc + } + } + + b.setCellularDeviceInfo(iface, &cellularDeviceInfo{ + device: dev, + generic: g, + name: iface, + hwAddress: hwAddr, + description: description, + }) + + if b.cellularDevice == nil { + b.cellularDevice = dev + } + case gonetworkmanager.NmDeviceTypeWifi: iface, err := dev.GetPropertyInterface() if err != nil { @@ -243,6 +284,7 @@ func (b *NetworkManagerBackend) Initialize() error { b.state.WiFiEnabled = wifiEnabled b.stateMutex.Unlock() } + b.updateCellularRadioState() if err := b.updateWiFiState(); err != nil { log.Warnf("Failed to update WiFi state: %v", err) @@ -266,6 +308,10 @@ func (b *NetworkManagerBackend) Initialize() error { b.updateAllWiFiDevices() b.updateAllEthernetDevices() + b.updateAllCellularDevices() + if _, err := b.listCellularConnections(); err != nil { + log.Warnf("Failed to get initial cellular configurations: %v", err) + } if err := b.updatePrimaryConnection(); err != nil { return err @@ -298,6 +344,14 @@ func (b *NetworkManagerBackend) wifiDevicesSnapshot() map[string]*wifiDeviceInfo return out } +func (b *NetworkManagerBackend) cellularDevicesSnapshot() map[string]*cellularDeviceInfo { + b.devMutex.RLock() + defer b.devMutex.RUnlock() + out := make(map[string]*cellularDeviceInfo, len(b.cellularDevices)) + maps.Copy(out, b.cellularDevices) + return out +} + func (b *NetworkManagerBackend) ethernetDeviceByIface(iface string) (*ethernetDeviceInfo, bool) { b.devMutex.RLock() defer b.devMutex.RUnlock() @@ -312,6 +366,13 @@ func (b *NetworkManagerBackend) wifiDeviceByIface(iface string) (*wifiDeviceInfo return info, ok } +func (b *NetworkManagerBackend) cellularDeviceByIface(iface string) (*cellularDeviceInfo, bool) { + b.devMutex.RLock() + defer b.devMutex.RUnlock() + info, ok := b.cellularDevices[iface] + return info, ok +} + func (b *NetworkManagerBackend) setEthernetDeviceInfo(iface string, info *ethernetDeviceInfo) { b.devMutex.Lock() b.ethernetDevices[iface] = info @@ -324,6 +385,12 @@ func (b *NetworkManagerBackend) setWifiDeviceInfo(iface string, info *wifiDevice b.devMutex.Unlock() } +func (b *NetworkManagerBackend) setCellularDeviceInfo(iface string, info *cellularDeviceInfo) { + b.devMutex.Lock() + b.cellularDevices[iface] = info + b.devMutex.Unlock() +} + // removeEthernetDeviceByPath deletes the device and returns a snapshot of // what's left so the caller can pick a replacement without holding devMutex func (b *NetworkManagerBackend) removeEthernetDeviceByPath(path dbus.ObjectPath) (removed *ethernetDeviceInfo, remaining map[string]*ethernetDeviceInfo, found bool) { @@ -356,6 +423,21 @@ func (b *NetworkManagerBackend) removeWifiDeviceByPath(path dbus.ObjectPath) (re return nil, nil, false } +func (b *NetworkManagerBackend) removeCellularDeviceByPath(path dbus.ObjectPath) (removed *cellularDeviceInfo, remaining map[string]*cellularDeviceInfo, found bool) { + b.devMutex.Lock() + defer b.devMutex.Unlock() + for iface, info := range b.cellularDevices { + if info.device.GetPath() != path { + continue + } + delete(b.cellularDevices, iface) + remaining = make(map[string]*cellularDeviceInfo, len(b.cellularDevices)) + maps.Copy(remaining, b.cellularDevices) + return info, remaining, true + } + return nil, nil, false +} + func (b *NetworkManagerBackend) Close() { close(b.stopChan) b.StopMonitoring() @@ -375,6 +457,8 @@ func (b *NetworkManagerBackend) GetCurrentState() (*BackendState, error) { state.WiFiDevices = append([]WiFiDevice(nil), b.state.WiFiDevices...) state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...) state.EthernetDevices = append([]EthernetDevice(nil), b.state.EthernetDevices...) + state.CellularConnections = append([]WiredConnection(nil), b.state.CellularConnections...) + state.CellularDevices = append([]CellularDevice(nil), b.state.CellularDevices...) state.VPNProfiles = append([]VPNProfile(nil), b.state.VPNProfiles...) state.VPNActive = append([]VPNActive(nil), b.state.VPNActive...) @@ -592,7 +676,7 @@ func (b *NetworkManagerBackend) getActiveConnections() (map[string]bool, error) continue } - if connType != "802-3-ethernet" { + if connType != "802-3-ethernet" && !isCellularConnectionType(connType) { continue } diff --git a/core/internal/server/network/backend_networkmanager_cellular.go b/core/internal/server/network/backend_networkmanager_cellular.go new file mode 100644 index 000000000..af26b5df6 --- /dev/null +++ b/core/internal/server/network/backend_networkmanager_cellular.go @@ -0,0 +1,362 @@ +package network + +import ( + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/Wifx/gonetworkmanager/v2" + "github.com/godbus/dbus/v5" +) + +func isCellularConnectionType(connType string) bool { + return connType == "gsm" || connType == "cdma" +} + +func (b *NetworkManagerBackend) GetCellularConnections() ([]WiredConnection, error) { + return b.listCellularConnections() +} + +func (b *NetworkManagerBackend) GetCellularDevices() []CellularDevice { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + return append([]CellularDevice(nil), b.state.CellularDevices...) +} + +func (b *NetworkManagerBackend) GetCellularEnabled() (bool, error) { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + return nm.GetPropertyWwanEnabled() +} + +func (b *NetworkManagerBackend) SetCellularEnabled(enabled bool) error { + conn := b.dbusConn + closeConn := false + if conn == nil { + var err error + conn, err = dbus.ConnectSystemBus() + if err != nil { + return fmt.Errorf("failed to connect to system bus: %w", err) + } + closeConn = true + } + if closeConn { + defer conn.Close() + } + + obj := conn.Object(dbusNMInterface, dbus.ObjectPath(dbusNMPath)) + if err := obj.SetProperty(dbusNMInterface+".WwanEnabled", dbus.MakeVariant(enabled)); err != nil { + return fmt.Errorf("failed to set cellular enabled: %w", err) + } + + b.updateCellularRadioState() + b.refreshCellularState() + + return nil +} + +func (b *NetworkManagerBackend) ConnectCellular() error { + if b.cellularDevice == nil { + return fmt.Errorf("no cellular modem available") + } + + if err := b.ensureCellularEnabled(); err != nil { + return err + } + + nm := b.nmConn.(gonetworkmanager.NetworkManager) + dev := b.cellularDevice.(gonetworkmanager.Device) + + settingsMgr, err := gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + for _, conn := range connections { + connSettings, err := conn.GetSettings() + if err != nil { + continue + } + + if connMeta, ok := connSettings["connection"]; ok { + if connType, ok := connMeta["type"].(string); ok && isCellularConnectionType(connType) { + if _, err := nm.ActivateConnection(conn, dev, nil); err != nil { + return fmt.Errorf("failed to activate cellular connection: %w", err) + } + + b.refreshCellularState() + return nil + } + } + } + + settings := map[string]map[string]any{ + "connection": { + "id": "Mobile broadband", + "type": "gsm", + }, + "gsm": { + "auto-config": true, + }, + "ipv4": { + "method": "auto", + }, + "ipv6": { + "method": "auto", + }, + } + + if _, err := nm.AddAndActivateConnection(settings, dev); err != nil { + // Older NetworkManager/ModemManager stacks reject gsm.auto-config + delete(settings["gsm"], "auto-config") + if _, retryErr := nm.AddAndActivateConnection(settings, dev); retryErr != nil { + return fmt.Errorf("failed to create and activate cellular connection: %w", retryErr) + } + } + + b.refreshCellularState() + return nil +} + +func (b *NetworkManagerBackend) refreshCellularState() { + b.updateAllCellularDevices() + b.updateCellularState() + b.listCellularConnections() + b.updatePrimaryConnection() + if b.onStateChange != nil { + b.onStateChange() + } +} + +func (b *NetworkManagerBackend) DisconnectCellular() error { + if b.cellularDevice == nil { + return fmt.Errorf("no cellular modem available") + } + + dev := b.cellularDevice.(gonetworkmanager.Device) + if err := dev.Disconnect(); err != nil { + return fmt.Errorf("failed to disconnect cellular modem: %w", err) + } + + b.refreshCellularState() + + return nil +} + +func (b *NetworkManagerBackend) DisconnectCellularDevice(device string) error { + info, ok := b.cellularDeviceByIface(device) + if !ok { + return fmt.Errorf("cellular modem %s not found", device) + } + + if err := info.device.Disconnect(); err != nil { + return fmt.Errorf("failed to disconnect %s: %w", device, err) + } + + b.refreshCellularState() + + return nil +} + +func (b *NetworkManagerBackend) ActivateCellularConnection(uuid string) error { + if b.cellularDevice == nil { + return fmt.Errorf("no cellular modem available") + } + + if err := b.ensureCellularEnabled(); err != nil { + return err + } + + nm := b.nmConn.(gonetworkmanager.NetworkManager) + dev := b.cellularDevice.(gonetworkmanager.Device) + + settingsMgr, err := gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + var targetConnection gonetworkmanager.Connection + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + + connectionSettings := settings["connection"] + connType, _ := connectionSettings["type"].(string) + connUUID, _ := connectionSettings["uuid"].(string) + if connUUID == uuid && isCellularConnectionType(connType) { + targetConnection = conn + break + } + } + + if targetConnection == nil { + return fmt.Errorf("cellular connection with UUID %s not found", uuid) + } + + if _, err := nm.ActivateConnection(targetConnection, dev, nil); err != nil { + return fmt.Errorf("failed to activate cellular connection: %w", err) + } + + b.refreshCellularState() + + return nil +} + +func (b *NetworkManagerBackend) listCellularConnections() ([]WiredConnection, error) { + if b.cellularDevice == nil { + b.stateMutex.Lock() + b.state.CellularConnectionUuid = "" + b.state.CellularConnections = []WiredConnection{} + b.stateMutex.Unlock() + return nil, nil + } + + s := b.settings + if s == nil { + settings, err := gonetworkmanager.NewSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + b.settings = settings + s = settings + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return nil, fmt.Errorf("failed to get connections: %w", err) + } + + activeUUIDs, err := b.getActiveConnections() + if err != nil { + return nil, fmt.Errorf("failed to get active cellular connections: %w", err) + } + + configs := make([]WiredConnection, 0) + currentUUID := "" + for _, connection := range connections { + path := connection.GetPath() + settings, err := connection.GetSettings() + if err != nil { + log.Errorf("unable to get settings for %s: %v", path, err) + continue + } + + connectionSettings := settings["connection"] + connType, _ := connectionSettings["type"].(string) + connID, _ := connectionSettings["id"].(string) + connUUID, _ := connectionSettings["uuid"].(string) + + if isCellularConnectionType(connType) { + configs = append(configs, WiredConnection{ + Path: path, + ID: connID, + UUID: connUUID, + Type: connType, + IsActive: activeUUIDs[connUUID], + }) + if activeUUIDs[connUUID] { + currentUUID = connUUID + } + } + } + + b.stateMutex.Lock() + b.state.CellularConnectionUuid = currentUUID + b.state.CellularConnections = configs + b.stateMutex.Unlock() + + return configs, nil +} + +func (b *NetworkManagerBackend) updateAllCellularDevices() { + cellularDevices := b.cellularDevicesSnapshot() + devices := make([]CellularDevice, 0, len(cellularDevices)) + + for name, info := range cellularDevices { + state, _ := info.device.GetPropertyState() + connected := state == gonetworkmanager.NmDeviceStateActivated + driver, _ := info.device.GetPropertyDriver() + + var ip string + if connected { + ip = b.getDeviceIP(info.device) + } + + stateStr := "disconnected" + switch state { + case gonetworkmanager.NmDeviceStateActivated: + stateStr = "activated" + case gonetworkmanager.NmDeviceStatePrepare: + stateStr = "preparing" + case gonetworkmanager.NmDeviceStateConfig: + stateStr = "configuring" + case gonetworkmanager.NmDeviceStateNeedAuth: + stateStr = "need-auth" + case gonetworkmanager.NmDeviceStateIpConfig: + stateStr = "ip-config" + case gonetworkmanager.NmDeviceStateIpCheck: + stateStr = "ip-check" + case gonetworkmanager.NmDeviceStateSecondaries: + stateStr = "secondaries" + case gonetworkmanager.NmDeviceStateDeactivating: + stateStr = "deactivating" + case gonetworkmanager.NmDeviceStateFailed: + stateStr = "failed" + case gonetworkmanager.NmDeviceStateUnavailable: + stateStr = "unavailable" + case gonetworkmanager.NmDeviceStateUnmanaged: + stateStr = "unmanaged" + } + + devices = append(devices, CellularDevice{ + Name: name, + HwAddress: info.hwAddress, + State: stateStr, + Connected: connected, + IP: ip, + Driver: driver, + Description: info.description, + }) + } + + b.stateMutex.Lock() + b.state.CellularDevices = devices + b.stateMutex.Unlock() +} + +func (b *NetworkManagerBackend) ensureCellularEnabled() error { + enabled, err := b.GetCellularEnabled() + if err != nil { + return fmt.Errorf("failed to get cellular radio state: %w", err) + } + if enabled { + return nil + } + return b.SetCellularEnabled(true) +} + +func (b *NetworkManagerBackend) updateCellularRadioState() { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + enabled, enabledErr := nm.GetPropertyWwanEnabled() + hardwareEnabled, hardwareErr := nm.GetPropertyWwanHardwareEnabled() + + b.stateMutex.Lock() + if enabledErr == nil { + b.state.CellularEnabled = enabled + } + if hardwareErr == nil { + b.state.CellularHardwareEnabled = hardwareEnabled + } + b.stateMutex.Unlock() +} diff --git a/core/internal/server/network/backend_networkmanager_signals.go b/core/internal/server/network/backend_networkmanager_signals.go index d5b951b0f..d25119d33 100644 --- a/core/internal/server/network/backend_networkmanager_signals.go +++ b/core/internal/server/network/backend_networkmanager_signals.go @@ -159,6 +159,18 @@ func (b *NetworkManagerBackend) startSignalPump() error { return err } + for _, info := range b.cellularDevicesSnapshot() { + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ); err != nil { + conn.RemoveSignal(signals) + conn.Close() + return err + } + } + b.sigWG.Add(1) go func() { defer b.sigWG.Done() @@ -243,6 +255,14 @@ func (b *NetworkManagerBackend) stopSignalPump() { ) } + for _, info := range b.cellularDevicesSnapshot() { + b.dbusConn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + } + if b.signals != nil { b.dbusConn.RemoveSignal(b.signals) close(b.signals) @@ -262,6 +282,7 @@ func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) { b.updateWiFiNetworks() } b.updateHotspotState() + b.listCellularConnections() if b.onStateChange != nil { b.onStateChange() } @@ -347,6 +368,11 @@ func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]db b.stateMutex.Unlock() needsUpdate = true } + case "WwanEnabled", "WwanHardwareEnabled": + b.updateCellularRadioState() + b.updateAllCellularDevices() + b.updateCellularState() + needsUpdate = true default: continue } @@ -357,6 +383,7 @@ func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]db if _, exists := changes["State"]; exists { b.updateEthernetState() b.updateWiFiState() + b.updateCellularState() } if _, exists := changes["ActiveConnections"]; exists { b.updateVPNConnectionState() @@ -431,11 +458,14 @@ func (b *NetworkManagerBackend) handleDeviceChange(devicePath dbus.ObjectPath, c b.updateAllEthernetDevices() b.updateEthernetState() + b.updateAllCellularDevices() + b.updateCellularState() b.updateAllWiFiDevices() b.updateWiFiState() b.updateHotspotState() if stateChanged { b.listEthernetConnections() + b.listCellularConnections() b.updatePrimaryConnection() } if b.onStateChange != nil { @@ -530,7 +560,7 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) { return } - if devType != gonetworkmanager.NmDeviceTypeEthernet && devType != gonetworkmanager.NmDeviceTypeWifi { + if devType != gonetworkmanager.NmDeviceTypeEthernet && devType != gonetworkmanager.NmDeviceTypeWifi && devType != gonetworkmanager.NmDeviceTypeModem { return } @@ -576,6 +606,34 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) { b.listEthernetConnections() b.updatePrimaryConnection() + case gonetworkmanager.NmDeviceTypeModem: + g, _ := gonetworkmanager.NewDeviceGeneric(devicePath) + hwAddr := "" + description := "Mobile broadband" + if g != nil { + hwAddr, _ = g.GetPropertyHwAddress() + if desc, err := g.GetPropertyTypeDescription(); err == nil && desc != "" { + description = desc + } + } + + b.setCellularDeviceInfo(iface, &cellularDeviceInfo{ + device: dev, + generic: g, + name: iface, + hwAddress: hwAddr, + description: description, + }) + + if b.cellularDevice == nil { + b.cellularDevice = dev + } + + b.updateAllCellularDevices() + b.updateCellularState() + b.listCellularConnections() + b.updatePrimaryConnection() + case gonetworkmanager.NmDeviceTypeWifi: w, err := gonetworkmanager.NewDeviceWireless(devicePath) if err != nil { @@ -660,4 +718,20 @@ func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath) } return } + + if _, remaining, found := b.removeCellularDeviceByPath(devicePath); found { + if b.cellularDevice != nil { + dev := b.cellularDevice.(gonetworkmanager.Device) + if dev.GetPath() == devicePath { + b.cellularDevice = nil + for _, r := range remaining { + b.cellularDevice = r.device + break + } + } + } + + b.refreshCellularState() + return + } } diff --git a/core/internal/server/network/backend_networkmanager_state.go b/core/internal/server/network/backend_networkmanager_state.go index 216c18903..970aea063 100644 --- a/core/internal/server/network/backend_networkmanager_state.go +++ b/core/internal/server/network/backend_networkmanager_state.go @@ -61,6 +61,8 @@ func (b *NetworkManagerBackend) updatePrimaryConnection() error { b.state.NetworkStatus = StatusEthernet case "802-11-wireless": b.state.NetworkStatus = StatusWiFi + case "gsm", "cdma": + b.state.NetworkStatus = StatusCellular case "vpn", "wireguard": b.state.NetworkStatus = StatusVPN default: @@ -105,6 +107,40 @@ func (b *NetworkManagerBackend) updateEthernetState() error { return nil } +func (b *NetworkManagerBackend) updateCellularState() error { + var connectedDevice string + var connectedIP string + var anyConnected bool + + for name, info := range b.cellularDevicesSnapshot() { + state, err := info.device.GetPropertyState() + if err != nil { + continue + } + + if state == gonetworkmanager.NmDeviceStateActivated { + anyConnected = true + connectedDevice = name + connectedIP = b.getDeviceIP(info.device) + break + } + } + + if !anyConnected && b.cellularDevice != nil { + dev := b.cellularDevice.(gonetworkmanager.Device) + iface, _ := dev.GetPropertyInterface() + connectedDevice = iface + } + + b.stateMutex.Lock() + b.state.CellularDevice = connectedDevice + b.state.CellularConnected = anyConnected + b.state.CellularIP = connectedIP + b.stateMutex.Unlock() + + return nil +} + func (b *NetworkManagerBackend) getDeviceStateReason(dev gonetworkmanager.Device) uint32 { path := dev.GetPath() obj := b.dbusConn.Object("org.freedesktop.NetworkManager", path) diff --git a/core/internal/server/network/backend_wpa_unimplemented.go b/core/internal/server/network/backend_wpa_unimplemented.go index 9898aa408..785f7b588 100644 --- a/core/internal/server/network/backend_wpa_unimplemented.go +++ b/core/internal/server/network/backend_wpa_unimplemented.go @@ -18,6 +18,38 @@ func (b *WpaSupplicantBackend) ActivateWiredConnection(uuid string) error { return fmt.Errorf("wired control not supported by wpa_supplicant backend") } +func (b *WpaSupplicantBackend) GetCellularDevices() []CellularDevice { + return []CellularDevice{} +} + +func (b *WpaSupplicantBackend) GetCellularEnabled() (bool, error) { + return false, fmt.Errorf("cellular radio control not supported by wpa_supplicant backend") +} + +func (b *WpaSupplicantBackend) SetCellularEnabled(enabled bool) error { + return fmt.Errorf("cellular radio control not supported by wpa_supplicant backend") +} + +func (b *WpaSupplicantBackend) GetCellularConnections() ([]WiredConnection, error) { + return nil, fmt.Errorf("cellular connections not supported by wpa_supplicant backend") +} + +func (b *WpaSupplicantBackend) ConnectCellular() error { + return fmt.Errorf("cellular connections not supported by wpa_supplicant backend") +} + +func (b *WpaSupplicantBackend) DisconnectCellular() error { + return fmt.Errorf("cellular connections not supported by wpa_supplicant backend") +} + +func (b *WpaSupplicantBackend) DisconnectCellularDevice(device string) error { + return fmt.Errorf("cellular connections not supported by wpa_supplicant backend") +} + +func (b *WpaSupplicantBackend) ActivateCellularConnection(uuid string) error { + return fmt.Errorf("cellular connections not supported by wpa_supplicant backend") +} + func (b *WpaSupplicantBackend) ListVPNProfiles() ([]VPNProfile, error) { return nil, fmt.Errorf("VPN not supported by wpa_supplicant backend") } diff --git a/core/internal/server/network/handlers.go b/core/internal/server/network/handlers.go index e5d97b1e0..dbf510f62 100644 --- a/core/internal/server/network/handlers.go +++ b/core/internal/server/network/handlers.go @@ -35,6 +35,18 @@ func HandleRequest(conn *models.Conn, req models.Request, manager *Manager) { handleConnectEthernet(conn, req, manager) case "network.ethernet.disconnect": handleDisconnectEthernet(conn, req, manager) + case "network.cellular.connect.config": + handleConnectCellularSpecificConfig(conn, req, manager) + case "network.cellular.connect": + handleConnectCellular(conn, req, manager) + case "network.cellular.disconnect": + handleDisconnectCellular(conn, req, manager) + case "network.cellular.toggle": + handleToggleCellular(conn, req, manager) + case "network.cellular.enable": + handleEnableCellular(conn, req, manager) + case "network.cellular.disable": + handleDisableCellular(conn, req, manager) case "network.preference.set": handleSetPreference(conn, req, manager) case "network.info": @@ -304,6 +316,68 @@ func handleDisconnectEthernet(conn *models.Conn, req models.Request, manager *Ma models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"}) } +func handleConnectCellularSpecificConfig(conn *models.Conn, req models.Request, manager *Manager) { + uuid, err := params.String(req.Params, "uuid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + if err := manager.activateCellularConnection(uuid); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connecting"}) +} + +func handleConnectCellular(conn *models.Conn, req models.Request, manager *Manager) { + if err := manager.ConnectCellular(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connecting"}) +} + +func handleDisconnectCellular(conn *models.Conn, req models.Request, manager *Manager) { + device := params.StringOpt(req.Params, "device", "") + var err error + if device != "" { + err = manager.DisconnectCellularDevice(device) + } else { + err = manager.DisconnectCellular() + } + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"}) +} + +func handleToggleCellular(conn *models.Conn, req models.Request, manager *Manager) { + if err := manager.ToggleCellular(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + state := manager.GetState() + models.Respond(conn, req.ID, map[string]bool{"enabled": state.CellularEnabled}) +} + +func handleEnableCellular(conn *models.Conn, req models.Request, manager *Manager) { + if err := manager.EnableCellular(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, map[string]bool{"enabled": true}) +} + +func handleDisableCellular(conn *models.Conn, req models.Request, manager *Manager) { + if err := manager.DisableCellular(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, map[string]bool{"enabled": false}) +} + func handleSetPreference(conn *models.Conn, req models.Request, manager *Manager) { preference, err := params.String(req.Params, "preference") if err != nil { diff --git a/core/internal/server/network/manager.go b/core/internal/server/network/manager.go index 8c586c07a..0e821470c 100644 --- a/core/internal/server/network/manager.go +++ b/core/internal/server/network/manager.go @@ -85,6 +85,7 @@ func NewManager() (*Manager, error) { Preference: PreferenceAuto, WiFiNetworks: []WiFiNetwork{}, SavedWiFiNetworks: []WiFiNetwork{}, + CellularDevices: []CellularDevice{}, }, stateMutex: sync.RWMutex{}, @@ -136,6 +137,14 @@ func (m *Manager) syncStateFromBackend() error { m.state.EthernetConnected = backendState.EthernetConnected m.state.EthernetConnectionUuid = backendState.EthernetConnectionUuid m.state.EthernetDevices = backendState.EthernetDevices + m.state.CellularIP = backendState.CellularIP + m.state.CellularDevice = backendState.CellularDevice + m.state.CellularConnected = backendState.CellularConnected + m.state.CellularEnabled = backendState.CellularEnabled + m.state.CellularHardwareEnabled = backendState.CellularHardwareEnabled + m.state.CellularConnectionUuid = backendState.CellularConnectionUuid + m.state.CellularDevices = backendState.CellularDevices + m.state.CellularConnections = backendState.CellularConnections m.state.WiFiIP = backendState.WiFiIP m.state.WiFiDevice = backendState.WiFiDevice m.state.WiFiConnected = backendState.WiFiConnected @@ -209,6 +218,8 @@ func (m *Manager) snapshotState() NetworkState { s.WiFiDevices = append([]WiFiDevice(nil), m.state.WiFiDevices...) s.WiredConnections = append([]WiredConnection(nil), m.state.WiredConnections...) s.EthernetDevices = append([]EthernetDevice(nil), m.state.EthernetDevices...) + s.CellularConnections = append([]WiredConnection(nil), m.state.CellularConnections...) + s.CellularDevices = append([]CellularDevice(nil), m.state.CellularDevices...) s.VPNProfiles = append([]VPNProfile(nil), m.state.VPNProfiles...) s.VPNActive = append([]VPNActive(nil), m.state.VPNActive...) return s @@ -227,6 +238,18 @@ func stateChangedMeaningfully(old, new *NetworkState) bool { if old.EthernetIP != new.EthernetIP { return true } + if old.CellularConnected != new.CellularConnected { + return true + } + if old.CellularEnabled != new.CellularEnabled { + return true + } + if old.CellularHardwareEnabled != new.CellularHardwareEnabled { + return true + } + if old.CellularIP != new.CellularIP { + return true + } if old.WiFiConnected != new.WiFiConnected { return true } @@ -302,6 +325,12 @@ func stateChangedMeaningfully(old, new *NetworkState) bool { if len(old.EthernetDevices) != len(new.EthernetDevices) { return true } + if len(old.CellularDevices) != len(new.CellularDevices) { + return true + } + if len(old.CellularConnections) != len(new.CellularConnections) { + return true + } for i := range old.WiFiNetworks { oldNet := &old.WiFiNetworks[i] @@ -387,6 +416,34 @@ func stateChangedMeaningfully(old, new *NetworkState) bool { } } + for i := range old.CellularConnections { + oldNet := &old.CellularConnections[i] + newNet := &new.CellularConnections[i] + if oldNet.ID != newNet.ID { + return true + } + if oldNet.IsActive != newNet.IsActive { + return true + } + } + + for i := range old.CellularDevices { + oldDev := &old.CellularDevices[i] + newDev := &new.CellularDevices[i] + if oldDev.Name != newDev.Name { + return true + } + if oldDev.Connected != newDev.Connected { + return true + } + if oldDev.State != newDev.State { + return true + } + if oldDev.IP != newDev.IP { + return true + } + } + // Check VPN profiles count if len(old.VPNProfiles) != len(new.VPNProfiles) { return true @@ -634,6 +691,38 @@ func (m *Manager) DisableWiFi() error { return nil } +func (m *Manager) ToggleCellular() error { + enabled, err := m.backend.GetCellularEnabled() + if err != nil { + return fmt.Errorf("failed to get cellular state: %w", err) + } + + err = m.backend.SetCellularEnabled(!enabled) + if err != nil { + return fmt.Errorf("failed to toggle cellular: %w", err) + } + + return nil +} + +func (m *Manager) EnableCellular() error { + err := m.backend.SetCellularEnabled(true) + if err != nil { + return fmt.Errorf("failed to enable cellular: %w", err) + } + + return nil +} + +func (m *Manager) DisableCellular() error { + err := m.backend.SetCellularEnabled(false) + if err != nil { + return fmt.Errorf("failed to disable cellular: %w", err) + } + + return nil +} + func (m *Manager) ConnectWiFi(req ConnectionRequest) error { return m.backend.ConnectWiFi(req) } @@ -741,10 +830,34 @@ func (m *Manager) GetEthernetDevices() []EthernetDevice { return devices } +func (m *Manager) ConnectCellular() error { + return m.backend.ConnectCellular() +} + +func (m *Manager) DisconnectCellular() error { + return m.backend.DisconnectCellular() +} + +func (m *Manager) DisconnectCellularDevice(device string) error { + return m.backend.DisconnectCellularDevice(device) +} + +func (m *Manager) GetCellularDevices() []CellularDevice { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + devices := make([]CellularDevice, len(m.state.CellularDevices)) + copy(devices, m.state.CellularDevices) + return devices +} + func (m *Manager) activateConnection(uuid string) error { return m.backend.ActivateWiredConnection(uuid) } +func (m *Manager) activateCellularConnection(uuid string) error { + return m.backend.ActivateCellularConnection(uuid) +} + func (m *Manager) ListVPNProfiles() ([]VPNProfile, error) { return m.backend.ListVPNProfiles() } diff --git a/core/internal/server/network/priority.go b/core/internal/server/network/priority.go index 35d2e88ae..612131f61 100644 --- a/core/internal/server/network/priority.go +++ b/core/internal/server/network/priority.go @@ -21,11 +21,14 @@ const ( func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error { switch pref { - case PreferenceWiFi, PreferenceEthernet, PreferenceAuto: + case PreferenceWiFi, PreferenceEthernet, PreferenceCellular, PreferenceAuto: default: return fmt.Errorf("invalid preference: %s", pref) } + m.priorityMutex.Lock() + defer m.priorityMutex.Unlock() + m.stateMutex.Lock() m.state.Preference = pref m.stateMutex.Unlock() @@ -40,6 +43,8 @@ func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error { return m.prioritizeWiFi() case PreferenceEthernet: return m.prioritizeEthernet() + case PreferenceCellular: + return m.prioritizeCellular() case PreferenceAuto: return m.balancePriorities() } @@ -56,6 +61,12 @@ func (m *Manager) prioritizeWiFi() error { log.Warnf("Failed to set Ethernet priority: %v", err) } + for _, connType := range []string{"gsm", "cdma"} { + if err := m.setConnectionPriority(connType, priorityLow, metricNonPreferred); err != nil { + log.Warnf("Failed to set cellular priority for %s: %v", connType, err) + } + } + m.reapplyActiveConnections() m.notifySubscribers() return nil @@ -70,6 +81,32 @@ func (m *Manager) prioritizeEthernet() error { log.Warnf("Failed to set WiFi priority: %v", err) } + for _, connType := range []string{"gsm", "cdma"} { + if err := m.setConnectionPriority(connType, priorityLow, metricNonPreferred); err != nil { + log.Warnf("Failed to set cellular priority for %s: %v", connType, err) + } + } + + m.reapplyActiveConnections() + m.notifySubscribers() + return nil +} + +func (m *Manager) prioritizeCellular() error { + for _, connType := range []string{"gsm", "cdma"} { + if err := m.setConnectionPriority(connType, priorityHigh, metricPreferred); err != nil { + log.Warnf("Failed to set cellular priority for %s: %v", connType, err) + } + } + + if err := m.setConnectionPriority("802-3-ethernet", priorityLow, metricNonPreferred); err != nil { + log.Warnf("Failed to set Ethernet priority: %v", err) + } + + if err := m.setConnectionPriority("802-11-wireless", priorityLow, metricNonPreferred); err != nil { + log.Warnf("Failed to set WiFi priority: %v", err) + } + m.reapplyActiveConnections() m.notifySubscribers() return nil @@ -84,6 +121,12 @@ func (m *Manager) balancePriorities() error { log.Warnf("Failed to reset WiFi priority: %v", err) } + for _, connType := range []string{"gsm", "cdma"} { + if err := m.setConnectionPriority(connType, priorityDefault, metricDefault); err != nil { + log.Warnf("Failed to reset cellular priority for %s: %v", connType, err) + } + } + m.reapplyActiveConnections() m.notifySubscribers() return nil @@ -93,6 +136,7 @@ func (m *Manager) reapplyActiveConnections() { m.stateMutex.RLock() ethDev := m.state.EthernetDevice wifiDev := m.state.WiFiDevice + cellularDev := m.state.CellularDevice m.stateMutex.RUnlock() if ethDev != "" { @@ -101,6 +145,9 @@ func (m *Manager) reapplyActiveConnections() { if wifiDev != "" { exec.Command("nmcli", "dev", "reapply", wifiDev).Run() } + if cellularDev != "" { + exec.Command("nmcli", "dev", "reapply", cellularDev).Run() + } } func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int32, routeMetric int64) error { @@ -161,10 +208,29 @@ func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int continue } - if err := exec.Command("nmcli", "con", "mod", connName, + connUUID := "" + if uuidVariant, ok := connSection["uuid"]; ok { + connUUID, _ = uuidVariant.Value().(string) + } + + if priorityMatches(connSection["autoconnect-priority"], int64(autoconnectPriority)) && + routeMetricMatches(settings["ipv4"], routeMetric) && + routeMetricMatches(settings["ipv6"], routeMetric) { + continue + } + + args := []string{"con", "mod"} + if connUUID != "" { + args = append(args, "uuid", connUUID) + } else { + args = append(args, connName) + } + args = append(args, "connection.autoconnect-priority", fmt.Sprintf("%d", autoconnectPriority), "ipv4.route-metric", fmt.Sprintf("%d", routeMetric), - "ipv6.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil { + "ipv6.route-metric", fmt.Sprintf("%d", routeMetric)) + + if err := exec.Command("nmcli", args...).Run(); err != nil { log.Warnf("Failed to set priority for %s: %v", connName, err) continue } @@ -175,6 +241,41 @@ func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int return nil } +func priorityMatches(variant dbus.Variant, expected int64) bool { + value, ok := variantInt64(variant) + return ok && value == expected +} + +func routeMetricMatches(section map[string]dbus.Variant, expected int64) bool { + if section == nil { + return false + } + value, ok := variantInt64(section["route-metric"]) + return ok && value == expected +} + +func variantInt64(variant dbus.Variant) (int64, bool) { + switch value := variant.Value().(type) { + case int: + return int64(value), true + case int32: + return int64(value), true + case int64: + return value, true + case uint: + return int64(value), true + case uint32: + return int64(value), true + case uint64: + if value > uint64(^uint64(0)>>1) { + return 0, false + } + return int64(value), true + default: + return 0, false + } +} + func (m *Manager) GetConnectionPreference() ConnectionPreference { m.stateMutex.RLock() defer m.stateMutex.RUnlock() diff --git a/core/internal/server/network/priority_test.go b/core/internal/server/network/priority_test.go index c0c65e307..111da19f6 100644 --- a/core/internal/server/network/priority_test.go +++ b/core/internal/server/network/priority_test.go @@ -28,6 +28,7 @@ func TestManager_GetConnectionPreference(t *testing.T) { {"auto", PreferenceAuto}, {"wifi", PreferenceWiFi}, {"ethernet", PreferenceEthernet}, + {"cellular", PreferenceCellular}, } for _, tt := range tests { diff --git a/core/internal/server/network/types.go b/core/internal/server/network/types.go index 3e28609de..6aecbbffe 100644 --- a/core/internal/server/network/types.go +++ b/core/internal/server/network/types.go @@ -13,6 +13,7 @@ const ( StatusDisconnected NetworkStatus = "disconnected" StatusEthernet NetworkStatus = "ethernet" StatusWiFi NetworkStatus = "wifi" + StatusCellular NetworkStatus = "cellular" StatusVPN NetworkStatus = "vpn" ) @@ -22,6 +23,7 @@ const ( PreferenceAuto ConnectionPreference = "auto" PreferenceWiFi ConnectionPreference = "wifi" PreferenceEthernet ConnectionPreference = "ethernet" + PreferenceCellular ConnectionPreference = "cellular" ) type WiFiNetwork struct { @@ -65,6 +67,16 @@ type EthernetDevice struct { Driver string `json:"driver,omitempty"` } +type CellularDevice struct { + Name string `json:"name"` + HwAddress string `json:"hwAddress"` + State string `json:"state"` + Connected bool `json:"connected"` + IP string `json:"ip,omitempty"` + Driver string `json:"driver,omitempty"` + Description string `json:"description,omitempty"` +} + type VPNProfile struct { Name string `json:"name"` UUID string `json:"uuid"` @@ -97,43 +109,51 @@ type VPNState struct { } type NetworkState struct { - Backend string `json:"backend"` - NetworkStatus NetworkStatus `json:"networkStatus"` - Preference ConnectionPreference `json:"preference"` - EthernetIP string `json:"ethernetIP"` - EthernetDevice string `json:"ethernetDevice"` - EthernetConnected bool `json:"ethernetConnected"` - EthernetConnectionUuid string `json:"ethernetConnectionUuid"` - EthernetDevices []EthernetDevice `json:"ethernetDevices"` - WiFiIP string `json:"wifiIP"` - WiFiDevice string `json:"wifiDevice"` - WiFiConnected bool `json:"wifiConnected"` - WiFiEnabled bool `json:"wifiEnabled"` - WiFiSSID string `json:"wifiSSID"` - WiFiBSSID string `json:"wifiBSSID"` - WiFiSignal uint8 `json:"wifiSignal"` - WiFiNetworks []WiFiNetwork `json:"wifiNetworks"` - SavedWiFiNetworks []WiFiNetwork `json:"savedWifiNetworks"` - WiFiDevices []WiFiDevice `json:"wifiDevices"` - HotspotSupported bool `json:"hotspotSupported"` - HotspotAvailable bool `json:"hotspotAvailable"` - HotspotConfigured bool `json:"hotspotConfigured"` - HotspotEnabled bool `json:"hotspotEnabled"` - HotspotActivating bool `json:"hotspotActivating"` - HotspotSecured bool `json:"hotspotSecured"` - HotspotSSID string `json:"hotspotSSID"` - HotspotDevice string `json:"hotspotDevice"` - HotspotBand string `json:"hotspotBand"` - HotspotLastError string `json:"hotspotLastError"` - WiredConnections []WiredConnection `json:"wiredConnections"` - VPNProfiles []VPNProfile `json:"vpnProfiles"` - VPNActive []VPNActive `json:"vpnActive"` - IsConnecting bool `json:"isConnecting"` - ConnectingSSID string `json:"connectingSSID"` - ConnectingDevice string `json:"connectingDevice,omitempty"` - LastError string `json:"lastError"` - VPNError string `json:"vpnError"` - VPNErrorUuid string `json:"vpnErrorUuid"` + Backend string `json:"backend"` + NetworkStatus NetworkStatus `json:"networkStatus"` + Preference ConnectionPreference `json:"preference"` + EthernetIP string `json:"ethernetIP"` + EthernetDevice string `json:"ethernetDevice"` + EthernetConnected bool `json:"ethernetConnected"` + EthernetConnectionUuid string `json:"ethernetConnectionUuid"` + EthernetDevices []EthernetDevice `json:"ethernetDevices"` + CellularIP string `json:"cellularIP"` + CellularDevice string `json:"cellularDevice"` + CellularConnected bool `json:"cellularConnected"` + CellularEnabled bool `json:"cellularEnabled"` + CellularHardwareEnabled bool `json:"cellularHardwareEnabled"` + CellularConnectionUuid string `json:"cellularConnectionUuid"` + CellularDevices []CellularDevice `json:"cellularDevices"` + CellularConnections []WiredConnection `json:"cellularConnections"` + WiFiIP string `json:"wifiIP"` + WiFiDevice string `json:"wifiDevice"` + WiFiConnected bool `json:"wifiConnected"` + WiFiEnabled bool `json:"wifiEnabled"` + WiFiSSID string `json:"wifiSSID"` + WiFiBSSID string `json:"wifiBSSID"` + WiFiSignal uint8 `json:"wifiSignal"` + WiFiNetworks []WiFiNetwork `json:"wifiNetworks"` + SavedWiFiNetworks []WiFiNetwork `json:"savedWifiNetworks"` + WiFiDevices []WiFiDevice `json:"wifiDevices"` + HotspotSupported bool `json:"hotspotSupported"` + HotspotAvailable bool `json:"hotspotAvailable"` + HotspotConfigured bool `json:"hotspotConfigured"` + HotspotEnabled bool `json:"hotspotEnabled"` + HotspotActivating bool `json:"hotspotActivating"` + HotspotSecured bool `json:"hotspotSecured"` + HotspotSSID string `json:"hotspotSSID"` + HotspotDevice string `json:"hotspotDevice"` + HotspotBand string `json:"hotspotBand"` + HotspotLastError string `json:"hotspotLastError"` + WiredConnections []WiredConnection `json:"wiredConnections"` + VPNProfiles []VPNProfile `json:"vpnProfiles"` + VPNActive []VPNActive `json:"vpnActive"` + IsConnecting bool `json:"isConnecting"` + ConnectingSSID string `json:"connectingSSID"` + ConnectingDevice string `json:"connectingDevice,omitempty"` + LastError string `json:"lastError"` + VPNError string `json:"vpnError"` + VPNErrorUuid string `json:"vpnErrorUuid"` } type ConnectionRequest struct { @@ -179,6 +199,7 @@ type Manager struct { subscribers syncmap.Map[string, chan NetworkState] stopChan chan struct{} dirty chan struct{} + priorityMutex sync.Mutex notifierWg sync.WaitGroup lastNotifiedState *NetworkState credentialSubscribers syncmap.Map[string, chan CredentialPrompt] diff --git a/core/internal/server/network/types_test.go b/core/internal/server/network/types_test.go index 1c3e0bffd..27ce72702 100644 --- a/core/internal/server/network/types_test.go +++ b/core/internal/server/network/types_test.go @@ -12,12 +12,14 @@ func TestNetworkStatus_Constants(t *testing.T) { assert.Equal(t, NetworkStatus("disconnected"), StatusDisconnected) assert.Equal(t, NetworkStatus("ethernet"), StatusEthernet) assert.Equal(t, NetworkStatus("wifi"), StatusWiFi) + assert.Equal(t, NetworkStatus("cellular"), StatusCellular) } func TestConnectionPreference_Constants(t *testing.T) { assert.Equal(t, ConnectionPreference("auto"), PreferenceAuto) assert.Equal(t, ConnectionPreference("wifi"), PreferenceWiFi) assert.Equal(t, ConnectionPreference("ethernet"), PreferenceEthernet) + assert.Equal(t, ConnectionPreference("cellular"), PreferenceCellular) } func TestEventType_Constants(t *testing.T) { diff --git a/core/internal/server/server.go b/core/internal/server/server.go index 913d8fee2..733882374 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -36,7 +36,7 @@ import ( "github.com/AvengeMedia/dankgo/syncmap" ) -const APIVersion = 30 +const APIVersion = 31 var CLIVersion = "dev" diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index 8e0ade880..ff64aae1e 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -1010,7 +1010,7 @@ Item { function tabs(): string { if (!PopoutService.settingsModal) - return "wallpaper\ntheme\ntypography\ntime_weather\nsounds\ndankbar\ndankbar_settings\ndankbar_appearance\ndankbar_widgets\nframe\nworkspaces\ncompositor\nmedia_player\nnotifications\nosd\nrunning_apps\nupdater\ndock\nlauncher\nkeybinds\ndisplays\nnetwork\nnetwork_status\nnetwork_ethernet\nnetwork_wifi\nnetwork_vpn\nprinters\nlock_screen\npower_sleep\nplugins\nabout"; + return "wallpaper\ntheme\ntypography\ntime_weather\nsounds\ndankbar\ndankbar_settings\ndankbar_appearance\ndankbar_widgets\nframe\nworkspaces\ncompositor\nmedia_player\nnotifications\nosd\nrunning_apps\nupdater\ndock\nlauncher\nkeybinds\ndisplays\nnetwork\nnetwork_status\nnetwork_ethernet\nnetwork_wifi\nnetwork_cellular\nnetwork_vpn\nprinters\nlock_screen\npower_sleep\nplugins\nabout"; var modal = PopoutService.settingsModal; var ids = []; var structure = modal.sidebar?.categoryStructure ?? []; diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index af2e8f6e5..4d0976fe2 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -286,6 +286,21 @@ FocusScope { } } + Loader { + id: networkCellularLoader + anchors.fill: parent + active: root.currentIndex === 46 + visible: active + focus: active + + sourceComponent: NetworkCellularTab {} + + onActiveChanged: { + if (active && item) + Qt.callLater(() => item.forceActiveFocus()); + } + } + Loader { id: printerLoader anchors.fill: parent diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index 02607520e..384a0ea4f 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -264,6 +264,12 @@ Rectangle { "icon": "wifi", "tabIndex": 40 }, + { + "id": "network_cellular", + "text": I18n.tr("Cellular"), + "icon": "network_cell", + "tabIndex": 46 + }, { "id": "network_vpn", "text": I18n.tr("VPN"), diff --git a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml index e3e8d60d3..20b666de0 100644 --- a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml @@ -117,8 +117,10 @@ Column { const status = NetworkService.networkStatus; if (status === "ethernet") return "settings_ethernet"; + if (status === "cellular") + return "network_cell"; if (status === "vpn") - return NetworkService.ethernetConnected ? "settings_ethernet" : NetworkService.wifiSignalIcon; + return NetworkService.ethernetConnected ? "settings_ethernet" : (NetworkService.cellularConnected ? "network_cell" : NetworkService.wifiSignalIcon); if (status === "wifi") return NetworkService.wifiSignalIcon; return "wifi"; @@ -163,8 +165,10 @@ Column { const status = NetworkService.networkStatus; if (status === "ethernet") return true; + if (status === "cellular") + return true; if (status === "vpn") - return NetworkService.ethernetConnected || NetworkService.wifiConnected; + return NetworkService.ethernetConnected || NetworkService.wifiConnected || NetworkService.cellularConnected; if (status === "wifi") return true; return NetworkService.wifiEnabled; @@ -485,9 +489,13 @@ Column { const status = NetworkService.networkStatus; if (status === "ethernet") return I18n.tr("Ethernet", "network status"); + if (status === "cellular") + return I18n.tr("Cellular", "network status"); if (status === "vpn") { if (NetworkService.ethernetConnected) return I18n.tr("Ethernet", "network status"); + if (NetworkService.cellularConnected) + return I18n.tr("Cellular", "network status"); if (NetworkService.wifiConnected && NetworkService.currentWifiSSID) return NetworkService.currentWifiSSID; } @@ -527,9 +535,13 @@ Column { const status = NetworkService.networkStatus; if (status === "ethernet") return I18n.tr("Connected", "network status"); + if (status === "cellular") + return NetworkService.cellularIP || I18n.tr("Connected", "network status"); if (status === "vpn") { if (NetworkService.ethernetConnected) return I18n.tr("Connected", "network status"); + if (NetworkService.cellularConnected) + return NetworkService.cellularIP || I18n.tr("Connected", "network status"); if (NetworkService.wifiConnected) return NetworkService.wifiSignalStrength > 0 ? NetworkService.wifiSignalStrength + "%" : I18n.tr("Connected", "network status"); } diff --git a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml index 890287343..7bb040e7c 100644 --- a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml @@ -18,6 +18,12 @@ Rectangle { implicitHeight: { if (height > 0) return height; + if (currentConnectionType === "cellular" && NetworkService.cellularToggling) + return headerRow.height + cellularToggleContent.height + Theme.spacingM; + if (currentConnectionType === "cellular" && NetworkService.cellularEnabled) + return headerRow.height + cellularContent.height + Theme.spacingM; + if (currentConnectionType === "cellular") + return headerRow.height + cellularOffContent.height + Theme.spacingM; if (NetworkService.wifiToggling) return headerRow.height + hotspotContentHeight + wifiToggleContent.height + Theme.spacingM; if (NetworkService.wifiEnabled) @@ -39,12 +45,28 @@ Rectangle { property bool hasEthernetAvailable: (NetworkService.ethernetDevices?.length ?? 0) > 0 property bool hasWifiAvailable: (NetworkService.wifiDevices?.length ?? 0) > 0 - property bool hasBothConnectionTypes: hasEthernetAvailable && hasWifiAvailable + property bool hasCellularAvailable: (NetworkService.cellularDevices?.length ?? 0) > 0 + property var connectionTypes: { + const types = []; + if (hasEthernetAvailable) + types.push("ethernet"); + if (hasWifiAvailable) + types.push("wifi"); + if (hasCellularAvailable) + types.push("cellular"); + return types.length > 0 ? types : ["wifi"]; + } + property string selectedType: "" + readonly property string currentConnectionType: { + if (selectedType && connectionTypes.includes(selectedType)) + return selectedType; + return connectionTypes[Math.max(0, currentPreferenceIndex)] || "wifi"; + } property int maxPinnedNetworks: 3 // Hosting on the only wifi adapter with no ethernet uplink just drops connectivity, // so the hotspot row only shows where sharing can actually work (or is already on). readonly property bool hotspotRelevant: NetworkService.hotspotEnabled || NetworkService.hotspotActivating || NetworkService.hotspotBusy || NetworkService.ethernetConnected || (NetworkService.wifiDevices?.length ?? 0) > 1 - readonly property bool showHotspotRow: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable && hotspotRelevant + readonly property bool showHotspotRow: currentConnectionType === "wifi" && NetworkService.hotspotAvailable && hotspotRelevant readonly property int hotspotContentHeight: showHotspotRow ? 56 + Theme.spacingS : 0 property var hotspotStartConfirm: ConfirmModal {} @@ -81,20 +103,13 @@ Rectangle { return 1; if (NetworkService.backend !== "networkmanager" || DMSService.apiVersion <= 10) return 1; - if (!hasEthernetAvailable) - return 1; - if (!hasWifiAvailable) - return 0; - const pref = NetworkService.userPreference; - switch (pref) { - case "ethernet": - return 0; - case "wifi": - return 1; - default: - return NetworkService.networkStatus === "ethernet" ? 0 : 1; - } + if (connectionTypes.indexOf(pref) !== -1) + return connectionTypes.indexOf(pref); + if (connectionTypes.indexOf(NetworkService.networkStatus) !== -1) + return connectionTypes.indexOf(NetworkService.networkStatus); + const wifiIndex = connectionTypes.indexOf("wifi"); + return wifiIndex !== -1 ? wifiIndex : 0; } Row { @@ -129,7 +144,7 @@ Rectangle { DankDropdown { id: wifiDeviceDropdown anchors.verticalCenter: parent.verticalCenter - visible: currentPreferenceIndex === 1 && (NetworkService.wifiDevices?.length ?? 0) > 1 + visible: currentConnectionType === "wifi" && (NetworkService.wifiDevices?.length ?? 0) > 1 compactMode: true dropdownWidth: 120 popupWidth: 160 @@ -153,20 +168,35 @@ Rectangle { DankButtonGroup { id: preferenceControls anchors.verticalCenter: parent.verticalCenter - visible: hasBothConnectionTypes && NetworkService.backend === "networkmanager" && DMSService.apiVersion > 10 buttonHeight: 28 textSize: Theme.fontSizeSmall - model: [I18n.tr("Ethernet"), I18n.tr("WiFi")] - currentIndex: currentPreferenceIndex + readonly property var labelsByType: ({ + "ethernet": I18n.tr("Ethernet"), + "wifi": I18n.tr("WiFi"), + "cellular": I18n.tr("Cellular") + }) + + visible: connectionTypes.length > 1 && NetworkService.backend === "networkmanager" && DMSService.apiVersion > 10 + model: connectionTypes.map(t => labelsByType[t] || t) + currentIndex: Math.max(0, connectionTypes.indexOf(currentConnectionType)) selectionMode: "single" onSelectionChanged: (index, selected) => { if (!selected) return; - NetworkService.setNetworkPreference(index === 0 ? "ethernet" : "wifi"); + selectedType = connectionTypes[index] || "wifi"; + NetworkService.setNetworkPreference(selectedType); } } + DankToggle { + anchors.verticalCenter: parent.verticalCenter + visible: currentConnectionType === "cellular" && NetworkService.backend === "networkmanager" + checked: NetworkService.cellularEnabled + enabled: NetworkService.cellularHardwareEnabled && !NetworkService.cellularToggling + onToggled: NetworkService.toggleCellularRadio() + } + DankActionButton { anchors.verticalCenter: parent.verticalCenter iconName: "settings" @@ -175,7 +205,12 @@ Rectangle { iconColor: Theme.surfaceVariantText onClicked: { PopoutService.closeControlCenter(); - PopoutService.openSettingsWithTab(currentPreferenceIndex === 0 ? "network_ethernet" : "network_wifi"); + if (currentConnectionType === "ethernet") + PopoutService.openSettingsWithTab("network_ethernet"); + else if (currentConnectionType === "cellular") + PopoutService.openSettingsWithTab("network_cellular"); + else + PopoutService.openSettingsWithTab("network_wifi"); } } } @@ -359,7 +394,7 @@ Rectangle { anchors.right: parent.right anchors.margins: Theme.spacingM anchors.topMargin: hotspotRow.visible ? Theme.spacingS : Theme.spacingM - visible: currentPreferenceIndex === 1 && NetworkService.wifiToggling + visible: currentConnectionType === "wifi" && NetworkService.wifiToggling height: visible ? wifiToggleColumn.implicitHeight + Theme.spacingM * 2 : 0 Column { @@ -400,7 +435,7 @@ Rectangle { anchors.right: parent.right anchors.margins: Theme.spacingM anchors.topMargin: hotspotRow.visible ? Theme.spacingS : Theme.spacingM - visible: currentPreferenceIndex === 1 && !NetworkService.wifiEnabled && !NetworkService.wifiToggling + visible: currentConnectionType === "wifi" && !NetworkService.wifiEnabled && !NetworkService.wifiToggling height: visible ? wifiOffColumn.implicitHeight + Theme.spacingM * 2 : 0 Column { @@ -454,6 +489,327 @@ Rectangle { } } + ScriptModel { + id: cellularConnectionsModel + objectProp: "uuid" + values: { + const networks = NetworkService.cellularConnections || []; + let sorted = [...networks]; + sorted.sort((a, b) => { + if (a.isActive && !b.isActive) + return -1; + if (!a.isActive && b.isActive) + return 1; + return (a.id || "").localeCompare(b.id || ""); + }); + return sorted; + } + } + + Item { + id: cellularToggleContent + anchors.top: headerRow.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacingM + anchors.topMargin: Theme.spacingM + visible: currentConnectionType === "cellular" && NetworkService.cellularToggling + height: visible ? cellularToggleColumn.implicitHeight + Theme.spacingM * 2 : 0 + + Column { + id: cellularToggleColumn + anchors.centerIn: parent + spacing: Theme.spacingM + + DankIcon { + anchors.horizontalCenter: parent.horizontalCenter + name: "sync" + size: 32 + color: Theme.primary + smoothTransform: NetworkService.cellularToggling + + RotationAnimator on rotation { + running: NetworkService.cellularToggling + loops: Animation.Infinite + from: 0 + to: 360 + duration: 1000 + } + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + text: NetworkService.cellularEnabled ? I18n.tr("Disabling cellular...") : I18n.tr("Enabling cellular...") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + horizontalAlignment: Text.AlignHCenter + } + } + } + + Item { + id: cellularOffContent + anchors.top: headerRow.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacingM + anchors.topMargin: Theme.spacingM + visible: currentConnectionType === "cellular" && !NetworkService.cellularEnabled && !NetworkService.cellularToggling + height: visible ? cellularOffColumn.implicitHeight + Theme.spacingM * 2 : 0 + + Column { + id: cellularOffColumn + anchors.centerIn: parent + spacing: Theme.spacingL + width: parent.width + + DankIcon { + anchors.horizontalCenter: parent.horizontalCenter + name: "network_cell" + size: 48 + color: Theme.surfaceTextSecondary + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + text: NetworkService.cellularHardwareEnabled ? I18n.tr("Disabled") : I18n.tr("Unavailable") + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + horizontalAlignment: Text.AlignHCenter + } + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: enableCellularLabel.implicitWidth + Theme.spacingL * 2 + height: enableCellularLabel.implicitHeight + Theme.spacingM * 2 + radius: height / 2 + color: enableCellularButton.containsMouse ? Theme.primaryHover : Theme.primaryHoverLight + border.width: 0 + visible: NetworkService.cellularHardwareEnabled + + StyledText { + id: enableCellularLabel + anchors.centerIn: parent + text: I18n.tr("Enable Cellular") + color: Theme.primary + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + } + + MouseArea { + id: enableCellularButton + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: NetworkService.toggleCellularRadio() + } + } + } + } + + DankFlickable { + id: cellularContent + anchors.top: headerRow.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Theme.spacingM + anchors.topMargin: Theme.spacingM + visible: currentConnectionType === "cellular" && NetworkService.backend === "networkmanager" && NetworkService.cellularEnabled && !NetworkService.cellularToggling + enabled: visible + contentHeight: cellularColumn.height + clip: true + + Column { + id: cellularColumn + width: parent.width + spacing: Theme.spacingS + + Repeater { + model: (NetworkService.cellularConnections?.length ?? 0) > 0 ? [] : (NetworkService.cellularDevices || []) + + delegate: Rectangle { + id: cellularDeviceDelegate + required property var modelData + + readonly property bool isActive: modelData.connected || false + + width: parent.width + height: 56 + radius: Theme.cornerRadius + color: cellularDeviceMouse.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight + border.color: isActive ? Theme.primary : Theme.outlineLight + border.width: isActive ? 2 : 1 + + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingM + anchors.right: cellularDeviceAction.left + anchors.rightMargin: Theme.spacingS + spacing: Theme.spacingS + + DankIcon { + name: "network_cell" + size: Theme.iconSize - 4 + color: cellularDeviceDelegate.isActive ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - Theme.iconSize - Theme.spacingS + spacing: 2 + + StyledText { + text: modelData.description || modelData.name || I18n.tr("Unknown") + font.pixelSize: Theme.fontSizeMedium + color: cellularDeviceDelegate.isActive ? Theme.primary : Theme.surfaceText + font.weight: cellularDeviceDelegate.isActive ? Font.Medium : Font.Normal + elide: Text.ElideRight + width: parent.width + } + + StyledText { + text: cellularDeviceDelegate.isActive ? I18n.tr("Connected") : (modelData.state || I18n.tr("Available")) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + width: parent.width + } + } + } + + DankActionButton { + id: cellularDeviceAction + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + iconName: cellularDeviceDelegate.isActive ? "link_off" : "link" + buttonSize: 28 + iconSize: 18 + iconColor: cellularDeviceDelegate.isActive ? Theme.error : Theme.primary + onClicked: NetworkService.toggleNetworkConnection("cellular") + } + + MouseArea { + id: cellularDeviceMouse + anchors.fill: parent + anchors.rightMargin: cellularDeviceAction.width + Theme.spacingS + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: NetworkService.toggleNetworkConnection("cellular") + } + } + } + + Repeater { + model: cellularConnectionsModel + + delegate: Rectangle { + id: cellularDelegate + required property var modelData + + readonly property bool isActive: modelData.isActive + readonly property string configName: modelData.id || I18n.tr("Unknown") + + width: parent.width + height: cellularContentRow.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: cellularMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight + border.color: cellularDelegate.isActive ? Theme.primary : Theme.outlineLight + border.width: cellularDelegate.isActive ? 2 : 1 + + Row { + id: cellularContentRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingM + anchors.right: cellularActionButton.left + anchors.rightMargin: Theme.spacingS + spacing: Theme.spacingS + + DankIcon { + name: "network_cell" + size: Theme.iconSize - 4 + color: cellularDelegate.isActive ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - Theme.iconSize - Theme.spacingS + spacing: 2 + + StyledText { + text: cellularDelegate.configName + font.pixelSize: Theme.fontSizeMedium + color: cellularDelegate.isActive ? Theme.primary : Theme.surfaceText + font.weight: cellularDelegate.isActive ? Font.Medium : Font.Normal + elide: Text.ElideRight + width: parent.width + } + + StyledText { + text: cellularDelegate.isActive ? I18n.tr("Connected") : (modelData.type || I18n.tr("Available")) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + width: parent.width + } + } + } + + DankActionButton { + id: cellularActionButton + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + iconName: cellularDelegate.isActive ? "link_off" : "link" + buttonSize: 28 + iconSize: 18 + iconColor: cellularDelegate.isActive ? Theme.error : Theme.primary + onClicked: { + if (cellularDelegate.isActive) + NetworkService.toggleNetworkConnection("cellular"); + else + NetworkService.connectToSpecificCellularConfig(modelData.uuid); + } + } + + DankRipple { + id: cellularRipple + cornerRadius: parent.radius + } + + MouseArea { + id: cellularMouseArea + anchors.fill: parent + anchors.rightMargin: cellularActionButton.width + Theme.spacingS + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => cellularRipple.trigger(mouse.x, mouse.y) + onClicked: function (event) { + if (!cellularDelegate.isActive) + NetworkService.connectToSpecificCellularConfig(modelData.uuid); + event.accepted = true; + } + } + } + } + + StyledText { + width: parent.width + visible: (NetworkService.cellularDevices?.length ?? 0) === 0 && cellularConnectionsModel.values.length === 0 + text: I18n.tr("No devices found") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + } + } + } + ScriptModel { id: wiredConnectionsModel objectProp: "uuid" @@ -481,7 +837,7 @@ Rectangle { anchors.bottom: parent.bottom anchors.margins: Theme.spacingM anchors.topMargin: Theme.spacingM - visible: currentPreferenceIndex === 0 && NetworkService.backend === "networkmanager" && DMSService.apiVersion > 10 + visible: currentConnectionType === "ethernet" && NetworkService.backend === "networkmanager" && DMSService.apiVersion > 10 contentHeight: wiredColumn.height clip: true @@ -682,7 +1038,7 @@ Rectangle { anchors.bottom: parent.bottom anchors.margins: Theme.spacingM anchors.topMargin: hotspotRow.visible ? Theme.spacingS : Theme.spacingM - visible: currentPreferenceIndex === 1 && NetworkService.wifiEnabled && !NetworkService.wifiToggling && NetworkService.wifiInterface && (NetworkService.wifiNetworks?.length ?? 0) < 1 && NetworkService.isScanning + visible: currentConnectionType === "wifi" && NetworkService.wifiEnabled && !NetworkService.wifiToggling && NetworkService.wifiInterface && (NetworkService.wifiNetworks?.length ?? 0) < 1 && NetworkService.isScanning DankIcon { anchors.centerIn: parent @@ -709,7 +1065,7 @@ Rectangle { anchors.bottom: parent.bottom anchors.margins: Theme.spacingM anchors.topMargin: hotspotRow.visible ? Theme.spacingS : Theme.spacingM - visible: currentPreferenceIndex === 1 && NetworkService.wifiEnabled && !NetworkService.wifiToggling && !wifiScanningOverlay.visible + visible: currentConnectionType === "wifi" && NetworkService.wifiEnabled && !NetworkService.wifiToggling && !wifiScanningOverlay.visible clip: true spacing: Theme.spacingS model: wifiNetworksModel diff --git a/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml b/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml index a32dcb9ca..c558b0bb7 100644 --- a/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml +++ b/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml @@ -123,8 +123,10 @@ BasePill { switch (NetworkService.networkStatus) { case "ethernet": return "lan"; + case "cellular": + return "network_cell"; case "vpn": - return NetworkService.ethernetConnected ? "lan" : NetworkService.wifiSignalIcon; + return NetworkService.ethernetConnected ? "lan" : (NetworkService.cellularConnected ? "network_cell" : NetworkService.wifiSignalIcon); default: return NetworkService.wifiSignalIcon; } diff --git a/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml index 46f02fe50..ab56251aa 100644 --- a/quickshell/Modules/Lock/LockScreenContent.qml +++ b/quickshell/Modules/Lock/LockScreenContent.qml @@ -1768,8 +1768,10 @@ Item { switch (NetworkService.networkStatus) { case "ethernet": return "lan"; + case "cellular": + return "network_cell"; case "vpn": - return NetworkService.ethernetConnected ? "lan" : NetworkService.wifiSignalIcon; + return NetworkService.ethernetConnected ? "lan" : (NetworkService.cellularConnected ? "network_cell" : NetworkService.wifiSignalIcon); default: return NetworkService.wifiSignalIcon; } diff --git a/quickshell/Modules/Settings/NetworkCellularTab.qml b/quickshell/Modules/Settings/NetworkCellularTab.qml new file mode 100644 index 000000000..27d280dd4 --- /dev/null +++ b/quickshell/Modules/Settings/NetworkCellularTab.qml @@ -0,0 +1,331 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.Settings.Widgets +import qs.Services +import qs.Widgets + +Item { + id: networkCellularTab + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + Component.onCompleted: NetworkService.addRef() + Component.onDestruction: NetworkService.removeRef() + + DankFlickable { + anchors.fill: parent + clip: true + contentHeight: mainColumn.height + Theme.spacingXL + contentWidth: width + + Column { + id: mainColumn + + topPadding: 4 + width: Math.min(600, parent.width - Theme.spacingL * 2) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingL + + SettingsCard { + id: root + + title: I18n.tr("Cellular") + iconName: "network_cell" + settingKey: "networkCellular" + tags: ["cellular", "mobile", "modem", "wwan", "lte", "gsm", "cdma", "network"] + width: parent.width + + Column { + width: parent.width + spacing: Theme.spacingM + + Row { + width: parent.width + spacing: Theme.spacingM + + StyledText { + text: { + if (!NetworkService.cellularHardwareEnabled) + return I18n.tr("Unavailable"); + if (NetworkService.cellularToggling) + return NetworkService.cellularEnabled ? I18n.tr("Disabling cellular...") : I18n.tr("Enabling cellular..."); + if (!NetworkService.cellularEnabled) + return I18n.tr("Disabled"); + const devices = NetworkService.cellularDevices || []; + const connected = devices.filter(d => d.connected).length; + if (devices.length === 0) + return I18n.tr("No devices found"); + if (connected === 0) + return I18n.tr("Disconnected"); + return I18n.tr("%1 connected").arg(connected); + } + font.pixelSize: Theme.fontSizeSmall + color: NetworkService.cellularConnected ? Theme.primary : Theme.surfaceVariantText + width: parent.width - cellularControls.width - Theme.spacingM + horizontalAlignment: Text.AlignLeft + anchors.verticalCenter: parent.verticalCenter + } + + Row { + id: cellularControls + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankToggle { + checked: NetworkService.cellularEnabled + enabled: NetworkService.cellularHardwareEnabled && !NetworkService.cellularToggling + onToggled: NetworkService.toggleCellularRadio() + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + } + + Column { + width: parent.width + spacing: 4 + visible: NetworkService.cellularEnabled && (NetworkService.cellularDevices?.length ?? 0) > 0 + + StyledText { + text: I18n.tr("Adapters") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + Repeater { + model: NetworkService.cellularDevices || [] + + delegate: Rectangle { + id: modemDelegate + required property var modelData + + readonly property bool isConnected: modelData.connected || false + + width: parent.width + height: 56 + radius: Theme.cornerRadius + color: modemMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight + border.width: isConnected ? 2 : 0 + border.color: Theme.primary + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.right: modemActions.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankIcon { + name: "network_cell" + size: 20 + color: modemDelegate.isConnected ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + width: parent.width - 20 - Theme.spacingS + + StyledText { + text: modelData.name || I18n.tr("Unknown") + font.pixelSize: Theme.fontSizeMedium + color: modemDelegate.isConnected ? Theme.primary : Theme.surfaceText + font.weight: modemDelegate.isConnected ? Font.Medium : Font.Normal + elide: Text.ElideRight + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + StyledText { + text: { + const state = modelData.state || I18n.tr("Unknown"); + const ip = modelData.ip || ""; + return ip.length > 0 ? state + " • " + ip : state; + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + width: parent.width + horizontalAlignment: Text.AlignLeft + } + } + } + + Row { + id: modemActions + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + Rectangle { + width: 28 + height: 28 + radius: 14 + color: modemActionBtn.containsMouse ? (modemDelegate.isConnected ? Theme.errorHover : Theme.primaryHover) : "transparent" + + DankIcon { + anchors.centerIn: parent + name: modemDelegate.isConnected ? "link_off" : "link" + size: 18 + color: modemActionBtn.containsMouse ? (modemDelegate.isConnected ? Theme.error : Theme.primary) : Theme.surfaceVariantText + } + + MouseArea { + id: modemActionBtn + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (modemDelegate.isConnected) + NetworkService.disconnectCellularDevice(modelData.name); + else + NetworkService.connectCellular(); + } + } + } + } + + MouseArea { + id: modemMouseArea + anchors.fill: parent + anchors.rightMargin: modemActions.width + Theme.spacingM + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (modemDelegate.isConnected) + NetworkService.disconnectCellularDevice(modelData.name); + else + NetworkService.connectCellular(); + } + } + } + } + } + + StyledText { + visible: NetworkService.cellularEnabled && (NetworkService.cellularDevices?.length ?? 0) === 0 + text: NetworkService.cellularHardwareEnabled ? I18n.tr("No devices found") : I18n.tr("Unavailable") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + width: parent.width + horizontalAlignment: Text.AlignHCenter + } + } + } + + SettingsCard { + title: I18n.tr("Saved Configurations") + iconName: "sim_card" + settingKey: "networkCellularProfiles" + tags: ["cellular", "mobile", "profile", "apn", "sim"] + width: parent.width + visible: NetworkService.cellularEnabled && (NetworkService.cellularConnections?.length ?? 0) > 0 + + Column { + width: parent.width + spacing: 4 + + Repeater { + model: NetworkService.cellularConnections || [] + + delegate: Rectangle { + id: profileDelegate + required property var modelData + + readonly property bool isActive: modelData.isActive || false + + width: parent.width + height: 56 + radius: Theme.cornerRadius + color: profileMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight + border.color: isActive ? Theme.primary : Theme.outlineLight + border.width: isActive ? 2 : 1 + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.right: profileAction.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankIcon { + name: "sim_card" + size: 20 + color: profileDelegate.isActive ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 20 - Theme.spacingS + spacing: 2 + + StyledText { + text: modelData.id || I18n.tr("Unknown") + font.pixelSize: Theme.fontSizeMedium + color: profileDelegate.isActive ? Theme.primary : Theme.surfaceText + font.weight: profileDelegate.isActive ? Font.Medium : Font.Normal + elide: Text.ElideRight + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + StyledText { + text: profileDelegate.isActive ? I18n.tr("Connected") : (modelData.type || I18n.tr("Available")) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + horizontalAlignment: Text.AlignLeft + } + } + } + + DankActionButton { + id: profileAction + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + iconName: profileDelegate.isActive ? "link_off" : "link" + buttonSize: 28 + iconSize: 18 + iconColor: profileDelegate.isActive ? Theme.error : Theme.primary + onClicked: { + if (profileDelegate.isActive) + NetworkService.toggleNetworkConnection("cellular"); + else + NetworkService.connectToSpecificCellularConfig(modelData.uuid); + } + } + + MouseArea { + id: profileMouseArea + anchors.fill: parent + anchors.rightMargin: profileAction.width + Theme.spacingS + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (!profileDelegate.isActive) + NetworkService.connectToSpecificCellularConfig(modelData.uuid); + } + } + } + } + } + } + } + } +} diff --git a/quickshell/Modules/Settings/NetworkStatusTab.qml b/quickshell/Modules/Settings/NetworkStatusTab.qml index d69a64ea2..a12a62c6e 100644 --- a/quickshell/Modules/Settings/NetworkStatusTab.qml +++ b/quickshell/Modules/Settings/NetworkStatusTab.qml @@ -99,6 +99,7 @@ Item { switch (NetworkService.networkStatus) { case "ethernet": case "wifi": + case "cellular": return Theme.success; case "disconnected": return Theme.error; @@ -115,6 +116,8 @@ Item { return I18n.tr("Ethernet"); case "wifi": return I18n.tr("WiFi"); + case "cellular": + return I18n.tr("Cellular"); case "disconnected": return I18n.tr("Disconnected"); default: @@ -145,7 +148,7 @@ Item { Row { width: parent.width spacing: Theme.spacingM - visible: NetworkService.backend === "networkmanager" && NetworkService.ethernetConnected && NetworkService.wifiConnected + visible: NetworkService.backend === "networkmanager" && [NetworkService.ethernetConnected, NetworkService.wifiConnected, NetworkService.cellularConnected].filter(v => v).length > 1 StyledText { text: I18n.tr("Preference") @@ -161,13 +164,15 @@ Item { DankButtonGroup { id: preferenceButtons - model: [I18n.tr("Auto"), I18n.tr("Ethernet"), I18n.tr("WiFi")] + model: [I18n.tr("Auto"), I18n.tr("Ethernet"), I18n.tr("WiFi"), I18n.tr("Cellular")] currentIndex: { switch (NetworkService.userPreference) { case "ethernet": return 1; case "wifi": return 2; + case "cellular": + return 3; default: return 0; } @@ -185,6 +190,9 @@ Item { case 2: NetworkService.setNetworkPreference("wifi"); break; + case 3: + NetworkService.setNetworkPreference("cellular"); + break; } } } diff --git a/quickshell/Services/DMSNetworkService.qml b/quickshell/Services/DMSNetworkService.qml index e19ae0ac9..839bfa092 100644 --- a/quickshell/Services/DMSNetworkService.qml +++ b/quickshell/Services/DMSNetworkService.qml @@ -24,6 +24,15 @@ Singleton { property var wiredConnections: [] + property string cellularIP: "" + property string cellularInterface: "" + property bool cellularConnected: false + property bool cellularEnabled: true + property bool cellularHardwareEnabled: true + property string cellularConnectionUuid: "" + property var cellularDevices: [] + property var cellularConnections: [] + property string wifiIP: "" property string wifiInterface: "" property bool wifiConnected: false @@ -66,6 +75,7 @@ Singleton { property bool wifiAvailable: true property bool wifiToggling: false + property bool cellularToggling: false property bool changingPreference: false property string targetPreference: "" property var savedWifiNetworks: [] @@ -330,6 +340,15 @@ Singleton { wiredConnections = state.wiredConnections || []; + cellularIP = state.cellularIP || ""; + cellularInterface = state.cellularDevice || ""; + cellularConnected = state.cellularConnected || false; + cellularEnabled = state.cellularEnabled !== undefined ? state.cellularEnabled : true; + cellularHardwareEnabled = state.cellularHardwareEnabled !== undefined ? state.cellularHardwareEnabled : true; + cellularConnectionUuid = state.cellularConnectionUuid || ""; + cellularDevices = state.cellularDevices || []; + cellularConnections = state.cellularConnections || []; + wifiIP = state.wifiIP || ""; wifiInterface = state.wifiDevice || ""; wifiConnected = state.wifiConnected || false; @@ -524,6 +543,53 @@ Singleton { }); } + function connectCellular() { + if (!networkAvailable || isConnecting) + return; + isConnecting = true; + connectionError = ""; + connectionStatus = "connecting"; + + DMSService.sendRequest("network.cellular.connect", null, response => { + if (response.error) { + connectionError = response.error; + lastConnectionError = response.error; + connectionStatus = "failed"; + ToastService.showError(I18n.tr("Failed to activate configuration"), response.error); + } else { + connectionError = ""; + connectionStatus = "connected"; + ToastService.showInfo(I18n.tr("Configuration activated")); + } + isConnecting = false; + }); + } + + function connectToSpecificCellularConfig(uuid) { + if (!networkAvailable || isConnecting) + return; + isConnecting = true; + connectionError = ""; + connectionStatus = "connecting"; + + DMSService.sendRequest("network.cellular.connect.config", { + uuid: uuid + }, response => { + if (response.error) { + connectionError = response.error; + lastConnectionError = response.error; + connectionStatus = "failed"; + ToastService.showError(I18n.tr("Failed to activate configuration"), response.error); + } else { + connectionError = ""; + connectionStatus = "connected"; + ToastService.showInfo(I18n.tr("Configuration activated")); + } + + isConnecting = false; + }); + } + function scanWifi() { if (!networkAvailable || isScanning || !wifiEnabled) return; @@ -716,6 +782,22 @@ Singleton { }); } + function toggleCellularRadio() { + if (!networkAvailable || cellularToggling) + return; + cellularToggling = true; + DMSService.sendRequest("network.cellular.toggle", null, response => { + cellularToggling = false; + + if (response.error) { + ToastService.showError(I18n.tr("Failed to toggle cellular"), response.error); + } else if (response.result) { + cellularEnabled = response.result.enabled; + ToastService.showInfo(cellularEnabled ? I18n.tr("Cellular enabled") : I18n.tr("Cellular disabled")); + } + }); + } + function enableWifiDevice() { if (!networkAvailable) return; @@ -731,6 +813,8 @@ Singleton { function setNetworkPreference(preference) { if (!networkAvailable) return; + if (userPreference === preference && !changingPreference) + return; changingPreference = true; targetPreference = preference; SettingsData.set("networkPreference", preference); @@ -752,6 +836,8 @@ Singleton { setNetworkPreference("wifi"); } else if (type === "ethernet") { setNetworkPreference("ethernet"); + } else if (type === "cellular") { + setNetworkPreference("cellular"); } } @@ -769,6 +855,12 @@ Singleton { } else { DMSService.sendRequest("network.ethernet.connect", null, null); } + } else if (type === "cellular") { + if (cellularConnected) { + DMSService.sendRequest("network.cellular.disconnect", null, null); + } else { + connectCellular(); + } } } @@ -780,6 +872,14 @@ Singleton { }, null); } + function disconnectCellularDevice(deviceName) { + if (!networkAvailable) + return; + DMSService.sendRequest("network.cellular.disconnect", { + device: deviceName + }, null); + } + function startAutoScan() { autoScan = true; autoRefreshEnabled = true; diff --git a/quickshell/Services/NetworkService.qml b/quickshell/Services/NetworkService.qml index 7bd82a8f9..18d602994 100644 --- a/quickshell/Services/NetworkService.qml +++ b/quickshell/Services/NetworkService.qml @@ -22,6 +22,15 @@ Singleton { property var wiredConnections: activeService?.wiredConnections ?? [] + property string cellularIP: activeService?.cellularIP ?? "" + property string cellularInterface: activeService?.cellularInterface ?? "" + property bool cellularConnected: activeService?.cellularConnected ?? false + property bool cellularEnabled: activeService?.cellularEnabled ?? true + property bool cellularHardwareEnabled: activeService?.cellularHardwareEnabled ?? true + property string cellularConnectionUuid: activeService?.cellularConnectionUuid ?? "" + property var cellularDevices: activeService?.cellularDevices ?? [] + property var cellularConnections: activeService?.cellularConnections ?? [] + property string wifiIP: activeService?.wifiIP ?? "" property string wifiInterface: activeService?.wifiInterface ?? "" property bool wifiConnected: activeService?.wifiConnected ?? false @@ -51,6 +60,7 @@ Singleton { property bool wifiAvailable: activeService?.wifiAvailable ?? true property bool wifiToggling: activeService?.wifiToggling ?? false + property bool cellularToggling: activeService?.cellularToggling ?? false property bool changingPreference: activeService?.changingPreference ?? false property string targetPreference: activeService?.targetPreference ?? "" property var savedWifiNetworks: activeService?.savedWifiNetworks ?? [] @@ -221,6 +231,12 @@ Singleton { } } + function toggleCellularRadio() { + if (activeService && activeService.toggleCellularRadio) { + activeService.toggleCellularRadio(); + } + } + function enableWifiDevice() { if (activeService && activeService.enableWifiDevice) { activeService.enableWifiDevice(); @@ -257,6 +273,24 @@ Singleton { } } + function disconnectCellularDevice(deviceName) { + if (activeService && activeService.disconnectCellularDevice) { + activeService.disconnectCellularDevice(deviceName); + } + } + + function connectCellular() { + if (activeService && activeService.connectCellular) { + activeService.connectCellular(); + } + } + + function connectToSpecificCellularConfig(uuid) { + if (activeService && activeService.connectToSpecificCellularConfig) { + activeService.connectToSpecificCellularConfig(uuid); + } + } + function startAutoScan() { if (activeService && activeService.startAutoScan) { activeService.startAutoScan(); diff --git a/quickshell/translations/en.json b/quickshell/translations/en.json index 1d9d14aca..284a62155 100644 --- a/quickshell/translations/en.json +++ b/quickshell/translations/en.json @@ -4069,6 +4069,34 @@ "settings" ] }, + { + "term": "Cellular", + "context": "Cellular", + "reference": "Modules/Settings/NetworkStatusTab.qml:120, Modules/Settings/NetworkStatusTab.qml:167, Modules/Settings/NetworkCellularTab.qml:35, Modules/ControlCenter/Components/DragDropGrid.qml:493, Modules/ControlCenter/Components/DragDropGrid.qml:498, Modules/ControlCenter/Details/NetworkDetail.qml:177, Modals/Settings/SettingsSidebar.qml:269", + "comment": "network status", + "tags": [ + "settings", + "shell" + ] + }, + { + "term": "Cellular disabled", + "context": "Cellular disabled", + "reference": "Services/DMSNetworkService.qml:796", + "comment": "", + "tags": [ + "shell" + ] + }, + { + "term": "Cellular enabled", + "context": "Cellular enabled", + "reference": "Services/DMSNetworkService.qml:796", + "comment": "", + "tags": [ + "shell" + ] + }, { "term": "Center Section", "context": "Center Section", @@ -7438,6 +7466,16 @@ "shell" ] }, + { + "term": "Disabling cellular...", + "context": "Disabling cellular...", + "reference": "Modules/Settings/NetworkCellularTab.qml:54, Modules/ControlCenter/Details/NetworkDetail.qml:542", + "comment": "", + "tags": [ + "settings", + "shell" + ] + }, { "term": "Disc", "context": "Disc", @@ -8315,6 +8353,15 @@ "settings" ] }, + { + "term": "Enable Cellular", + "context": "Enable Cellular", + "reference": "Modules/ControlCenter/Details/NetworkDetail.qml:594", + "comment": "", + "tags": [ + "shell" + ] + }, { "term": "Enable Do Not Disturb", "context": "Enable Do Not Disturb", @@ -8506,6 +8553,16 @@ "shell" ] }, + { + "term": "Enabling cellular...", + "context": "Enabling cellular...", + "reference": "Modules/Settings/NetworkCellularTab.qml:54, Modules/ControlCenter/Details/NetworkDetail.qml:542", + "comment": "", + "tags": [ + "settings", + "shell" + ] + }, { "term": "End", "context": "End", @@ -9688,6 +9745,15 @@ "shell" ] }, + { + "term": "Failed to toggle cellular", + "context": "Failed to toggle cellular", + "reference": "Services/DMSNetworkService.qml:793", + "comment": "", + "tags": [ + "shell" + ] + }, { "term": "Failed to unpin entry", "context": "Failed to unpin entry", diff --git a/quickshell/translations/extract_settings_index.py b/quickshell/translations/extract_settings_index.py index 357da0799..1e537880f 100755 --- a/quickshell/translations/extract_settings_index.py +++ b/quickshell/translations/extract_settings_index.py @@ -106,6 +106,7 @@ "NetworkEthernetTab.qml": 39, "NetworkWifiTab.qml": 40, "NetworkVpnTab.qml": 41, + "NetworkCellularTab.qml": 46, "PrinterTab.qml": 8, "LauncherTab.qml": 9, "ThemeColorsTab.qml": 10, @@ -191,6 +192,7 @@ 43: "Dank Dash", 44: "System", 45: "System", + 46: "Network", } SEARCHABLE_COMPONENTS = [ diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 085a7d95e..3c171ee04 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -11791,5 +11791,46 @@ ], "description": "Choose a shortcut key to cycle between keyboard layouts", "conditionKey": "isNiri" + }, + { + "section": "networkCellular", + "label": "Cellular", + "tabIndex": 46, + "category": "Network", + "keywords": [ + "cdma", + "cellular", + "connectivity", + "gsm", + "lte", + "mobile", + "modem", + "network", + "online", + "wwan" + ], + "icon": "network_cell" + }, + { + "section": "networkCellularProfiles", + "label": "Saved Configurations", + "tabIndex": 46, + "category": "Network", + "keywords": [ + "apn", + "cellular", + "config", + "configurations", + "configure", + "connectivity", + "mobile", + "network", + "online", + "profile", + "saved", + "setup", + "sim" + ], + "icon": "sim_card" } ] diff --git a/quickshell/translations/template.json b/quickshell/translations/template.json index 3cfe0c9b4..5412837de 100644 --- a/quickshell/translations/template.json +++ b/quickshell/translations/template.json @@ -3128,6 +3128,27 @@ "reference": "", "comment": "plugin browser sort option" }, + { + "term": "Cellular", + "translation": "", + "context": "Cellular", + "reference": "", + "comment": "network status" + }, + { + "term": "Cellular disabled", + "translation": "", + "context": "Cellular disabled", + "reference": "", + "comment": "" + }, + { + "term": "Cellular enabled", + "translation": "", + "context": "Cellular enabled", + "reference": "", + "comment": "" + }, { "term": "Center Section", "translation": "", @@ -5718,6 +5739,13 @@ "reference": "", "comment": "" }, + { + "term": "Disabling cellular...", + "translation": "", + "context": "Disabling cellular...", + "reference": "", + "comment": "" + }, { "term": "Disc", "translation": "", @@ -6390,6 +6418,13 @@ "reference": "", "comment": "" }, + { + "term": "Enable Cellular", + "translation": "", + "context": "Enable Cellular", + "reference": "", + "comment": "" + }, { "term": "Enable Do Not Disturb", "translation": "", @@ -6537,6 +6572,13 @@ "reference": "", "comment": "network status" }, + { + "term": "Enabling cellular...", + "translation": "", + "context": "Enabling cellular...", + "reference": "", + "comment": "" + }, { "term": "End", "translation": "", @@ -7454,6 +7496,13 @@ "reference": "", "comment": "" }, + { + "term": "Failed to toggle cellular", + "translation": "", + "context": "Failed to toggle cellular", + "reference": "", + "comment": "" + }, { "term": "Failed to unpin entry", "translation": "",