initial commit
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user