From a64eadd14a65ce91463263d20d162136c50426dc Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 9 Jul 2026 17:33:13 +0200 Subject: [PATCH] feat: add password overlay for network popout + redesign --- Components/ButtonBase.qml | 4 +- Components/CircularIndicator.qml | 10 +- Components/CustomTextField.qml | 4 +- Config/DockConfig.qml | 2 +- Drawers/Interactions.qml | 2 +- Drawers/Windows.qml | 13 +- Helpers/Network.qml | 65 +++- Modules/Dock/Wrapper.qml | 2 +- .../Popouts/Network/NetworkOverlay.qml | 70 ++++ .../SysTray/Popouts/Network/PSKOverlay.qml | 102 ++++++ .../SysTray/Popouts/Network/PopupManager.qml | 22 ++ Modules/SysTray/Popouts/NetworkPopout.qml | 309 +++++++++++++----- network-dev/wifi-sim.sh | 183 +++++++++-- 13 files changed, 665 insertions(+), 123 deletions(-) create mode 100644 Modules/SysTray/Popouts/Network/NetworkOverlay.qml create mode 100644 Modules/SysTray/Popouts/Network/PSKOverlay.qml create mode 100644 Modules/SysTray/Popouts/Network/PopupManager.qml diff --git a/Components/ButtonBase.qml b/Components/ButtonBase.qml index 5eb4bb7..58f7aca 100644 --- a/Components/ButtonBase.qml +++ b/Components/ButtonBase.qml @@ -14,7 +14,7 @@ CustomRect { property color activeOnColor property bool checked property real checkedRadius: Appearance.rounding.medium - property real defaultRadius: Appearance.rounding.large + property real defaultRadius: Math.min(width, height) / 2 property color disabledColor: Qt.alpha(DynamicColors.palette.m3onSurface, 0.1) property color disabledOnColor: Qt.alpha(DynamicColors.palette.m3onSurface, 0.38) property bool fillWidth @@ -35,7 +35,7 @@ CustomRect { readonly property color onColor: !enabled ? disabledOnColor : internalChecked ? activeOnColor : inactiveOnColor property real padding readonly property alias pressed: stateLayer.pressed - property real pressedRadius: Appearance.rounding.small + property real pressedRadius: Appearance.rounding.smallest readonly property alias radiusAnim: radiusAnim property bool radiusMorph: true property alias shapeMorph: stateLayer.shapeMorph diff --git a/Components/CircularIndicator.qml b/Components/CircularIndicator.qml index 60466e5..54bc5bd 100644 --- a/Components/CircularIndicator.qml +++ b/Components/CircularIndicator.qml @@ -1,7 +1,7 @@ -import qs.Config -import ZShell.Internal import QtQuick import QtQuick.Templates +import ZShell.Internal +import qs.Config BusyIndicator { id: root @@ -19,10 +19,10 @@ BusyIndicator { property int animState property color bgColor: DynamicColors.palette.m3secondaryContainer property color fgColor: DynamicColors.palette.m3primary - property real implicitSize: Appearance.font.size.normal * 3 + property real implicitSize: Appearance.font.size.medium * 3 property real internalStrokeWidth: strokeWidth readonly property alias progress: manager.progress - property real strokeWidth: Appearance.padding.small * 0.8 + property real strokeWidth: Appearance.padding.extraSmall property alias type: manager.indeterminateAnimationType implicitHeight: implicitSize @@ -33,6 +33,7 @@ BusyIndicator { anchors.fill: parent bgColor: root.bgColor fgColor: root.fgColor + hasEndIndicator: false padding: root.padding rotation: manager.rotation startAngle: manager.startFraction * 360 @@ -52,6 +53,7 @@ BusyIndicator { Anim { duration: manager.completeEndDuration * Appearance.anim.durations.scale properties: "opacity,internalStrokeWidth" + type: Anim.DefaultEffects } } diff --git a/Components/CustomTextField.qml b/Components/CustomTextField.qml index ae4f052..023f265 100644 --- a/Components/CustomTextField.qml +++ b/Components/CustomTextField.qml @@ -22,6 +22,7 @@ TextFieldBase { property string leadingIcon readonly property int leadingOffset: leadingIcon ? leadingIconLoader.width + leadingIconLoader.anchors.leftMargin : 0 property int radius: Appearance.rounding.small + property alias sLayer: stateLayer readonly property real smallFontScale: smallFontSize / font.pointSize property int smallFontSize: Appearance.font.size.small property string supportingText @@ -139,11 +140,12 @@ TextFieldBase { active: root.leadingIcon anchors.left: parent.left - anchors.leftMargin: Appearance.padding.normal + anchors.leftMargin: Appearance.padding.larger anchors.verticalCenter: parent.verticalCenter sourceComponent: MaterialIcon { color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.larger text: root.leadingIcon } } diff --git a/Config/DockConfig.qml b/Config/DockConfig.qml index eeaf1de..6b364f2 100644 --- a/Config/DockConfig.qml +++ b/Config/DockConfig.qml @@ -1,7 +1,7 @@ import Quickshell.Io JsonObject { - property bool enable: false + property bool enabled: false property real height: 60 property bool hoverToReveal: true property list ignoredAppRegexes: [] diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index c72f55f..f9d591d 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -216,7 +216,7 @@ Item { } } - if (Config.dock.enable && Config.dock.hoverToReveal && !root.visibilities.dock && !root.visibilities.launcher && root.inBottomPanel(root.panels.dock, x, y)) + if (Config.dock.enabled && Config.dock.hoverToReveal && !root.visibilities.dock && !root.visibilities.launcher && root.inBottomPanel(root.panels.dock, x, y)) root.visibilities.dock = true; if (y < root.bar.implicitHeight) diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index 23f9028..3993c72 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -8,6 +8,7 @@ import Quickshell.Io import Quickshell.Wayland import Quickshell.Hyprland import ZShell.Blobs +import qs.Modules.SysTray.Popouts.Network as N import qs.Daemons import qs.Components import qs.Modules.Bar @@ -128,7 +129,17 @@ CustomWindow { HyprlandFocusGrab { id: focusGrab - active: visibilities.dock || visibilities.resources || visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || visibilities.clipboard || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") && (!Config.bar.tray.showOnHover || (panels.popouts.current as StackView)?.depth > 1)) + active: { + const v = visibilities; + const conf = Config; + if (!N.PopupManager.closed && panels.popouts.hasCurrent) + return true; + if ((v.launcher && conf.launcher.enabled) || (v.sidebar && conf.sidebar.enabled) || (v.resources && conf.dashboard.performance.enabled) || (v.dashboard && conf.dashboard.enabled) || v.settings || (v.clipboard && conf.clipboard.enabled) || (v.dock && conf.dock.enabled)) + return true; + if (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") && (!conf.bar.tray.showOnHover || (panels.popouts.current as StackView)?.depth > 1)) + return true; + return false; + } windows: [root] onCleared: { diff --git a/Helpers/Network.qml b/Helpers/Network.qml index a73c00d..a8e56bb 100644 --- a/Helpers/Network.qml +++ b/Helpers/Network.qml @@ -9,8 +9,10 @@ Singleton { property bool active: false readonly property list connectedNetworks: networks.filter(n => n.connected) + property Network connectingNetwork readonly property list devices: Networking.devices.values - readonly property list knownNetworks: networks.filter(n => n.known && !n.known.ConnectionState.Connected) + property Network failedNetwork + readonly property list knownNetworks: networks.filter(n => n.known && !n.connected) readonly property list networks: { const list = []; for (const d of wifiDevices) { @@ -26,10 +28,21 @@ Singleton { readonly property list wifiDevices: devices.filter(d => wifiDevice(d)) readonly property bool wifiEnabled: Networking.wifiEnabled + signal networkConnected + function isSecure(security): bool { return (security === WifiSecurityType.WpaPsk || security === WifiSecurityType.Wpa2Psk || security === WifiSecurityType.Sae); } + function requestConnect(network: Network, secret = undefined): void { + connectingNetwork = network; + if (secret === undefined || secret === "") { + network.connect(); + } else { + (network as WifiNetwork).connectWithPsk(secret); + } + } + function setScan(value: bool): void { for (const d of wifiDevices) { d.scannerEnabled = value; @@ -48,4 +61,54 @@ Singleton { for (const d of wifiDevices) d.scannerEnabled = active; } + + Timer { + id: flushFailed + + interval: 5000 + running: root.failedNetwork + + onTriggered: { + root.failedNetwork = null; + } + } + + Timer { + id: checkConnection + + interval: 500 + repeat: true + running: root.connectingNetwork + + onTriggered: { + var completedNetwork; + + switch (root.connectingNetwork.state) { + case ConnectionState.Connecting: + break; + case ConnectionState.Connected: + completedNetwork = root.connectingNetwork; + root.networkConnected(); + break; + case ConnectionState.Disconnected: + root.failedNetwork = root.connectingNetwork; + completedNetwork = root.connectingNetwork; + break; + } + + if (completedNetwork) { + root.connectingNetwork = null; + } + } + } + + Timer { + interval: 10000 + running: checkConnection.running + + onTriggered: { + root.failedNetwork = root.connectingNetwork; + root.connectingNetwork = null; + } + } } diff --git a/Modules/Dock/Wrapper.qml b/Modules/Dock/Wrapper.qml index d4d5aa3..24f530a 100644 --- a/Modules/Dock/Wrapper.qml +++ b/Modules/Dock/Wrapper.qml @@ -12,7 +12,7 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 required property var panels required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.dock && Config.dock.enable + readonly property bool shouldBeActive: visibilities.dock && Config.dock.enabled required property PersistentProperties visibilities anchors.bottomMargin: (-implicitHeight - 5) * offsetScale diff --git a/Modules/SysTray/Popouts/Network/NetworkOverlay.qml b/Modules/SysTray/Popouts/Network/NetworkOverlay.qml new file mode 100644 index 0000000..29eca7a --- /dev/null +++ b/Modules/SysTray/Popouts/Network/NetworkOverlay.qml @@ -0,0 +1,70 @@ +import QtQuick +import qs.Helpers +import qs.Config +import qs.Components + +CustomRect { + id: root + + readonly property bool closing: PopupManager.closed + readonly property url currentPopup: PopupManager.currentPopup + property bool shouldBeVisible: loader.status === Loader.Ready + + color: Qt.alpha(DynamicColors.palette.m3shadow, 0.3) + opacity: shouldBeVisible && !closing ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim { + } + } + + onVisibleChanged: { + if (!visible) { + PopupManager.requestClose(); + PopupManager.currentPopup = ""; + } + } + + CustomMouseArea { + anchors.fill: parent + hoverEnabled: true + preventStealing: true + propagateComposedEvents: false + + onClicked: { + const insideItemWidth = mouseX < loader.x + loader.item.width && mouseX > loader.x; + const insideItemHeight = mouseY < loader.y + loader.item.height && mouseY > loader.y; + if (insideItemHeight && insideItemWidth) + return; + + PopupManager.requestClose(); + } + } + + Loader { + id: loader + + anchors.centerIn: parent + + Connections { + function onCurrentPopupChanged(): void { + if (PopupManager.currentPopup) { + loader.setSource(PopupManager.currentPopup, PopupManager.currentPopupProps); + } else { + loader.source = null; + } + } + + target: PopupManager + } + } + + Connections { + function onNetworkConnected(): void { + PopupManager.requestClose(); + } + + target: Network + } +} diff --git a/Modules/SysTray/Popouts/Network/PSKOverlay.qml b/Modules/SysTray/Popouts/Network/PSKOverlay.qml new file mode 100644 index 0000000..b8f2fdc --- /dev/null +++ b/Modules/SysTray/Popouts/Network/PSKOverlay.qml @@ -0,0 +1,102 @@ +import Quickshell +import QtQuick +import QtQuick.Layouts +import ZShell.Components +import qs.Config +import qs.Components +import qs.Helpers + +CustomClippingRect { + id: root + + required property var network + + color: DynamicColors.palette.m3surfaceContainer + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + implicitWidth: layout.implicitWidth + layout.anchors.margins * 2 + radius: Appearance.rounding.large + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: Appearance.padding.extraLarge + spacing: Appearance.spacing.normal + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + font.pointSize: Appearance.font.size.extraLarge * 2 + text: "lock" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3onSurfaceVariant + text: qsTr("Connect to %1").arg(root.network.name) + } + + CustomTextField { + id: passwordField + + Layout.fillWidth: true + Layout.preferredWidth: 400 + echoMode: CustomTextField.Password + errorText: qsTr("Failed to connect with given password") + isError: Network.failedNetwork + leadingIcon: "lock" + passwordMaskDelay: 5000 + placeholderText: qsTr("Input password") + } + + ButtonRow { + id: buttonRow + + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + + IconTextButton { + fillWidth: true + font.pointSize: Appearance.font.size.normal + icon: "close" + inactiveColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) + inactiveOnColor: DynamicColors.palette.m3onSurfaceVariant + isRound: true + isToggle: false + shapeMorph: true + text: "Cancel" + + onClicked: PopupManager.requestClose() + } + + IconTextButton { + fillWidth: true + font.pointSize: Appearance.font.size.normal + icon: Network.connectingNetwork ? "" : "check" + inactiveColor: Network.connectingNetwork ? DynamicColors.palette.m3primaryContainer : Network.failedNetwork ? DynamicColors.palette.m3error : DynamicColors.palette.m3primary + inactiveOnColor: Network.connectingNetwork ? DynamicColors.palette.m3onPrimaryContainer : Network.failedNetwork ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onPrimary + isRound: true + isToggle: false + shapeMorph: true + text: Network.connectingNetwork ? "" : "Apply" + + onClicked: Network.requestConnect(root.network, passwordField.text) + + Loader { + active: opacity > 0 + anchors.centerIn: parent + opacity: Network.connectingNetwork ? 1 : 0 + + Behavior on opacity { + Anim { + } + } + sourceComponent: CircularIndicator { + bgColor: "transparent" + implicitSize: buttonRow.height - Appearance.padding.normal + running: true + } + } + } + } + } +} diff --git a/Modules/SysTray/Popouts/Network/PopupManager.qml b/Modules/SysTray/Popouts/Network/PopupManager.qml new file mode 100644 index 0000000..e0e5834 --- /dev/null +++ b/Modules/SysTray/Popouts/Network/PopupManager.qml @@ -0,0 +1,22 @@ +pragma Singleton + +import Quickshell +import QtQuick + +Singleton { + id: root + + property bool closed: true + property url currentPopup: "" + property var currentPopupProps: ({}) + + function requestClose(): void { + closed = true; + } + + function requestOpen(source: url, properties = {}): void { + currentPopupProps = properties; + currentPopup = source; + closed = false; + } +} diff --git a/Modules/SysTray/Popouts/NetworkPopout.qml b/Modules/SysTray/Popouts/NetworkPopout.qml index 9ff2f5d..82549bc 100644 --- a/Modules/SysTray/Popouts/NetworkPopout.qml +++ b/Modules/SysTray/Popouts/NetworkPopout.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import Quickshell +import qs.Modules.SysTray.Popouts.Network import qs.Components import qs.Config import qs.Helpers @@ -13,7 +14,6 @@ CustomClippingRect { required property var wrapper anchors.horizontalCenter: parent.horizontalCenter - color: DynamicColors.tPalette.m3surfaceContainer implicitHeight: networkPopContent.height + networks.implicitHeight + networkPopContent.anchors.margins + networks.anchors.margins * 2 implicitWidth: 500 + 8 * 2 radius: (20 - Appearance.padding.small) * Appearance.rounding.scale @@ -28,6 +28,7 @@ CustomClippingRect { anchors.margins: Appearance.padding.large anchors.right: parent.right anchors.top: parent.top + spacing: Appearance.spacing.extraSmall / 2 CustomText { Layout.preferredHeight: visible ? implicitHeight : 0 @@ -39,28 +40,17 @@ CustomClippingRect { Toggle { Layout.preferredHeight: visible ? implicitHeight : 0 checked: Network.wifiEnabled - label: qsTr("WiFi enabled") + first: true + last: connected.count === 0 + subtext: qsTr("Toggle WiFi on or off") + text: qsTr("Set WiFi") - toggle.onToggled: Network.setWifi(checked) - } - } - - ColumnLayout { - id: networks - - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.margins: Appearance.padding.normal - anchors.right: parent.right - anchors.top: networkPopContent.bottom - - CustomText { - Layout.leftMargin: Appearance.padding.normal - text: qsTr("Connected") - visible: Network.connectedNetworks.length > 0 + onToggled: Network.setWifi(checked) } Repeater { + id: connected + visible: Network.connectedNetworks.length > 0 model: ScriptModel { @@ -70,20 +60,40 @@ CustomClippingRect { } NetworkItem { + first: false + known: true + repeater: connected } } + } + + ColumnLayout { + id: networks + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: Appearance.padding.large + anchors.right: parent.right + anchors.top: networkPopContent.bottom + spacing: Appearance.spacing.extraSmall / 2 Spacer { + repeater: known visible: Network.connectedNetworks.length > 0 } CustomText { Layout.leftMargin: Appearance.padding.normal - text: qsTr("Known networks") + Layout.preferredHeight: visible ? implicitHeight : 0 + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + text: Network.knownNetworks.length > 1 ? qsTr("%1 known networks available").arg(Network.knownNetworks.length) : qsTr("1 known network available") // qmllint disable missing-property visible: Network.knownNetworks.length > 0 } Repeater { + id: known + visible: Network.knownNetworks.length > 0 model: ScriptModel { @@ -93,28 +103,27 @@ CustomClippingRect { } NetworkItem { + known: true + repeater: known } } Spacer { + repeater: unknown visible: Network.knownNetworks.length > 0 } - CustomText { - Layout.leftMargin: Appearance.padding.normal - text: qsTr("Networks") - } - CustomText { Layout.leftMargin: Appearance.padding.normal Layout.preferredHeight: visible ? implicitHeight : 0 color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.extraSmall - text: qsTr("%1 networks available").arg(Network.networks.length) // qmllint disable missing-property + font.pointSize: Appearance.font.size.small + text: Network.unknownNetworks.length > 1 ? qsTr("%1 networks available").arg(Network.unknownNetworks.length) : qsTr("1 network available") // qmllint disable missing-property + visible: Network.unknownNetworks.length > 0 } Repeater { - id: networkRepeater + id: unknown model: ScriptModel { values: [...Network.unknownNetworks].sort((a, b) => { @@ -123,101 +132,227 @@ CustomClippingRect { } NetworkItem { + known: false + repeater: unknown } } } - component NetworkItem: CustomRect { - id: knownNetworkItem + NetworkOverlay { + anchors.fill: parent + } + component ConnectedRect: CustomRect { + id: bg + + property bool first + property bool last + + bottomLeftRadius: last ? Appearance.rounding.large : Appearance.rounding.extraSmall + bottomRightRadius: last ? Appearance.rounding.large : Appearance.rounding.extraSmall + color: DynamicColors.tPalette.m3surfaceContainer + topLeftRadius: first ? Appearance.rounding.large : Appearance.rounding.extraSmall + topRightRadius: first ? Appearance.rounding.large : Appearance.rounding.extraSmall + } + component NetworkItem: ConnectedRect { + id: networkItem + + readonly property bool connecting: Network.connectingNetwork === modelData + property int horizontalPadding: Appearance.padding.largeIncreased + required property int index + readonly property bool isSecure: Network.isSecure(modelData.security) + required property bool known required property var modelData + required property Repeater repeater Layout.fillWidth: true - Layout.preferredHeight: visible ? knownNetworkRow.implicitHeight + Appearance.padding.smaller * 2 : 0 - Layout.rightMargin: Appearance.padding.extraSmall + Layout.preferredHeight: visible ? rowLayout.implicitHeight + Appearance.padding.smaller * 2 : 0 + first: index === 0 + last: index === (repeater.count - 1) radius: Appearance.rounding.small RowLayout { - id: knownNetworkRow + id: rowLayout anchors.fill: parent - anchors.leftMargin: Appearance.padding.larger - anchors.rightMargin: Appearance.padding.normal - opacity: 0 - scale: 0.7 - spacing: Appearance.spacing.small + anchors.leftMargin: networkItem.horizontalPadding + anchors.rightMargin: networkItem.horizontalPadding - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } - Behavior on scale { - Anim { - } - } + Column { + id: column - Component.onCompleted: { - opacity = 1; - scale = 1; - } - - MaterialIcon { - color: knownNetworkItem.modelData.active ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurfaceVariant - text: Icons.getNetworkIcon(knownNetworkItem.modelData.signalStrength * 100, Network.isSecure(knownNetworkItem.modelData.security)) - } - - CustomText { Layout.fillWidth: true - Layout.leftMargin: Appearance.spacing.extraSmall - Layout.rightMargin: Appearance.spacing.extraSmall - color: knownNetworkItem.modelData.active ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurface - elide: Text.ElideRight - text: knownNetworkItem.modelData.name + + CustomText { + Layout.fillWidth: true + Layout.leftMargin: Appearance.spacing.extraSmall + Layout.rightMargin: Appearance.spacing.extraSmall + color: networkItem.modelData.active ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurface + elide: Text.ElideRight + text: networkItem.modelData.name + } + + CustomText { + id: subtext + + anchors.left: parent.left + anchors.right: parent.right + color: DynamicColors.palette.m3outline + elide: Text.ElideRight + font: { + const f = Qt.font(subtext.font); + f.pointSize = Appearance.font.size.small; + return f; + } + text: qsTr("%1 strength").arg((networkItem.modelData.signalStrength * 100) + "%") + } } - CustomRect { - color: Qt.alpha(DynamicColors.palette.m3primary, knownNetworkItem.modelData.active ? 1 : 0) - implicitHeight: knownWirelessConnectIcon.implicitHeight + Appearance.padding.extraSmall - implicitWidth: implicitHeight - radius: Appearance.rounding.full + Item { + Layout.alignment: Qt.AlignRight + implicitHeight: icon.height + implicitWidth: icon.width - // CircularIndicator { - // anchors.fill: parent - // running: knownNetworkItem.loading - // } + MaterialIcon { + id: icon + + color: networkItem.modelData.active ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + opacity: networkItem.connecting ? 0 : 1 + text: Icons.getNetworkIcon(networkItem.modelData.signalStrength * 100, networkItem.isSecure) + + Behavior on opacity { + Anim { + } + } + } + + Loader { + active: opacity > 0 + anchors.fill: parent + opacity: networkItem.connecting ? 1 : 0 + + Behavior on opacity { + Anim { + } + } + sourceComponent: CircularIndicator { + bgColor: "transparent" + running: true + } + } } } StateLayer { - color: knownNetworkItem.modelData.active ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + color: networkItem.modelData.active ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface onClicked: { - NetworkPassword.requestOpen(); + if (networkItem.isSecure && !networkItem.known) { + PopupManager.requestOpen(Qt.resolvedUrl("Network/PSKOverlay.qml"), { + network: networkItem.modelData + }); + } else { + Network.requestConnect(networkItem.modelData); + } } } + + IconButton { + anchors.right: rowLayout.right + anchors.rightMargin: rowLayout.anchors.rightMargin + icon.width + anchors.verticalCenter: parent.verticalCenter + font.pointSize: Appearance.font.size.large + icon: "cancel" + implicitHeight: parent.height - Appearance.padding.larger * 2 + type: IconButton.Text + visible: networkItem.known + + onClicked: networkItem.modelData.forget() + } } component Spacer: Item { id: spacer - Layout.preferredHeight: networkRepeater.count > 0 ? Appearance.spacing.extraSmall : 0 + required property Repeater repeater + + Layout.preferredHeight: repeater.count > 0 ? Appearance.spacing.extraSmall : 0 } - component Toggle: RowLayout { - property alias checked: toggle.checked - required property string label - property alias toggle: toggle + component Toggle: CustomSwitch { + id: toggleItem + + readonly property alias bg: bg + property alias first: bg.first + property alias last: bg.last + property string subtext Layout.fillWidth: true - Layout.rightMargin: Appearance.padding.extraSmall - spacing: Appearance.spacing.small + cLayer: 2 + horizontalPadding: Appearance.padding.largeIncreased + implicitHeight: Math.max(implicitContentHeight, implicitIndicatorHeight) + verticalPadding * 2 + implicitWidth: implicitContentWidth + implicitIndicatorWidth + horizontalPadding * 2 + indicator.anchors.right: right + indicator.anchors.rightMargin: toggleItem.horizontalPadding + indicator.anchors.verticalCenter: verticalCenter + verticalPadding: Appearance.padding.normal - CustomText { - Layout.fillWidth: true - text: parent.label + background: ConnectedRect { + id: bg + + StateLayer { + id: stateLayer + + manualPressOverride: toggleItem.pressed + } + } + contentItem: Item { + anchors.left: parent.left + anchors.leftMargin: toggleItem.horizontalPadding + anchors.right: toggleItem.indicator.left + anchors.rightMargin: Appearance.spacing.normal + implicitHeight: column.implicitHeight + implicitWidth: column.implicitWidth + + Column { + id: column + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + CustomText { + id: label + + anchors.left: parent.left + anchors.right: parent.right + elide: Text.ElideRight + font: { + const f = Qt.font(label.font); + f.pointSize = Appearance.font.size.smaller; + return f; + } + text: toggleItem.text + } + + CustomText { + id: subtext + + anchors.left: parent.left + anchors.right: parent.right + color: DynamicColors.palette.m3outline + elide: Text.ElideRight + font: { + const f = Qt.font(subtext.font); + f.pointSize = Appearance.font.size.small; + return f; + } + text: toggleItem.subtext + visible: toggleItem.subtext + } + } } - CustomSwitch { - id: toggle - } + onPressed: stateLayer.press(stateLayer.mouseX, stateLayer.mouseY) } } diff --git a/network-dev/wifi-sim.sh b/network-dev/wifi-sim.sh index f3b2cea..58512c4 100755 --- a/network-dev/wifi-sim.sh +++ b/network-dev/wifi-sim.sh @@ -1,16 +1,29 @@ #!/bin/bash + # ============================================================================= + # wifi-sim.sh — Virtual WiFi environment for development & testing + # -# Creates 3 dummy networks using mac80211_hwsim + hostapd: -# - FreeWifi (open, strong signal, ch 1) -# - OfficeNetwork (open, medium signal, ch 6) -# - HomeNetwork (WPA2, weak signal, ch 11) + +# Creates 3 dummy networks using mac80211_hwsim + hostapd + dnsmasq: + +# - FreeWifi (open, strong signal, ch 1) + +# - OfficeNetwork (open, medium signal, ch 6) + +# - HomeNetwork (WPA2, weak signal, ch 11) + # + # Usage: -# sudo ./wifi-sim.sh start -# sudo ./wifi-sim.sh stop -# sudo ./wifi-sim.sh status + +# sudo ./wifi-sim.sh start + +# sudo ./wifi-sim.sh stop + +# sudo ./wifi-sim.sh status + # ============================================================================= set -euo pipefail @@ -23,11 +36,27 @@ SSID_SECURE="HomeNetwork" # WPA2 network, weak signal WIFI_PASSWORD="testpassword123" # TX power levels in mBm (millibel-milliwatts). Higher = stronger signal. + # These affect the RSSI reported to scanning clients. + TXPOWER_STRONG=3000 # 30 dBm TXPOWER_MEDIUM=1500 # 15 dBm TXPOWER_WEAK=100 # 1 dBm +# Per-network IP plan for DHCP. + +AP1_IP="10.10.1.1" +AP1_RANGE_START="10.10.1.50" +AP1_RANGE_END="10.10.1.150" + +AP2_IP="10.10.2.1" +AP2_RANGE_START="10.10.2.50" +AP2_RANGE_END="10.10.2.150" + +AP3_IP="10.10.3.1" +AP3_RANGE_START="10.10.3.50" +AP3_RANGE_END="10.10.3.150" + RUN_DIR="/tmp/wifi-sim" # --- Helpers ----------------------------------------------------------------- @@ -36,8 +65,14 @@ die() { echo "ERROR: $*" >&2 exit 1 } -info() { echo "[*] $*"; } -ok() { echo "[+] $*"; } + +info() { + echo "[*] $*" +} + +ok() { + echo "[+] $*" +} require_cmds() { local missing=() @@ -46,7 +81,7 @@ require_cmds() { done if [[ ${#missing[@]} -gt 0 ]]; then die "Missing required tools: ${missing[*]} - Install with: sudo apt install hostapd wpasupplicant iw" +Install with: sudo apt install hostapd dnsmasq wpasupplicant iw" fi } @@ -54,11 +89,60 @@ get_phy_for_iface() { iw dev "$1" info | awk '/wiphy/{print "phy"$2}' } +start_dnsmasq() { + local name="$1" + local iface="$2" + local gw_ip="$3" + local dhcp_start="$4" + local dhcp_end="$5" + + dnsmasq \ + --interface="$iface" \ + --bind-interfaces \ + --except-interface=lo \ + --port=0 \ + --no-resolv \ + --no-hosts \ + --dhcp-authoritative \ + --dhcp-range="${dhcp_start},${dhcp_end},255.255.255.0,12h" \ + --dhcp-option=3,"$gw_ip" \ + --dhcp-option=6,"$gw_ip" \ + --pid-file="$RUN_DIR/${name}.dnsmasq.pid" \ + --dhcp-leasefile="$RUN_DIR/${name}.leases" \ + >"$RUN_DIR/${name}.dnsmasq.log" 2>&1 || + { + cat "$RUN_DIR/${name}.dnsmasq.log" + die "dnsmasq failed for $name. See log above." + } + +} + +stop_pidfile() { + local pidfile="$1" + local label="$2" + + if [[ -f "$pidfile" ]]; then + local pid + pid="$(cat "$pidfile")" + if kill "$pid" 2>/dev/null; then + info "Stopped $label (PID $pid)" + fi + rm -f "$pidfile" + fi + +} + +remove_iface_addr() { + local iface="$1" + local cidr="$2" + ip addr del "$cidr" dev "$iface" 2>/dev/null || true +} + # --- Start ------------------------------------------------------------------- cmd_start() { [[ $EUID -ne 0 ]] && die "Please run as root: sudo $0 start" - require_cmds hostapd iw ip + require_cmds hostapd dnsmasq iw ip if [[ -d "$RUN_DIR" && -f "$RUN_DIR/interfaces" ]]; then die "wifi-sim appears to already be running. Run 'sudo $0 stop' first." @@ -75,7 +159,7 @@ cmd_start() { die "mac80211_hwsim is already loaded. Run 'sudo $0 stop' or 'sudo rmmod mac80211_hwsim' first." fi modprobe mac80211_hwsim radios=4 - sleep 1 # Give the kernel a moment to register all interfaces + sleep 1 # Find the interfaces created by our modprobe AFTER=($(iw dev 2>/dev/null | awk '/Interface/{print $2}' | grep -v '^hwsim')) @@ -97,30 +181,31 @@ cmd_start() { info "Interfaces assigned: AP1=$AP1 AP2=$AP2 AP3=$AP3 Client=$CLIENT" echo "$AP1 $AP2 $AP3 $CLIENT" >"$RUN_DIR/interfaces" - # Prevent NetworkManager from fighting us over these interfaces + # Prevent NetworkManager from fighting us over the AP interfaces. + # Leave the client managed so your app can use NetworkManager normally. if command -v nmcli &>/dev/null && nmcli general status &>/dev/null 2>&1; then - info "Marking interfaces as unmanaged in NetworkManager..." + info "Marking AP interfaces as unmanaged in NetworkManager..." for iface in "$AP1" "$AP2" "$AP3"; do nmcli device set "$iface" managed no 2>/dev/null || true done - nmcli device set "$CLIENT" managed yes + nmcli device set "$CLIENT" managed yes 2>/dev/null || true fi # Set TX power on each physical radio to simulate signal strength differences. # mac80211_hwsim factors TX power into the RSSI reported to scanning clients. - # Note: for precise RSSI control, consider 'wmediumd' (a wireless medium daemon). info "Setting TX power levels for signal strength simulation..." PHY1=$(get_phy_for_iface "$AP1") PHY2=$(get_phy_for_iface "$AP2") PHY3=$(get_phy_for_iface "$AP3") - iw phy "$PHY1" set txpower fixed $TXPOWER_STRONG - iw phy "$PHY2" set txpower fixed $TXPOWER_MEDIUM - iw phy "$PHY3" set txpower fixed $TXPOWER_WEAK + iw phy "$PHY1" set txpower fixed "$TXPOWER_STRONG" + iw phy "$PHY2" set txpower fixed "$TXPOWER_MEDIUM" + iw phy "$PHY3" set txpower fixed "$TXPOWER_WEAK" # --- hostapd config: AP1 (open, strong, channel 1) ----------------------- cat >"$RUN_DIR/ap1.conf" <"$RUN_DIR/ap2.conf" <"$RUN_DIR/ap3.conf" < $AP1_IP/24" + echo " $SSID_MEDIUM -> $AP2_IP/24" + echo " $SSID_SECURE -> $AP3_IP/24" + echo "" echo " Useful commands:" echo " Scan for networks : sudo iw dev $CLIENT scan | grep -E 'SSID|signal'" echo " Connect (open) : sudo iw dev $CLIENT connect \"$SSID_STRONG\"" - echo " Connect (WPA2) : sudo wpa_supplicant -Dnl80211 -i$CLIENT \\" - echo " -c <(wpa_passphrase \"$SSID_SECURE\" \"$WIFI_PASSWORD\")" + echo " Connect (WPA2) : sudo nmcli device wifi connect \"$SSID_SECURE\" password \"$WIFI_PASSWORD\" ifname \"$CLIENT\"" echo " Stop simulation : sudo $0 stop" echo "" - echo " Logs: $RUN_DIR/ap{1,2,3}.log" + echo " Logs: $RUN_DIR/ap{1,2,3}.log and $RUN_DIR/ap{1,2,3}.dnsmasq.log" + } # --- Stop -------------------------------------------------------------------- @@ -218,6 +324,19 @@ cmd_stop() { info "Stopping virtual WiFi environment..." local had_something=false + # Stop dnsmasq instances first. + for ap in ap1 ap2 ap3; do + PID_FILE="$RUN_DIR/$ap.dnsmasq.pid" + if [[ -f "$PID_FILE" ]]; then + PID=$(cat "$PID_FILE") + if kill "$PID" 2>/dev/null; then + info "Stopped dnsmasq for $ap (PID $PID)" + fi + rm -f "$PID_FILE" + had_something=true + fi + done + # Kill hostapd processes via PID files for ap in ap1 ap2 ap3; do PID_FILE="$RUN_DIR/$ap.pid" @@ -231,9 +350,14 @@ cmd_stop() { fi done - # Re-enable NetworkManager management for these interfaces + # Restore addresses if we still know the interface names. if [[ -f "$RUN_DIR/interfaces" ]]; then read -r AP1 AP2 AP3 CLIENT <"$RUN_DIR/interfaces" + + remove_iface_addr "$AP1" "$AP1_IP/24" + remove_iface_addr "$AP2" "$AP2_IP/24" + remove_iface_addr "$AP3" "$AP3_IP/24" + if command -v nmcli &>/dev/null; then for iface in "$AP1" "$AP2" "$AP3" "$CLIENT"; do nmcli device set "$iface" managed yes 2>/dev/null || true @@ -255,6 +379,7 @@ cmd_stop() { else echo "Nothing was running." fi + } # --- Status ------------------------------------------------------------------ @@ -286,6 +411,16 @@ cmd_status() { echo " hostapd ($ap) PID=$PID DEAD (check $RUN_DIR/$ap.log)" fi fi + + DNSMASQ_PID_FILE="$RUN_DIR/$ap.dnsmasq.pid" + if [[ -f "$DNSMASQ_PID_FILE" ]]; then + PID=$(cat "$DNSMASQ_PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + echo " dnsmasq ($ap) PID=$PID running" + else + echo " dnsmasq ($ap) PID=$PID DEAD (check $RUN_DIR/$ap.dnsmasq.log)" + fi + fi done } @@ -298,7 +433,7 @@ status) cmd_status ;; *) echo "Usage: sudo $0 {start|stop|status}" echo "" - echo " start Load mac80211_hwsim and bring up 3 virtual APs" + echo " start Load mac80211_hwsim and bring up 3 virtual APs with DHCP" echo " stop Tear down all virtual interfaces and unload module" echo " status Show current state of interfaces and processes" echo ""