diff --git a/.gitignore b/.gitignore index dc9e4ef94..ac648a175 100644 --- a/.gitignore +++ b/.gitignore @@ -112,3 +112,9 @@ bin/ .direnv/ quickshell/dms-plugins __pycache__ + + +# maybe remove +distro/arch +quickshell/.qmlls.ini +quickshell/.qs-build/ diff --git a/core/dms b/core/dms new file mode 100755 index 000000000..b195895e0 Binary files /dev/null and b/core/dms differ diff --git a/core/internal/mocks/network/mock_Backend.go b/core/internal/mocks/network/mock_Backend.go index d7eb2df28..a4ba79fe5 100644 --- a/core/internal/mocks/network/mock_Backend.go +++ b/core/internal/mocks/network/mock_Backend.go @@ -1960,6 +1960,528 @@ func (_c *MockBackend_UpdateVPNConfig_Call) RunAndReturn(run func(string, map[st return _c } +// ActivateCellularConnection provides a mock function with given fields: uuid +func (_m *MockBackend) ActivateCellularConnection(uuid string) error { + ret := _m.Called(uuid) + + if len(ret) == 0 { + panic("no return value specified for ActivateCellularConnection") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(uuid) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type MockBackend_ActivateCellularConnection_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) ActivateCellularConnection(uuid interface{}) *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) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_ActivateCellularConnection_Call) Return(_a0 error) *MockBackend_ActivateCellularConnection_Call { + _c.Call.Return(_a0) + return _c +} + +// ConnectCellular provides a mock function with given fields: uuid +func (_m *MockBackend) ConnectCellular(uuid string) error { + ret := _m.Called(uuid) + + if len(ret) == 0 { + panic("no return value specified for ConnectCellular") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(uuid) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type MockBackend_ConnectCellular_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) ConnectCellular(uuid interface{}) *MockBackend_ConnectCellular_Call { + return &MockBackend_ConnectCellular_Call{Call: _e.mock.On("ConnectCellular", uuid)} +} + +// DisconnectCellular provides a mock function with given fields: +func (_m *MockBackend) DisconnectCellular() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for DisconnectCellular") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type MockBackend_DisconnectCellular_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) DisconnectCellular() *MockBackend_DisconnectCellular_Call { + return &MockBackend_DisconnectCellular_Call{Call: _e.mock.On("DisconnectCellular")} +} + +// DisconnectCellularDevice provides a mock function with given fields: device +func (_m *MockBackend) DisconnectCellularDevice(device string) error { + ret := _m.Called(device) + + if len(ret) == 0 { + panic("no return value specified for DisconnectCellularDevice") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(device) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type MockBackend_DisconnectCellularDevice_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) DisconnectCellularDevice(device interface{}) *MockBackend_DisconnectCellularDevice_Call { + return &MockBackend_DisconnectCellularDevice_Call{Call: _e.mock.On("DisconnectCellularDevice", device)} +} + +// GetCellularConnections provides a mock function with given fields: +func (_m *MockBackend) GetCellularConnections() ([]network.CellularConnection, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetCellularConnections") + } + + var r0 []network.CellularConnection + var r1 error + if rf, ok := ret.Get(0).(func() ([]network.CellularConnection, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []network.CellularConnection); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]network.CellularConnection) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type MockBackend_GetCellularConnections_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) GetCellularConnections() *MockBackend_GetCellularConnections_Call { + return &MockBackend_GetCellularConnections_Call{Call: _e.mock.On("GetCellularConnections")} +} + +// GetCellularDevices provides a mock function with given fields: +func (_m *MockBackend) GetCellularDevices() []network.CellularDevice { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetCellularDevices") + } + + var r0 []network.CellularDevice + if rf, ok := ret.Get(0).(func() []network.CellularDevice); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]network.CellularDevice) + } + } + + return r0 +} + +type MockBackend_GetCellularDevices_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) GetCellularDevices() *MockBackend_GetCellularDevices_Call { + return &MockBackend_GetCellularDevices_Call{Call: _e.mock.On("GetCellularDevices")} +} + +// GetCellularEnabled provides a mock function with given fields: +func (_m *MockBackend) GetCellularEnabled() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetCellularEnabled") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type MockBackend_GetCellularEnabled_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) GetCellularEnabled() *MockBackend_GetCellularEnabled_Call { + return &MockBackend_GetCellularEnabled_Call{Call: _e.mock.On("GetCellularEnabled")} +} + +// GetCellularNetworkDetails provides a mock function with given fields: uuid +func (_m *MockBackend) GetCellularNetworkDetails(uuid string) (*network.CellularNetworkInfoResponse, error) { + ret := _m.Called(uuid) + + if len(ret) == 0 { + panic("no return value specified for GetCellularNetworkDetails") + } + + var r0 *network.CellularNetworkInfoResponse + var r1 error + if rf, ok := ret.Get(0).(func(string) (*network.CellularNetworkInfoResponse, error)); ok { + return rf(uuid) + } + if rf, ok := ret.Get(0).(func(string) *network.CellularNetworkInfoResponse); ok { + r0 = rf(uuid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*network.CellularNetworkInfoResponse) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(uuid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type MockBackend_GetCellularNetworkDetails_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) GetCellularNetworkDetails(uuid interface{}) *MockBackend_GetCellularNetworkDetails_Call { + return &MockBackend_GetCellularNetworkDetails_Call{Call: _e.mock.On("GetCellularNetworkDetails", uuid)} +} + +// GetCellularProfile provides a mock function with given fields: uuidOrName +func (_m *MockBackend) GetCellularProfile(uuidOrName string) (*network.CellularProfile, error) { + ret := _m.Called(uuidOrName) + + if len(ret) == 0 { + panic("no return value specified for GetCellularProfile") + } + + var r0 *network.CellularProfile + var r1 error + if rf, ok := ret.Get(0).(func(string) (*network.CellularProfile, error)); ok { + return rf(uuidOrName) + } + if rf, ok := ret.Get(0).(func(string) *network.CellularProfile); ok { + r0 = rf(uuidOrName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*network.CellularProfile) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(uuidOrName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type MockBackend_GetCellularProfile_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) GetCellularProfile(uuidOrName interface{}) *MockBackend_GetCellularProfile_Call { + return &MockBackend_GetCellularProfile_Call{Call: _e.mock.On("GetCellularProfile", uuidOrName)} +} + +// ListActiveCellular provides a mock function with given fields: +func (_m *MockBackend) ListActiveCellular() ([]network.CellularActive, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ListActiveCellular") + } + + var r0 []network.CellularActive + var r1 error + if rf, ok := ret.Get(0).(func() ([]network.CellularActive, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []network.CellularActive); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]network.CellularActive) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type MockBackend_ListActiveCellular_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) ListActiveCellular() *MockBackend_ListActiveCellular_Call { + return &MockBackend_ListActiveCellular_Call{Call: _e.mock.On("ListActiveCellular")} +} + +// ListCellularProfiles provides a mock function with given fields: +func (_m *MockBackend) ListCellularProfiles() ([]network.CellularProfile, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ListCellularProfiles") + } + + var r0 []network.CellularProfile + var r1 error + if rf, ok := ret.Get(0).(func() ([]network.CellularProfile, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []network.CellularProfile); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]network.CellularProfile) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type MockBackend_ListCellularProfiles_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) ListCellularProfiles() *MockBackend_ListCellularProfiles_Call { + return &MockBackend_ListCellularProfiles_Call{Call: _e.mock.On("ListCellularProfiles")} +} + +// SetCellularEnabled provides a mock function with given fields: enabled +func (_m *MockBackend) SetCellularEnabled(enabled bool) error { + ret := _m.Called(enabled) + + if len(ret) == 0 { + panic("no return value specified for SetCellularEnabled") + } + + var r0 error + if rf, ok := ret.Get(0).(func(bool) error); ok { + r0 = rf(enabled) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type MockBackend_SetCellularEnabled_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) SetCellularEnabled(enabled interface{}) *MockBackend_SetCellularEnabled_Call { + return &MockBackend_SetCellularEnabled_Call{Call: _e.mock.On("SetCellularEnabled", enabled)} +} + +// UpdateCellularProfile provides a mock function with given fields: uuid, updates +func (_m *MockBackend) UpdateCellularProfile(uuid string, updates map[string]interface{}) error { + ret := _m.Called(uuid, updates) + + if len(ret) == 0 { + panic("no return value specified for UpdateCellularProfile") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, map[string]interface{}) error); ok { + r0 = rf(uuid, updates) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type MockBackend_UpdateCellularProfile_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) UpdateCellularProfile(uuid interface{}, updates interface{}) *MockBackend_UpdateCellularProfile_Call { + return &MockBackend_UpdateCellularProfile_Call{Call: _e.mock.On("UpdateCellularProfile", uuid, updates)} +} + +// GetSIMStatus provides a mock function with given fields: device +func (_m *MockBackend) GetSIMStatus(device string) (*network.CellularDevice, error) { + ret := _m.Called(device) + + if len(ret) == 0 { + panic("no return value specified for GetSIMStatus") + } + + var r0 *network.CellularDevice + var r1 error + if rf, ok := ret.Get(0).(func(string) (*network.CellularDevice, error)); ok { + return rf(device) + } + if rf, ok := ret.Get(0).(func(string) *network.CellularDevice); ok { + r0 = rf(device) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*network.CellularDevice) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(device) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type MockBackend_GetSIMStatus_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) GetSIMStatus(device interface{}) *MockBackend_GetSIMStatus_Call { + return &MockBackend_GetSIMStatus_Call{Call: _e.mock.On("GetSIMStatus", device)} +} + +// SubmitSIMPin provides a mock function with given fields: device, pin +func (_m *MockBackend) SubmitSIMPin(device string, pin string) error { + ret := _m.Called(device, pin) + + if len(ret) == 0 { + panic("no return value specified for SubmitSIMPin") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(device, pin) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type MockBackend_SubmitSIMPin_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) SubmitSIMPin(device interface{}, pin interface{}) *MockBackend_SubmitSIMPin_Call { + return &MockBackend_SubmitSIMPin_Call{Call: _e.mock.On("SubmitSIMPin", device, pin)} +} + +// GetSIMPinTriesLeft provides a mock function with given fields: device +func (_m *MockBackend) GetSIMPinTriesLeft(device string) (int, error) { + ret := _m.Called(device) + + if len(ret) == 0 { + panic("no return value specified for GetSIMPinTriesLeft") + } + + var r0 int + var r1 error + if rf, ok := ret.Get(0).(func(string) (int, error)); ok { + return rf(device) + } + if rf, ok := ret.Get(0).(func(string) int); ok { + r0 = rf(device) + } else { + r0 = ret.Get(0).(int) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(device) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type MockBackend_GetSIMPinTriesLeft_Call struct { + *mock.Call +} + +func (_e *MockBackend_Expecter) GetSIMPinTriesLeft(device interface{}) *MockBackend_GetSIMPinTriesLeft_Call { + return &MockBackend_GetSIMPinTriesLeft_Call{Call: _e.mock.On("GetSIMPinTriesLeft", device)} +} + // NewMockBackend creates a new instance of MockBackend. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockBackend(t interface { diff --git a/core/internal/server/network/backend.go b/core/internal/server/network/backend.go index f1bb04d9c..e73bdb3ab 100644 --- a/core/internal/server/network/backend.go +++ b/core/internal/server/network/backend.go @@ -27,6 +27,23 @@ type Backend interface { DisconnectEthernetDevice(device string) error ActivateWiredConnection(uuid string) error + GetCellularEnabled() (bool, error) + SetCellularEnabled(enabled bool) error + GetCellularDevices() []CellularDevice + GetCellularConnections() ([]CellularConnection, error) + GetCellularNetworkDetails(uuid string) (*CellularNetworkInfoResponse, error) + ConnectCellular(uuid string) error + DisconnectCellular() error + DisconnectCellularDevice(device string) error + ActivateCellularConnection(uuid string) error + ListCellularProfiles() ([]CellularProfile, error) + ListActiveCellular() ([]CellularActive, error) + GetCellularProfile(uuidOrName string) (*CellularProfile, error) + UpdateCellularProfile(uuid string, updates map[string]any) error + GetSIMStatus(device string) (*CellularDevice, error) + SubmitSIMPin(device string, pin string) error + GetSIMPinTriesLeft(device string) (int, error) + ListVPNProfiles() ([]VPNProfile, error) ListActiveVPN() ([]VPNActive, error) ConnectVPN(uuidOrName string, singleActive bool) error @@ -69,6 +86,17 @@ type BackendState struct { WiFiNetworks []WiFiNetwork WiFiDevices []WiFiDevice WiredConnections []WiredConnection + CellularIP string + CellularDevice string + CellularConnected bool + CellularEnabled bool + CellularOperator string + CellularTechnology string + CellularSignal uint8 + CellularDevices []CellularDevice + CellularConnections []CellularConnection + CellularProfiles []CellularProfile + CellularActive []CellularActive VPNProfiles []VPNProfile VPNActive []VPNActive IsConnecting bool diff --git a/core/internal/server/network/backend_hybrid_iwd_networkd.go b/core/internal/server/network/backend_hybrid_iwd_networkd.go index ce883ba26..44a0ac516 100644 --- a/core/internal/server/network/backend_hybrid_iwd_networkd.go +++ b/core/internal/server/network/backend_hybrid_iwd_networkd.go @@ -243,3 +243,67 @@ func (b *HybridIwdNetworkdBackend) GetWiFiDevices() []WiFiDevice { func (b *HybridIwdNetworkdBackend) SetVPNCredentials(uuid, username, password string, save bool) error { return fmt.Errorf("VPN not supported in hybrid mode") } + +func (b *HybridIwdNetworkdBackend) GetCellularEnabled() (bool, error) { + return false, fmt.Errorf("cellular not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) SetCellularEnabled(enabled bool) error { + return fmt.Errorf("cellular not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) GetCellularDevices() []CellularDevice { + return []CellularDevice{} +} + +func (b *HybridIwdNetworkdBackend) GetCellularConnections() ([]CellularConnection, error) { + return nil, fmt.Errorf("cellular not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) GetCellularNetworkDetails(uuid string) (*CellularNetworkInfoResponse, error) { + return nil, fmt.Errorf("cellular not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) ConnectCellular(uuid string) error { + return fmt.Errorf("cellular not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) DisconnectCellular() error { + return fmt.Errorf("cellular not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) DisconnectCellularDevice(device string) error { + return fmt.Errorf("cellular not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) ActivateCellularConnection(uuid string) error { + return fmt.Errorf("cellular not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) ListCellularProfiles() ([]CellularProfile, error) { + return []CellularProfile{}, nil +} + +func (b *HybridIwdNetworkdBackend) ListActiveCellular() ([]CellularActive, error) { + return []CellularActive{}, nil +} + +func (b *HybridIwdNetworkdBackend) GetCellularProfile(uuidOrName string) (*CellularProfile, error) { + return nil, fmt.Errorf("cellular not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) UpdateCellularProfile(uuid string, updates map[string]any) error { + return fmt.Errorf("cellular not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) GetSIMStatus(device string) (*CellularDevice, error) { + return nil, fmt.Errorf("cellular not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) SubmitSIMPin(device string, pin string) error { + return fmt.Errorf("cellular not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) GetSIMPinTriesLeft(device string) (int, error) { + return 0, fmt.Errorf("cellular not supported in hybrid mode") +} diff --git a/core/internal/server/network/backend_iwd_unimplemented.go b/core/internal/server/network/backend_iwd_unimplemented.go index f5a8b033a..085b254ff 100644 --- a/core/internal/server/network/backend_iwd_unimplemented.go +++ b/core/internal/server/network/backend_iwd_unimplemented.go @@ -131,3 +131,67 @@ func (b *IWDBackend) GetWiFiQRCodeContent(ssid string) (string, error) { return FormatWiFiQRString("WPA", ssid, passphrase), nil } + +func (b *IWDBackend) GetCellularEnabled() (bool, error) { + return false, fmt.Errorf("cellular not supported by iwd backend") +} + +func (b *IWDBackend) SetCellularEnabled(enabled bool) error { + return fmt.Errorf("cellular not supported by iwd backend") +} + +func (b *IWDBackend) GetCellularDevices() []CellularDevice { + return []CellularDevice{} +} + +func (b *IWDBackend) GetCellularConnections() ([]CellularConnection, error) { + return []CellularConnection{}, nil +} + +func (b *IWDBackend) GetCellularNetworkDetails(uuid string) (*CellularNetworkInfoResponse, error) { + return nil, fmt.Errorf("cellular not supported by iwd backend") +} + +func (b *IWDBackend) ConnectCellular(uuid string) error { + return fmt.Errorf("cellular not supported by iwd backend") +} + +func (b *IWDBackend) DisconnectCellular() error { + return fmt.Errorf("cellular not supported by iwd backend") +} + +func (b *IWDBackend) DisconnectCellularDevice(device string) error { + return fmt.Errorf("cellular not supported by iwd backend") +} + +func (b *IWDBackend) ActivateCellularConnection(uuid string) error { + return fmt.Errorf("cellular not supported by iwd backend") +} + +func (b *IWDBackend) ListCellularProfiles() ([]CellularProfile, error) { + return []CellularProfile{}, nil +} + +func (b *IWDBackend) ListActiveCellular() ([]CellularActive, error) { + return []CellularActive{}, nil +} + +func (b *IWDBackend) GetCellularProfile(uuidOrName string) (*CellularProfile, error) { + return nil, fmt.Errorf("cellular not supported by iwd backend") +} + +func (b *IWDBackend) UpdateCellularProfile(uuid string, updates map[string]any) error { + return fmt.Errorf("cellular not supported by iwd backend") +} + +func (b *IWDBackend) GetSIMStatus(device string) (*CellularDevice, error) { + return nil, fmt.Errorf("cellular not supported by iwd backend") +} + +func (b *IWDBackend) SubmitSIMPin(device string, pin string) error { + return fmt.Errorf("cellular not supported by iwd backend") +} + +func (b *IWDBackend) GetSIMPinTriesLeft(device string) (int, error) { + return 0, fmt.Errorf("cellular 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..eba839e9f 100644 --- a/core/internal/server/network/backend_networkd_unimplemented.go +++ b/core/internal/server/network/backend_networkd_unimplemented.go @@ -97,3 +97,67 @@ func (b *SystemdNetworkdBackend) DisconnectWiFiDevice(device string) error { func (b *SystemdNetworkdBackend) GetWiFiDevices() []WiFiDevice { return nil } + +func (b *SystemdNetworkdBackend) GetCellularEnabled() (bool, error) { + return false, fmt.Errorf("cellular not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) SetCellularEnabled(enabled bool) error { + return fmt.Errorf("cellular not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) GetCellularDevices() []CellularDevice { + return []CellularDevice{} +} + +func (b *SystemdNetworkdBackend) GetCellularConnections() ([]CellularConnection, error) { + return nil, fmt.Errorf("cellular not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) GetCellularNetworkDetails(uuid string) (*CellularNetworkInfoResponse, error) { + return nil, fmt.Errorf("cellular not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ConnectCellular(uuid string) error { + return fmt.Errorf("cellular not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) DisconnectCellular() error { + return fmt.Errorf("cellular not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) DisconnectCellularDevice(device string) error { + return fmt.Errorf("cellular not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ActivateCellularConnection(uuid string) error { + return fmt.Errorf("cellular not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ListCellularProfiles() ([]CellularProfile, error) { + return []CellularProfile{}, nil +} + +func (b *SystemdNetworkdBackend) ListActiveCellular() ([]CellularActive, error) { + return []CellularActive{}, nil +} + +func (b *SystemdNetworkdBackend) GetCellularProfile(uuidOrName string) (*CellularProfile, error) { + return nil, fmt.Errorf("cellular not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) UpdateCellularProfile(uuid string, updates map[string]any) error { + return fmt.Errorf("cellular not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) GetSIMStatus(device string) (*CellularDevice, error) { + return nil, fmt.Errorf("cellular not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) SubmitSIMPin(device string, pin string) error { + return fmt.Errorf("cellular not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) GetSIMPinTriesLeft(device string) (int, error) { + return 0, fmt.Errorf("cellular not supported by networkd backend") +} diff --git a/core/internal/server/network/backend_networkmanager.go b/core/internal/server/network/backend_networkmanager.go index 2233975a2..326b91c17 100644 --- a/core/internal/server/network/backend_networkmanager.go +++ b/core/internal/server/network/backend_networkmanager.go @@ -136,6 +136,17 @@ func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*Netwo } func (b *NetworkManagerBackend) Initialize() error { + // Initialize D-Bus connection for ModemManager queries + if b.dbusConn == nil { + conn, err := dbus.ConnectSystemBus() + if err != nil { + log.Error("Failed to connect to D-Bus system bus for ModemManager: %v", err) + } else { + b.dbusConn = conn + log.Debug("D-Bus system bus connection established for ModemManager") + } + } + nm := b.nmConn.(gonetworkmanager.NetworkManager) if s, err := gonetworkmanager.NewSettings(); err == nil { @@ -243,6 +254,17 @@ func (b *NetworkManagerBackend) Initialize() error { log.Warnf("Failed to get initial active VPNs: %v", err) } + b.updateCellularState() + if _, err := b.listCellularConnections(); err != nil { + log.Warnf("Failed to get initial cellular connections: %v", err) + } + if _, err := b.ListCellularProfiles(); err != nil { + log.Warnf("Failed to get initial cellular profiles: %v", err) + } + if _, err := b.ListActiveCellular(); err != nil { + log.Warnf("Failed to get initial active cellular: %v", err) + } + return nil } @@ -266,6 +288,10 @@ func (b *NetworkManagerBackend) GetCurrentState() (*BackendState, error) { state.EthernetDevices = append([]EthernetDevice(nil), b.state.EthernetDevices...) state.VPNProfiles = append([]VPNProfile(nil), b.state.VPNProfiles...) state.VPNActive = append([]VPNActive(nil), b.state.VPNActive...) + state.CellularDevices = append([]CellularDevice(nil), b.state.CellularDevices...) + state.CellularConnections = append([]CellularConnection(nil), b.state.CellularConnections...) + state.CellularProfiles = append([]CellularProfile(nil), b.state.CellularProfiles...) + state.CellularActive = append([]CellularActive(nil), b.state.CellularActive...) return &state, nil } 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..025646f1e --- /dev/null +++ b/core/internal/server/network/backend_networkmanager_cellular.go @@ -0,0 +1,1041 @@ +package network + +import ( + "fmt" + "net" + "strconv" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/Wifx/gonetworkmanager/v2" + "github.com/godbus/dbus/v5" +) + +// ModemManager D-Bus constants +const ( + mmInterface = "org.freedesktop.ModemManager1" + mmModemInterface = "org.freedesktop.ModemManager1.Modem" + mmModem3GPP = "org.freedesktop.ModemManager1.Modem.Modem3gpp" + mmModemCDMA = "org.freedesktop.ModemManager1.Modem.ModemCdma" + mmPath = "/org/freedesktop/ModemManager1" + mmObjectManager = "org.freedesktop.DBus.ObjectManager" +) + +// getModemManagerCellularDetails fetches cellular details from ModemManager via D-Bus +func (b *NetworkManagerBackend) getModemManagerCellularDetails(iface string) (operator, technology string, signal uint8) { + log.Debug("getModemManagerCellularDetails called for interface: %s", iface) + + if b.dbusConn == nil { + log.Warn("D-Bus connection is nil, cannot fetch ModemManager data") + return "", "", 0 + } + + // Get list of modems from ObjectManager + var result map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err := b.dbusConn.Object(mmInterface, mmPath).Call(mmObjectManager+".GetManagedObjects", 0).Store(&result) + if err != nil { + log.Error("Failed to get ModemManager modems: %v", err) + return "", "", 0 + } + + log.Debug("Found %d ModemManager objects", len(result)) + + // Find the modem matching our interface + for _, obj := range result { + modemData, ok := obj[mmModemInterface] + if !ok { + continue + } + + // Get the primary port/interface + var device string + if v, ok := modemData["PrimaryPort"]; ok { + device = v.Value().(string) + } else if v, ok := modemData["Device"]; ok { + device = v.Value().(string) + } + + log.Debug("Checking modem with device: %s against iface: %s", device, iface) + + // Match by interface name (e.g., "wwan0mbim0") + if device != iface && !strings.Contains(device, iface) && !strings.Contains(iface, device) { + log.Debug("Modem device %s does not match interface %s", device, iface) + continue + } + + log.Debug("Found matching modem for interface %s", iface) + + // Get signal quality + if v, ok := modemData["SignalQuality"]; ok { + // SignalQuality is a struct (array) with [signal_strength, recent] + sigData := v.Value().([]any) + if len(sigData) > 0 { + switch val := sigData[0].(type) { + case uint32: + signal = uint8(val) + case int32: + signal = uint8(val) + case uint8: + signal = val + } + } + } + + // Get access technology + if v, ok := modemData["AccessTechnologies"]; ok { + tech := v.Value().(uint32) + technology = convertAccessTechnology(tech) + } + + // Try 3GPP interface for operator name + if gppData, ok := obj[mmModem3GPP]; ok { + if v, ok := gppData["OperatorName"]; ok { + operator = v.Value().(string) + } + } + + // Fallback to CDMA interface for operator name + if operator == "" { + if cdmaData, ok := obj[mmModemCDMA]; ok { + if v, ok := cdmaData["Nid"]; ok { + nid := v.Value().(uint32) + operator = fmt.Sprintf("CDMA Network %d", nid) + } + } + } + + // Get operator from 3GPP registration state if not found + if operator == "" { + if gppData, ok := obj[mmModem3GPP]; ok { + if v, ok := gppData["RegistrationState"]; ok { + state := v.Value().(uint32) + // 1 = idle, 2 = home, 3 = searching, 4 = denied, 5 = roaming + switch state { + case 2: + operator = "Home Network" + case 5: + operator = "Roaming" + } + } + } + } + + log.Debug("ModemManager cellular details for %s: operator=%s, tech=%s, signal=%d", iface, operator, technology, signal) + return operator, technology, signal + } + + log.Warn("No matching modem found for interface %s", iface) + return "", "", 0 +} + +// convertAccessTechnology converts ModemManager access tech bits to string +func convertAccessTechnology(tech uint32) string { + // ModemManager MM_MODEM_ACCESS_TECHNOLOGY_* constants + const ( + MM_MODEM_ACCESS_TECHNOLOGY_UNKNOWN = 0 + MM_MODEM_ACCESS_TECHNOLOGY_GPRS = 1 << 0 + MM_MODEM_ACCESS_TECHNOLOGY_EDGE = 1 << 1 + MM_MODEM_ACCESS_TECHNOLOGY_UMTS = 1 << 2 + MM_MODEM_ACCESS_TECHNOLOGY_HSDPA = 1 << 3 + MM_MODEM_ACCESS_TECHNOLOGY_HSUPA = 1 << 4 + MM_MODEM_ACCESS_TECHNOLOGY_HSPA = 1 << 5 + MM_MODEM_ACCESS_TECHNOLOGY_HSPA_PLUS = 1 << 6 + MM_MODEM_ACCESS_TECHNOLOGY_1XRTT = 1 << 7 + MM_MODEM_ACCESS_TECHNOLOGY_EVDO0 = 1 << 8 + MM_MODEM_ACCESS_TECHNOLOGY_EVDOA = 1 << 9 + MM_MODEM_ACCESS_TECHNOLOGY_EVDOB = 1 << 10 + MM_MODEM_ACCESS_TECHNOLOGY_LTE = 1 << 11 + MM_MODEM_ACCESS_TECHNOLOGY_5GNR = 1 << 12 + MM_MODEM_ACCESS_TECHNOLOGY_LTE_CAT_M = 1 << 13 + MM_MODEM_ACCESS_TECHNOLOGY_LTE_NB_IOT = 1 << 14 + ) + + // Return the highest/best technology + switch { + case tech&MM_MODEM_ACCESS_TECHNOLOGY_5GNR != 0: + return "5G" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_LTE != 0: + return "4G" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_LTE_CAT_M != 0: + return "LTE-M" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_LTE_NB_IOT != 0: + return "NB-IoT" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_HSPA_PLUS != 0: + return "HSPA+" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_HSPA != 0: + return "HSPA" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_HSDPA != 0: + return "HSDPA" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_HSUPA != 0: + return "HSUPA" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_UMTS != 0: + return "3G" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_EDGE != 0: + return "EDGE" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_GPRS != 0: + return "GPRS" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_EVDOB != 0: + return "EV-DO B" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_EVDOA != 0: + return "EV-DO A" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_EVDO0 != 0: + return "EV-DO" + case tech&MM_MODEM_ACCESS_TECHNOLOGY_1XRTT != 0: + return "1xRTT" + default: + return "" + } +} + +func (b *NetworkManagerBackend) GetCellularEnabled() (bool, error) { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + return nm.GetPropertyWwanEnabled() +} + +func (b *NetworkManagerBackend) SetCellularEnabled(enabled bool) error { + // gonetworkmanager lacks SetPropertyWwanEnabled, use raw D-Bus + conn := b.dbusConn + if conn == nil { + var err error + conn, err = dbus.ConnectSystemBus() + if err != nil { + return fmt.Errorf("failed to connect to system bus: %w", err) + } + defer conn.Close() + } + obj := conn.Object(dbusNMInterface, dbus.ObjectPath(dbusNMPath)) + err := obj.Call(dbusPropsInterface+".Set", 0, dbusNMInterface, "WwanEnabled", dbus.MakeVariant(enabled)).Err + if err != nil { + return fmt.Errorf("failed to set cellular enabled: %w", err) + } + + b.stateMutex.Lock() + b.state.CellularEnabled = enabled + b.stateMutex.Unlock() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) GetCellularDevices() []CellularDevice { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + return append([]CellularDevice(nil), b.state.CellularDevices...) +} + +func (b *NetworkManagerBackend) GetCellularConnections() ([]CellularConnection, error) { + return b.listCellularConnections() +} + +func (b *NetworkManagerBackend) GetCellularNetworkDetails(uuid string) (*CellularNetworkInfoResponse, error) { + // Find the cellular device + b.stateMutex.RLock() + devices := b.state.CellularDevices + b.stateMutex.RUnlock() + + if len(devices) == 0 { + return nil, fmt.Errorf("no cellular device available") + } + + // For now, use the first available device + dev := devices[0] + + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return nil, fmt.Errorf("failed to get connections: %w", err) + } + + var targetConn gonetworkmanager.Connection + for _, conn := range connections { + connSettings, err := conn.GetSettings() + if err != nil { + continue + } + + if connMeta, ok := connSettings["connection"]; ok { + if connType, ok := connMeta["type"].(string); ok && (connType == "gsm" || connType == "cdma") { + if connUUID, ok := connMeta["uuid"].(string); ok && connUUID == uuid { + targetConn = conn + break + } + } + } + } + + if targetConn == nil { + return nil, fmt.Errorf("cellular connection with UUID %s not found", uuid) + } + + var ipv4Config CellularIPConfig + var ipv6Config CellularIPConfig + + // Find active connection to get IP info + activeConns, err := b.getActiveConnections() + if err == nil && activeConns[uuid] { + // Look for the active connection with this UUID + nm := b.nmConn.(gonetworkmanager.NetworkManager) + activeConnections, _ := nm.GetPropertyActiveConnections() + for _, activeConn := range activeConnections { + conn, _ := activeConn.GetPropertyConnection() + if conn == nil { + continue + } + connSettings, _ := conn.GetSettings() + if connMeta, ok := connSettings["connection"]; ok { + if connUUID, ok := connMeta["uuid"].(string); ok && connUUID == uuid { + // Found active connection, get IP config + ip4Config, err := activeConn.GetPropertyIP4Config() + if err == nil && ip4Config != nil { + var ips []string + addresses, err := ip4Config.GetPropertyAddressData() + if err == nil && len(addresses) > 0 { + for _, addr := range addresses { + ips = append(ips, fmt.Sprintf("%s/%s", addr.Address, strconv.Itoa(int(addr.Prefix)))) + } + } + + gateway, _ := ip4Config.GetPropertyGateway() + dnsAddrs := "" + dns, err := ip4Config.GetPropertyNameserverData() + if err == nil && len(dns) > 0 { + for _, d := range dns { + if len(dnsAddrs) > 0 { + dnsAddrs = strings.Join([]string{dnsAddrs, d.Address}, "; ") + } else { + dnsAddrs = d.Address + } + } + } + + ipv4Config = CellularIPConfig{ + IPs: ips, + Gateway: gateway, + DNS: dnsAddrs, + } + } + + ip6Config, err := activeConn.GetPropertyIP6Config() + if err == nil && ip6Config != nil { + var ips []string + addresses, err := ip6Config.GetPropertyAddressData() + if err == nil && len(addresses) > 0 { + for _, addr := range addresses { + ips = append(ips, fmt.Sprintf("%s/%s", addr.Address, strconv.Itoa(int(addr.Prefix)))) + } + } + + gateway, _ := ip6Config.GetPropertyGateway() + dnsAddrs := "" + dns, err := ip6Config.GetPropertyNameservers() + if err == nil && len(dns) > 0 { + for _, d := range dns { + if len(d) == 16 { + ip := net.IP(d) + if len(dnsAddrs) > 0 { + dnsAddrs = strings.Join([]string{dnsAddrs, ip.String()}, "; ") + } else { + dnsAddrs = ip.String() + } + } + } + } + + ipv6Config = CellularIPConfig{ + IPs: ips, + Gateway: gateway, + DNS: dnsAddrs, + } + } + break + } + } + } + } + + return &CellularNetworkInfoResponse{ + UUID: uuid, + IFace: dev.Name, + HwAddr: dev.HwAddress, + IMEI: dev.IMEI, + Operator: dev.Operator, + Technology: dev.Technology, + Signal: dev.Signal, + IPv4: ipv4Config, + IPv6: ipv6Config, + }, nil +} + +func (b *NetworkManagerBackend) ConnectCellular(uuid string) error { + b.stateMutex.RLock() + devices := b.state.CellularDevices + b.stateMutex.RUnlock() + + if len(devices) == 0 { + return fmt.Errorf("no cellular device available") + } + + nm := b.nmConn.(gonetworkmanager.NetworkManager) + + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + var targetConn gonetworkmanager.Connection + for _, conn := range connections { + connSettings, err := conn.GetSettings() + if err != nil { + continue + } + + if connMeta, ok := connSettings["connection"]; ok { + if connUUID, ok := connMeta["uuid"].(string); ok && connUUID == uuid { + targetConn = conn + break + } + } + } + + if targetConn == nil { + return fmt.Errorf("connection with UUID %s not found", uuid) + } + + // Find the cellular device to activate on + devicesList, err := nm.GetDevices() + if err != nil { + return fmt.Errorf("failed to get devices: %w", err) + } + + var targetDevice gonetworkmanager.Device + for _, dev := range devicesList { + devType, err := dev.GetPropertyDeviceType() + if err != nil { + continue + } + // NmDeviceTypeModem = 8 + if devType == 8 { + targetDevice = dev + break + } + } + + if targetDevice == nil { + return fmt.Errorf("no modem device available") + } + + _, err = nm.ActivateConnection(targetConn, targetDevice, nil) + if err != nil { + return fmt.Errorf("failed to activate cellular connection: %w", err) + } + + b.updateCellularState() + b.listCellularConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) DisconnectCellular() error { + b.stateMutex.RLock() + devices := b.state.CellularDevices + b.stateMutex.RUnlock() + + if len(devices) == 0 { + return fmt.Errorf("no cellular device available") + } + + nm := b.nmConn.(gonetworkmanager.NetworkManager) + activeConnections, err := nm.GetPropertyActiveConnections() + if err != nil { + return fmt.Errorf("failed to get active connections: %w", err) + } + + for _, activeConn := range activeConnections { + conn, err := activeConn.GetPropertyConnection() + if err != nil || conn == nil { + continue + } + + connSettings, err := conn.GetSettings() + if err != nil { + continue + } + + if connMeta, ok := connSettings["connection"]; ok { + if connType, ok := connMeta["type"].(string); ok && (connType == "gsm" || connType == "cdma") { + err := nm.DeactivateConnection(activeConn) + if err != nil { + return fmt.Errorf("failed to deactivate cellular connection: %w", err) + } + break + } + } + } + + b.updateCellularState() + b.listCellularConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) DisconnectCellularDevice(device string) error { + return b.DisconnectCellular() +} + +func (b *NetworkManagerBackend) ActivateCellularConnection(uuid string) error { + return b.ConnectCellular(uuid) +} + +func (b *NetworkManagerBackend) ListCellularProfiles() ([]CellularProfile, error) { + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return nil, fmt.Errorf("failed to get connections: %w", err) + } + + profiles := make([]CellularProfile, 0) + activeUUIDs, err := b.getActiveConnections() + if err != nil { + activeUUIDs = make(map[string]bool) + } + + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + + connMeta, ok := settings["connection"] + if !ok { + continue + } + + connType, _ := connMeta["type"].(string) + if connType != "gsm" && connType != "cdma" { + continue + } + + connID, _ := connMeta["id"].(string) + connUUID, _ := connMeta["uuid"].(string) + + autoconnect := true + if ac, ok := connMeta["autoconnect"].(bool); ok { + autoconnect = ac + } + + apn := "" + if gsmSettings, ok := settings["gsm"]; ok { + if apnVal, ok := gsmSettings["apn"].(string); ok { + apn = apnVal + } + } + + profile := CellularProfile{ + UUID: connUUID, + Name: connID, + APN: apn, + Autoconnect: autoconnect, + } + + // Only show username if saved + if gsmSettings, ok := settings["gsm"]; ok { + if username, ok := gsmSettings["username"].(string); ok && username != "" { + profile.Username = username + } + } + + profiles = append(profiles, profile) + _ = activeUUIDs[connUUID] // Mark as seen + } + + return profiles, nil +} + +func (b *NetworkManagerBackend) ListActiveCellular() ([]CellularActive, error) { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + activeConnections, err := nm.GetPropertyActiveConnections() + if err != nil { + return nil, fmt.Errorf("failed to get active connections: %w", err) + } + + active := make([]CellularActive, 0) + + for _, activeConn := range activeConnections { + conn, err := activeConn.GetPropertyConnection() + if err != nil || conn == nil { + continue + } + + connSettings, err := conn.GetSettings() + if err != nil { + continue + } + + connMeta, ok := connSettings["connection"] + if !ok { + continue + } + + connType, _ := connMeta["type"].(string) + if connType != "gsm" && connType != "cdma" { + continue + } + + connID, _ := connMeta["id"].(string) + connUUID, _ := connMeta["uuid"].(string) + + state, _ := activeConn.GetPropertyState() + stateStr := "unknown" + switch state { + case gonetworkmanager.NmActiveConnectionStateActivating: + stateStr = "activating" + case gonetworkmanager.NmActiveConnectionStateActivated: + stateStr = "activated" + case gonetworkmanager.NmActiveConnectionStateDeactivating: + stateStr = "deactivating" + case gonetworkmanager.NmActiveConnectionStateDeactivated: + stateStr = "deactivated" + } + + ipConfig, _ := activeConn.GetPropertyIP4Config() + ip := "" + if ipConfig != nil { + addresses, _ := ipConfig.GetPropertyAddressData() + if len(addresses) > 0 { + ip = addresses[0].Address + } + } + + // Get device for this connection to match with ModemManager + deviceName := "" + if dev, err := activeConn.GetPropertyDevices(); err == nil && len(dev) > 0 { + deviceName, _ = dev[0].GetPropertyInterface() + } + + // Fetch cellular details from ModemManager + operator, technology, signal := b.getModemManagerCellularDetails(deviceName) + + active = append(active, CellularActive{ + Name: connID, + UUID: connUUID, + State: stateStr, + IP: ip, + Device: deviceName, + Operator: operator, + Technology: technology, + Signal: signal, + }) + } + + return active, nil +} + +func (b *NetworkManagerBackend) GetCellularProfile(uuidOrName string) (*CellularProfile, error) { + profiles, err := b.ListCellularProfiles() + if err != nil { + return nil, err + } + + for _, profile := range profiles { + if profile.UUID == uuidOrName || profile.Name == uuidOrName { + return &profile, nil + } + } + + return nil, fmt.Errorf("cellular profile not found: %s", uuidOrName) +} + +func (b *NetworkManagerBackend) UpdateCellularProfile(uuid string, updates map[string]any) error { + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + + connMeta, ok := settings["connection"] + if !ok { + continue + } + + connType, _ := connMeta["type"].(string) + if connType != "gsm" && connType != "cdma" { + continue + } + + existingUUID, _ := connMeta["uuid"].(string) + if existingUUID != uuid { + continue + } + + if name, ok := updates["name"].(string); ok && name != "" { + connMeta["id"] = name + } + + if autoconnect, ok := updates["autoconnect"].(bool); ok { + connMeta["autoconnect"] = autoconnect + } + + if apn, ok := updates["apn"].(string); ok && apn != "" { + if gsmSettings, ok := settings["gsm"]; ok { + gsmSettings["apn"] = apn + } + } + + if username, ok := updates["username"].(string); ok { + if gsmSettings, ok := settings["gsm"]; ok { + gsmSettings["username"] = username + } + } + + if password, ok := updates["password"].(string); ok && password != "" { + if gsmSettings, ok := settings["gsm"]; ok { + gsmSettings["password"] = password + } + } + + if ipv4, ok := settings["ipv4"]; ok { + delete(ipv4, "addresses") + delete(ipv4, "routes") + delete(ipv4, "dns") + } + if ipv6, ok := settings["ipv6"]; ok { + delete(ipv6, "addresses") + delete(ipv6, "routes") + delete(ipv6, "dns") + } + + if err := conn.Update(settings); err != nil { + return fmt.Errorf("failed to update connection: %w", err) + } + + b.ListCellularProfiles() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil + } + + return fmt.Errorf("cellular connection not found: %s", uuid) +} + +func (b *NetworkManagerBackend) listCellularConnections() ([]CellularConnection, error) { + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return nil, fmt.Errorf("failed to get connections: %w", err) + } + + cellularConns := make([]CellularConnection, 0) + activeUUIDs, err := b.getActiveConnections() + if err != nil { + activeUUIDs = make(map[string]bool) + } + + for _, connection := range connections { + path := connection.GetPath() + settings, err := connection.GetSettings() + if err != nil { + log.Errorf("unable to get settings for %s: %v", path, err) + continue + } + + connectionSettings := settings["connection"] + connType, _ := connectionSettings["type"].(string) + connID, _ := connectionSettings["id"].(string) + connUUID, _ := connectionSettings["uuid"].(string) + + if connType == "gsm" || connType == "cdma" { + apn := "" + if gsmSettings, ok := settings["gsm"]; ok { + if apnVal, ok := gsmSettings["apn"].(string); ok { + apn = apnVal + } + } + + cellularConns = append(cellularConns, CellularConnection{ + Path: path, + ID: connID, + UUID: connUUID, + Type: connType, + IsActive: activeUUIDs[connUUID], + APN: apn, + }) + } + } + + b.stateMutex.Lock() + b.state.CellularConnections = cellularConns + b.stateMutex.Unlock() + + return cellularConns, nil +} + +func (b *NetworkManagerBackend) GetSIMStatus(device string) (*CellularDevice, error) { + devices, err := b.getModemDevices() + if err != nil { + return nil, err + } + + if len(devices) == 0 { + return nil, fmt.Errorf("no cellular devices found") + } + + // Return first device when no specific device is requested + if device == "" { + return &devices[0], nil + } + + for _, dev := range devices { + if dev.Name == device || dev.IMEI == device { + return &dev, nil + } + } + + return nil, fmt.Errorf("cellular device not found: %s", device) +} + +func (b *NetworkManagerBackend) SubmitSIMPin(device string, pin string) error { + // TODO: For actual SIM unlock, use ModemManager D-Bus: + // org.freedesktop.ModemManager1.Sim.SendPin(pin) + // Current approach stores PIN in connection settings for auto-unlock on activation + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + // Find the first GSM connection and update it with the PIN + for _, conn := range connections { + connSettings, err := conn.GetSettings() + if err != nil { + continue + } + + if connMeta, ok := connSettings["connection"]; ok { + if connType, ok := connMeta["type"].(string); ok && (connType == "gsm" || connType == "cdma") { + // Update GSM settings with PIN + if gsmSettings, ok := connSettings["gsm"]; ok { + gsmSettings["pin"] = pin + } else { + connSettings["gsm"] = map[string]any{ + "pin": pin, + } + } + + if err := conn.Update(connSettings); err != nil { + return fmt.Errorf("failed to update connection with PIN: %w", err) + } + + // Try to activate the connection + return b.ConnectCellular(connMeta["uuid"].(string)) + } + } + } + + return fmt.Errorf("no cellular connection found to submit PIN") +} + +func (b *NetworkManagerBackend) GetSIMPinTriesLeft(device string) (int, error) { + // TODO: Query ModemManager D-Bus for real PIN retry count: + // org.freedesktop.ModemManager1.Sim → RetriesLeft property + return 3, nil +} + +func (b *NetworkManagerBackend) getModemDevices() ([]CellularDevice, error) { + return b.GetCellularDevices(), nil +} + +func (b *NetworkManagerBackend) updateCellularState() { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + + // Check WWAN enabled + wwanEnabled, _ := nm.GetPropertyWwanEnabled() + + // Get all devices + devices, err := nm.GetDevices() + if err != nil { + return + } + + cellularDevices := make([]CellularDevice, 0) + var connectedDevice *CellularDevice + + for _, dev := range devices { + devType, err := dev.GetPropertyDeviceType() + if err != nil { + continue + } + + // NmDeviceTypeModem = 8 + if devType != 8 { + continue + } + + name, _ := dev.GetPropertyInterface() + driver, _ := dev.GetPropertyDriver() + state, _ := dev.GetPropertyState() + ipConfig, _ := dev.GetPropertyIP4Config() + + stateStr := "unknown" + connected := false + switch state { + case gonetworkmanager.NmDeviceStateUnknown: + stateStr = "unknown" + case gonetworkmanager.NmDeviceStateUnmanaged: + stateStr = "unmanaged" + case gonetworkmanager.NmDeviceStateUnavailable: + stateStr = "unavailable" + case gonetworkmanager.NmDeviceStateDisconnected: + stateStr = "disconnected" + case gonetworkmanager.NmDeviceStatePrepare: + stateStr = "prepare" + case gonetworkmanager.NmDeviceStateConfig: + stateStr = "config" + 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.NmDeviceStateActivated: + stateStr = "activated" + connected = true + case gonetworkmanager.NmDeviceStateDeactivating: + stateStr = "deactivating" + case gonetworkmanager.NmDeviceStateFailed: + stateStr = "failed" + } + + ip := "" + if ipConfig != nil { + addresses, _ := ipConfig.GetPropertyAddressData() + if len(addresses) > 0 { + ip = addresses[0].Address + } + } + + // Try to get modem-specific info via ModemManager + operator, technology, signal := b.getModemManagerCellularDetails(name) + + cellDev := CellularDevice{ + Name: name, + HwAddress: driver, // Using driver as placeholder + State: stateStr, + Connected: connected, + IP: ip, + Operator: operator, + Technology: technology, + Signal: signal, + } + + cellularDevices = append(cellularDevices, cellDev) + + if connected { + connectedDevice = &cellDev + } + } + + b.stateMutex.Lock() + b.state.CellularEnabled = wwanEnabled + b.state.CellularDevices = cellularDevices + if connectedDevice != nil { + b.state.CellularConnected = true + b.state.CellularDevice = connectedDevice.Name + b.state.CellularIP = connectedDevice.IP + b.state.CellularOperator = connectedDevice.Operator + b.state.CellularTechnology = connectedDevice.Technology + b.state.CellularSignal = connectedDevice.Signal + } else { + b.state.CellularConnected = false + b.state.CellularDevice = "" + b.state.CellularIP = "" + b.state.CellularOperator = "" + b.state.CellularTechnology = "" + b.state.CellularSignal = 0 + } + b.stateMutex.Unlock() +} diff --git a/core/internal/server/network/backend_networkmanager_cellular_test.go b/core/internal/server/network/backend_networkmanager_cellular_test.go new file mode 100644 index 000000000..05dfc888b --- /dev/null +++ b/core/internal/server/network/backend_networkmanager_cellular_test.go @@ -0,0 +1,438 @@ +package network + +import ( + "testing" + + mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2" + "github.com/Wifx/gonetworkmanager/v2" + "github.com/stretchr/testify/assert" +) + +func TestNetworkManagerBackend_GetCellularEnabled(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyWwanEnabled().Return(true, nil) + + enabled, err := backend.GetCellularEnabled() + assert.NoError(t, err) + assert.True(t, enabled) +} + +func TestNetworkManagerBackend_GetCellularEnabled_Disabled(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyWwanEnabled().Return(false, nil) + + enabled, err := backend.GetCellularEnabled() + assert.NoError(t, err) + assert.False(t, enabled) +} + +func TestNetworkManagerBackend_SetCellularEnabled_NoDBusConn(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + // Without a real D-Bus connection, this should return an error + // (no system bus available in test environment) + backend.dbusConn = nil + err = backend.SetCellularEnabled(true) + // In CI/test environments without D-Bus, this will error + // In environments with D-Bus, it may succeed + if err != nil { + assert.Contains(t, err.Error(), "failed") + } +} + +func TestNetworkManagerBackend_GetCellularDevices_Empty(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + devices := backend.GetCellularDevices() + assert.Empty(t, devices) +} + +func TestNetworkManagerBackend_GetCellularDevices_FromState(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.stateMutex.Lock() + backend.state.CellularDevices = []CellularDevice{ + {Name: "wwan0", State: "connected", Connected: true, Operator: "Test Mobile"}, + } + backend.stateMutex.Unlock() + + devices := backend.GetCellularDevices() + assert.Len(t, devices, 1) + assert.Equal(t, "wwan0", devices[0].Name) + assert.Equal(t, "Test Mobile", devices[0].Operator) + assert.True(t, devices[0].Connected) +} + +func TestNetworkManagerBackend_GetCellularDevices_ReturnsCopy(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.stateMutex.Lock() + backend.state.CellularDevices = []CellularDevice{ + {Name: "wwan0"}, + } + backend.stateMutex.Unlock() + + devices := backend.GetCellularDevices() + devices[0].Name = "modified" + + // Original should be unchanged + backend.stateMutex.RLock() + assert.Equal(t, "wwan0", backend.state.CellularDevices[0].Name) + backend.stateMutex.RUnlock() +} + +func TestNetworkManagerBackend_GetCellularConnections_NoSettings(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + backend.settings = nil + + // listCellularConnections will try to create settings via gonetworkmanager.NewSettings() + // In test env without D-Bus, this will fail — that's expected + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe() + + conns, err := backend.GetCellularConnections() + if err != nil { + assert.Nil(t, conns) + } else { + assert.NotNil(t, conns) + } +} + +func TestNetworkManagerBackend_GetCellularConnections_Empty(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + backend.settings = mockSettings + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil) + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe() + + conns, err := backend.GetCellularConnections() + assert.NoError(t, err) + assert.Empty(t, conns) +} + +func TestNetworkManagerBackend_ListCellularProfiles_Empty(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + backend.settings = mockSettings + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil) + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe() + + profiles, err := backend.ListCellularProfiles() + assert.NoError(t, err) + assert.Empty(t, profiles) +} + +func TestNetworkManagerBackend_ListActiveCellular_Empty(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil) + + active, err := backend.ListActiveCellular() + assert.NoError(t, err) + assert.Empty(t, active) +} + +func TestNetworkManagerBackend_ConnectCellular_NoDevices(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + // ConnectCellular checks state.CellularDevices first + err = backend.ConnectCellular("some-uuid") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no cellular device available") +} + +func TestNetworkManagerBackend_ConnectCellular_NotFound(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + backend.settings = mockSettings + + // Populate state with a device so ConnectCellular proceeds past device check + backend.stateMutex.Lock() + backend.state.CellularDevices = []CellularDevice{{Name: "wwan0"}} + backend.stateMutex.Unlock() + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil) + + err = backend.ConnectCellular("non-existent-uuid") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestNetworkManagerBackend_DisconnectCellular_NoDevices(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + // DisconnectCellular checks state.CellularDevices first + err = backend.DisconnectCellular() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no cellular device available") +} + +func TestNetworkManagerBackend_DisconnectCellular_NoActiveConnections(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + // Populate state with a device so DisconnectCellular proceeds + backend.stateMutex.Lock() + backend.state.CellularDevices = []CellularDevice{{Name: "wwan0"}} + backend.stateMutex.Unlock() + + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil) + mockNM.EXPECT().GetPropertyWwanEnabled().Return(false, nil).Maybe() + mockNM.EXPECT().GetDevices().Return([]gonetworkmanager.Device{}, nil).Maybe() + mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil).Maybe() + + err = backend.DisconnectCellular() + assert.NoError(t, err) +} + +func TestNetworkManagerBackend_GetSIMStatus_NoDevices(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + status, err := backend.GetSIMStatus("") + assert.Error(t, err) + assert.Nil(t, status) + assert.Contains(t, err.Error(), "no cellular devices found") +} + +func TestNetworkManagerBackend_GetSIMStatus_FirstDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.stateMutex.Lock() + backend.state.CellularDevices = []CellularDevice{ + {Name: "wwan0", SimLocked: true, PinRequired: true}, + {Name: "wwan1", SimLocked: false}, + } + backend.stateMutex.Unlock() + + // Empty device string should return first device + status, err := backend.GetSIMStatus("") + assert.NoError(t, err) + assert.NotNil(t, status) + assert.Equal(t, "wwan0", status.Name) + assert.True(t, status.SimLocked) + assert.True(t, status.PinRequired) +} + +func TestNetworkManagerBackend_GetSIMStatus_ByName(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.stateMutex.Lock() + backend.state.CellularDevices = []CellularDevice{ + {Name: "wwan0", SimLocked: true}, + {Name: "wwan1", SimLocked: false}, + } + backend.stateMutex.Unlock() + + status, err := backend.GetSIMStatus("wwan1") + assert.NoError(t, err) + assert.NotNil(t, status) + assert.Equal(t, "wwan1", status.Name) + assert.False(t, status.SimLocked) +} + +func TestNetworkManagerBackend_GetSIMStatus_ByIMEI(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.stateMutex.Lock() + backend.state.CellularDevices = []CellularDevice{ + {Name: "wwan0", IMEI: "123456789012345"}, + } + backend.stateMutex.Unlock() + + status, err := backend.GetSIMStatus("123456789012345") + assert.NoError(t, err) + assert.NotNil(t, status) + assert.Equal(t, "wwan0", status.Name) +} + +func TestNetworkManagerBackend_GetSIMStatus_NotFound(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + backend.stateMutex.Lock() + backend.state.CellularDevices = []CellularDevice{ + {Name: "wwan0"}, + } + backend.stateMutex.Unlock() + + status, err := backend.GetSIMStatus("wwan99") + assert.Error(t, err) + assert.Nil(t, status) + assert.Contains(t, err.Error(), "not found") +} + +func TestNetworkManagerBackend_GetSIMPinTriesLeft(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + tries, err := backend.GetSIMPinTriesLeft("") + assert.NoError(t, err) + assert.Equal(t, 3, tries) +} + +func TestNetworkManagerBackend_SubmitSIMPin_NoConnections(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + backend.settings = mockSettings + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil) + + err = backend.SubmitSIMPin("", "1234") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no cellular connection found") +} + +func TestNetworkManagerBackend_ActivateCellularConnection_NoDevices(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + // ActivateCellularConnection delegates to ConnectCellular which checks devices + err = backend.ActivateCellularConnection("non-existent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no cellular device available") +} + +func TestNetworkManagerBackend_UpdateCellularState_NoModems(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyWwanEnabled().Return(false, nil) + mockNM.EXPECT().GetDevices().Return([]gonetworkmanager.Device{}, nil) + + assert.NotPanics(t, func() { + backend.updateCellularState() + }) + + backend.stateMutex.RLock() + assert.False(t, backend.state.CellularEnabled) + assert.Empty(t, backend.state.CellularDevices) + assert.False(t, backend.state.CellularConnected) + backend.stateMutex.RUnlock() +} + +func TestNetworkManagerBackend_UpdateCellularState_WwanEnabled(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + mockNM.EXPECT().GetPropertyWwanEnabled().Return(true, nil) + mockNM.EXPECT().GetDevices().Return([]gonetworkmanager.Device{}, nil) + + backend.updateCellularState() + + backend.stateMutex.RLock() + assert.True(t, backend.state.CellularEnabled) + backend.stateMutex.RUnlock() +} + +func TestNetworkManagerBackend_DisconnectCellularDevice_NoDevices(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + + // DisconnectCellularDevice delegates to DisconnectCellular which checks state + err = backend.DisconnectCellularDevice("wwan0") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no cellular device available") +} + +func TestNetworkManagerBackend_GetCellularProfile_NotFound(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + backend.settings = mockSettings + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil) + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe() + + profile, err := backend.GetCellularProfile("non-existent") + assert.Error(t, err) + assert.Nil(t, profile) + assert.Contains(t, err.Error(), "not found") +} + +func TestNetworkManagerBackend_UpdateCellularProfile_NotFound(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + backend.settings = mockSettings + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil) + + err = backend.UpdateCellularProfile("non-existent", map[string]any{"apn": "test"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} diff --git a/core/internal/server/network/backend_networkmanager_signals.go b/core/internal/server/network/backend_networkmanager_signals.go index 5274ed0c7..7afa6a6fa 100644 --- a/core/internal/server/network/backend_networkmanager_signals.go +++ b/core/internal/server/network/backend_networkmanager_signals.go @@ -238,6 +238,14 @@ func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]db b.stateMutex.Unlock() needsUpdate = true } + case "WwanEnabled": + nm := b.nmConn.(gonetworkmanager.NetworkManager) + if enabled, err := nm.GetPropertyWwanEnabled(); err == nil { + b.stateMutex.Lock() + b.state.CellularEnabled = enabled + b.stateMutex.Unlock() + needsUpdate = true + } default: continue } @@ -248,10 +256,14 @@ 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() b.ListActiveVPN() + b.updateCellularState() + b.listCellularConnections() + b.ListActiveCellular() } if b.onStateChange != nil { b.onStateChange() @@ -295,8 +307,10 @@ func (b *NetworkManagerBackend) handleDeviceChange(devicePath dbus.ObjectPath, c b.updateEthernetState() b.updateAllWiFiDevices() b.updateWiFiState() + b.updateCellularState() if stateChanged { b.listEthernetConnections() + b.listCellularConnections() b.updatePrimaryConnection() } if b.onStateChange != nil { @@ -391,7 +405,8 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) { return } - if devType != gonetworkmanager.NmDeviceTypeEthernet && devType != gonetworkmanager.NmDeviceTypeWifi { + // NmDeviceTypeModem = 8 + if devType != gonetworkmanager.NmDeviceTypeEthernet && devType != gonetworkmanager.NmDeviceTypeWifi && devType != 8 { return } @@ -458,6 +473,12 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) { b.updateAllWiFiDevices() b.updateWiFiState() + + case 8: // NmDeviceTypeModem + b.updateCellularState() + b.listCellularConnections() + b.ListCellularProfiles() + b.ListActiveCellular() } if b.onStateChange != nil { @@ -527,4 +548,12 @@ func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath) return } } + + // If not found in ethernet/wifi maps, it may be a modem device + b.updateCellularState() + b.listCellularConnections() + b.ListActiveCellular() + if b.onStateChange != nil { + b.onStateChange() + } } diff --git a/core/internal/server/network/backend_networkmanager_signals_test.go b/core/internal/server/network/backend_networkmanager_signals_test.go index 6b58684ae..6c1df7901 100644 --- a/core/internal/server/network/backend_networkmanager_signals_test.go +++ b/core/internal/server/network/backend_networkmanager_signals_test.go @@ -97,6 +97,8 @@ func TestNetworkManagerBackend_HandleNetworkManagerChange(t *testing.T) { mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe() mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil).Maybe() + mockNM.EXPECT().GetPropertyWwanEnabled().Return(false, nil).Maybe() + mockNM.EXPECT().GetDevices().Return([]gonetworkmanager.Device{}, nil).Maybe() changes := map[string]dbus.Variant{ "PrimaryConnection": dbus.MakeVariant("/"), @@ -135,6 +137,8 @@ func TestNetworkManagerBackend_HandleNetworkManagerChange_ActiveConnections(t *t mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil) mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil).Maybe() + mockNM.EXPECT().GetPropertyWwanEnabled().Return(false, nil).Maybe() + mockNM.EXPECT().GetDevices().Return([]gonetworkmanager.Device{}, nil).Maybe() changes := map[string]dbus.Variant{ "ActiveConnections": dbus.MakeVariant([]any{}), @@ -153,6 +157,8 @@ func TestNetworkManagerBackend_HandleDeviceChange(t *testing.T) { mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe() mockNM.EXPECT().GetPropertyPrimaryConnection().Return(nil, nil).Maybe() + mockNM.EXPECT().GetPropertyWwanEnabled().Return(false, nil).Maybe() + mockNM.EXPECT().GetDevices().Return([]gonetworkmanager.Device{}, nil).Maybe() changes := map[string]dbus.Variant{ "State": dbus.MakeVariant(uint32(100)), @@ -169,6 +175,9 @@ func TestNetworkManagerBackend_HandleDeviceChange_Ip4Config(t *testing.T) { backend, err := NewNetworkManagerBackend(mockNM) assert.NoError(t, err) + mockNM.EXPECT().GetPropertyWwanEnabled().Return(false, nil).Maybe() + mockNM.EXPECT().GetDevices().Return([]gonetworkmanager.Device{}, nil).Maybe() + changes := map[string]dbus.Variant{ "Ip4Config": dbus.MakeVariant("/"), } diff --git a/core/internal/server/network/backend_networkmanager_state.go b/core/internal/server/network/backend_networkmanager_state.go index ba77f6d47..408e62492 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: diff --git a/core/internal/server/network/handlers.go b/core/internal/server/network/handlers.go index 2b3e75e8a..f640a190f 100644 --- a/core/internal/server/network/handlers.go +++ b/core/internal/server/network/handlers.go @@ -79,6 +79,34 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { handleSetVPNCredentials(conn, req, manager) case "network.wifi.setAutoconnect": handleSetWiFiAutoconnect(conn, req, manager) + case "network.cellular.enabled": + handleGetCellularEnabled(conn, req, manager) + case "network.cellular.enable": + handleEnableCellular(conn, req, manager) + case "network.cellular.disable": + handleDisableCellular(conn, req, manager) + case "network.cellular.devices": + handleGetCellularDevices(conn, req, manager) + case "network.cellular.connections": + handleGetCellularConnections(conn, req, manager) + case "network.cellular.connect": + handleConnectCellular(conn, req, manager) + case "network.cellular.disconnect": + handleDisconnectCellular(conn, req, manager) + case "network.cellular.info": + handleGetCellularNetworkInfo(conn, req, manager) + case "network.cellular.profiles": + handleListCellularProfiles(conn, req, manager) + case "network.cellular.active": + handleListActiveCellular(conn, req, manager) + case "network.cellular.updateProfile": + handleUpdateCellularProfile(conn, req, manager) + case "network.cellular.simStatus": + handleGetSIMStatus(conn, req, manager) + case "network.cellular.submitPin": + handleSubmitSIMPin(conn, req, manager) + case "network.cellular.pinTriesLeft": + handleGetSIMPinTriesLeft(conn, req, manager) default: models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) } @@ -628,3 +656,201 @@ func handleSetVPNCredentials(conn net.Conn, req models.Request, manager *Manager models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "VPN credentials set"}) } + +func handleGetCellularEnabled(conn net.Conn, req models.Request, manager *Manager) { + enabled, err := manager.GetCellularEnabled() + if err != nil { + log.Warnf("handleGetCellularEnabled: failed to get state: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to get cellular state: %v", err)) + return + } + models.Respond(conn, req.ID, map[string]bool{"enabled": enabled}) +} + +func handleEnableCellular(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.SetCellularEnabled(true); err != nil { + log.Warnf("handleEnableCellular: failed to enable: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to enable cellular: %v", err)) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "Cellular enabled"}) +} + +func handleDisableCellular(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.SetCellularEnabled(false); err != nil { + log.Warnf("handleDisableCellular: failed to disable: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to disable cellular: %v", err)) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "Cellular disabled"}) +} + +func handleGetCellularDevices(conn net.Conn, req models.Request, manager *Manager) { + devices := manager.GetCellularDevices() + models.Respond(conn, req.ID, devices) +} + +func handleGetCellularConnections(conn net.Conn, req models.Request, manager *Manager) { + connections, err := manager.GetCellularConnections() + if err != nil { + log.Warnf("handleGetCellularConnections: failed to get connections: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to get cellular connections: %v", err)) + return + } + models.Respond(conn, req.ID, connections) +} + +func handleConnectCellular(conn net.Conn, req models.Request, manager *Manager) { + uuid, err := params.String(req.Params, "uuid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.ConnectCellular(uuid); err != nil { + log.Warnf("handleConnectCellular: failed to connect: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to connect cellular: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "Cellular connection activated"}) +} + +func handleDisconnectCellular(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.DisconnectCellular(); err != nil { + log.Warnf("handleDisconnectCellular: failed to disconnect: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to disconnect cellular: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "Cellular disconnected"}) +} + +func handleGetCellularNetworkInfo(conn net.Conn, req models.Request, manager *Manager) { + uuid, err := params.String(req.Params, "uuid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + info, err := manager.GetCellularNetworkInfoDetailed(uuid) + if err != nil { + log.Warnf("handleGetCellularNetworkInfo: failed to get info: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to get cellular info: %v", err)) + return + } + + models.Respond(conn, req.ID, info) +} + +func handleListCellularProfiles(conn net.Conn, req models.Request, manager *Manager) { + profiles, err := manager.ListCellularProfiles() + if err != nil { + log.Warnf("handleListCellularProfiles: failed to list profiles: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list cellular profiles: %v", err)) + return + } + models.Respond(conn, req.ID, profiles) +} + +func handleListActiveCellular(conn net.Conn, req models.Request, manager *Manager) { + active, err := manager.ListActiveCellular() + if err != nil { + log.Warnf("handleListActiveCellular: failed to list active: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to list active cellular: %v", err)) + return + } + models.Respond(conn, req.ID, active) +} + +func handleUpdateCellularProfile(conn net.Conn, req models.Request, manager *Manager) { + connUUID, err := params.String(req.Params, "uuid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + updates := make(map[string]any) + + if name, ok := models.Get[string](req, "name"); ok { + updates["name"] = name + } + if autoconnect, ok := models.Get[bool](req, "autoconnect"); ok { + updates["autoconnect"] = autoconnect + } + if apn, ok := models.Get[string](req, "apn"); ok { + updates["apn"] = apn + } + if username, ok := models.Get[string](req, "username"); ok { + updates["username"] = username + } + if password, ok := models.Get[string](req, "password"); ok { + updates["password"] = password + } + + if len(updates) == 0 { + models.RespondError(conn, req.ID, "no updates provided") + return + } + + if err := manager.UpdateCellularProfile(connUUID, updates); err != nil { + log.Warnf("handleUpdateCellularProfile: failed to update: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to update cellular profile: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "Cellular profile updated"}) +} + +func handleGetSIMStatus(conn net.Conn, req models.Request, manager *Manager) { + device, err := params.String(req.Params, "device") + if err != nil { + device = "" // Use first available device if not specified + } + + status, err := manager.GetSIMStatus(device) + if err != nil { + log.Warnf("handleGetSIMStatus: failed to get status: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to get SIM status: %v", err)) + return + } + + models.Respond(conn, req.ID, status) +} + +func handleSubmitSIMPin(conn net.Conn, req models.Request, manager *Manager) { + device, err := params.String(req.Params, "device") + if err != nil { + device = "" // Use first available device + } + + pin, err := params.String(req.Params, "pin") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SubmitSIMPin(device, pin); err != nil { + log.Warnf("handleSubmitSIMPin: failed to submit PIN: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to submit SIM PIN: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "SIM PIN submitted"}) +} + +func handleGetSIMPinTriesLeft(conn net.Conn, req models.Request, manager *Manager) { + device, err := params.String(req.Params, "device") + if err != nil { + device = "" // Use first available device + } + + tries, err := manager.GetSIMPinTriesLeft(device) + if err != nil { + log.Warnf("handleGetSIMPinTriesLeft: failed to get tries left: %v", err) + models.RespondError(conn, req.ID, fmt.Sprintf("failed to get PIN tries left: %v", err)) + return + } + + models.Respond(conn, req.ID, map[string]int{"triesLeft": tries}) +} diff --git a/core/internal/server/network/manager.go b/core/internal/server/network/manager.go index 5c6657ef0..25e0b1051 100644 --- a/core/internal/server/network/manager.go +++ b/core/internal/server/network/manager.go @@ -128,6 +128,17 @@ func (m *Manager) syncStateFromBackend() error { m.state.ConnectingSSID = backendState.ConnectingSSID m.state.ConnectingDevice = backendState.ConnectingDevice m.state.LastError = backendState.LastError + m.state.CellularIP = backendState.CellularIP + m.state.CellularDevice = backendState.CellularDevice + m.state.CellularConnected = backendState.CellularConnected + m.state.CellularEnabled = backendState.CellularEnabled + m.state.CellularOperator = backendState.CellularOperator + m.state.CellularTechnology = backendState.CellularTechnology + m.state.CellularSignal = backendState.CellularSignal + m.state.CellularDevices = backendState.CellularDevices + m.state.CellularProfiles = backendState.CellularProfiles + m.state.CellularActive = backendState.CellularActive + m.state.CellularConnections = backendState.CellularConnections m.stateMutex.Unlock() return nil @@ -161,6 +172,10 @@ func (m *Manager) snapshotState() NetworkState { s.EthernetDevices = append([]EthernetDevice(nil), m.state.EthernetDevices...) s.VPNProfiles = append([]VPNProfile(nil), m.state.VPNProfiles...) s.VPNActive = append([]VPNActive(nil), m.state.VPNActive...) + s.CellularDevices = append([]CellularDevice(nil), m.state.CellularDevices...) + s.CellularConnections = append([]CellularConnection(nil), m.state.CellularConnections...) + s.CellularProfiles = append([]CellularProfile(nil), m.state.CellularProfiles...) + s.CellularActive = append([]CellularActive(nil), m.state.CellularActive...) return s } @@ -624,3 +639,67 @@ func (m *Manager) ScanWiFiDevice(device string) error { func (m *Manager) DisconnectWiFiDevice(device string) error { return m.backend.DisconnectWiFiDevice(device) } + +func (m *Manager) GetCellularEnabled() (bool, error) { + return m.backend.GetCellularEnabled() +} + +func (m *Manager) SetCellularEnabled(enabled bool) error { + return m.backend.SetCellularEnabled(enabled) +} + +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) GetCellularConnections() ([]CellularConnection, error) { + return m.backend.GetCellularConnections() +} + +func (m *Manager) GetCellularNetworkInfoDetailed(uuid string) (*CellularNetworkInfoResponse, error) { + return m.backend.GetCellularNetworkDetails(uuid) +} + +func (m *Manager) ConnectCellular(uuid string) error { + return m.backend.ConnectCellular(uuid) +} + +func (m *Manager) DisconnectCellular() error { + return m.backend.DisconnectCellular() +} + +func (m *Manager) DisconnectCellularDevice(device string) error { + return m.backend.DisconnectCellularDevice(device) +} + +func (m *Manager) ListCellularProfiles() ([]CellularProfile, error) { + return m.backend.ListCellularProfiles() +} + +func (m *Manager) ListActiveCellular() ([]CellularActive, error) { + return m.backend.ListActiveCellular() +} + +func (m *Manager) GetCellularProfile(uuidOrName string) (*CellularProfile, error) { + return m.backend.GetCellularProfile(uuidOrName) +} + +func (m *Manager) UpdateCellularProfile(uuid string, updates map[string]any) error { + return m.backend.UpdateCellularProfile(uuid, updates) +} + +func (m *Manager) GetSIMStatus(device string) (*CellularDevice, error) { + return m.backend.GetSIMStatus(device) +} + +func (m *Manager) SubmitSIMPin(device string, pin string) error { + return m.backend.SubmitSIMPin(device, pin) +} + +func (m *Manager) GetSIMPinTriesLeft(device string) (int, error) { + return m.backend.GetSIMPinTriesLeft(device) +} diff --git a/core/internal/server/network/types.go b/core/internal/server/network/types.go index 93448cfb7..443d9a950 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 { @@ -113,6 +115,17 @@ type NetworkState struct { WiFiNetworks []WiFiNetwork `json:"wifiNetworks"` WiFiDevices []WiFiDevice `json:"wifiDevices"` WiredConnections []WiredConnection `json:"wiredConnections"` + CellularIP string `json:"cellularIP"` + CellularDevice string `json:"cellularDevice"` + CellularConnected bool `json:"cellularConnected"` + CellularEnabled bool `json:"cellularEnabled"` + CellularOperator string `json:"cellularOperator"` + CellularTechnology string `json:"cellularTechnology"` + CellularSignal uint8 `json:"cellularSignal"` + CellularDevices []CellularDevice `json:"cellularDevices"` + CellularProfiles []CellularProfile `json:"cellularProfiles"` + CellularActive []CellularActive `json:"cellularActive"` + CellularConnections []CellularConnection `json:"cellularConnections"` VPNProfiles []VPNProfile `json:"vpnProfiles"` VPNActive []VPNActive `json:"vpnActive"` IsConnecting bool `json:"isConnecting"` @@ -146,6 +159,80 @@ type WiredConnection struct { IsActive bool `json:"isActive"` } +type CellularDevice struct { + Name string `json:"name"` + HwAddress string `json:"hwAddress"` + State string `json:"state"` + Connected bool `json:"connected"` + IP string `json:"ip,omitempty"` + Operator string `json:"operator,omitempty"` + Technology string `json:"technology,omitempty"` + Signal uint8 `json:"signal,omitempty"` + IMEI string `json:"imei,omitempty"` + SimLocked bool `json:"simLocked,omitempty"` + PinRequired bool `json:"pinRequired,omitempty"` +} + +type SIMPinRequest struct { + Token string `json:"token"` + Device string `json:"device"` + IMEI string `json:"imei,omitempty"` + Operator string `json:"operator,omitempty"` + PinTriesLeft int `json:"pinTriesLeft,omitempty"` +} + +type SIMPinResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} + +type CellularConnection struct { + Path dbus.ObjectPath `json:"path"` + ID string `json:"id"` + UUID string `json:"uuid"` + Type string `json:"type"` + IsActive bool `json:"isActive"` + APN string `json:"apn,omitempty"` +} + +type CellularProfile struct { + UUID string `json:"uuid"` + Name string `json:"name"` + APN string `json:"apn,omitempty"` + Username string `json:"username,omitempty"` + Autoconnect bool `json:"autoconnect"` +} + +type CellularActive struct { + Name string `json:"name"` + UUID string `json:"uuid"` + Device string `json:"device,omitempty"` + State string `json:"state,omitempty"` + IP string `json:"ip,omitempty"` + Gateway string `json:"gateway,omitempty"` + Operator string `json:"operator,omitempty"` + Technology string `json:"technology,omitempty"` + Signal uint8 `json:"signal,omitempty"` +} + +type CellularIPConfig struct { + IPs []string `json:"ips"` + Gateway string `json:"gateway"` + DNS string `json:"dns"` +} + +type CellularNetworkInfoResponse struct { + UUID string `json:"uuid"` + IFace string `json:"iface"` + HwAddr string `json:"hwAddr"` + IMEI string `json:"imei"` + Operator string `json:"operator"` + Technology string `json:"technology"` + Signal uint8 `json:"signal"` + IPv4 CellularIPConfig `json:"IPv4s"` + IPv6 CellularIPConfig `json:"IPv6s"` +} + type PriorityUpdate struct { Preference ConnectionPreference `json:"preference"` } diff --git a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml index 5f1738aef..860efd082 100644 --- a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml @@ -12,14 +12,17 @@ Rectangle { LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.childrenInherit: true + readonly property real cellularBarHeight: cellularBar.visible ? cellularBar.height + Theme.spacingS : 0 + implicitHeight: { if (height > 0) return height; + let h = headerRow.height + cellularBarHeight; if (NetworkService.wifiToggling) - return headerRow.height + wifiToggleContent.height + Theme.spacingM; + return h + wifiToggleContent.height + Theme.spacingM; if (NetworkService.wifiEnabled) - return headerRow.height + wifiContent.height + Theme.spacingM; - return headerRow.height + wifiOffContent.height + Theme.spacingM; + return h + wifiContent.height + Theme.spacingM; + return h + wifiOffContent.height + Theme.spacingM; } radius: Theme.cornerRadius color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) @@ -282,7 +285,7 @@ Rectangle { anchors.top: headerRow.bottom anchors.left: parent.left anchors.right: parent.right - anchors.bottom: parent.bottom + anchors.bottom: cellularBar.visible ? cellularBar.top : parent.bottom anchors.margins: Theme.spacingM anchors.topMargin: Theme.spacingM visible: currentPreferenceIndex === 0 && NetworkService.backend === "networkmanager" && DMSService.apiVersion > 10 @@ -509,7 +512,7 @@ Rectangle { anchors.top: headerRow.bottom anchors.left: parent.left anchors.right: parent.right - anchors.bottom: parent.bottom + anchors.bottom: cellularBar.visible ? cellularBar.top : parent.bottom anchors.margins: Theme.spacingM anchors.topMargin: Theme.spacingM visible: currentPreferenceIndex === 1 && NetworkService.wifiEnabled && !NetworkService.wifiToggling && !wifiScanningOverlay.visible @@ -879,6 +882,79 @@ Rectangle { } } + // Cellular status bar + Rectangle { + id: cellularBar + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + anchors.bottomMargin: Theme.spacingS + height: cellularBarRow.implicitHeight + Theme.spacingS * 2 + radius: Theme.cornerRadius + color: Theme.surfaceLight + visible: NetworkService.cellularAvailable + + Row { + id: cellularBarRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + spacing: Theme.spacingS + + DankIcon { + name: NetworkService.cellularEnabled ? (NetworkService.cellularConnected ? "signal_cellular_4_bar" : "signal_cellular_connected_no_internet_4_bar") : "signal_cellular_off" + size: 20 + color: NetworkService.cellularConnected ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - 20 - cellularToggle.width - Theme.spacingS * 3 + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + StyledText { + text: I18n.tr("Cellular") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + elide: Text.ElideRight + width: parent.width + } + + StyledText { + text: { + if (NetworkService.cellularToggling) + return I18n.tr("Toggling..."); + if (!NetworkService.cellularEnabled) + return I18n.tr("Off"); + if (NetworkService.cellularConnected) + return NetworkService.cellularOperator || I18n.tr("Connected"); + return I18n.tr("Not connected"); + } + font.pixelSize: Theme.fontSizeSmall + color: NetworkService.cellularConnected ? Theme.primary : Theme.surfaceVariantText + elide: Text.ElideRight + width: parent.width + } + } + + DankToggle { + id: cellularToggle + checked: NetworkService.cellularEnabled + enabled: !NetworkService.cellularToggling + anchors.verticalCenter: parent.verticalCenter + onToggled: checked => { + NetworkService.setCellularEnabled(checked); + } + } + } + } + Loader { id: networkInfoModalLoader active: false diff --git a/quickshell/Modules/Settings/NetworkTab.qml b/quickshell/Modules/Settings/NetworkTab.qml index b9b256616..9e8efdc1b 100644 --- a/quickshell/Modules/Settings/NetworkTab.qml +++ b/quickshell/Modules/Settings/NetworkTab.qml @@ -18,6 +18,7 @@ Item { property string expandedVpnUuid: "" property string expandedWifiSsid: "" property string expandedEthDevice: "" + property string expandedCellularUuid: "" property int maxPinnedWifiNetworks: 3 Component.onCompleted: { @@ -92,6 +93,89 @@ Item { id: forgetNetworkConfirm } + // SIM PIN Entry Dialog + Rectangle { + id: simPinDialog + visible: false + anchors.centerIn: parent + width: 320 + height: simPinColumn.height + Theme.spacingL * 2 + radius: Theme.cornerRadius + color: Theme.surfaceContainerHigh + border.width: 1 + border.color: Theme.outline + z: 100 + + Column { + id: simPinColumn + anchors.centerIn: parent + width: parent.width - Theme.spacingL * 2 + spacing: Theme.spacingM + + StyledText { + text: I18n.tr("Enter SIM PIN") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width + horizontalAlignment: Text.AlignHCenter + } + + StyledText { + text: I18n.tr("Tries left: %1").arg(NetworkService.pinTriesLeft) + font.pixelSize: Theme.fontSizeSmall + color: NetworkService.pinTriesLeft <= 1 ? Theme.error : Theme.surfaceVariantText + width: parent.width + horizontalAlignment: Text.AlignHCenter + visible: NetworkService.pinTriesLeft < 3 + } + + DankTextField { + id: simPinInput + width: parent.width + placeholderText: I18n.tr("PIN code") + echoMode: TextInput.Password + maximumLength: 8 + validator: RegularExpressionValidator { + regularExpression: /^[0-9]*$/ + } + } + + Row { + width: parent.width + spacing: Theme.spacingM + + DankButton { + text: I18n.tr("Cancel") + backgroundColor: "transparent" + textColor: Theme.primary + onClicked: { + simPinDialog.visible = false; + simPinInput.text = ""; + } + } + + DankButton { + text: I18n.tr("Submit") + backgroundColor: Theme.primary + textColor: Theme.onPrimary + enabled: simPinInput.text.length >= 4 + onClicked: { + NetworkService.submitSIMPin(simPinInput.text); + simPinDialog.visible = false; + simPinInput.text = ""; + } + } + } + } + + function open() { + simPinInput.text = ""; + simPinInput.forceActiveFocus(); + visible = true; + } + } + DankFlickable { anchors.fill: parent clip: true @@ -1510,6 +1594,359 @@ Item { } } + // Cellular Section + StyledRect { + width: parent.width + height: cellularSection.implicitHeight + Theme.spacingL * 2 + radius: Theme.cornerRadius + color: Theme.surfaceContainerHigh + visible: NetworkService.cellularAvailable + + Column { + id: cellularSection + + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + + readonly property bool isExpanded: networkTab.expandedCellularUuid !== "" + + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: NetworkService.cellularEnabled ? (NetworkService.simLocked ? "sim_card_alert" : "signal_cellular_4_bar") : "signal_cellular_off" + size: Theme.iconSize + color: NetworkService.cellularConnected ? Theme.primary : (NetworkService.simLocked ? Theme.error : Theme.surfaceText) + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - Theme.spacingM - cellularControls.width - Theme.spacingM + spacing: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + + StyledText { + text: I18n.tr("Cellular / Mobile Data") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + StyledText { + text: { + if (NetworkService.cellularToggling) + return I18n.tr("Toggling..."); + if (!NetworkService.cellularEnabled) + return I18n.tr("Disabled"); + if (NetworkService.simLocked) + return I18n.tr("SIM locked - PIN required"); + if (NetworkService.cellularConnected) + return NetworkService.cellularOperator || I18n.tr("Connected"); + return I18n.tr("Not connected"); + } + font.pixelSize: Theme.fontSizeSmall + color: NetworkService.cellularConnected ? Theme.primary : (NetworkService.simLocked ? Theme.error : Theme.surfaceVariantText) + width: parent.width + horizontalAlignment: Text.AlignLeft + } + } + + Row { + id: cellularControls + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankActionButton { + iconName: "vpn_key" + buttonSize: 32 + visible: NetworkService.simLocked && NetworkService.cellularEnabled + iconColor: Theme.error + onClicked: { + NetworkService.getSIMPinTriesLeft(); + simPinDialog.open(); + } + } + + Rectangle { + width: 28 + height: 28 + radius: 14 + color: cellularExpandBtn.containsMouse ? Theme.surfacePressed : "transparent" + visible: NetworkService.cellularConnected + + DankIcon { + anchors.centerIn: parent + name: cellularSection.isExpanded ? "expand_less" : "expand_more" + size: 18 + color: Theme.surfaceText + } + + MouseArea { + id: cellularExpandBtn + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (cellularSection.isExpanded) { + networkTab.expandedCellularUuid = ""; + } else { + networkTab.expandedCellularUuid = NetworkService.cellularActive[0]?.uuid || "cellular"; + NetworkService.getActiveCellular(); + } + } + } + } + + DankToggle { + checked: NetworkService.cellularEnabled + enabled: !NetworkService.cellularToggling + onToggled: checked => { + NetworkService.setCellularEnabled(checked); + } + } + } + } + + // Signal strength indicator + Row { + width: parent.width + visible: NetworkService.cellularConnected && NetworkService.cellularSignal > 0 + spacing: Theme.spacingS + + DankIcon { + name: { + const strength = NetworkService.cellularSignal; + if (strength >= 75) return "signal_cellular_4_bar"; + if (strength >= 50) return "signal_cellular_3_bar"; + if (strength >= 25) return "signal_cellular_2_bar"; + return "signal_cellular_1_bar"; + } + size: 18 + color: Theme.surfaceVariantText + } + + StyledText { + text: NetworkService.cellularSignal + "%" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + text: "•" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + visible: NetworkService.cellularTechnology !== "" + } + + StyledText { + text: NetworkService.cellularTechnology + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + visible: NetworkService.cellularTechnology !== "" + } + } + + // IP address + StyledText { + text: NetworkService.cellularIP + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + visible: NetworkService.cellularConnected && NetworkService.cellularIP !== "" && !cellularSection.isExpanded + } + + // Expanded Cellular Details + Item { + width: parent.width + height: cellularDetailsColumn.implicitHeight + Theme.spacingM * 2 + visible: cellularSection.isExpanded && NetworkService.cellularConnected + + Column { + id: cellularDetailsColumn + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingS + + Rectangle { + width: parent.width + height: 1 + color: Theme.outlineLight + } + + Flow { + width: parent.width + spacing: Theme.spacingXS + + Repeater { + model: { + const fields = []; + const signal = NetworkService.cellularSignal; + const tech = NetworkService.cellularTechnology; + const op = NetworkService.cellularOperator; + const ip = NetworkService.cellularIP; + const iface = NetworkService.cellularInterface; + + if (signal > 0) + fields.push({ + label: I18n.tr("Signal"), + value: signal + "%" + }); + if (tech && tech !== "") + fields.push({ + label: I18n.tr("Technology"), + value: tech + }); + if (op && op !== "") + fields.push({ + label: I18n.tr("Operator"), + value: op + }); + if (ip && ip !== "") + fields.push({ + label: I18n.tr("IP"), + value: ip + }); + if (iface && iface !== "") + fields.push({ + label: I18n.tr("Interface"), + value: iface + }); + return fields; + } + + delegate: Rectangle { + required property var modelData + required property int index + + width: cellularFieldContent.width + Theme.spacingM * 2 + height: 32 + radius: Theme.cornerRadius - 2 + color: Theme.surfaceContainerHigh + border.width: 1 + border.color: Theme.outlineLight + + Row { + id: cellularFieldContent + anchors.centerIn: parent + spacing: Theme.spacingXS + + StyledText { + text: modelData.label + ":" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: modelData.value + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } + } + } + + // Saved Connections Header + Rectangle { + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + visible: NetworkService.cellularProfiles.length > 0 + } + + StyledText { + text: I18n.tr("Saved Profiles") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width + horizontalAlignment: Text.AlignLeft + visible: NetworkService.cellularProfiles.length > 0 + } + + // Cellular Profiles List + Column { + width: parent.width + spacing: Theme.spacingS + visible: NetworkService.cellularProfiles.length > 0 + + Repeater { + model: NetworkService.cellularProfiles + + delegate: Rectangle { + required property var modelData + required property int index + + readonly property bool isActive: NetworkService.isActiveCellularUuid(modelData.uuid) + + width: parent.width + height: 48 + radius: Theme.cornerRadius + color: cellularMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight + border.width: isActive ? 2 : 0 + border.color: Theme.primary + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankIcon { + name: isActive ? "signal_cellular_4_bar" : "signal_cellular_off" + size: 20 + color: isActive ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + StyledText { + text: modelData.name || I18n.tr("Unknown") + font.pixelSize: Theme.fontSizeMedium + color: isActive ? Theme.primary : Theme.surfaceText + font.weight: isActive ? Font.Medium : Font.Normal + } + + StyledText { + text: modelData.apn ? I18n.tr("APN: %1").arg(modelData.apn) : "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + visible: modelData.apn !== "" + } + } + } + + MouseArea { + id: cellularMouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (isActive) { + NetworkService.disconnectCellular(); + } else { + NetworkService.connectCellular(modelData.uuid); + } + } + } + } + } + } + } + } + StyledRect { width: parent.width height: vpnSection.implicitHeight + Theme.spacingL * 2 diff --git a/quickshell/Services/DMSNetworkService.qml b/quickshell/Services/DMSNetworkService.qml index e234cf471..0941dc3ee 100644 --- a/quickshell/Services/DMSNetworkService.qml +++ b/quickshell/Services/DMSNetworkService.qml @@ -92,6 +92,25 @@ Singleton { property alias isBusy: root.vpnIsBusy property alias connected: root.vpnConnected + // Cellular properties + property bool cellularAvailable: false + property bool cellularEnabled: false + property bool cellularConnected: false + property string cellularIP: "" + property string cellularInterface: "" + property string cellularOperator: "" + property string cellularTechnology: "" + property int cellularSignal: 0 + property var cellularDevices: [] + property var cellularConnections: [] + property var cellularProfiles: [] + property var cellularActive: [] + property bool cellularToggling: false + property bool simLocked: false + property bool pinRequired: false + property int pinTriesLeft: 3 + property string pendingCellularUuid: "" + property string networkInfoSSID: "" property string networkInfoDetails: "" property bool networkInfoLoading: false @@ -288,6 +307,29 @@ Singleton { vpnProfiles = state.vpnProfiles; } + // Update cellular state + cellularAvailable = networkAvailable && state.backend === "networkmanager"; + if (cellularAvailable) { + cellularEnabled = state.cellularEnabled || false; + cellularConnected = state.cellularConnected || false; + cellularIP = state.cellularIP || ""; + cellularInterface = state.cellularDevice || ""; + cellularOperator = state.cellularOperator || ""; + cellularTechnology = state.cellularTechnology || ""; + cellularSignal = state.cellularSignal || 0; + cellularDevices = state.cellularDevices || []; + cellularConnections = state.cellularConnections || []; + cellularProfiles = state.cellularProfiles || []; + cellularActive = state.cellularActive || []; + + // Check for SIM lock from first device + if (state.cellularDevices && state.cellularDevices.length > 0) { + const dev = state.cellularDevices[0]; + simLocked = dev.simLocked || false; + pinRequired = dev.pinRequired || false; + } + } + const previousVpnActive = vpnActive; vpnActive = state.vpnActive || []; @@ -941,4 +983,173 @@ Singleton { } }); } + + // Cellular functions + function getCellularState() { + if (!networkAvailable) + return; + DMSService.sendRequest("network.cellular.enabled", null, response => { + if (response.result) { + cellularEnabled = response.result.enabled || false; + } + }); + } + + function setCellularEnabled(enabled) { + if (!networkAvailable || cellularToggling) + return; + cellularToggling = true; + + const method = enabled ? "network.cellular.enable" : "network.cellular.disable"; + DMSService.sendRequest(method, null, response => { + cellularToggling = false; + if (response.error) { + ToastService.showError(I18n.tr("Failed to toggle cellular"), response.error); + } else { + cellularEnabled = enabled; + Qt.callLater(() => getState()); + } + }); + } + + function getCellularDevices() { + if (!networkAvailable) + return; + DMSService.sendRequest("network.cellular.devices", null, response => { + if (response.result) { + cellularDevices = response.result; + // Update SIM lock status from first device + if (response.result.length > 0) { + const dev = response.result[0]; + simLocked = dev.simLocked || false; + pinRequired = dev.pinRequired || false; + } + } + }); + } + + function getCellularConnections() { + if (!networkAvailable) + return; + DMSService.sendRequest("network.cellular.connections", null, response => { + if (response.result) { + cellularConnections = response.result; + } + }); + } + + function getCellularProfiles() { + if (!networkAvailable) + return; + DMSService.sendRequest("network.cellular.profiles", null, response => { + if (response.result) { + cellularProfiles = response.result; + } + }); + } + + function getActiveCellular() { + if (!networkAvailable) + return; + DMSService.sendRequest("network.cellular.active", null, response => { + if (response.result) { + cellularActive = response.result; + // Update individual properties from first active connection + if (Array.isArray(response.result) && response.result.length > 0) { + const active = response.result[0]; + cellularOperator = active.operator || ""; + cellularTechnology = active.technology || ""; + cellularSignal = active.signal || 0; + cellularInterface = active.device || ""; + } + } + }); + } + + function connectCellular(uuid) { + if (!networkAvailable || !cellularEnabled) + return; + pendingCellularUuid = uuid; + + const params = { uuid: uuid }; + DMSService.sendRequest("network.cellular.connect", params, response => { + if (response.error) { + ToastService.showError(I18n.tr("Failed to connect cellular"), response.error); + pendingCellularUuid = ""; + } else { + ToastService.showInfo(I18n.tr("Cellular connecting...")); + Qt.callLater(() => getState()); + } + }); + } + + function disconnectCellular() { + if (!networkAvailable) + return; + DMSService.sendRequest("network.cellular.disconnect", null, response => { + if (response.error) { + ToastService.showError(I18n.tr("Failed to disconnect cellular"), response.error); + } else { + ToastService.showInfo(I18n.tr("Cellular disconnected")); + Qt.callLater(() => getState()); + } + }); + } + + function getSIMStatus(device) { + if (!networkAvailable) + return; + const params = device ? { device: device } : null; + DMSService.sendRequest("network.cellular.simStatus", params, response => { + if (response.result) { + simLocked = response.result.simLocked || false; + pinRequired = response.result.pinRequired || false; + } + }); + } + + function submitSIMPin(pin, device) { + if (!networkAvailable) + return; + const params = { pin: pin }; + if (device) + params.device = device; + + DMSService.sendRequest("network.cellular.submitPin", params, response => { + if (response.error) { + ToastService.showError(I18n.tr("Failed to submit PIN"), response.error); + // Refresh PIN tries left + getSIMPinTriesLeft(device); + } else { + ToastService.showInfo(I18n.tr("PIN submitted successfully")); + simLocked = false; + Qt.callLater(() => getState()); + } + }); + } + + function getSIMPinTriesLeft(device) { + if (!networkAvailable) + return; + const params = device ? { device: device } : null; + DMSService.sendRequest("network.cellular.pinTriesLeft", params, response => { + if (response.result) { + pinTriesLeft = response.result.triesLeft || 3; + } + }); + } + + function isActiveCellularUuid(uuid) { + return cellularActive && cellularActive.some(a => a.uuid === uuid); + } + + function refreshCellularState() { + if (networkAvailable) { + getCellularState(); + getCellularDevices(); + getCellularConnections(); + getCellularProfiles(); + getActiveCellular(); + } + } } diff --git a/quickshell/Services/NetworkService.qml b/quickshell/Services/NetworkService.qml index 38383d289..b5679f636 100644 --- a/quickshell/Services/NetworkService.qml +++ b/quickshell/Services/NetworkService.qml @@ -87,6 +87,24 @@ Singleton { property string credentialsReason: activeService?.credentialsReason ?? "" property bool credentialsRequested: activeService?.credentialsRequested ?? false + // Cellular properties + property bool cellularAvailable: activeService?.cellularAvailable ?? false + property bool cellularEnabled: activeService?.cellularEnabled ?? false + property bool cellularConnected: activeService?.cellularConnected ?? false + property string cellularIP: activeService?.cellularIP ?? "" + property string cellularInterface: activeService?.cellularInterface ?? "" + property string cellularOperator: activeService?.cellularOperator ?? "" + property string cellularTechnology: activeService?.cellularTechnology ?? "" + property int cellularSignal: activeService?.cellularSignal ?? 0 + property var cellularDevices: activeService?.cellularDevices ?? [] + property var cellularConnections: activeService?.cellularConnections ?? [] + property var cellularProfiles: activeService?.cellularProfiles ?? [] + property var cellularActive: activeService?.cellularActive ?? [] + property bool cellularToggling: activeService?.cellularToggling ?? false + property bool simLocked: activeService?.simLocked ?? false + property bool pinRequired: activeService?.pinRequired ?? false + property int pinTriesLeft: activeService?.pinTriesLeft ?? 3 + signal networksUpdated signal connectionChanged signal credentialsNeeded(string token, string ssid, string setting, var fields, var hints, string reason, string connType, string connName, string vpnService, var fieldsInfo) @@ -310,4 +328,78 @@ Singleton { activeService.setWifiDeviceOverride(deviceName); } } + + // Cellular functions + function setCellularEnabled(enabled) { + if (activeService && activeService.setCellularEnabled) { + activeService.setCellularEnabled(enabled); + } + } + + function getCellularDevices() { + if (activeService && activeService.getCellularDevices) { + activeService.getCellularDevices(); + } + } + + function getCellularConnections() { + if (activeService && activeService.getCellularConnections) { + activeService.getCellularConnections(); + } + } + + function getCellularProfiles() { + if (activeService && activeService.getCellularProfiles) { + activeService.getCellularProfiles(); + } + } + + function getActiveCellular() { + if (activeService && activeService.getActiveCellular) { + activeService.getActiveCellular(); + } + } + + function connectCellular(uuid) { + if (activeService && activeService.connectCellular) { + activeService.connectCellular(uuid); + } + } + + function disconnectCellular() { + if (activeService && activeService.disconnectCellular) { + activeService.disconnectCellular(); + } + } + + function getSIMStatus(device) { + if (activeService && activeService.getSIMStatus) { + activeService.getSIMStatus(device); + } + } + + function submitSIMPin(pin, device) { + if (activeService && activeService.submitSIMPin) { + activeService.submitSIMPin(pin, device); + } + } + + function getSIMPinTriesLeft(device) { + if (activeService && activeService.getSIMPinTriesLeft) { + activeService.getSIMPinTriesLeft(device); + } + } + + function isActiveCellularUuid(uuid) { + if (activeService && activeService.isActiveCellularUuid) { + return activeService.isActiveCellularUuid(uuid); + } + return false; + } + + function refreshCellularState() { + if (activeService && activeService.refreshCellularState) { + activeService.refreshCellularState(); + } + } }