Files

683 lines
29 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
import qs.Modals
import qs.Modals.Common
import qs.Modules.ControlCenter
import qs.Modules.ControlCenter.Widgets
import qs.Modules.Network
import qs.Services
import qs.DCommon.Widgets
import qs.Widgets
import "../../../Common/QmlUtils.js" as QmlUtils
Item {
id: root
implicitHeight: pageList.contentHeight
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
property var transientSurfaceTracker: null
readonly property string title: I18n.tr("Network")
property bool transitioning: false
property bool holdsScanRef: false
Component.onCompleted: acquireScanRef()
Component.onDestruction: {
if (holdsScanRef)
NetworkService.removeRef();
}
onTransitioningChanged: acquireScanRef()
readonly property bool hasEthernetAvailable: (NetworkService.ethernetDevices?.length ?? 0) > 0
readonly property bool hasWifiAvailable: (NetworkService.wifiDevices?.length ?? 0) > 0
readonly property bool hasCellularAvailable: (NetworkService.cellularDevices?.length ?? 0) > 0
readonly property var connectionTypes: {
const types = [];
if (hasEthernetAvailable)
types.push("ethernet");
if (hasWifiAvailable)
types.push("wifi");
if (hasCellularAvailable)
types.push("cellular");
return types.length > 0 ? types : ["wifi"];
}
property string selectedType: ""
readonly property string currentConnectionType: {
if (selectedType && connectionTypes.includes(selectedType))
return selectedType;
return connectionTypes[defaultTypeIndex] || "wifi";
}
readonly property bool wifiMode: currentConnectionType === "wifi"
readonly property bool ethernetMode: currentConnectionType === "ethernet"
readonly property bool cellularMode: currentConnectionType === "cellular"
readonly property bool networkManager: NetworkService.backend === "networkmanager"
// hosting on the sole wifi radio with no ethernet uplink drops connectivity
readonly property bool hotspotRelevant: NetworkService.hotspotEnabled || NetworkService.hotspotActivating || NetworkService.hotspotBusy || NetworkService.ethernetConnected || (NetworkService.wifiDevices?.length ?? 0) > 1
readonly property bool showHotspotRow: wifiMode && NetworkService.hotspotAvailable && NetworkService.hotspotConfigured && hotspotRelevant
readonly property bool hotspotWorking: NetworkService.hotspotBusy || NetworkService.hotspotActivating
readonly property var pinnedNetworks: QmlUtils.normalizePinList((CacheData.wifiNetworkPins || {})["preferredWifi"])
readonly property bool wifiScanningEmpty: wifiMode && NetworkService.wifiEnabled && !NetworkService.wifiToggling && NetworkService.wifiInterface && (NetworkService.wifiNetworks?.length ?? 0) < 1 && (NetworkService.isScanning || transitioning)
readonly property bool wifiListVisible: wifiMode && NetworkService.wifiEnabled && !NetworkService.wifiToggling && !wifiScanningEmpty
readonly property bool paneSwitchSupported: connectionTypes.length > 1 && networkManager && DMSService.apiVersion > 10
readonly property var typeLabels: ({
"ethernet": I18n.tr("Ethernet"),
"wifi": I18n.tr("Wi-Fi", "wireless network, page and section title"),
"cellular": I18n.tr("Cellular")
})
readonly property int defaultTypeIndex: {
const wifiIndex = Math.max(0, connectionTypes.indexOf("wifi"));
if (!paneSwitchSupported)
return wifiIndex;
const byStatus = connectionTypes.indexOf(NetworkService.networkStatus);
if (byStatus !== -1)
return byStatus;
const byPreference = connectionTypes.indexOf(NetworkService.userPreference);
return byPreference !== -1 ? byPreference : wifiIndex;
}
readonly property Item headerActions: Row {
spacing: Theme.spacingS
DDropdown {
anchors.verticalCenter: parent.verticalCenter
visible: root.wifiMode && (NetworkService.wifiDevices?.length ?? 0) > 1
compactMode: true
dropdownWidth: CcMetrics.headerDropdownWidth
popupWidth: CcMetrics.headerPopupWidth
alignPopupRight: true
options: [I18n.tr("Auto")].concat((NetworkService.wifiDevices || []).map(d => d.name))
currentValue: NetworkService.wifiDeviceOverride || I18n.tr("Auto")
onValueChanged: value => NetworkService.setWifiDeviceOverride(value === I18n.tr("Auto") ? "" : value)
}
DRefreshButton {
Accessible.name: I18n.tr("Scan")
anchors.verticalCenter: parent.verticalCenter
buttonSize: CcMetrics.headerActionSize
iconSize: CcMetrics.headerActionIconSize
iconColor: Theme.surfaceText
visible: root.wifiMode && NetworkService.wifiEnabled && !NetworkService.wifiToggling
busy: NetworkService.isScanning
onClicked: NetworkService.scanWifi()
}
CcSettingsButton {
anchors.verticalCenter: parent.verticalCenter
settingsTab: "network_" + root.currentConnectionType
}
}
function acquireScanRef() {
if (transitioning || holdsScanRef)
return;
holdsScanRef = true;
NetworkService.addRef();
}
function dismissTransient() {
if (wifiMenu.open) {
wifiMenu.close();
return true;
}
if (wiredMenu.open) {
wiredMenu.close();
return true;
}
return false;
}
function explainHotspotNeedsWiFi() {
ToastService.showError(I18n.tr("Wi-Fi is disabled", "hotspot start error title"), I18n.tr("Enable Wi-Fi before starting the hotspot.", "hotspot WiFi requirement message"));
}
function startHotspotWithConfirm() {
if (!NetworkService.hotspotWouldDisconnectWifi) {
NetworkService.startHotspot();
return;
}
hotspotConfirmLoader.active = true;
const confirm = hotspotConfirmLoader.item;
if (!confirm)
return;
confirm.showWithOptions({
title: I18n.tr("Start Hotspot?", "hotspot start confirmation title"),
message: I18n.tr("Starting the hotspot disconnects Wi-Fi from \"%1\". The radio can\'t do both at once, so sharing internet needs another connection such as Ethernet.", "hotspot WiFi disconnection warning, %1 is the network name").arg(NetworkService.currentWifiSSID),
confirmText: I18n.tr("Start", "hotspot start confirmation action"),
onConfirm: () => NetworkService.startHotspot()
});
}
function togglePin(ssid) {
CacheData.set("wifiNetworkPins", QmlUtils.togglePinEntry(CacheData.wifiNetworkPins, "preferredWifi", ssid, CcMetrics.maxPins));
}
function openWifiMenu(network, connected, connecting, anchor) {
const ssid = network.ssid;
const saved = network.saved || false;
const showSavedOptions = saved || connected;
wifiMenu.items = [
{
"label": connecting ? I18n.tr("Connecting...") : (connected ? I18n.tr("Disconnect") : I18n.tr("Connect")),
"iconName": connected ? "wifi_off" : "wifi",
"enabled": !connecting,
"action": () => WifiConnectionActions.connectToNetworkFromDetails(ssid, network.secured, saved, network.enterprise, connected, {
disconnectWhenConnected: true
})
},
{
"label": I18n.tr("Network Info"),
"iconName": "info",
"action": () => {
networkInfoModalLoader.active = true;
networkInfoModalLoader.item.showNetworkInfo(ssid, NetworkService.getNetworkInfo(ssid));
}
},
{
"label": network.autoconnect ? I18n.tr("Disable autoconnect") : I18n.tr("Enable autoconnect"),
"iconName": "autorenew",
"visible": showSavedOptions && DMSService.apiVersion > 13,
"action": () => NetworkService.setWifiAutoconnect(ssid, !network.autoconnect)
},
{
"label": I18n.tr("Forget network"),
"iconName": "delete",
"destructive": true,
"visible": showSavedOptions,
"action": () => NetworkService.forgetWifiNetwork(ssid)
}
];
wifiMenu.openAt(anchor);
}
function openWiredMenu(connection, anchor) {
const connected = connection.isActive;
wiredMenu.items = [
{
"label": I18n.tr("Activate"),
"iconName": "lan",
"visible": !connected,
"action": () => NetworkService.connectToSpecificWiredConfig(connection.uuid)
},
{
"label": I18n.tr("Disconnect"),
"iconName": "link_off",
"destructive": true,
"visible": connected,
"action": () => NetworkService.toggleNetworkConnection("ethernet")
},
{
"label": I18n.tr("Network Info"),
"iconName": "info",
"visible": connected,
"action": () => {
networkWiredInfoModalLoader.active = true;
networkWiredInfoModalLoader.item.showNetworkInfo(connection.id, NetworkService.getWiredNetworkInfo(connection.uuid));
}
}
];
wiredMenu.openAt(anchor);
}
ScriptModel {
id: wifiListModel
objectProp: "ssid"
readonly property bool frozen: wifiMenu.open || root.transitioning
property var frozenNetworks: []
readonly property var sortedNetworks: {
const ssid = NetworkService.currentWifiSSID;
const pinnedList = root.pinnedNetworks;
const sorted = [...(NetworkService.wifiNetworks || [])];
sorted.sort((a, b) => {
const aPinnedIndex = pinnedList.indexOf(a.ssid);
const bPinnedIndex = pinnedList.indexOf(b.ssid);
if (aPinnedIndex !== -1 || bPinnedIndex !== -1) {
if (aPinnedIndex === -1)
return 1;
if (bPinnedIndex === -1)
return -1;
return aPinnedIndex - bPinnedIndex;
}
if (a.ssid === ssid)
return -1;
if (b.ssid === ssid)
return 1;
const aKnown = !!a.saved && (a.signal || 0) > 0;
const bKnown = !!b.saved && (b.signal || 0) > 0;
if (aKnown !== bKnown)
return aKnown ? -1 : 1;
const aBucket = Math.floor((a.signal || 0) / CcMetrics.wifiSignalBucket);
const bBucket = Math.floor((b.signal || 0) / CcMetrics.wifiSignalBucket);
if (aBucket !== bBucket)
return bBucket - aBucket;
return (a.ssid || "").localeCompare(b.ssid || "");
});
return sorted;
}
values: frozen ? frozenNetworks : sortedNetworks
onSortedNetworksChanged: {
if (!frozen)
frozenNetworks = sortedNetworks;
}
onFrozenChanged: {
if (frozen)
frozenNetworks = sortedNetworks;
}
}
ScriptModel {
id: wiredConnectionsModel
objectProp: "uuid"
values: {
const sorted = [...(NetworkService.wiredConnections || [])];
sorted.sort((a, b) => {
if (a.isActive !== b.isActive)
return a.isActive ? -1 : 1;
return a.id.localeCompare(b.id);
});
return sorted;
}
}
ScriptModel {
id: cellularConnectionsModel
objectProp: "uuid"
values: {
const sorted = [...(NetworkService.cellularConnections || [])];
sorted.sort((a, b) => {
if (a.isActive !== b.isActive)
return a.isActive ? -1 : 1;
return (a.id || "").localeCompare(b.id || "");
});
return sorted;
}
}
DListView {
id: pageList
objectName: "networkList"
anchors.fill: parent
clip: true
spacing: Theme.groupedListGap
add: null
displaced: null
move: null
model: root.wifiListVisible ? wifiListModel : null
header: Column {
width: pageList.width
spacing: CcMetrics.detailContentGap
bottomPadding: pageList.count > 0 ? CcMetrics.detailContentGap : 0
DButtonGroup {
anchors.horizontalCenter: parent.horizontalCenter
size: "small"
visible: root.paneSwitchSupported
model: root.connectionTypes.map(t => root.typeLabels[t] || t)
currentIndex: Math.max(0, root.connectionTypes.indexOf(root.currentConnectionType))
selectionMode: "single"
onSelectionChanged: (index, selected) => {
if (!selected)
return;
root.selectedType = root.connectionTypes[index] || "wifi";
}
}
CcGroup {
visible: root.wifiMode
CcToggleRow {
iconName: NetworkService.wifiEnabled ? "wifi" : "wifi_off"
iconColor: NetworkService.wifiEnabled ? Theme.primary : Theme.surfaceText
text: I18n.tr("Wi-Fi", "wireless network, page and section title")
description: {
if (NetworkService.wifiToggling)
return NetworkService.wifiEnabled ? I18n.tr("Disabling WiFi...") : I18n.tr("Enabling WiFi...");
if (!NetworkService.wifiEnabled)
return I18n.tr("WiFi is off", "network status when the wifi radio is disabled");
return NetworkService.currentWifiSSID || I18n.tr("Not connected", "network status");
}
checked: NetworkService.wifiEnabled
toggling: NetworkService.wifiToggling
onToggled: NetworkService.toggleWifiRadio()
}
CcListRow {
id: hotspotRow
readonly property bool warnsWifiDrop: NetworkService.wifiEnabled && !root.hotspotWorking && !NetworkService.hotspotEnabled && NetworkService.hotspotWouldDisconnectWifi
visible: root.showHotspotRow
iconName: NetworkService.hotspotEnabled ? "wifi_tethering" : "wifi_tethering_off"
active: NetworkService.hotspotEnabled
title: I18n.tr("Hotspot", "hotspot control label")
subtitle: {
if (warnsWifiDrop)
return (NetworkService.hotspotSSID || I18n.tr("Ready", "hotspot ready status")) + " • " + I18n.tr("Will disconnect \"%1\"", "hotspot WiFi disconnection warning").arg(NetworkService.currentWifiSSID);
if (root.hotspotWorking)
return I18n.tr("Starting...", "hotspot activation status");
if (NetworkService.hotspotEnabled)
return NetworkService.hotspotSSID || I18n.tr("Running", "hotspot active status");
if (!NetworkService.wifiEnabled)
return I18n.tr("WiFi disabled", "hotspot unavailable status");
return NetworkService.hotspotSSID || I18n.tr("Ready", "hotspot ready status");
}
subtitleColor: warnsWifiDrop ? Theme.warning : Theme.surfaceVariantText
DSpinner {
anchors.verticalCenter: parent.verticalCenter
size: Theme.iconSizeMedium
strokeWidth: CcMetrics.spinnerStroke
color: Theme.primary
visible: root.hotspotWorking
running: visible
}
DToggle {
anchors.verticalCenter: parent.verticalCenter
hideText: true
visible: !root.hotspotWorking
checked: NetworkService.hotspotEnabled
onToggled: checked => {
if (!checked) {
NetworkService.stopHotspot();
return;
}
if (!NetworkService.wifiEnabled) {
root.explainHotspotNeedsWiFi();
return;
}
root.startHotspotWithConfirm();
}
}
}
}
CcEmptyState {
visible: root.wifiMode && NetworkService.wifiToggling
spinning: true
title: NetworkService.wifiEnabled ? I18n.tr("Disabling WiFi...") : I18n.tr("Enabling WiFi...")
}
CcEmptyState {
visible: root.wifiMode && !NetworkService.wifiEnabled && !NetworkService.wifiToggling
iconName: "wifi_off"
title: I18n.tr("WiFi is off", "network status when the wifi radio is disabled")
}
CcEmptyState {
visible: root.wifiScanningEmpty
spinning: true
title: I18n.tr("Scanning...")
}
CcSectionLabel {
text: I18n.tr("Available networks")
visible: root.wifiListVisible
}
CcGroup {
visible: root.ethernetMode && root.networkManager && DMSService.apiVersion > 10
Repeater {
model: wiredConnectionsModel
CcListRow {
id: wiredRow
required property var modelData
iconName: "lan"
active: modelData.isActive
title: modelData.id || I18n.tr("Unknown Config")
subtitle: active ? I18n.tr("Connected") : I18n.tr("Available")
clickable: true
onClicked: {
if (modelData.uuid === NetworkService.ethernetConnectionUuid)
return;
NetworkService.connectToSpecificWiredConfig(modelData.uuid);
}
DActionButton {
id: wiredOptionsButton
anchors.verticalCenter: parent.verticalCenter
buttonSize: Theme.buttonHeightXS
iconSize: Theme.iconSizeMedium
iconName: "more_horiz"
Accessible.name: I18n.tr("Options")
iconColor: Theme.surfaceText
onClicked: root.openWiredMenu(wiredRow.modelData, wiredOptionsButton)
}
}
}
}
CcGroup {
visible: root.cellularMode && root.networkManager
CcToggleRow {
iconName: "network_cell"
iconColor: NetworkService.cellularEnabled ? Theme.primary : Theme.surfaceText
text: I18n.tr("Cellular")
description: {
if (NetworkService.cellularToggling)
return NetworkService.cellularEnabled ? I18n.tr("Disabling cellular...") : I18n.tr("Enabling cellular...");
if (!NetworkService.cellularHardwareEnabled)
return I18n.tr("Unavailable");
return NetworkService.cellularEnabled ? "" : I18n.tr("Disabled");
}
checked: NetworkService.cellularEnabled
enabled: NetworkService.cellularHardwareEnabled
toggling: NetworkService.cellularToggling
onToggled: NetworkService.toggleCellularRadio()
}
}
CcEmptyState {
visible: root.cellularMode && NetworkService.cellularToggling
spinning: true
title: NetworkService.cellularEnabled ? I18n.tr("Disabling cellular...") : I18n.tr("Enabling cellular...")
}
CcGroup {
visible: root.cellularMode && root.networkManager && NetworkService.cellularEnabled && !NetworkService.cellularToggling && (cellularDevices.count > 0 || cellularConnections.count > 0)
Repeater {
id: cellularDevices
model: (NetworkService.cellularConnections?.length ?? 0) > 0 ? [] : (NetworkService.cellularDevices || [])
CcListRow {
id: cellularDeviceRow
required property var modelData
iconName: "network_cell"
active: modelData.connected || false
title: modelData.description || modelData.name || I18n.tr("Unknown")
subtitle: active ? I18n.tr("Connected") : (modelData.state || I18n.tr("Available"))
clickable: true
onClicked: NetworkService.toggleNetworkConnection("cellular")
DActionButton {
anchors.verticalCenter: parent.verticalCenter
buttonSize: Theme.buttonHeightXS
iconSize: Theme.iconSizeMedium
iconName: cellularDeviceRow.active ? "link_off" : "link"
tooltipText: cellularDeviceRow.active ? I18n.tr("Disconnect") : I18n.tr("Connect")
iconColor: cellularDeviceRow.active ? Theme.error : Theme.primary
onClicked: NetworkService.toggleNetworkConnection("cellular")
}
}
}
Repeater {
id: cellularConnections
model: cellularConnectionsModel
CcListRow {
id: cellularRow
required property var modelData
iconName: "network_cell"
active: modelData.isActive
title: modelData.id || I18n.tr("Unknown")
subtitle: active ? I18n.tr("Connected") : (modelData.type || I18n.tr("Available"))
clickable: !active
onClicked: NetworkService.connectToSpecificCellularConfig(modelData.uuid)
DActionButton {
anchors.verticalCenter: parent.verticalCenter
buttonSize: Theme.buttonHeightXS
iconSize: Theme.iconSizeMedium
iconName: cellularRow.active ? "link_off" : "link"
tooltipText: cellularRow.active ? I18n.tr("Disconnect") : I18n.tr("Connect")
iconColor: cellularRow.active ? Theme.error : Theme.primary
onClicked: {
if (cellularRow.active) {
NetworkService.toggleNetworkConnection("cellular");
return;
}
NetworkService.connectToSpecificCellularConfig(cellularRow.modelData.uuid);
}
}
}
}
}
CcEmptyState {
visible: root.cellularMode && NetworkService.cellularEnabled && !NetworkService.cellularToggling && (NetworkService.cellularDevices?.length ?? 0) === 0 && cellularConnectionsModel.values.length === 0
iconName: "network_cell"
title: I18n.tr("No devices found")
}
}
delegate: CcListRow {
id: wifiRow
required property var modelData
required property int index
readonly property bool isConnected: modelData.ssid === NetworkService.currentWifiSSID
readonly property bool isConnecting: NetworkService.isWifiConnecting && NetworkService.connectingSSID === modelData.ssid
readonly property int signalStrength: modelData.signal || 0
readonly property bool sharesQrCode: modelData.secured && modelData.saved && !(modelData.enterprise || false)
width: pageList.width
topRadius: index === 0 ? Theme.groupedListOuterRadius : Theme.groupedListInnerRadius
bottomRadius: index === pageList.count - 1 ? Theme.groupedListOuterRadius : Theme.groupedListInnerRadius
iconName: {
if (isConnecting)
return "";
if (signalStrength >= CcMetrics.wifiSignalStrong)
return "wifi";
return signalStrength >= CcMetrics.wifiSignalBucket ? "wifi_2_bar" : "wifi_1_bar";
}
active: isConnected
title: modelData.ssid || I18n.tr("Unknown Network")
subtitle: {
const parts = [];
if (isConnecting)
parts.push(I18n.tr("Connecting..."));
else if (isConnected)
parts.push(I18n.tr("Connected"));
else
parts.push(modelData.secured ? I18n.tr("Secured") : I18n.tr("Open", "network security type", true));
if (modelData.saved)
parts.push(I18n.tr("Saved", "wifi network status, network has a saved profile", true));
parts.push(signalStrength + "%");
return parts.join(" • ");
}
subtitleColor: isConnecting ? Theme.warning : Theme.surfaceVariantText
clickable: true
onClicked: {
if (isConnected || NetworkService.isWifiConnecting)
return;
WifiConnectionActions.connectToNetwork(modelData, {
connected: isConnected
});
}
leading: Loader {
active: wifiRow.isConnecting
sourceComponent: DSpinner {
size: Theme.iconSizeMedium
strokeWidth: CcMetrics.spinnerStroke
color: Theme.warning
}
}
Loader {
anchors.verticalCenter: parent.verticalCenter
active: wifiRow.sharesQrCode
sourceComponent: DActionButton {
buttonSize: Theme.buttonHeightXS
iconSize: Theme.iconSizeMedium
iconName: "qr_code"
tooltipText: I18n.tr("Show QR Code")
iconColor: Theme.surfaceText
onClicked: PopoutService.showWifiQRCodeModal(wifiRow.modelData.ssid)
}
}
CcPinChip {
anchors.verticalCenter: parent.verticalCenter
pinned: root.pinnedNetworks.includes(wifiRow.modelData.ssid)
onToggled: root.togglePin(wifiRow.modelData.ssid)
}
DActionButton {
id: wifiOptionsButton
anchors.verticalCenter: parent.verticalCenter
buttonSize: Theme.buttonHeightXS
iconSize: Theme.iconSizeMedium
iconName: "more_horiz"
Accessible.name: I18n.tr("Options")
iconColor: Theme.surfaceText
onClicked: root.openWifiMenu(wifiRow.modelData, wifiRow.isConnected, wifiRow.isConnecting, wifiOptionsButton)
}
}
}
CcMenu {
id: wifiMenu
transientSurfaceTracker: root.transientSurfaceTracker
}
CcMenu {
id: wiredMenu
transientSurfaceTracker: root.transientSurfaceTracker
}
Loader {
id: networkInfoModalLoader
active: false
sourceComponent: NetworkInfoModal {}
}
Loader {
id: networkWiredInfoModalLoader
active: false
sourceComponent: NetworkInfoModal {
wired: true
}
}
Loader {
id: hotspotConfirmLoader
active: false
sourceComponent: ConfirmModal {}
}
}