From 9a11f918c5119a52685e7e144905446b70ecdac2 Mon Sep 17 00:00:00 2001 From: Yechiel Worenklein <41305372+yechielw@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:57:04 +0300 Subject: [PATCH 1/2] feat: add cellular support --- core/internal/mocks/network/mock_Backend.go | 182 ++++++++- core/internal/server/network/backend.go | 76 ++-- .../network/backend_hybrid_iwd_networkd.go | 32 ++ .../network/backend_iwd_unimplemented.go | 32 ++ .../network/backend_networkd_unimplemented.go | 32 ++ .../server/network/backend_networkmanager.go | 50 ++- .../backend_networkmanager_cellular.go | 360 ++++++++++++++++++ .../network/backend_networkmanager_signals.go | 87 ++++- .../network/backend_networkmanager_state.go | 36 ++ core/internal/server/network/handlers.go | 74 ++++ core/internal/server/network/manager.go | 113 ++++++ core/internal/server/network/priority.go | 102 ++++- core/internal/server/network/priority_test.go | 1 + core/internal/server/network/types.go | 75 ++-- core/internal/server/network/types_test.go | 2 + quickshell/DMSShellIPC.qml | 2 +- .../Modals/Settings/SettingsContent.qml | 15 + .../Modals/Settings/SettingsSidebar.qml | 6 + .../ControlCenter/Components/DragDropGrid.qml | 16 +- .../ControlCenter/Details/NetworkDetail.qml | 330 ++++++++++++++-- .../DankBar/Widgets/ControlCenterButton.qml | 4 +- quickshell/Modules/Greetd/GreeterContent.qml | 2 +- quickshell/Modules/Lock/LockScreenContent.qml | 4 +- .../Modules/Settings/NetworkCellularTab.qml | 320 ++++++++++++++++ .../Modules/Settings/NetworkStatusTab.qml | 12 +- quickshell/Services/DMSNetworkService.qml | 94 +++++ quickshell/Services/NetworkService.qml | 34 ++ .../translations/settings_search_index.json | 11 + quickshell/translations/term_freeze.json | 20 + 29 files changed, 2022 insertions(+), 102 deletions(-) create mode 100644 core/internal/server/network/backend_networkmanager_cellular.go create mode 100644 quickshell/Modules/Settings/NetworkCellularTab.qml diff --git a/core/internal/mocks/network/mock_Backend.go b/core/internal/mocks/network/mock_Backend.go index da31780d1..946bc6013 100644 --- a/core/internal/mocks/network/mock_Backend.go +++ b/core/internal/mocks/network/mock_Backend.go @@ -87,9 +87,81 @@ func (_c *MockBackend_ActivateWiredConnection_Call) RunAndReturn(run func(uuid s return _c } -// CancelCredentials provides a mock function for the type MockBackend -func (_mock *MockBackend) CancelCredentials(token string) error { - ret := _mock.Called(token) +// 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 +} + +// ConnectCellular provides a mock function with no fields +func (_m *MockBackend) ConnectCellular() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ConnectCellular") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DisconnectCellular provides a mock function with no 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 +} + +// 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 +} + +// CancelCredentials provides a mock function with given fields: token +func (_m *MockBackend) CancelCredentials(token string) error { + ret := _m.Called(token) if len(ret) == 0 { panic("no return value specified for CancelCredentials") @@ -816,9 +888,83 @@ func (_c *MockBackend_GetCurrentState_Call) RunAndReturn(run func() (*network.Ba return _c } -// GetEthernetDevices provides a mock function for the type MockBackend -func (_mock *MockBackend) GetEthernetDevices() []network.EthernetDevice { - ret := _mock.Called() +// GetCellularConnections provides a mock function with no fields +func (_m *MockBackend) GetCellularConnections() ([]network.WiredConnection, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetCellularConnections") + } + + var r0 []network.WiredConnection + var r1 error + if rf, ok := ret.Get(0).(func() ([]network.WiredConnection, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []network.WiredConnection); ok { + r0 = rf() + } else if ret.Get(0) != nil { + r0 = ret.Get(0).([]network.WiredConnection) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCellularDevices provides a mock function with no 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 +} + +// GetCellularEnabled provides a mock function with no 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 +} + +// GetEthernetDevices provides a mock function with no fields +func (_m *MockBackend) GetEthernetDevices() []network.EthernetDevice { + ret := _m.Called() if len(ret) == 0 { panic("no return value specified for GetEthernetDevices") @@ -1857,9 +2003,27 @@ func (_c *MockBackend_SetWiFiAutoconnect_Call) RunAndReturn(run func(ssid string return _c } -// SetWiFiEnabled provides a mock function for the type MockBackend -func (_mock *MockBackend) SetWiFiEnabled(enabled bool) error { - ret := _mock.Called(enabled) +// 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 +} + +// SetWiFiEnabled provides a mock function with given fields: enabled +func (_m *MockBackend) SetWiFiEnabled(enabled bool) error { + ret := _m.Called(enabled) if len(ret) == 0 { panic("no return value specified for SetWiFiEnabled") diff --git a/core/internal/server/network/backend.go b/core/internal/server/network/backend.go index f000dc48f..3e03cfe68 100644 --- a/core/internal/server/network/backend.go +++ b/core/internal/server/network/backend.go @@ -7,6 +7,9 @@ type Backend interface { GetWiFiEnabled() (bool, error) SetWiFiEnabled(enabled bool) error + GetCellularEnabled() (bool, error) + SetCellularEnabled(enabled bool) error + ScanWiFi() error ScanWiFiDevice(device string) error GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) @@ -27,6 +30,13 @@ type Backend interface { DisconnectEthernetDevice(device string) error ActivateWiredConnection(uuid string) error + GetCellularDevices() []CellularDevice + GetCellularConnections() ([]WiredConnection, error) + ConnectCellular() error + DisconnectCellular() error + DisconnectCellularDevice(device string) error + ActivateCellularConnection(uuid string) error + ListVPNProfiles() ([]VPNProfile, error) ListActiveVPN() ([]VPNActive, error) ConnectVPN(uuidOrName string, singleActive bool) error @@ -52,33 +62,41 @@ type Backend interface { } type BackendState struct { - Backend string - NetworkStatus NetworkStatus - EthernetIP string - EthernetDevice string - EthernetConnected bool - EthernetConnectionUuid string - EthernetDevices []EthernetDevice - WiFiIP string - WiFiDevice string - WiFiConnected bool - WiFiEnabled bool - WiFiSSID string - WiFiBSSID string - WiFiSignal uint8 - WiFiNetworks []WiFiNetwork - SavedWiFiNetworks []WiFiNetwork - WiFiDevices []WiFiDevice - WiredConnections []WiredConnection - VPNProfiles []VPNProfile - VPNActive []VPNActive - IsConnecting bool - ConnectingSSID string - ConnectingDevice string - ConnectingPreExisting bool - IsConnectingVPN bool - ConnectingVPNUUID string - LastError string - VPNError string - VPNErrorUuid string + Backend string + NetworkStatus NetworkStatus + EthernetIP string + EthernetDevice string + EthernetConnected bool + EthernetConnectionUuid string + EthernetDevices []EthernetDevice + CellularIP string + CellularDevice string + CellularConnected bool + CellularEnabled bool + CellularHardwareEnabled bool + CellularConnectionUuid string + CellularDevices []CellularDevice + CellularConnections []WiredConnection + WiFiIP string + WiFiDevice string + WiFiConnected bool + WiFiEnabled bool + WiFiSSID string + WiFiBSSID string + WiFiSignal uint8 + WiFiNetworks []WiFiNetwork + SavedWiFiNetworks []WiFiNetwork + WiFiDevices []WiFiDevice + WiredConnections []WiredConnection + VPNProfiles []VPNProfile + VPNActive []VPNActive + IsConnecting bool + ConnectingSSID string + ConnectingDevice string + ConnectingPreExisting bool + IsConnectingVPN bool + ConnectingVPNUUID string + LastError string + VPNError string + VPNErrorUuid string } diff --git a/core/internal/server/network/backend_hybrid_iwd_networkd.go b/core/internal/server/network/backend_hybrid_iwd_networkd.go index ce883ba26..d59790139 100644 --- a/core/internal/server/network/backend_hybrid_iwd_networkd.go +++ b/core/internal/server/network/backend_hybrid_iwd_networkd.go @@ -164,6 +164,38 @@ func (b *HybridIwdNetworkdBackend) ActivateWiredConnection(uuid string) error { return b.l3.ActivateWiredConnection(uuid) } +func (b *HybridIwdNetworkdBackend) GetCellularDevices() []CellularDevice { + return []CellularDevice{} +} + +func (b *HybridIwdNetworkdBackend) GetCellularEnabled() (bool, error) { + return false, fmt.Errorf("cellular radio control not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) SetCellularEnabled(enabled bool) error { + return fmt.Errorf("cellular radio control not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) GetCellularConnections() ([]WiredConnection, error) { + return nil, fmt.Errorf("cellular connections not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) ConnectCellular() error { + return fmt.Errorf("cellular connections not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) DisconnectCellular() error { + return fmt.Errorf("cellular connections not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) DisconnectCellularDevice(device string) error { + return fmt.Errorf("cellular connections not supported in hybrid mode") +} + +func (b *HybridIwdNetworkdBackend) ActivateCellularConnection(uuid string) error { + return fmt.Errorf("cellular connections not supported in hybrid mode") +} + func (b *HybridIwdNetworkdBackend) ListVPNProfiles() ([]VPNProfile, error) { return []VPNProfile{}, nil } diff --git a/core/internal/server/network/backend_iwd_unimplemented.go b/core/internal/server/network/backend_iwd_unimplemented.go index f5a8b033a..05c7cf7a1 100644 --- a/core/internal/server/network/backend_iwd_unimplemented.go +++ b/core/internal/server/network/backend_iwd_unimplemented.go @@ -33,6 +33,38 @@ func (b *IWDBackend) ActivateWiredConnection(uuid string) error { return fmt.Errorf("wired connections not supported by iwd") } +func (b *IWDBackend) GetCellularDevices() []CellularDevice { + return []CellularDevice{} +} + +func (b *IWDBackend) GetCellularEnabled() (bool, error) { + return false, fmt.Errorf("cellular radio control not supported by iwd") +} + +func (b *IWDBackend) SetCellularEnabled(enabled bool) error { + return fmt.Errorf("cellular radio control not supported by iwd") +} + +func (b *IWDBackend) GetCellularConnections() ([]WiredConnection, error) { + return nil, fmt.Errorf("cellular connections not supported by iwd") +} + +func (b *IWDBackend) ConnectCellular() error { + return fmt.Errorf("cellular connections not supported by iwd") +} + +func (b *IWDBackend) DisconnectCellular() error { + return fmt.Errorf("cellular connections not supported by iwd") +} + +func (b *IWDBackend) DisconnectCellularDevice(device string) error { + return fmt.Errorf("cellular connections not supported by iwd") +} + +func (b *IWDBackend) ActivateCellularConnection(uuid string) error { + return fmt.Errorf("cellular connections not supported by iwd") +} + func (b *IWDBackend) ListVPNProfiles() ([]VPNProfile, error) { return nil, fmt.Errorf("VPN not supported by iwd backend") } diff --git a/core/internal/server/network/backend_networkd_unimplemented.go b/core/internal/server/network/backend_networkd_unimplemented.go index 695c3c5f3..3e9bc50e7 100644 --- a/core/internal/server/network/backend_networkd_unimplemented.go +++ b/core/internal/server/network/backend_networkd_unimplemented.go @@ -97,3 +97,35 @@ func (b *SystemdNetworkdBackend) DisconnectWiFiDevice(device string) error { func (b *SystemdNetworkdBackend) GetWiFiDevices() []WiFiDevice { return nil } + +func (b *SystemdNetworkdBackend) GetCellularDevices() []CellularDevice { + return []CellularDevice{} +} + +func (b *SystemdNetworkdBackend) GetCellularEnabled() (bool, error) { + return false, fmt.Errorf("cellular radio control not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) SetCellularEnabled(enabled bool) error { + return fmt.Errorf("cellular radio control not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) GetCellularConnections() ([]WiredConnection, error) { + return nil, fmt.Errorf("cellular connections not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ConnectCellular() error { + return fmt.Errorf("cellular connections not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) DisconnectCellular() error { + return fmt.Errorf("cellular connections not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) DisconnectCellularDevice(device string) error { + return fmt.Errorf("cellular connections not supported by networkd backend") +} + +func (b *SystemdNetworkdBackend) ActivateCellularConnection(uuid string) error { + return fmt.Errorf("cellular connections not supported by networkd backend") +} diff --git a/core/internal/server/network/backend_networkmanager.go b/core/internal/server/network/backend_networkmanager.go index e292bdfe7..77ef4aa52 100644 --- a/core/internal/server/network/backend_networkmanager.go +++ b/core/internal/server/network/backend_networkmanager.go @@ -48,10 +48,20 @@ type ethernetDeviceInfo struct { hwAddress string } +type cellularDeviceInfo struct { + device gonetworkmanager.Device + generic gonetworkmanager.DeviceGeneric + name string + hwAddress string + description string +} + type NetworkManagerBackend struct { nmConn any ethernetDevice any ethernetDevices map[string]*ethernetDeviceInfo + cellularDevice any + cellularDevices map[string]*cellularDeviceInfo wifiDevice any settings any wifiDev any @@ -143,6 +153,7 @@ func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*Netwo nmConn: nm, stopChan: make(chan struct{}), ethernetDevices: make(map[string]*ethernetDeviceInfo), + cellularDevices: make(map[string]*cellularDeviceInfo), wifiDevices: make(map[string]*wifiDeviceInfo), state: &BackendState{ Backend: "networkmanager", @@ -203,6 +214,36 @@ func (b *NetworkManagerBackend) Initialize() error { return fmt.Errorf("failed to get wired configurations: %w", err) } + case gonetworkmanager.NmDeviceTypeModem: + if managed, _ := dev.GetPropertyManaged(); !managed { + continue + } + iface, err := dev.GetPropertyInterface() + if err != nil { + continue + } + g, _ := gonetworkmanager.NewDeviceGeneric(dev.GetPath()) + hwAddr := "" + description := "Mobile broadband" + if g != nil { + hwAddr, _ = g.GetPropertyHwAddress() + if desc, err := g.GetPropertyTypeDescription(); err == nil && desc != "" { + description = desc + } + } + + b.cellularDevices[iface] = &cellularDeviceInfo{ + device: dev, + generic: g, + name: iface, + hwAddress: hwAddr, + description: description, + } + + if b.cellularDevice == nil { + b.cellularDevice = dev + } + case gonetworkmanager.NmDeviceTypeWifi: iface, err := dev.GetPropertyInterface() if err != nil { @@ -234,6 +275,7 @@ func (b *NetworkManagerBackend) Initialize() error { b.state.WiFiEnabled = wifiEnabled b.stateMutex.Unlock() } + b.updateCellularRadioState() if err := b.updateWiFiState(); err != nil { log.Warnf("Failed to update WiFi state: %v", err) @@ -251,6 +293,10 @@ func (b *NetworkManagerBackend) Initialize() error { } b.updateAllEthernetDevices() + b.updateAllCellularDevices() + if _, err := b.listCellularConnections(); err != nil { + log.Warnf("Failed to get initial cellular configurations: %v", err) + } if err := b.updatePrimaryConnection(); err != nil { return err @@ -286,6 +332,8 @@ func (b *NetworkManagerBackend) GetCurrentState() (*BackendState, error) { state.WiFiDevices = append([]WiFiDevice(nil), b.state.WiFiDevices...) state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...) state.EthernetDevices = append([]EthernetDevice(nil), b.state.EthernetDevices...) + state.CellularConnections = append([]WiredConnection(nil), b.state.CellularConnections...) + state.CellularDevices = append([]CellularDevice(nil), b.state.CellularDevices...) state.VPNProfiles = append([]VPNProfile(nil), b.state.VPNProfiles...) state.VPNActive = append([]VPNActive(nil), b.state.VPNActive...) @@ -503,7 +551,7 @@ func (b *NetworkManagerBackend) getActiveConnections() (map[string]bool, error) continue } - if connType != "802-3-ethernet" { + if connType != "802-3-ethernet" && !isCellularConnectionType(connType) { continue } diff --git a/core/internal/server/network/backend_networkmanager_cellular.go b/core/internal/server/network/backend_networkmanager_cellular.go new file mode 100644 index 000000000..be698f038 --- /dev/null +++ b/core/internal/server/network/backend_networkmanager_cellular.go @@ -0,0 +1,360 @@ +package network + +import ( + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/Wifx/gonetworkmanager/v2" + "github.com/godbus/dbus/v5" +) + +func isCellularConnectionType(connType string) bool { + return connType == "gsm" || connType == "cdma" +} + +func (b *NetworkManagerBackend) GetCellularConnections() ([]WiredConnection, error) { + return b.listCellularConnections() +} + +func (b *NetworkManagerBackend) GetCellularDevices() []CellularDevice { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + return append([]CellularDevice(nil), b.state.CellularDevices...) +} + +func (b *NetworkManagerBackend) GetCellularEnabled() (bool, error) { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + return nm.GetPropertyWwanEnabled() +} + +func (b *NetworkManagerBackend) SetCellularEnabled(enabled bool) error { + conn := b.dbusConn + closeConn := false + if conn == nil { + var err error + conn, err = dbus.ConnectSystemBus() + if err != nil { + return fmt.Errorf("failed to connect to system bus: %w", err) + } + closeConn = true + } + if closeConn { + defer conn.Close() + } + + obj := conn.Object(dbusNMInterface, dbus.ObjectPath(dbusNMPath)) + if err := obj.SetProperty(dbusNMInterface+".WwanEnabled", dbus.MakeVariant(enabled)); err != nil { + return fmt.Errorf("failed to set cellular enabled: %w", err) + } + + b.updateCellularRadioState() + b.updateAllCellularDevices() + b.updateCellularState() + b.listCellularConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) ConnectCellular() error { + if b.cellularDevice == nil { + return fmt.Errorf("no cellular modem available") + } + + if err := b.ensureCellularEnabled(); err != nil { + return err + } + + nm := b.nmConn.(gonetworkmanager.NetworkManager) + dev := b.cellularDevice.(gonetworkmanager.Device) + + settingsMgr, err := gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + for _, conn := range connections { + connSettings, err := conn.GetSettings() + if err != nil { + continue + } + + if connMeta, ok := connSettings["connection"]; ok { + if connType, ok := connMeta["type"].(string); ok && isCellularConnectionType(connType) { + if _, err := nm.ActivateConnection(conn, dev, nil); err != nil { + return fmt.Errorf("failed to activate cellular connection: %w", err) + } + + b.updateAllCellularDevices() + b.updateCellularState() + b.listCellularConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + return nil + } + } + } + + return fmt.Errorf("no cellular connection profile available") +} + +func (b *NetworkManagerBackend) DisconnectCellular() error { + if b.cellularDevice == nil { + return fmt.Errorf("no cellular modem available") + } + + dev := b.cellularDevice.(gonetworkmanager.Device) + if err := dev.Disconnect(); err != nil { + return fmt.Errorf("failed to disconnect cellular modem: %w", err) + } + + b.updateAllCellularDevices() + b.updateCellularState() + b.listCellularConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) DisconnectCellularDevice(device string) error { + info, ok := b.cellularDevices[device] + if !ok { + return fmt.Errorf("cellular modem %s not found", device) + } + + if err := info.device.Disconnect(); err != nil { + return fmt.Errorf("failed to disconnect %s: %w", device, err) + } + + b.updateAllCellularDevices() + b.updateCellularState() + b.listCellularConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) ActivateCellularConnection(uuid string) error { + if b.cellularDevice == nil { + return fmt.Errorf("no cellular modem available") + } + + if err := b.ensureCellularEnabled(); err != nil { + return err + } + + nm := b.nmConn.(gonetworkmanager.NetworkManager) + dev := b.cellularDevice.(gonetworkmanager.Device) + + settingsMgr, err := gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + var targetConnection gonetworkmanager.Connection + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + + connectionSettings := settings["connection"] + connType, _ := connectionSettings["type"].(string) + connUUID, _ := connectionSettings["uuid"].(string) + if connUUID == uuid && isCellularConnectionType(connType) { + targetConnection = conn + break + } + } + + if targetConnection == nil { + return fmt.Errorf("cellular connection with UUID %s not found", uuid) + } + + if _, err := nm.ActivateConnection(targetConnection, dev, nil); err != nil { + return fmt.Errorf("failed to activate cellular connection: %w", err) + } + + b.updateCellularState() + b.listCellularConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + + return nil +} + +func (b *NetworkManagerBackend) listCellularConnections() ([]WiredConnection, error) { + if b.cellularDevice == nil { + b.stateMutex.Lock() + b.state.CellularConnectionUuid = "" + b.state.CellularConnections = []WiredConnection{} + b.stateMutex.Unlock() + return nil, nil + } + + s := b.settings + if s == nil { + settings, err := gonetworkmanager.NewSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + b.settings = settings + s = settings + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return nil, fmt.Errorf("failed to get connections: %w", err) + } + + activeUUIDs, err := b.getActiveConnections() + if err != nil { + return nil, fmt.Errorf("failed to get active cellular connections: %w", err) + } + + configs := make([]WiredConnection, 0) + currentUUID := "" + for _, connection := range connections { + path := connection.GetPath() + settings, err := connection.GetSettings() + if err != nil { + log.Errorf("unable to get settings for %s: %v", path, err) + continue + } + + connectionSettings := settings["connection"] + connType, _ := connectionSettings["type"].(string) + connID, _ := connectionSettings["id"].(string) + connUUID, _ := connectionSettings["uuid"].(string) + + if isCellularConnectionType(connType) { + configs = append(configs, WiredConnection{ + Path: path, + ID: connID, + UUID: connUUID, + Type: connType, + IsActive: activeUUIDs[connUUID], + }) + if activeUUIDs[connUUID] { + currentUUID = connUUID + } + } + } + + b.stateMutex.Lock() + b.state.CellularConnectionUuid = currentUUID + b.state.CellularConnections = configs + b.stateMutex.Unlock() + + return configs, nil +} + +func (b *NetworkManagerBackend) updateAllCellularDevices() { + devices := make([]CellularDevice, 0, len(b.cellularDevices)) + + for name, info := range b.cellularDevices { + state, _ := info.device.GetPropertyState() + connected := state == gonetworkmanager.NmDeviceStateActivated + driver, _ := info.device.GetPropertyDriver() + + var ip string + if connected { + ip = b.getDeviceIP(info.device) + } + + stateStr := "disconnected" + switch state { + case gonetworkmanager.NmDeviceStateActivated: + stateStr = "activated" + case gonetworkmanager.NmDeviceStatePrepare: + stateStr = "preparing" + case gonetworkmanager.NmDeviceStateConfig: + stateStr = "configuring" + case gonetworkmanager.NmDeviceStateNeedAuth: + stateStr = "need-auth" + case gonetworkmanager.NmDeviceStateIpConfig: + stateStr = "ip-config" + case gonetworkmanager.NmDeviceStateIpCheck: + stateStr = "ip-check" + case gonetworkmanager.NmDeviceStateSecondaries: + stateStr = "secondaries" + case gonetworkmanager.NmDeviceStateDeactivating: + stateStr = "deactivating" + case gonetworkmanager.NmDeviceStateFailed: + stateStr = "failed" + case gonetworkmanager.NmDeviceStateUnavailable: + stateStr = "unavailable" + case gonetworkmanager.NmDeviceStateUnmanaged: + stateStr = "unmanaged" + } + + devices = append(devices, CellularDevice{ + Name: name, + HwAddress: info.hwAddress, + State: stateStr, + Connected: connected, + IP: ip, + Driver: driver, + Description: info.description, + }) + } + + b.stateMutex.Lock() + b.state.CellularDevices = devices + b.stateMutex.Unlock() +} + +func (b *NetworkManagerBackend) ensureCellularEnabled() error { + enabled, err := b.GetCellularEnabled() + if err != nil { + return fmt.Errorf("failed to get cellular radio state: %w", err) + } + if enabled { + return nil + } + return b.SetCellularEnabled(true) +} + +func (b *NetworkManagerBackend) updateCellularRadioState() { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + enabled, enabledErr := nm.GetPropertyWwanEnabled() + hardwareEnabled, hardwareErr := nm.GetPropertyWwanHardwareEnabled() + + b.stateMutex.Lock() + if enabledErr == nil { + b.state.CellularEnabled = enabled + } + if hardwareErr == nil { + b.state.CellularHardwareEnabled = hardwareEnabled + } + b.stateMutex.Unlock() +} diff --git a/core/internal/server/network/backend_networkmanager_signals.go b/core/internal/server/network/backend_networkmanager_signals.go index 1ea52cba9..149f73137 100644 --- a/core/internal/server/network/backend_networkmanager_signals.go +++ b/core/internal/server/network/backend_networkmanager_signals.go @@ -159,6 +159,18 @@ func (b *NetworkManagerBackend) startSignalPump() error { return err } + for _, info := range b.cellularDevices { + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ); err != nil { + conn.RemoveSignal(signals) + conn.Close() + return err + } + } + b.sigWG.Add(1) go func() { defer b.sigWG.Done() @@ -243,6 +255,14 @@ func (b *NetworkManagerBackend) stopSignalPump() { ) } + for _, info := range b.cellularDevices { + b.dbusConn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + } + if b.signals != nil { b.dbusConn.RemoveSignal(b.signals) close(b.signals) @@ -261,6 +281,7 @@ func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) { if err := b.updateSavedWiFiNetworks(); err != nil { b.updateWiFiNetworks() } + b.listCellularConnections() if b.onStateChange != nil { b.onStateChange() } @@ -346,6 +367,11 @@ func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]db b.stateMutex.Unlock() needsUpdate = true } + case "WwanEnabled", "WwanHardwareEnabled": + b.updateCellularRadioState() + b.updateAllCellularDevices() + b.updateCellularState() + needsUpdate = true default: continue } @@ -356,6 +382,7 @@ func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]db if _, exists := changes["State"]; exists { b.updateEthernetState() b.updateWiFiState() + b.updateCellularState() } if _, exists := changes["ActiveConnections"]; exists { b.updateVPNConnectionState() @@ -422,10 +449,13 @@ func (b *NetworkManagerBackend) handleDeviceChange(devicePath dbus.ObjectPath, c b.updateAllEthernetDevices() b.updateEthernetState() + b.updateAllCellularDevices() + b.updateCellularState() b.updateAllWiFiDevices() b.updateWiFiState() if stateChanged { b.listEthernetConnections() + b.listCellularConnections() b.updatePrimaryConnection() } if b.onStateChange != nil { @@ -520,7 +550,7 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) { return } - if devType != gonetworkmanager.NmDeviceTypeEthernet && devType != gonetworkmanager.NmDeviceTypeWifi { + if devType != gonetworkmanager.NmDeviceTypeEthernet && devType != gonetworkmanager.NmDeviceTypeWifi && devType != gonetworkmanager.NmDeviceTypeModem { return } @@ -566,6 +596,34 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) { b.listEthernetConnections() b.updatePrimaryConnection() + case gonetworkmanager.NmDeviceTypeModem: + g, _ := gonetworkmanager.NewDeviceGeneric(devicePath) + hwAddr := "" + description := "Mobile broadband" + if g != nil { + hwAddr, _ = g.GetPropertyHwAddress() + if desc, err := g.GetPropertyTypeDescription(); err == nil && desc != "" { + description = desc + } + } + + b.cellularDevices[iface] = &cellularDeviceInfo{ + device: dev, + generic: g, + name: iface, + hwAddress: hwAddr, + description: description, + } + + if b.cellularDevice == nil { + b.cellularDevice = dev + } + + b.updateAllCellularDevices() + b.updateCellularState() + b.listCellularConnections() + b.updatePrimaryConnection() + case gonetworkmanager.NmDeviceTypeWifi: w, err := gonetworkmanager.NewDeviceWireless(devicePath) if err != nil { @@ -656,4 +714,31 @@ func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath) return } } + + for iface, info := range b.cellularDevices { + if info.device.GetPath() == devicePath { + delete(b.cellularDevices, iface) + + if b.cellularDevice != nil { + dev := b.cellularDevice.(gonetworkmanager.Device) + if dev.GetPath() == devicePath { + b.cellularDevice = nil + for _, remaining := range b.cellularDevices { + b.cellularDevice = remaining.device + break + } + } + } + + b.updateAllCellularDevices() + b.updateCellularState() + b.listCellularConnections() + b.updatePrimaryConnection() + + if b.onStateChange != nil { + b.onStateChange() + } + return + } + } } diff --git a/core/internal/server/network/backend_networkmanager_state.go b/core/internal/server/network/backend_networkmanager_state.go index 348fed301..a359fbd52 100644 --- a/core/internal/server/network/backend_networkmanager_state.go +++ b/core/internal/server/network/backend_networkmanager_state.go @@ -61,6 +61,8 @@ func (b *NetworkManagerBackend) updatePrimaryConnection() error { b.state.NetworkStatus = StatusEthernet case "802-11-wireless": b.state.NetworkStatus = StatusWiFi + case "gsm", "cdma": + b.state.NetworkStatus = StatusCellular case "vpn", "wireguard": b.state.NetworkStatus = StatusVPN default: @@ -105,6 +107,40 @@ func (b *NetworkManagerBackend) updateEthernetState() error { return nil } +func (b *NetworkManagerBackend) updateCellularState() error { + var connectedDevice string + var connectedIP string + var anyConnected bool + + for name, info := range b.cellularDevices { + state, err := info.device.GetPropertyState() + if err != nil { + continue + } + + if state == gonetworkmanager.NmDeviceStateActivated { + anyConnected = true + connectedDevice = name + connectedIP = b.getDeviceIP(info.device) + break + } + } + + if !anyConnected && b.cellularDevice != nil { + dev := b.cellularDevice.(gonetworkmanager.Device) + iface, _ := dev.GetPropertyInterface() + connectedDevice = iface + } + + b.stateMutex.Lock() + b.state.CellularDevice = connectedDevice + b.state.CellularConnected = anyConnected + b.state.CellularIP = connectedIP + b.stateMutex.Unlock() + + return nil +} + func (b *NetworkManagerBackend) getDeviceStateReason(dev gonetworkmanager.Device) uint32 { path := dev.GetPath() obj := b.dbusConn.Object("org.freedesktop.NetworkManager", path) diff --git a/core/internal/server/network/handlers.go b/core/internal/server/network/handlers.go index 2b3e75e8a..b1d43a9ed 100644 --- a/core/internal/server/network/handlers.go +++ b/core/internal/server/network/handlers.go @@ -37,6 +37,18 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { handleConnectEthernet(conn, req, manager) case "network.ethernet.disconnect": handleDisconnectEthernet(conn, req, manager) + case "network.cellular.connect.config": + handleConnectCellularSpecificConfig(conn, req, manager) + case "network.cellular.connect": + handleConnectCellular(conn, req, manager) + case "network.cellular.disconnect": + handleDisconnectCellular(conn, req, manager) + case "network.cellular.toggle": + handleToggleCellular(conn, req, manager) + case "network.cellular.enable": + handleEnableCellular(conn, req, manager) + case "network.cellular.disable": + handleDisableCellular(conn, req, manager) case "network.preference.set": handleSetPreference(conn, req, manager) case "network.info": @@ -294,6 +306,68 @@ func handleDisconnectEthernet(conn net.Conn, req models.Request, manager *Manage models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"}) } +func handleConnectCellularSpecificConfig(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.activateCellularConnection(uuid); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connecting"}) +} + +func handleConnectCellular(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.ConnectCellular(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connecting"}) +} + +func handleDisconnectCellular(conn net.Conn, req models.Request, manager *Manager) { + device := params.StringOpt(req.Params, "device", "") + var err error + if device != "" { + err = manager.DisconnectCellularDevice(device) + } else { + err = manager.DisconnectCellular() + } + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"}) +} + +func handleToggleCellular(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.ToggleCellular(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + state := manager.GetState() + models.Respond(conn, req.ID, map[string]bool{"enabled": state.CellularEnabled}) +} + +func handleEnableCellular(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.EnableCellular(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, map[string]bool{"enabled": true}) +} + +func handleDisableCellular(conn net.Conn, req models.Request, manager *Manager) { + if err := manager.DisableCellular(); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, map[string]bool{"enabled": false}) +} + func handleSetPreference(conn net.Conn, req models.Request, manager *Manager) { preference, err := params.String(req.Params, "preference") if err != nil { diff --git a/core/internal/server/network/manager.go b/core/internal/server/network/manager.go index 7e10d9dd9..7264c9e09 100644 --- a/core/internal/server/network/manager.go +++ b/core/internal/server/network/manager.go @@ -73,6 +73,7 @@ func NewManager() (*Manager, error) { Preference: PreferenceAuto, WiFiNetworks: []WiFiNetwork{}, SavedWiFiNetworks: []WiFiNetwork{}, + CellularDevices: []CellularDevice{}, }, stateMutex: sync.RWMutex{}, @@ -118,6 +119,14 @@ func (m *Manager) syncStateFromBackend() error { m.state.EthernetConnected = backendState.EthernetConnected m.state.EthernetConnectionUuid = backendState.EthernetConnectionUuid m.state.EthernetDevices = backendState.EthernetDevices + m.state.CellularIP = backendState.CellularIP + m.state.CellularDevice = backendState.CellularDevice + m.state.CellularConnected = backendState.CellularConnected + m.state.CellularEnabled = backendState.CellularEnabled + m.state.CellularHardwareEnabled = backendState.CellularHardwareEnabled + m.state.CellularConnectionUuid = backendState.CellularConnectionUuid + m.state.CellularDevices = backendState.CellularDevices + m.state.CellularConnections = backendState.CellularConnections m.state.WiFiIP = backendState.WiFiIP m.state.WiFiDevice = backendState.WiFiDevice m.state.WiFiConnected = backendState.WiFiConnected @@ -169,6 +178,8 @@ func (m *Manager) snapshotState() NetworkState { s.WiFiDevices = append([]WiFiDevice(nil), m.state.WiFiDevices...) s.WiredConnections = append([]WiredConnection(nil), m.state.WiredConnections...) s.EthernetDevices = append([]EthernetDevice(nil), m.state.EthernetDevices...) + s.CellularConnections = append([]WiredConnection(nil), m.state.CellularConnections...) + s.CellularDevices = append([]CellularDevice(nil), m.state.CellularDevices...) s.VPNProfiles = append([]VPNProfile(nil), m.state.VPNProfiles...) s.VPNActive = append([]VPNActive(nil), m.state.VPNActive...) return s @@ -187,6 +198,18 @@ func stateChangedMeaningfully(old, new *NetworkState) bool { if old.EthernetIP != new.EthernetIP { return true } + if old.CellularConnected != new.CellularConnected { + return true + } + if old.CellularEnabled != new.CellularEnabled { + return true + } + if old.CellularHardwareEnabled != new.CellularHardwareEnabled { + return true + } + if old.CellularIP != new.CellularIP { + return true + } if old.WiFiConnected != new.WiFiConnected { return true } @@ -236,6 +259,12 @@ func stateChangedMeaningfully(old, new *NetworkState) bool { if len(old.EthernetDevices) != len(new.EthernetDevices) { return true } + if len(old.CellularDevices) != len(new.CellularDevices) { + return true + } + if len(old.CellularConnections) != len(new.CellularConnections) { + return true + } for i := range old.WiFiNetworks { oldNet := &old.WiFiNetworks[i] @@ -299,6 +328,34 @@ func stateChangedMeaningfully(old, new *NetworkState) bool { } } + for i := range old.CellularConnections { + oldNet := &old.CellularConnections[i] + newNet := &new.CellularConnections[i] + if oldNet.ID != newNet.ID { + return true + } + if oldNet.IsActive != newNet.IsActive { + return true + } + } + + for i := range old.CellularDevices { + oldDev := &old.CellularDevices[i] + newDev := &new.CellularDevices[i] + if oldDev.Name != newDev.Name { + return true + } + if oldDev.Connected != newDev.Connected { + return true + } + if oldDev.State != newDev.State { + return true + } + if oldDev.IP != newDev.IP { + return true + } + } + // Check VPN profiles count if len(old.VPNProfiles) != len(new.VPNProfiles) { return true @@ -542,6 +599,38 @@ func (m *Manager) DisableWiFi() error { return nil } +func (m *Manager) ToggleCellular() error { + enabled, err := m.backend.GetCellularEnabled() + if err != nil { + return fmt.Errorf("failed to get cellular state: %w", err) + } + + err = m.backend.SetCellularEnabled(!enabled) + if err != nil { + return fmt.Errorf("failed to toggle cellular: %w", err) + } + + return nil +} + +func (m *Manager) EnableCellular() error { + err := m.backend.SetCellularEnabled(true) + if err != nil { + return fmt.Errorf("failed to enable cellular: %w", err) + } + + return nil +} + +func (m *Manager) DisableCellular() error { + err := m.backend.SetCellularEnabled(false) + if err != nil { + return fmt.Errorf("failed to disable cellular: %w", err) + } + + return nil +} + func (m *Manager) ConnectWiFi(req ConnectionRequest) error { return m.backend.ConnectWiFi(req) } @@ -586,10 +675,34 @@ func (m *Manager) GetEthernetDevices() []EthernetDevice { return devices } +func (m *Manager) ConnectCellular() error { + return m.backend.ConnectCellular() +} + +func (m *Manager) DisconnectCellular() error { + return m.backend.DisconnectCellular() +} + +func (m *Manager) DisconnectCellularDevice(device string) error { + return m.backend.DisconnectCellularDevice(device) +} + +func (m *Manager) GetCellularDevices() []CellularDevice { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + devices := make([]CellularDevice, len(m.state.CellularDevices)) + copy(devices, m.state.CellularDevices) + return devices +} + func (m *Manager) activateConnection(uuid string) error { return m.backend.ActivateWiredConnection(uuid) } +func (m *Manager) activateCellularConnection(uuid string) error { + return m.backend.ActivateCellularConnection(uuid) +} + func (m *Manager) ListVPNProfiles() ([]VPNProfile, error) { return m.backend.ListVPNProfiles() } diff --git a/core/internal/server/network/priority.go b/core/internal/server/network/priority.go index 8ee7fdf89..da0cabc68 100644 --- a/core/internal/server/network/priority.go +++ b/core/internal/server/network/priority.go @@ -21,11 +21,14 @@ const ( func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error { switch pref { - case PreferenceWiFi, PreferenceEthernet, PreferenceAuto: + case PreferenceWiFi, PreferenceEthernet, PreferenceCellular, PreferenceAuto: default: return fmt.Errorf("invalid preference: %s", pref) } + m.priorityMutex.Lock() + defer m.priorityMutex.Unlock() + m.stateMutex.Lock() m.state.Preference = pref m.stateMutex.Unlock() @@ -40,6 +43,8 @@ func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error { return m.prioritizeWiFi() case PreferenceEthernet: return m.prioritizeEthernet() + case PreferenceCellular: + return m.prioritizeCellular() case PreferenceAuto: return m.balancePriorities() } @@ -56,6 +61,12 @@ func (m *Manager) prioritizeWiFi() error { log.Warnf("Failed to set Ethernet priority: %v", err) } + for _, connType := range []string{"gsm", "cdma"} { + if err := m.setConnectionPriority(connType, priorityLow, metricNonPreferred); err != nil { + log.Warnf("Failed to set cellular priority for %s: %v", connType, err) + } + } + m.reapplyActiveConnections() m.notifySubscribers() return nil @@ -70,6 +81,32 @@ func (m *Manager) prioritizeEthernet() error { log.Warnf("Failed to set WiFi priority: %v", err) } + for _, connType := range []string{"gsm", "cdma"} { + if err := m.setConnectionPriority(connType, priorityLow, metricNonPreferred); err != nil { + log.Warnf("Failed to set cellular priority for %s: %v", connType, err) + } + } + + m.reapplyActiveConnections() + m.notifySubscribers() + return nil +} + +func (m *Manager) prioritizeCellular() error { + for _, connType := range []string{"gsm", "cdma"} { + if err := m.setConnectionPriority(connType, priorityHigh, metricPreferred); err != nil { + log.Warnf("Failed to set cellular priority for %s: %v", connType, err) + } + } + + if err := m.setConnectionPriority("802-3-ethernet", priorityLow, metricNonPreferred); err != nil { + log.Warnf("Failed to set Ethernet priority: %v", err) + } + + if err := m.setConnectionPriority("802-11-wireless", priorityLow, metricNonPreferred); err != nil { + log.Warnf("Failed to set WiFi priority: %v", err) + } + m.reapplyActiveConnections() m.notifySubscribers() return nil @@ -84,6 +121,12 @@ func (m *Manager) balancePriorities() error { log.Warnf("Failed to reset WiFi priority: %v", err) } + for _, connType := range []string{"gsm", "cdma"} { + if err := m.setConnectionPriority(connType, priorityDefault, metricDefault); err != nil { + log.Warnf("Failed to reset cellular priority for %s: %v", connType, err) + } + } + m.reapplyActiveConnections() m.notifySubscribers() return nil @@ -93,6 +136,7 @@ func (m *Manager) reapplyActiveConnections() { m.stateMutex.RLock() ethDev := m.state.EthernetDevice wifiDev := m.state.WiFiDevice + cellularDev := m.state.CellularDevice m.stateMutex.RUnlock() if ethDev != "" { @@ -101,6 +145,9 @@ func (m *Manager) reapplyActiveConnections() { if wifiDev != "" { exec.Command("nmcli", "dev", "reapply", wifiDev).Run() } + if cellularDev != "" { + exec.Command("nmcli", "dev", "reapply", cellularDev).Run() + } } func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int32, routeMetric int64) error { @@ -149,7 +196,23 @@ func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int continue } - if err := exec.Command("nmcli", "con", "mod", connName, + connUUID := "" + if uuidVariant, ok := connSection["uuid"]; ok { + connUUID, _ = uuidVariant.Value().(string) + } + + if priorityMatches(connSection["autoconnect-priority"], int64(autoconnectPriority)) && + routeMetricMatches(settings["ipv4"], routeMetric) && + routeMetricMatches(settings["ipv6"], routeMetric) { + continue + } + + connRef := connName + if connUUID != "" { + connRef = "uuid:" + connUUID + } + + if err := exec.Command("nmcli", "con", "mod", connRef, "connection.autoconnect-priority", fmt.Sprintf("%d", autoconnectPriority), "ipv4.route-metric", fmt.Sprintf("%d", routeMetric), "ipv6.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil { @@ -163,6 +226,41 @@ func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int return nil } +func priorityMatches(variant dbus.Variant, expected int64) bool { + value, ok := variantInt64(variant) + return ok && value == expected +} + +func routeMetricMatches(section map[string]dbus.Variant, expected int64) bool { + if section == nil { + return false + } + value, ok := variantInt64(section["route-metric"]) + return ok && value == expected +} + +func variantInt64(variant dbus.Variant) (int64, bool) { + switch value := variant.Value().(type) { + case int: + return int64(value), true + case int32: + return int64(value), true + case int64: + return value, true + case uint: + return int64(value), true + case uint32: + return int64(value), true + case uint64: + if value > uint64(^uint64(0)>>1) { + return 0, false + } + return int64(value), true + default: + return 0, false + } +} + func (m *Manager) GetConnectionPreference() ConnectionPreference { m.stateMutex.RLock() defer m.stateMutex.RUnlock() diff --git a/core/internal/server/network/priority_test.go b/core/internal/server/network/priority_test.go index c0c65e307..111da19f6 100644 --- a/core/internal/server/network/priority_test.go +++ b/core/internal/server/network/priority_test.go @@ -28,6 +28,7 @@ func TestManager_GetConnectionPreference(t *testing.T) { {"auto", PreferenceAuto}, {"wifi", PreferenceWiFi}, {"ethernet", PreferenceEthernet}, + {"cellular", PreferenceCellular}, } for _, tt := range tests { diff --git a/core/internal/server/network/types.go b/core/internal/server/network/types.go index 6f7191c4c..d56599290 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 { @@ -64,6 +66,16 @@ type EthernetDevice struct { Driver string `json:"driver,omitempty"` } +type CellularDevice struct { + Name string `json:"name"` + HwAddress string `json:"hwAddress"` + State string `json:"state"` + Connected bool `json:"connected"` + IP string `json:"ip,omitempty"` + Driver string `json:"driver,omitempty"` + Description string `json:"description,omitempty"` +} + type VPNProfile struct { Name string `json:"name"` UUID string `json:"uuid"` @@ -96,33 +108,41 @@ type VPNState struct { } type NetworkState struct { - Backend string `json:"backend"` - NetworkStatus NetworkStatus `json:"networkStatus"` - Preference ConnectionPreference `json:"preference"` - EthernetIP string `json:"ethernetIP"` - EthernetDevice string `json:"ethernetDevice"` - EthernetConnected bool `json:"ethernetConnected"` - EthernetConnectionUuid string `json:"ethernetConnectionUuid"` - EthernetDevices []EthernetDevice `json:"ethernetDevices"` - WiFiIP string `json:"wifiIP"` - WiFiDevice string `json:"wifiDevice"` - WiFiConnected bool `json:"wifiConnected"` - WiFiEnabled bool `json:"wifiEnabled"` - WiFiSSID string `json:"wifiSSID"` - WiFiBSSID string `json:"wifiBSSID"` - WiFiSignal uint8 `json:"wifiSignal"` - WiFiNetworks []WiFiNetwork `json:"wifiNetworks"` - SavedWiFiNetworks []WiFiNetwork `json:"savedWifiNetworks"` - WiFiDevices []WiFiDevice `json:"wifiDevices"` - WiredConnections []WiredConnection `json:"wiredConnections"` - VPNProfiles []VPNProfile `json:"vpnProfiles"` - VPNActive []VPNActive `json:"vpnActive"` - IsConnecting bool `json:"isConnecting"` - ConnectingSSID string `json:"connectingSSID"` - ConnectingDevice string `json:"connectingDevice,omitempty"` - LastError string `json:"lastError"` - VPNError string `json:"vpnError"` - VPNErrorUuid string `json:"vpnErrorUuid"` + Backend string `json:"backend"` + NetworkStatus NetworkStatus `json:"networkStatus"` + Preference ConnectionPreference `json:"preference"` + EthernetIP string `json:"ethernetIP"` + EthernetDevice string `json:"ethernetDevice"` + EthernetConnected bool `json:"ethernetConnected"` + EthernetConnectionUuid string `json:"ethernetConnectionUuid"` + EthernetDevices []EthernetDevice `json:"ethernetDevices"` + CellularIP string `json:"cellularIP"` + CellularDevice string `json:"cellularDevice"` + CellularConnected bool `json:"cellularConnected"` + CellularEnabled bool `json:"cellularEnabled"` + CellularHardwareEnabled bool `json:"cellularHardwareEnabled"` + CellularConnectionUuid string `json:"cellularConnectionUuid"` + CellularDevices []CellularDevice `json:"cellularDevices"` + CellularConnections []WiredConnection `json:"cellularConnections"` + WiFiIP string `json:"wifiIP"` + WiFiDevice string `json:"wifiDevice"` + WiFiConnected bool `json:"wifiConnected"` + WiFiEnabled bool `json:"wifiEnabled"` + WiFiSSID string `json:"wifiSSID"` + WiFiBSSID string `json:"wifiBSSID"` + WiFiSignal uint8 `json:"wifiSignal"` + WiFiNetworks []WiFiNetwork `json:"wifiNetworks"` + SavedWiFiNetworks []WiFiNetwork `json:"savedWifiNetworks"` + WiFiDevices []WiFiDevice `json:"wifiDevices"` + WiredConnections []WiredConnection `json:"wiredConnections"` + VPNProfiles []VPNProfile `json:"vpnProfiles"` + VPNActive []VPNActive `json:"vpnActive"` + IsConnecting bool `json:"isConnecting"` + ConnectingSSID string `json:"connectingSSID"` + ConnectingDevice string `json:"connectingDevice,omitempty"` + LastError string `json:"lastError"` + VPNError string `json:"vpnError"` + VPNErrorUuid string `json:"vpnErrorUuid"` } type ConnectionRequest struct { @@ -161,6 +181,7 @@ type Manager struct { subscribers syncmap.Map[string, chan NetworkState] stopChan chan struct{} dirty chan struct{} + priorityMutex sync.Mutex notifierWg sync.WaitGroup lastNotifiedState *NetworkState credentialSubscribers syncmap.Map[string, chan CredentialPrompt] diff --git a/core/internal/server/network/types_test.go b/core/internal/server/network/types_test.go index 1c3e0bffd..27ce72702 100644 --- a/core/internal/server/network/types_test.go +++ b/core/internal/server/network/types_test.go @@ -12,12 +12,14 @@ func TestNetworkStatus_Constants(t *testing.T) { assert.Equal(t, NetworkStatus("disconnected"), StatusDisconnected) assert.Equal(t, NetworkStatus("ethernet"), StatusEthernet) assert.Equal(t, NetworkStatus("wifi"), StatusWiFi) + assert.Equal(t, NetworkStatus("cellular"), StatusCellular) } func TestConnectionPreference_Constants(t *testing.T) { assert.Equal(t, ConnectionPreference("auto"), PreferenceAuto) assert.Equal(t, ConnectionPreference("wifi"), PreferenceWiFi) assert.Equal(t, ConnectionPreference("ethernet"), PreferenceEthernet) + assert.Equal(t, ConnectionPreference("cellular"), PreferenceCellular) } func TestEventType_Constants(t *testing.T) { diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index 496942ee1..2e34f2ff2 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -972,7 +972,7 @@ Item { function tabs(): string { if (!PopoutService.settingsModal) - return "wallpaper\ntheme\ntypography\ntime_weather\nsounds\ndankbar\ndankbar_settings\ndankbar_appearance\ndankbar_widgets\nframe\nworkspaces\ncompositor\nmedia_player\nnotifications\nosd\nrunning_apps\nupdater\ndock\nlauncher\nkeybinds\ndisplays\nnetwork\nnetwork_status\nnetwork_ethernet\nnetwork_wifi\nnetwork_vpn\nprinters\nlock_screen\npower_sleep\nplugins\nabout"; + return "wallpaper\ntheme\ntypography\ntime_weather\nsounds\ndankbar\ndankbar_settings\ndankbar_appearance\ndankbar_widgets\nframe\nworkspaces\ncompositor\nmedia_player\nnotifications\nosd\nrunning_apps\nupdater\ndock\nlauncher\nkeybinds\ndisplays\nnetwork\nnetwork_status\nnetwork_ethernet\nnetwork_wifi\nnetwork_cellular\nnetwork_vpn\nprinters\nlock_screen\npower_sleep\nplugins\nabout"; var modal = PopoutService.settingsModal; var ids = []; var structure = modal.sidebar?.categoryStructure ?? []; diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 4de081855..6de0949fd 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -286,6 +286,21 @@ FocusScope { } } + Loader { + id: networkCellularLoader + anchors.fill: parent + active: root.currentIndex === 42 + visible: active + focus: active + + sourceComponent: NetworkCellularTab {} + + onActiveChanged: { + if (active && item) + Qt.callLater(() => item.forceActiveFocus()); + } + } + Loader { id: printerLoader anchors.fill: parent diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index 20a8a9a6d..5b9195e0e 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -264,6 +264,12 @@ Rectangle { "icon": "wifi", "tabIndex": 40 }, + { + "id": "network_cellular", + "text": I18n.tr("Cellular"), + "icon": "network_cell", + "tabIndex": 42 + }, { "id": "network_vpn", "text": I18n.tr("VPN"), diff --git a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml index 368e27cd5..92cc30102 100644 --- a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml @@ -117,8 +117,10 @@ Column { const status = NetworkService.networkStatus; if (status === "ethernet") return "settings_ethernet"; + if (status === "cellular") + return "network_cell"; if (status === "vpn") - return NetworkService.ethernetConnected ? "settings_ethernet" : NetworkService.wifiSignalIcon; + return NetworkService.ethernetConnected ? "settings_ethernet" : (NetworkService.cellularConnected ? "network_cell" : NetworkService.wifiSignalIcon); if (status === "wifi") return NetworkService.wifiSignalIcon; return "wifi"; @@ -163,8 +165,10 @@ Column { const status = NetworkService.networkStatus; if (status === "ethernet") return true; + if (status === "cellular") + return true; if (status === "vpn") - return NetworkService.ethernetConnected || NetworkService.wifiConnected; + return NetworkService.ethernetConnected || NetworkService.wifiConnected || NetworkService.cellularConnected; if (status === "wifi") return true; return NetworkService.wifiEnabled; @@ -487,9 +491,13 @@ Column { const status = NetworkService.networkStatus; if (status === "ethernet") return I18n.tr("Ethernet", "network status"); + if (status === "cellular") + return I18n.tr("Cellular", "network status"); if (status === "vpn") { if (NetworkService.ethernetConnected) return I18n.tr("Ethernet", "network status"); + if (NetworkService.cellularConnected) + return I18n.tr("Cellular", "network status"); if (NetworkService.wifiConnected && NetworkService.currentWifiSSID) return NetworkService.currentWifiSSID; } @@ -529,9 +537,13 @@ Column { const status = NetworkService.networkStatus; if (status === "ethernet") return I18n.tr("Connected", "network status"); + if (status === "cellular") + return NetworkService.cellularIP || I18n.tr("Connected", "network status"); if (status === "vpn") { if (NetworkService.ethernetConnected) return I18n.tr("Connected", "network status"); + if (NetworkService.cellularConnected) + return NetworkService.cellularIP || I18n.tr("Connected", "network status"); if (NetworkService.wifiConnected) return NetworkService.wifiSignalStrength > 0 ? NetworkService.wifiSignalStrength + "%" : I18n.tr("Connected", "network status"); } diff --git a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml index 73d0339d1..884ee25f6 100644 --- a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml @@ -16,9 +16,15 @@ Rectangle { implicitHeight: { if (height > 0) return height; + if (currentConnectionType === "cellular" && NetworkService.cellularToggling) + return headerRow.height + cellularToggleContent.height + Theme.spacingM; if (NetworkService.wifiToggling) return headerRow.height + wifiToggleContent.height + Theme.spacingM; - if (NetworkService.wifiEnabled) + if (currentConnectionType === "cellular" && NetworkService.cellularEnabled) + return headerRow.height + cellularContent.height + Theme.spacingM; + if (currentConnectionType === "cellular") + return headerRow.height + cellularOffContent.height + Theme.spacingM; + if (currentConnectionType === "wifi" && NetworkService.wifiEnabled) return headerRow.height + wifiContent.height + Theme.spacingM; return headerRow.height + wifiOffContent.height + Theme.spacingM; } @@ -37,7 +43,18 @@ Rectangle { property bool hasEthernetAvailable: (NetworkService.ethernetDevices?.length ?? 0) > 0 property bool hasWifiAvailable: (NetworkService.wifiDevices?.length ?? 0) > 0 - property bool hasBothConnectionTypes: hasEthernetAvailable && hasWifiAvailable + property bool hasCellularAvailable: (NetworkService.cellularDevices?.length ?? 0) > 0 + property var connectionTypes: { + const types = []; + if (hasEthernetAvailable) + types.push("ethernet"); + if (hasWifiAvailable) + types.push("wifi"); + if (hasCellularAvailable) + types.push("cellular"); + return types.length > 0 ? types : ["wifi"]; + } + property string currentConnectionType: connectionTypes[Math.max(0, currentPreferenceIndex)] || "wifi" property int maxPinnedNetworks: 3 function normalizePinList(value) { @@ -58,20 +75,13 @@ Rectangle { return 1; if (NetworkService.backend !== "networkmanager" || DMSService.apiVersion <= 10) return 1; - if (!hasEthernetAvailable) - return 1; - if (!hasWifiAvailable) - return 0; - const pref = NetworkService.userPreference; - switch (pref) { - case "ethernet": - return 0; - case "wifi": - return 1; - default: - return NetworkService.networkStatus === "ethernet" ? 0 : 1; - } + if (connectionTypes.indexOf(pref) !== -1) + return connectionTypes.indexOf(pref); + if (connectionTypes.indexOf(NetworkService.networkStatus) !== -1) + return connectionTypes.indexOf(NetworkService.networkStatus); + const wifiIndex = connectionTypes.indexOf("wifi"); + return wifiIndex !== -1 ? wifiIndex : 0; } Row { @@ -106,7 +116,7 @@ Rectangle { DankDropdown { id: wifiDeviceDropdown anchors.verticalCenter: parent.verticalCenter - visible: currentPreferenceIndex === 1 && (NetworkService.wifiDevices?.length ?? 0) > 1 + visible: currentConnectionType === "wifi" && (NetworkService.wifiDevices?.length ?? 0) > 1 compactMode: true dropdownWidth: 120 popupWidth: 160 @@ -130,20 +140,34 @@ Rectangle { DankButtonGroup { id: preferenceControls anchors.verticalCenter: parent.verticalCenter - visible: hasBothConnectionTypes && NetworkService.backend === "networkmanager" && DMSService.apiVersion > 10 buttonHeight: 28 textSize: Theme.fontSizeSmall - model: [I18n.tr("Ethernet"), I18n.tr("WiFi")] + readonly property var labelsByType: ({ + "ethernet": I18n.tr("Ethernet"), + "wifi": I18n.tr("WiFi"), + "cellular": I18n.tr("Cellular") + }) + + visible: connectionTypes.length > 1 && NetworkService.backend === "networkmanager" && DMSService.apiVersion > 10 + model: connectionTypes.map(t => labelsByType[t] || t) currentIndex: currentPreferenceIndex selectionMode: "single" onSelectionChanged: (index, selected) => { if (!selected) return; - NetworkService.setNetworkPreference(index === 0 ? "ethernet" : "wifi"); + NetworkService.setNetworkPreference(connectionTypes[index] || "wifi"); } } + DankToggle { + anchors.verticalCenter: parent.verticalCenter + visible: currentConnectionType === "cellular" && NetworkService.backend === "networkmanager" + checked: NetworkService.cellularEnabled + enabled: NetworkService.cellularHardwareEnabled && !NetworkService.cellularToggling + onToggled: NetworkService.toggleCellularRadio() + } + DankActionButton { anchors.verticalCenter: parent.verticalCenter iconName: "settings" @@ -152,7 +176,12 @@ Rectangle { iconColor: Theme.surfaceVariantText onClicked: { PopoutService.closeControlCenter(); - PopoutService.openSettingsWithTab(currentPreferenceIndex === 0 ? "network_ethernet" : "network_wifi"); + if (currentConnectionType === "ethernet") + PopoutService.openSettingsWithTab("network_ethernet"); + else if (currentConnectionType === "cellular") + PopoutService.openSettingsWithTab("network_cellular"); + else + PopoutService.openSettingsWithTab("network_wifi"); } } } @@ -165,7 +194,7 @@ Rectangle { anchors.right: parent.right anchors.margins: Theme.spacingM anchors.topMargin: Theme.spacingM - visible: currentPreferenceIndex === 1 && NetworkService.wifiToggling + visible: currentConnectionType === "wifi" && NetworkService.wifiToggling height: visible ? wifiToggleColumn.implicitHeight + Theme.spacingM * 2 : 0 Column { @@ -206,7 +235,7 @@ Rectangle { anchors.right: parent.right anchors.margins: Theme.spacingM anchors.topMargin: Theme.spacingM - visible: currentPreferenceIndex === 1 && !NetworkService.wifiEnabled && !NetworkService.wifiToggling + visible: currentConnectionType === "wifi" && !NetworkService.wifiEnabled && !NetworkService.wifiToggling height: visible ? wifiOffColumn.implicitHeight + Theme.spacingM * 2 : 0 Column { @@ -260,6 +289,257 @@ Rectangle { } } + ScriptModel { + id: cellularConnectionsModel + objectProp: "uuid" + values: { + const networks = NetworkService.cellularConnections || []; + let sorted = [...networks]; + sorted.sort((a, b) => { + if (a.isActive && !b.isActive) + return -1; + if (!a.isActive && b.isActive) + return 1; + return (a.id || "").localeCompare(b.id || ""); + }); + return sorted; + } + } + + Item { + id: cellularToggleContent + anchors.top: headerRow.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacingM + anchors.topMargin: Theme.spacingM + visible: currentConnectionType === "cellular" && NetworkService.cellularToggling + height: visible ? cellularToggleColumn.implicitHeight + Theme.spacingM * 2 : 0 + + Column { + id: cellularToggleColumn + anchors.centerIn: parent + spacing: Theme.spacingM + + DankIcon { + anchors.horizontalCenter: parent.horizontalCenter + name: "sync" + size: 32 + color: Theme.primary + smoothTransform: NetworkService.cellularToggling + + RotationAnimator on rotation { + running: NetworkService.cellularToggling + loops: Animation.Infinite + from: 0 + to: 360 + duration: 1000 + } + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + text: NetworkService.cellularEnabled ? I18n.tr("Disabling cellular...") : I18n.tr("Enabling cellular...") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + horizontalAlignment: Text.AlignHCenter + } + } + } + + Item { + id: cellularOffContent + anchors.top: headerRow.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacingM + anchors.topMargin: Theme.spacingM + visible: currentConnectionType === "cellular" && !NetworkService.cellularEnabled && !NetworkService.cellularToggling + height: visible ? cellularOffColumn.implicitHeight + Theme.spacingM * 2 : 0 + + Column { + id: cellularOffColumn + anchors.centerIn: parent + spacing: Theme.spacingL + width: parent.width + + DankIcon { + anchors.horizontalCenter: parent.horizontalCenter + name: "network_cell" + size: 48 + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5) + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + text: NetworkService.cellularHardwareEnabled ? I18n.tr("Cellular is off") : I18n.tr("Cellular unavailable") + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + horizontalAlignment: Text.AlignHCenter + } + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: enableCellularLabel.implicitWidth + Theme.spacingL * 2 + height: enableCellularLabel.implicitHeight + Theme.spacingM * 2 + radius: height / 2 + color: enableCellularButton.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) + border.width: 0 + visible: NetworkService.cellularHardwareEnabled + + StyledText { + id: enableCellularLabel + anchors.centerIn: parent + text: I18n.tr("Enable Cellular") + color: Theme.primary + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + } + + MouseArea { + id: enableCellularButton + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: NetworkService.toggleCellularRadio() + } + } + } + } + + DankFlickable { + id: cellularContent + anchors.top: headerRow.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Theme.spacingM + anchors.topMargin: Theme.spacingM + visible: currentConnectionType === "cellular" && NetworkService.backend === "networkmanager" && NetworkService.cellularEnabled && !NetworkService.cellularToggling + contentHeight: cellularColumn.height + clip: true + + Column { + id: cellularColumn + width: parent.width + spacing: Theme.spacingS + + StyledText { + width: parent.width + visible: (NetworkService.cellularDevices?.length ?? 0) === 0 + text: I18n.tr("No cellular modems detected") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + } + + Repeater { + model: cellularConnectionsModel + + delegate: Rectangle { + id: cellularDelegate + required property var modelData + + readonly property bool isActive: modelData.isActive + readonly property string configName: modelData.id || I18n.tr("Unknown Config") + + width: parent.width + height: cellularContentRow.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: cellularMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight + border.color: cellularDelegate.isActive ? Theme.primary : Theme.outlineLight + border.width: cellularDelegate.isActive ? 2 : 1 + + Row { + id: cellularContentRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingM + anchors.right: cellularActionButton.left + anchors.rightMargin: Theme.spacingS + spacing: Theme.spacingS + + DankIcon { + name: "network_cell" + size: Theme.iconSize - 4 + color: cellularDelegate.isActive ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - Theme.iconSize - Theme.spacingS + spacing: 2 + + StyledText { + text: cellularDelegate.configName + font.pixelSize: Theme.fontSizeMedium + color: cellularDelegate.isActive ? Theme.primary : Theme.surfaceText + font.weight: cellularDelegate.isActive ? Font.Medium : Font.Normal + elide: Text.ElideRight + width: parent.width + } + + StyledText { + text: cellularDelegate.isActive ? I18n.tr("Connected") : (modelData.type || I18n.tr("Available")) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + width: parent.width + } + } + } + + DankActionButton { + id: cellularActionButton + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + iconName: cellularDelegate.isActive ? "link_off" : "link" + buttonSize: 28 + iconSize: 18 + iconColor: cellularDelegate.isActive ? Theme.error : Theme.primary + onClicked: { + if (cellularDelegate.isActive) + NetworkService.toggleNetworkConnection("cellular"); + else + NetworkService.connectToSpecificCellularConfig(modelData.uuid); + } + } + + DankRipple { + id: cellularRipple + cornerRadius: parent.radius + } + + MouseArea { + id: cellularMouseArea + anchors.fill: parent + anchors.rightMargin: cellularActionButton.width + Theme.spacingS + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => cellularRipple.trigger(mouse.x, mouse.y) + onClicked: function (event) { + if (!cellularDelegate.isActive) + NetworkService.connectToSpecificCellularConfig(modelData.uuid); + event.accepted = true; + } + } + } + } + + StyledText { + width: parent.width + visible: (NetworkService.cellularDevices?.length ?? 0) > 0 && cellularConnectionsModel.values.length === 0 + text: I18n.tr("No cellular profiles configured") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + } + } + } + ScriptModel { id: wiredConnectionsModel objectProp: "uuid" @@ -287,7 +567,7 @@ Rectangle { anchors.bottom: parent.bottom anchors.margins: Theme.spacingM anchors.topMargin: Theme.spacingM - visible: currentPreferenceIndex === 0 && NetworkService.backend === "networkmanager" && DMSService.apiVersion > 10 + visible: currentConnectionType === "ethernet" && NetworkService.backend === "networkmanager" && DMSService.apiVersion > 10 contentHeight: wiredColumn.height clip: true @@ -488,7 +768,7 @@ Rectangle { anchors.bottom: parent.bottom anchors.margins: Theme.spacingM anchors.topMargin: Theme.spacingM - visible: currentPreferenceIndex === 1 && NetworkService.wifiEnabled && !NetworkService.wifiToggling && NetworkService.wifiInterface && (NetworkService.wifiNetworks?.length ?? 0) < 1 && NetworkService.isScanning + visible: currentConnectionType === "wifi" && NetworkService.wifiEnabled && !NetworkService.wifiToggling && NetworkService.wifiInterface && (NetworkService.wifiNetworks?.length ?? 0) < 1 && NetworkService.isScanning DankIcon { anchors.centerIn: parent @@ -515,7 +795,7 @@ Rectangle { anchors.bottom: parent.bottom anchors.margins: Theme.spacingM anchors.topMargin: Theme.spacingM - visible: currentPreferenceIndex === 1 && NetworkService.wifiEnabled && !NetworkService.wifiToggling && !wifiScanningOverlay.visible + visible: currentConnectionType === "wifi" && NetworkService.wifiEnabled && !NetworkService.wifiToggling && !wifiScanningOverlay.visible clip: true spacing: Theme.spacingS model: wifiNetworksModel diff --git a/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml b/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml index 97044f45c..18a9c21de 100644 --- a/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml +++ b/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml @@ -123,8 +123,10 @@ BasePill { switch (NetworkService.networkStatus) { case "ethernet": return "lan"; + case "cellular": + return "network_cell"; case "vpn": - return NetworkService.ethernetConnected ? "lan" : NetworkService.wifiSignalIcon; + return NetworkService.ethernetConnected ? "lan" : (NetworkService.cellularConnected ? "network_cell" : NetworkService.wifiSignalIcon); default: return NetworkService.wifiSignalIcon; } diff --git a/quickshell/Modules/Greetd/GreeterContent.qml b/quickshell/Modules/Greetd/GreeterContent.qml index b5b583a87..0d0d20616 100644 --- a/quickshell/Modules/Greetd/GreeterContent.qml +++ b/quickshell/Modules/Greetd/GreeterContent.qml @@ -1545,7 +1545,7 @@ Item { visible: NetworkService.networkStatus !== "disconnected" || (BluetoothService.available && BluetoothService.enabled) || (AudioService.sink && AudioService.sink.audio) DankIcon { - name: NetworkService.networkStatus === "ethernet" ? "lan" : NetworkService.wifiSignalIcon + name: NetworkService.networkStatus === "ethernet" ? "lan" : (NetworkService.networkStatus === "cellular" ? "network_cell" : NetworkService.wifiSignalIcon) size: Theme.iconSize - 2 color: NetworkService.networkStatus !== "disconnected" ? "white" : Qt.rgba(255, 255, 255, 0.5) anchors.verticalCenter: parent.verticalCenter diff --git a/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml index a58421fce..8d6393f5f 100644 --- a/quickshell/Modules/Lock/LockScreenContent.qml +++ b/quickshell/Modules/Lock/LockScreenContent.qml @@ -1624,8 +1624,10 @@ Item { switch (NetworkService.networkStatus) { case "ethernet": return "lan"; + case "cellular": + return "network_cell"; case "vpn": - return NetworkService.ethernetConnected ? "lan" : NetworkService.wifiSignalIcon; + return NetworkService.ethernetConnected ? "lan" : (NetworkService.cellularConnected ? "network_cell" : NetworkService.wifiSignalIcon); default: return NetworkService.wifiSignalIcon; } diff --git a/quickshell/Modules/Settings/NetworkCellularTab.qml b/quickshell/Modules/Settings/NetworkCellularTab.qml new file mode 100644 index 000000000..db607663d --- /dev/null +++ b/quickshell/Modules/Settings/NetworkCellularTab.qml @@ -0,0 +1,320 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.Settings.Widgets +import qs.Services +import qs.Widgets + +Item { + id: networkCellularTab + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + Component.onCompleted: NetworkService.addRef() + Component.onDestruction: NetworkService.removeRef() + + DankFlickable { + anchors.fill: parent + clip: true + contentHeight: mainColumn.height + Theme.spacingXL + contentWidth: width + + Column { + id: mainColumn + + topPadding: 4 + width: Math.min(600, parent.width - Theme.spacingL * 2) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingL + + SettingsCard { + id: root + + title: I18n.tr("Cellular") + iconName: "network_cell" + settingKey: "networkCellular" + tags: ["cellular", "mobile", "modem", "wwan", "lte", "gsm", "cdma", "network"] + width: parent.width + + Column { + width: parent.width + spacing: Theme.spacingM + + Row { + width: parent.width + spacing: Theme.spacingM + + StyledText { + text: { + if (!NetworkService.cellularHardwareEnabled) + return I18n.tr("Unavailable"); + if (NetworkService.cellularToggling) + return I18n.tr("Toggling..."); + if (!NetworkService.cellularEnabled) + return I18n.tr("Disabled"); + const devices = NetworkService.cellularDevices || []; + const connected = devices.filter(d => d.connected).length; + if (devices.length === 0) + return I18n.tr("No modems"); + if (connected === 0) + return devices.length === 1 ? I18n.tr("%1 modem, none connected").arg(devices.length) : I18n.tr("%1 modems, none connected").arg(devices.length); + return I18n.tr("%1 connected").arg(connected); + } + font.pixelSize: Theme.fontSizeSmall + color: NetworkService.cellularConnected ? Theme.primary : Theme.surfaceVariantText + width: parent.width - cellularControls.width - Theme.spacingM + horizontalAlignment: Text.AlignLeft + anchors.verticalCenter: parent.verticalCenter + } + + Row { + id: cellularControls + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankToggle { + checked: NetworkService.cellularEnabled + enabled: NetworkService.cellularHardwareEnabled && !NetworkService.cellularToggling + onToggled: NetworkService.toggleCellularRadio() + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + } + + Column { + width: parent.width + spacing: 4 + visible: NetworkService.cellularEnabled && (NetworkService.cellularDevices?.length ?? 0) > 0 + + StyledText { + text: I18n.tr("Modems") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + Repeater { + model: NetworkService.cellularDevices || [] + + delegate: Rectangle { + id: modemDelegate + required property var modelData + + readonly property bool isConnected: modelData.connected || false + + width: parent.width + height: 56 + radius: Theme.cornerRadius + color: modemMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight + border.width: isConnected ? 2 : 0 + border.color: Theme.primary + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.right: modemActions.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankIcon { + name: "network_cell" + size: 20 + color: modemDelegate.isConnected ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + width: parent.width - 20 - Theme.spacingS + + StyledText { + text: modelData.name || I18n.tr("Unknown") + font.pixelSize: Theme.fontSizeMedium + color: modemDelegate.isConnected ? Theme.primary : Theme.surfaceText + font.weight: modemDelegate.isConnected ? Font.Medium : Font.Normal + elide: Text.ElideRight + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + StyledText { + text: { + const state = modelData.state || I18n.tr("Unknown"); + const ip = modelData.ip || ""; + return ip.length > 0 ? state + " • " + ip : state; + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + width: parent.width + horizontalAlignment: Text.AlignLeft + } + } + } + + Row { + id: modemActions + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + Rectangle { + width: 28 + height: 28 + radius: 14 + color: modemDisconnectBtn.containsMouse ? Theme.errorHover : "transparent" + visible: modemDelegate.isConnected + + DankIcon { + anchors.centerIn: parent + name: "link_off" + size: 18 + color: modemDisconnectBtn.containsMouse ? Theme.error : Theme.surfaceVariantText + } + + MouseArea { + id: modemDisconnectBtn + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: NetworkService.disconnectCellularDevice(modelData.name) + } + } + } + + MouseArea { + id: modemMouseArea + anchors.fill: parent + anchors.rightMargin: modemActions.width + Theme.spacingM + hoverEnabled: true + } + } + } + } + + StyledText { + visible: NetworkService.cellularEnabled && (NetworkService.cellularDevices?.length ?? 0) === 0 + text: NetworkService.cellularHardwareEnabled ? I18n.tr("No cellular modems detected") : I18n.tr("Cellular hardware unavailable") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + width: parent.width + horizontalAlignment: Text.AlignHCenter + } + } + } + + SettingsCard { + title: I18n.tr("Cellular Profiles") + iconName: "sim_card" + settingKey: "networkCellularProfiles" + tags: ["cellular", "mobile", "profile", "apn", "sim"] + width: parent.width + visible: NetworkService.cellularEnabled && (NetworkService.cellularConnections?.length ?? 0) > 0 + + Column { + width: parent.width + spacing: 4 + + Repeater { + model: NetworkService.cellularConnections || [] + + delegate: Rectangle { + id: profileDelegate + required property var modelData + + readonly property bool isActive: modelData.isActive || false + + width: parent.width + height: 56 + radius: Theme.cornerRadius + color: profileMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight + border.color: isActive ? Theme.primary : Theme.outlineLight + border.width: isActive ? 2 : 1 + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.right: profileAction.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankIcon { + name: "sim_card" + size: 20 + color: profileDelegate.isActive ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 20 - Theme.spacingS + spacing: 2 + + StyledText { + text: modelData.id || I18n.tr("Unknown") + font.pixelSize: Theme.fontSizeMedium + color: profileDelegate.isActive ? Theme.primary : Theme.surfaceText + font.weight: profileDelegate.isActive ? Font.Medium : Font.Normal + elide: Text.ElideRight + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + StyledText { + text: profileDelegate.isActive ? I18n.tr("Connected") : (modelData.type || I18n.tr("Available")) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + horizontalAlignment: Text.AlignLeft + } + } + } + + DankActionButton { + id: profileAction + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + iconName: profileDelegate.isActive ? "link_off" : "link" + buttonSize: 28 + iconSize: 18 + iconColor: profileDelegate.isActive ? Theme.error : Theme.primary + onClicked: { + if (profileDelegate.isActive) + NetworkService.toggleNetworkConnection("cellular"); + else + NetworkService.connectToSpecificCellularConfig(modelData.uuid); + } + } + + MouseArea { + id: profileMouseArea + anchors.fill: parent + anchors.rightMargin: profileAction.width + Theme.spacingS + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (!profileDelegate.isActive) + NetworkService.connectToSpecificCellularConfig(modelData.uuid); + } + } + } + } + } + } + } + } +} diff --git a/quickshell/Modules/Settings/NetworkStatusTab.qml b/quickshell/Modules/Settings/NetworkStatusTab.qml index 9b55b63a0..4ab364c00 100644 --- a/quickshell/Modules/Settings/NetworkStatusTab.qml +++ b/quickshell/Modules/Settings/NetworkStatusTab.qml @@ -99,6 +99,7 @@ Item { switch (NetworkService.networkStatus) { case "ethernet": case "wifi": + case "cellular": return Theme.success; case "disconnected": return Theme.error; @@ -115,6 +116,8 @@ Item { return I18n.tr("Ethernet"); case "wifi": return I18n.tr("WiFi"); + case "cellular": + return I18n.tr("Cellular"); case "disconnected": return I18n.tr("Disconnected"); default: @@ -145,7 +148,7 @@ Item { Row { width: parent.width spacing: Theme.spacingM - visible: NetworkService.backend === "networkmanager" && NetworkService.ethernetConnected && NetworkService.wifiConnected + visible: NetworkService.backend === "networkmanager" && [NetworkService.ethernetConnected, NetworkService.wifiConnected, NetworkService.cellularConnected].filter(v => v).length > 1 StyledText { text: I18n.tr("Preference") @@ -161,13 +164,15 @@ Item { DankButtonGroup { id: preferenceButtons - model: [I18n.tr("Auto"), I18n.tr("Ethernet"), I18n.tr("WiFi")] + model: [I18n.tr("Auto"), I18n.tr("Ethernet"), I18n.tr("WiFi"), I18n.tr("Cellular")] currentIndex: { switch (NetworkService.userPreference) { case "ethernet": return 1; case "wifi": return 2; + case "cellular": + return 3; default: return 0; } @@ -185,6 +190,9 @@ Item { case 2: NetworkService.setNetworkPreference("wifi"); break; + case 3: + NetworkService.setNetworkPreference("cellular"); + break; } } } diff --git a/quickshell/Services/DMSNetworkService.qml b/quickshell/Services/DMSNetworkService.qml index 6284ec576..53b5ce45d 100644 --- a/quickshell/Services/DMSNetworkService.qml +++ b/quickshell/Services/DMSNetworkService.qml @@ -24,6 +24,15 @@ Singleton { property var wiredConnections: [] + property string cellularIP: "" + property string cellularInterface: "" + property bool cellularConnected: false + property bool cellularEnabled: true + property bool cellularHardwareEnabled: true + property string cellularConnectionUuid: "" + property var cellularDevices: [] + property var cellularConnections: [] + property string wifiIP: "" property string wifiInterface: "" property bool wifiConnected: false @@ -66,6 +75,7 @@ Singleton { property bool wifiAvailable: true property bool wifiToggling: false + property bool cellularToggling: false property bool changingPreference: false property string targetPreference: "" property var savedWifiNetworks: [] @@ -308,6 +318,15 @@ Singleton { wiredConnections = state.wiredConnections || []; + cellularIP = state.cellularIP || ""; + cellularInterface = state.cellularDevice || ""; + cellularConnected = state.cellularConnected || false; + cellularEnabled = state.cellularEnabled !== undefined ? state.cellularEnabled : true; + cellularHardwareEnabled = state.cellularHardwareEnabled !== undefined ? state.cellularHardwareEnabled : true; + cellularConnectionUuid = state.cellularConnectionUuid || ""; + cellularDevices = state.cellularDevices || []; + cellularConnections = state.cellularConnections || []; + wifiIP = state.wifiIP || ""; wifiInterface = state.wifiDevice || ""; wifiConnected = state.wifiConnected || false; @@ -465,6 +484,31 @@ Singleton { }); } + function connectToSpecificCellularConfig(uuid) { + if (!networkAvailable || isConnecting) + return; + isConnecting = true; + connectionError = ""; + connectionStatus = "connecting"; + + DMSService.sendRequest("network.cellular.connect.config", { + uuid: uuid + }, response => { + if (response.error) { + connectionError = response.error; + lastConnectionError = response.error; + connectionStatus = "failed"; + ToastService.showError(I18n.tr("Failed to activate cellular connection"), response.error); + } else { + connectionError = ""; + connectionStatus = "connected"; + ToastService.showInfo(I18n.tr("Cellular connection activated")); + } + + isConnecting = false; + }); + } + function scanWifi() { if (!networkAvailable || isScanning || !wifiEnabled) return; @@ -651,6 +695,22 @@ Singleton { }); } + function toggleCellularRadio() { + if (!networkAvailable || cellularToggling) + return; + cellularToggling = true; + DMSService.sendRequest("network.cellular.toggle", null, response => { + cellularToggling = false; + + if (response.error) { + ToastService.showError(I18n.tr("Failed to toggle cellular"), response.error); + } else if (response.result) { + cellularEnabled = response.result.enabled; + ToastService.showInfo(cellularEnabled ? I18n.tr("Cellular enabled") : I18n.tr("Cellular disabled")); + } + }); + } + function enableWifiDevice() { if (!networkAvailable) return; @@ -663,9 +723,27 @@ Singleton { }); } + function enableCellularRadio() { + if (!networkAvailable || cellularToggling) + return; + cellularToggling = true; + DMSService.sendRequest("network.cellular.enable", null, response => { + cellularToggling = false; + + if (response.error) { + ToastService.showError(I18n.tr("Failed to enable cellular"), response.error); + } else { + cellularEnabled = true; + ToastService.showInfo(I18n.tr("Cellular enabled")); + } + }); + } + function setNetworkPreference(preference) { if (!networkAvailable) return; + if (userPreference === preference && !changingPreference) + return; userPreference = preference; changingPreference = true; targetPreference = preference; @@ -688,6 +766,8 @@ Singleton { setNetworkPreference("wifi"); } else if (type === "ethernet") { setNetworkPreference("ethernet"); + } else if (type === "cellular") { + setNetworkPreference("cellular"); } } @@ -705,6 +785,12 @@ Singleton { } else { DMSService.sendRequest("network.ethernet.connect", null, null); } + } else if (type === "cellular") { + if (cellularConnected) { + DMSService.sendRequest("network.cellular.disconnect", null, null); + } else { + DMSService.sendRequest("network.cellular.connect", null, null); + } } } @@ -716,6 +802,14 @@ Singleton { }, null); } + function disconnectCellularDevice(deviceName) { + if (!networkAvailable) + return; + DMSService.sendRequest("network.cellular.disconnect", { + device: deviceName + }, null); + } + function startAutoScan() { autoScan = true; autoRefreshEnabled = true; diff --git a/quickshell/Services/NetworkService.qml b/quickshell/Services/NetworkService.qml index 32c55c7ab..2b066b6d2 100644 --- a/quickshell/Services/NetworkService.qml +++ b/quickshell/Services/NetworkService.qml @@ -22,6 +22,15 @@ Singleton { property var wiredConnections: activeService?.wiredConnections ?? [] + property string cellularIP: activeService?.cellularIP ?? "" + property string cellularInterface: activeService?.cellularInterface ?? "" + property bool cellularConnected: activeService?.cellularConnected ?? false + property bool cellularEnabled: activeService?.cellularEnabled ?? true + property bool cellularHardwareEnabled: activeService?.cellularHardwareEnabled ?? true + property string cellularConnectionUuid: activeService?.cellularConnectionUuid ?? "" + property var cellularDevices: activeService?.cellularDevices ?? [] + property var cellularConnections: activeService?.cellularConnections ?? [] + property string wifiIP: activeService?.wifiIP ?? "" property string wifiInterface: activeService?.wifiInterface ?? "" property bool wifiConnected: activeService?.wifiConnected ?? false @@ -51,6 +60,7 @@ Singleton { property bool wifiAvailable: activeService?.wifiAvailable ?? true property bool wifiToggling: activeService?.wifiToggling ?? false + property bool cellularToggling: activeService?.cellularToggling ?? false property bool changingPreference: activeService?.changingPreference ?? false property string targetPreference: activeService?.targetPreference ?? "" property var savedWifiNetworks: activeService?.savedWifiNetworks ?? [] @@ -211,12 +221,24 @@ Singleton { } } + function toggleCellularRadio() { + if (activeService && activeService.toggleCellularRadio) { + activeService.toggleCellularRadio(); + } + } + function enableWifiDevice() { if (activeService && activeService.enableWifiDevice) { activeService.enableWifiDevice(); } } + function enableCellularRadio() { + if (activeService && activeService.enableCellularRadio) { + activeService.enableCellularRadio(); + } + } + function setNetworkPreference(preference) { if (activeService && activeService.setNetworkPreference) { activeService.setNetworkPreference(preference); @@ -247,6 +269,18 @@ Singleton { } } + function disconnectCellularDevice(deviceName) { + if (activeService && activeService.disconnectCellularDevice) { + activeService.disconnectCellularDevice(deviceName); + } + } + + function connectToSpecificCellularConfig(uuid) { + if (activeService && activeService.connectToSpecificCellularConfig) { + activeService.connectToSpecificCellularConfig(uuid); + } + } + function startAutoScan() { if (activeService && activeService.startAutoScan) { activeService.startAutoScan(); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 45ce40040..3cd262c32 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -9419,6 +9419,17 @@ ], "icon": "vpn_key" }, + { + "section": "_tab_42", + "label": "Cellular", + "tabIndex": 42, + "category": "Settings", + "keywords": [ + "cellular", + "settings" + ], + "icon": "network_cell" + }, { "section": "_tab_42", "label": "Power & Security", diff --git a/quickshell/translations/term_freeze.json b/quickshell/translations/term_freeze.json index 8fc025b48..1eab74e8a 100644 --- a/quickshell/translations/term_freeze.json +++ b/quickshell/translations/term_freeze.json @@ -23,6 +23,8 @@ "%1 issue found", "%1 issues found", "%1 min left", + "%1 modem, none connected", + "%1 modems, none connected", "%1 notifications", "%1 online", "%1 tasks", @@ -441,6 +443,14 @@ "Caps Lock is on", "Cast Target", "Category", + "Cellular", + "Cellular Profiles", + "Cellular connection activated", + "Cellular disabled", + "Cellular enabled", + "Cellular hardware unavailable", + "Cellular is off", + "Cellular unavailable", "Center Section", "Center Single Column", "Center Tiling", @@ -794,6 +804,7 @@ "Disabled by Frame Mode", "Disabling WiFi...", "Disabling auto-login on startup...", + "Disabling cellular...", "Disc", "Discard", "Discharging", @@ -891,6 +902,7 @@ "Enable 10-bit color depth for wider color gamut and HDR support", "Enable Autoconnect", "Enable Bar", + "Enable Cellular", "Enable Do Not Disturb", "Enable Frame", "Enable History", @@ -920,6 +932,7 @@ "Enabled. PAM already provides security-key auth.", "Enabled. PAM provides fingerprint auth, but no prints are enrolled yet.", "Enabling WiFi...", + "Enabling cellular...", "End", "End must be after start", "Enlarge on Hover", @@ -975,6 +988,7 @@ "Fade", "Fade to lock screen", "Fade to monitor off", + "Failed to activate cellular connection", "Failed to activate configuration", "Failed to add binds include", "Failed to add printer to class", @@ -1001,6 +1015,7 @@ "Failed to empty trash", "Failed to enable IP location", "Failed to enable WiFi", + "Failed to enable cellular", "Failed to enable job acceptance", "Failed to enable night mode", "Failed to fetch network QR code: %1", @@ -1040,6 +1055,7 @@ "Failed to set profile image", "Failed to set profile image: %1", "Failed to start connection to %1", + "Failed to toggle cellular", "Failed to unpin entry", "Failed to update %1: %2", "Failed to update VPN", @@ -1585,6 +1601,7 @@ "Mode", "Mode:", "Model", + "Modems", "Modified", "Modular widget bar", "Monitor", @@ -1694,6 +1711,8 @@ "No battery", "No brightness devices available", "No calendar source available", + "No cellular modems detected", + "No cellular profiles configured", "No changes", "No checks passed", "No codecs found", @@ -1723,6 +1742,7 @@ "No matches", "No matching devices", "No matching processes", + "No modems", "No monitors", "No mount points found", "No other active sessions on this seat", From ca76b0ea5dbfb1eb882e9522c533ac1313bb2c8d Mon Sep 17 00:00:00 2001 From: Yechiel Worenklein <41305372+yechielw@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:38:30 +0300 Subject: [PATCH 2/2] fix(settings): resolve cellular tab index collision --- .../Modals/Settings/SettingsContent.qml | 2 +- .../Modals/Settings/SettingsSidebar.qml | 2 +- .../translations/settings_search_index.json | 22 +++++++++---------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 6de0949fd..c96d26c07 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -289,7 +289,7 @@ FocusScope { Loader { id: networkCellularLoader anchors.fill: parent - active: root.currentIndex === 42 + active: root.currentIndex === 44 visible: active focus: active diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index 5b9195e0e..262168b7a 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -268,7 +268,7 @@ Rectangle { "id": "network_cellular", "text": I18n.tr("Cellular"), "icon": "network_cell", - "tabIndex": 42 + "tabIndex": 44 }, { "id": "network_vpn", diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 3cd262c32..817de985e 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -9419,17 +9419,6 @@ ], "icon": "vpn_key" }, - { - "section": "_tab_42", - "label": "Cellular", - "tabIndex": 42, - "category": "Settings", - "keywords": [ - "cellular", - "settings" - ], - "icon": "network_cell" - }, { "section": "_tab_42", "label": "Power & Security", @@ -9466,5 +9455,16 @@ "widgets" ], "icon": "dashboard" + }, + { + "section": "_tab_44", + "label": "Cellular", + "tabIndex": 44, + "category": "Settings", + "keywords": [ + "cellular", + "settings" + ], + "icon": "network_cell" } ]