403 lines
13 KiB
QML
403 lines
13 KiB
QML
import Quickshell
|
|
import Quickshell.Wayland
|
|
import Quickshell.Networking
|
|
import QtQuick
|
|
import qs.config
|
|
import qs.components
|
|
import qs.services
|
|
|
|
// The wifi menu: radio power and the networks in range. Same full-screen layer
|
|
// surface as the launcher, so click-away and Escape work everywhere.
|
|
//
|
|
// NetworkManager is the backend Quickshell speaks; on a machine configured with
|
|
// `wifi.backend=iwd` the connections still go through iwd underneath, without
|
|
// this having to race NetworkManager's own policy.
|
|
PanelWindow {
|
|
id: root
|
|
|
|
required property var bar
|
|
|
|
readonly property string screenName: root.bar.screen ? root.bar.screen.name : ""
|
|
|
|
visible: Session.isOpen("wifi", root.screenName)
|
|
|
|
screen: root.bar.screen
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: Config.namespace + "-wifi"
|
|
// Exclusive so the passphrase field receives keys immediately; the surface
|
|
// is unmapped while closed, so nothing is held hostage.
|
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
|
|
|
anchors {
|
|
left: true
|
|
right: true
|
|
top: true
|
|
bottom: true
|
|
}
|
|
exclusionMode: ExclusionMode.Ignore
|
|
color: "transparent"
|
|
|
|
// ---- Data ------------------------------------------------------------
|
|
|
|
readonly property WifiDevice device: Networking.devices.values.find(d => d.type === DeviceType.Wifi) ?? null
|
|
readonly property bool wifiOn: Networking.wifiEnabled && Networking.wifiHardwareEnabled
|
|
|
|
readonly property var networks: {
|
|
if (!root.device || !root.wifiOn)
|
|
return [];
|
|
|
|
// Sorted by signal in quarters rather than by the raw value: strength
|
|
// moves a little on every scan, and rows must not reshuffle under the
|
|
// cursor for that. Connected first, then saved, then strongest.
|
|
const bars = n => Math.min(3, Math.floor(n.signalStrength * 4));
|
|
|
|
return [...root.device.networks.values].sort((a, b) => {
|
|
if (a.connected !== b.connected)
|
|
return a.connected ? -1 : 1;
|
|
if (a.known !== b.known)
|
|
return a.known ? -1 : 1;
|
|
if (bars(a) !== bars(b))
|
|
return bars(b) - bars(a);
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
}
|
|
|
|
// Network waiting on a passphrase, or null.
|
|
property var pskNetwork: null
|
|
// Network we last asked to connect, watched for a failure report.
|
|
property var pendingNetwork: null
|
|
property string error: ""
|
|
|
|
function secured(network: var): bool {
|
|
// Owe is opportunistic encryption: nothing to ask the user for.
|
|
return network.security !== WifiSecurityType.Open && network.security !== WifiSecurityType.Owe;
|
|
}
|
|
|
|
function activate(network: var): void {
|
|
if (!network)
|
|
return;
|
|
|
|
root.error = "";
|
|
|
|
if (network.connected || network.state === ConnectionState.Connecting) {
|
|
network.disconnect();
|
|
return;
|
|
}
|
|
|
|
// A saved network has its secrets in NetworkManager already.
|
|
if (!network.known && root.secured(network)) {
|
|
root.promptFor(network);
|
|
return;
|
|
}
|
|
|
|
root.pendingNetwork = network;
|
|
network.connect();
|
|
}
|
|
|
|
function promptFor(network: var): void {
|
|
root.pskNetwork = network;
|
|
psk.text = "";
|
|
psk.forceActiveFocus();
|
|
}
|
|
|
|
function submitPsk(): void {
|
|
const network = root.pskNetwork;
|
|
if (!network || psk.text === "")
|
|
return;
|
|
|
|
root.pskNetwork = null;
|
|
root.pendingNetwork = network;
|
|
network.connectWithPsk(psk.text);
|
|
psk.text = "";
|
|
keyHandler.forceActiveFocus();
|
|
}
|
|
|
|
function cancelPsk(): void {
|
|
root.pskNetwork = null;
|
|
psk.text = "";
|
|
keyHandler.forceActiveFocus();
|
|
}
|
|
|
|
// Right click drops a saved network. Without it a mistyped passphrase would
|
|
// be saved by NetworkManager and retried forever with no way out from here.
|
|
function forget(network: var): void {
|
|
if (!network || !network.known)
|
|
return;
|
|
|
|
root.error = "";
|
|
network.forget();
|
|
}
|
|
|
|
Connections {
|
|
target: root.pendingNetwork
|
|
|
|
function onConnectionFailed(reason: int): void {
|
|
switch (reason) {
|
|
case ConnectionFailReason.NoSecrets:
|
|
root.error = "Wrong password for " + root.pendingNetwork.name;
|
|
root.promptFor(root.pendingNetwork);
|
|
break;
|
|
case ConnectionFailReason.WifiAuthTimeout:
|
|
root.error = "Authentication timed out";
|
|
break;
|
|
case ConnectionFailReason.WifiNetworkLost:
|
|
root.error = "Network went out of range";
|
|
break;
|
|
default:
|
|
root.error = "Could not connect to " + root.pendingNetwork.name;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scanning costs power, so it only runs while the menu is on screen. As a
|
|
// Binding rather than a one-shot, an adapter that appears later still gets
|
|
// switched on.
|
|
Binding {
|
|
target: root.device
|
|
property: "scannerEnabled"
|
|
value: root.visible
|
|
when: root.device !== null
|
|
}
|
|
|
|
onVisibleChanged: {
|
|
if (root.visible) {
|
|
list.currentIndex = 0;
|
|
root.pskNetwork = null;
|
|
root.error = "";
|
|
keyHandler.forceActiveFocus();
|
|
}
|
|
}
|
|
|
|
// ---- Chrome ----------------------------------------------------------
|
|
|
|
// Click-away scrim.
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: Session.close()
|
|
}
|
|
|
|
Item {
|
|
id: keyHandler
|
|
|
|
anchors.fill: parent
|
|
focus: true
|
|
|
|
Keys.onEscapePressed: Session.close()
|
|
Keys.onDownPressed: list.currentIndex = Math.min(list.currentIndex + 1, list.count - 1)
|
|
Keys.onUpPressed: list.currentIndex = Math.max(list.currentIndex - 1, 0)
|
|
Keys.onReturnPressed: root.activate(root.networks[list.currentIndex])
|
|
Keys.onEnterPressed: root.activate(root.networks[list.currentIndex])
|
|
}
|
|
|
|
Rectangle {
|
|
id: menu
|
|
|
|
// Anchored to the panel's right edge, under the button that opens it.
|
|
anchors {
|
|
right: parent.right
|
|
bottom: parent.bottom
|
|
rightMargin: Theme.padding
|
|
bottomMargin: Theme.barHeight + Theme.padding
|
|
}
|
|
|
|
readonly property int maxHeight: Math.min(Config.wifiMenuHeight, root.height - Theme.barHeight - Theme.padding * 2)
|
|
readonly property int bodyHeight: root.wifiOn && list.count > 0 ? list.contentHeight : 48
|
|
readonly property int footerHeight: footer.visible ? footer.height + Theme.padding : 0
|
|
|
|
width: Math.min(Config.wifiMenuWidth, root.width - Theme.padding * 2)
|
|
height: Math.min(menu.maxHeight, Theme.padding * 4 + header.height + Theme.padding * 2 + menu.bodyHeight + menu.footerHeight)
|
|
|
|
color: Theme.surface
|
|
radius: Theme.radius * 2
|
|
border.width: Theme.borderWidth
|
|
border.color: Theme.border
|
|
|
|
// Swallow clicks so they do not reach the scrim behind.
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
}
|
|
|
|
Item {
|
|
id: header
|
|
|
|
anchors {
|
|
left: parent.left
|
|
right: parent.right
|
|
top: parent.top
|
|
margins: Theme.padding * 2
|
|
}
|
|
height: 24
|
|
|
|
WifiGlyph {
|
|
id: headerGlyph
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
size: 18
|
|
struck: !root.wifiOn
|
|
level: root.wifiOn ? 3 : 0
|
|
color: root.wifiOn ? Theme.text : Theme.textDim
|
|
dimColor: Theme.border
|
|
}
|
|
|
|
Text {
|
|
anchors {
|
|
left: headerGlyph.right
|
|
right: toggle.left
|
|
leftMargin: Theme.padding
|
|
rightMargin: Theme.padding
|
|
verticalCenter: parent.verticalCenter
|
|
}
|
|
elide: Text.ElideRight
|
|
text: Networking.wifiHardwareEnabled ? "Wi-Fi" : "Wi-Fi (blocked)"
|
|
color: Theme.text
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
font.bold: true
|
|
}
|
|
|
|
ToggleSwitch {
|
|
id: toggle
|
|
|
|
anchors {
|
|
right: parent.right
|
|
verticalCenter: parent.verticalCenter
|
|
}
|
|
checked: Networking.wifiEnabled
|
|
enabled: Networking.wifiHardwareEnabled
|
|
|
|
// NetworkManager reports the new state back, so `checked` stays
|
|
// bound to it rather than to what was clicked.
|
|
onToggled: on => Networking.wifiEnabled = on
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: separator
|
|
|
|
anchors {
|
|
left: parent.left
|
|
right: parent.right
|
|
top: header.bottom
|
|
topMargin: Theme.padding * 2
|
|
}
|
|
height: Theme.borderWidth
|
|
color: Theme.border
|
|
}
|
|
|
|
ListView {
|
|
id: list
|
|
|
|
anchors {
|
|
left: parent.left
|
|
right: parent.right
|
|
top: separator.bottom
|
|
bottom: footer.visible ? footer.top : parent.bottom
|
|
margins: Theme.padding
|
|
topMargin: Theme.padding
|
|
}
|
|
visible: root.wifiOn
|
|
clip: true
|
|
spacing: 1
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
keyNavigationEnabled: false
|
|
model: root.networks
|
|
|
|
delegate: WifiNetworkEntry {
|
|
required property var modelData
|
|
required property int index
|
|
|
|
network: modelData
|
|
width: list.width
|
|
selected: list.currentIndex === index
|
|
|
|
onActivated: root.activate(modelData)
|
|
onForgetRequested: root.forget(modelData)
|
|
onEntered: list.currentIndex = index
|
|
}
|
|
}
|
|
|
|
Text {
|
|
anchors {
|
|
left: parent.left
|
|
right: parent.right
|
|
top: separator.bottom
|
|
bottom: parent.bottom
|
|
margins: Theme.padding * 2
|
|
}
|
|
visible: !root.wifiOn || list.count === 0
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
wrapMode: Text.WordWrap
|
|
text: !root.device ? "No wifi adapter" : !Networking.wifiHardwareEnabled ? "Wi-Fi is blocked by hardware" : !Networking.wifiEnabled ? "Wi-Fi is off" : "Scanning…"
|
|
color: Theme.textDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
}
|
|
|
|
// Passphrase prompt, and where failures are reported.
|
|
Item {
|
|
id: footer
|
|
|
|
anchors {
|
|
left: parent.left
|
|
right: parent.right
|
|
bottom: parent.bottom
|
|
margins: Theme.padding * 2
|
|
}
|
|
visible: root.pskNetwork !== null || root.error !== ""
|
|
height: root.pskNetwork !== null ? 30 : errorText.implicitHeight
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
visible: root.pskNetwork !== null
|
|
radius: Theme.radius
|
|
color: Theme.background
|
|
border.width: Theme.borderWidth
|
|
border.color: psk.activeFocus ? Theme.accent : Theme.border
|
|
|
|
TextInput {
|
|
id: psk
|
|
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Theme.padding * 2
|
|
anchors.rightMargin: Theme.padding * 2
|
|
verticalAlignment: TextInput.AlignVCenter
|
|
echoMode: TextInput.Password
|
|
color: Theme.text
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
selectByMouse: true
|
|
selectionColor: Theme.accent
|
|
|
|
Keys.onEscapePressed: root.cancelPsk()
|
|
Keys.onReturnPressed: root.submitPsk()
|
|
Keys.onEnterPressed: root.submitPsk()
|
|
|
|
Text {
|
|
anchors.fill: parent
|
|
verticalAlignment: Text.AlignVCenter
|
|
visible: psk.text === ""
|
|
text: root.pskNetwork ? "Password for " + root.pskNetwork.name : ""
|
|
color: Theme.textDim
|
|
font: psk.font
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: errorText
|
|
|
|
anchors.fill: parent
|
|
visible: root.pskNetwork === null && root.error !== ""
|
|
verticalAlignment: Text.AlignVCenter
|
|
wrapMode: Text.WordWrap
|
|
text: root.error
|
|
color: Theme.danger
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
}
|
|
}
|
|
}
|