Switch Wi-Fi setup to NetworkManager

This commit is contained in:
David Heinemeier Hansson
2026-05-21 10:19:36 +02:00
parent 190c31503c
commit b19dc7eae2
5 changed files with 120 additions and 162 deletions
+4
View File
@@ -3,5 +3,9 @@
# omarchy:summary=Unblock and restart the Wi-Fi service.
echo -e "Unblocking wifi...\n"
rfkill unblock wifi
nmcli networking on
nmcli radio wifi on
nmcli device wifi rescan 2>/dev/null || true
rfkill list wifi
+3 -2
View File
@@ -1,5 +1,6 @@
# Ensure iwd service will be started
sudo systemctl enable iwd.service
# Ensure NetworkManager will be started
sudo systemctl enable NetworkManager.service
sudo systemctl disable iwd.service 2>/dev/null || true
# Prevent systemd-networkd-wait-online timeout on boot
sudo systemctl disable systemd-networkd-wait-online.service
+1 -1
View File
@@ -62,7 +62,7 @@ imagemagick
imv
inetutils
inxi
iwd
networkmanager
jq
kdenlive
kernel-modules-hook
+12
View File
@@ -0,0 +1,12 @@
echo "Switch Wi-Fi management from iwd to NetworkManager"
omarchy-pkg-add networkmanager
sudo systemctl enable --now NetworkManager.service
sudo systemctl disable --now iwd.service 2>/dev/null || true
omarchy-pkg-drop iwd
nmcli networking on
nmcli radio wifi on
omarchy-state set reboot-required
+100 -159
View File
@@ -154,8 +154,8 @@ Panel {
}
// Enter/Space on the highlighted row. Mirrors row-click semantics:
// connected → disconnect, protected-unknown → password prompt (via
// known-network probe), open/known → connect.
// connected → disconnect, protected-unknown → password prompt,
// open/known → connect.
function activateSelected() {
if (busy || selectedIndex < 0 || selectedIndex >= wifiNetworks.length) return
var net = wifiNetworks[selectedIndex]
@@ -297,6 +297,7 @@ Panel {
for (var i = 0; i < networks.length; i++) {
var network = networks[i]
if (!network) continue
checkActionCompletion(network)
var isConnected = network.connected
var ssid = network.name || ""
if (isConnected && ssid !== root.actionSsid) continue // Skip the connected network so it doesn't appear in the list, unless we are currently trying to connect to it
@@ -351,81 +352,81 @@ Panel {
}
function isProtected(security) {
var s = String(security || "").toLowerCase()
return s !== "" && s !== "open"
return security !== WifiSecurityType.Open
}
function runAction(kind, ssid, command) {
if (actionProc.running) return
function networkForSsid(ssid) {
var networks = wifiNetworkObjects || []
for (var i = 0; i < networks.length; i++) {
if (networks[i] && networks[i].name === ssid) return networks[i]
}
return null
}
function runNetworkAction(kind, network, callback) {
if (actionKind !== "" || !network) return
var ssid = network.name || ""
actionSsid = ssid
actionKind = kind
failureSsid = ""
failureReason = ""
actionProc.command = ["bash", "-c", command]
actionProc.running = true
callback(network)
// Safety net: if onExited never fires (process death, signal handler
// throws, etc.), clear the busy state so the row doesn't get stuck on
// "Connecting…" / "Disconnecting…" forever.
actionTimeout.restart()
}
function clearNetworkAction() {
actionTimeout.stop()
if (actionKind === "connect") passwordSsid = ""
failureSsid = ""
failureReason = ""
actionSsid = ""
actionKind = ""
refresh()
}
function failNetworkAction(network, reason) {
if (!network || actionKind === "" || actionSsid !== (network.name || "")) return
actionTimeout.stop()
failureSsid = actionSsid
failureReason = networkFailureReason(reason)
actionSsid = ""
actionKind = ""
refresh()
}
function networkFailureReason(reason) {
if (reason === ConnectionFailReason.NoSecrets) return "Passphrase required"
if (reason === ConnectionFailReason.WifiAuthTimeout) return "Wrong password"
if (reason === ConnectionFailReason.WifiNetworkLost) return "Network lost"
if (reason === ConnectionFailReason.WifiClientDisconnected) return "Disconnected"
if (reason === ConnectionFailReason.WifiClientFailed) return "Connection failed"
return "Failed to connect"
}
function checkActionCompletion(network) {
if (!network || actionKind === "" || actionSsid !== (network.name || "")) return
if (actionKind === "connect" && network.connected) clearNetworkAction()
else if (actionKind === "disconnect" && !network.connected && !network.stateChanging) clearNetworkAction()
else if (actionKind === "forget" && !network.known && !network.stateChanging) clearNetworkAction()
}
function connectKnown(ssid) {
var quotedSsid = Util.shellQuote(ssid)
runAction("connect", ssid, `
station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }')
[[ -z $station ]] && { echo "no Wi-Fi station available" >&2; exit 1; }
iwctl --dont-ask station "$station" connect ${quotedSsid}
`)
runNetworkAction("connect", networkForSsid(ssid), function(network) { network.connect() })
}
function connectWithPassphrase(ssid, passphrase) {
var quotedSsid = Util.shellQuote(ssid)
var quotedPass = Util.shellQuote(passphrase)
runAction("connect", ssid, `
station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }')
[[ -z $station ]] && { echo "No Wi-Fi station available" >&2; exit 1; }
output=$(iwctl --passphrase ${quotedPass} station "$station" connect ${quotedSsid} 2>&1)
exit_code=$?
if [[ $exit_code -ne 0 ]]; then
echo "$output" | sed -e 's/\\x1b\\[[0-9;]*m//g' >&2
exit $exit_code
fi
`)
runNetworkAction("connect", networkForSsid(ssid), function(network) { network.connectWithPsk(passphrase) })
}
function disconnect(ssid) {
runAction("disconnect", ssid, `
station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }')
[[ -z $station ]] && { echo "no Wi-Fi station available" >&2; exit 1; }
iwctl station "$station" disconnect
`)
function disconnect(network) {
runNetworkAction("disconnect", network || connectedWifiNetwork, function(net) { net.disconnect() })
}
// iwd doesn't reliably tear down an active connection when you forget the
// network underneath it, so if the station is currently on this SSID we
// disconnect first and bail on failure rather than reporting a misleading
// success. If the station isn't on this SSID (or there is no station),
// skip straight to forget.
function forget(ssid) {
var quotedSsid = Util.shellQuote(ssid)
runAction("forget", ssid, `
station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }')
if [[ -n $station ]]; then
current=$(iwctl station "$station" show 2>/dev/null \
| sed -e 's/\\x1b\\[[0-9;]*m//g' \
| awk '/Connected network/ {
s = $0
sub(/.*Connected network[[:space:]]+/, "", s)
sub(/[[:space:]]+$/, "", s)
print s
exit
}')
if [[ $current == ${quotedSsid} ]]; then
iwctl station "$station" disconnect || { echo "failed to disconnect before forget" >&2; exit 1; }
fi
fi
iwctl known-networks ${quotedSsid} forget
`)
function forget(net) {
runNetworkAction("forget", net ? net.network : null, function(network) { network.forget() })
}
implicitWidth: button.implicitWidth
@@ -471,17 +472,23 @@ fi
}
}
// Wi-Fi scan via iwctl. Emits a STATION_AVAILABLE marker followed by TSV
// rows (connected, ssid, signal_pct, security). Strip ANSI escapes early —
// iwctl's table layout starts each row with one, which breaks naive parsing.
Process {
id: wifiProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.updateWifi(text)
Timer {
id: scanRestart
interval: 100
repeat: false
onTriggered: {
if (root.wifiDevice) root.wifiDevice.scannerEnabled = true
scanDone.start()
}
}
Timer {
id: scanDone
interval: 1500
repeat: false
onTriggered: root.syncWifiNetworks()
}
Process {
id: dnsProc
stdout: StdioCollector {
@@ -490,64 +497,22 @@ fi
}
}
// Action runner for connect/disconnect/forget and DNS provider changes.
// Streams stderr for wifi actions so we can surface "Operation failed" or
// "Invalid passphrase" inline rather than dropping the user back to a
// silent UI. setDns() and runAction() both gate on `actionProc.running`,
// so the two flows can't overlap on this shared process.
// Action runner for DNS provider changes. Wi-Fi actions use the
// Quickshell.Networking NetworkManager backend directly.
Process {
id: actionProc
stdout: StdioCollector { id: actionStdout; waitForEnd: true }
stderr: StdioCollector { id: actionStderr; waitForEnd: true }
onExited: function(exitCode) {
// DNS change finished — pendingDnsProvider is the in-flight marker
// for that flow (setDns doesn't touch actionKind), so handle it and
// return before the wifi-action path.
if (root.pendingDnsProvider !== "") {
if (exitCode === 0) root.dnsProvider = root.pendingDnsProvider
root.pendingDnsProvider = ""
return
}
// Timeout already cleared our state and killed us — the exit signal
// is stale, don't clobber whatever the user has done since.
if (root.actionKind === "") return
actionTimeout.stop()
var ssid = root.actionSsid
var kind = root.actionKind
if (exitCode === 0) {
if (kind === "connect") root.passwordSsid = ""
root.failureSsid = ""
root.failureReason = ""
} else {
root.failureSsid = ssid
var reason = (actionStderr.text || actionStdout.text || "").trim()
if (!reason) {
if (kind === "connect") reason = "Failed to connect"
else if (kind === "disconnect") reason = "Failed to disconnect"
else reason = "Failed to forget"
}
// Squash multi-line iwctl errors into a single readable line.
// Also strip bash script error prefixes like "bash: line 4: " and "Operation failed"
var finalReason = reason.split("\n").pop().replace(/^bash: line \d+: /, "").replace(/^Operation failed/, "").trim()
if (!finalReason) {
if (kind === "connect") finalReason = "Failed to connect"
else if (kind === "disconnect") finalReason = "Failed to disconnect"
else finalReason = "Failed to forget"
}
root.failureReason = finalReason
}
root.actionSsid = ""
root.actionKind = ""
root.refresh()
}
}
// Poll detailsProc while the panel is open. `iwctl connect` returns
// success the moment iwd accepts credentials — the IP/route isn't
// actually assigned until a beat later, so a single post-action refresh
// races against routing and the header stays blank. Polling fills the
// details in as soon as the route comes up; cheap since the script is
// small and only runs while the panel is visible.
// Poll details while the panel is open so the IP/route header catches up
// as soon as NetworkManager finishes activating a connection.
Timer {
id: detailsPoll
interval: 1500
@@ -566,13 +531,10 @@ fi
if (root.actionKind === "connect") reason = "Timed out connecting"
else if (root.actionKind === "disconnect") reason = "Timed out disconnecting"
else reason = "Timed out forgetting"
// Clear state *before* killing the process so the eventual onExited
// sees actionKind === "" and bails out as stale.
root.failureSsid = root.actionSsid
root.failureReason = reason
root.actionSsid = ""
root.actionKind = ""
if (actionProc.running) actionProc.running = false // SIGTERM
root.refresh()
}
}
@@ -693,9 +655,9 @@ fi
foreground: root.bar.foreground
hoverColor: root.bar.foreground // Override the dimming behavior
fontFamily: root.bar.fontFamily
enabled: true
enabled: root.networkManagerAvailable
onClicked: {
root.bar.run("rfkill toggle wlan")
Networking.wifiEnabled = !Networking.wifiEnabled
Qt.callLater(function() { root.refresh(true) })
}
}
@@ -756,7 +718,7 @@ fi
root.focusSection = "header"
root.headerIndex = 0
}
onClicked: root.disconnect(root.info.ssid)
onClicked: root.disconnect(root.connectedWifiNetwork)
}
Button {
@@ -974,7 +936,7 @@ fi
readonly property bool isConnected: net && net.connected
readonly property bool isKnown: !!(net && net.known)
readonly property bool isProtected: root.isProtected(net ? net.security : "")
readonly property bool isProtected: net ? root.isProtected(net.security) : false
readonly property bool canForget: isConnected || isKnown
readonly property bool isSelected: root.focusSection === "wifi" && root.selectedIndex === index
@@ -989,6 +951,23 @@ fi
readonly property bool isFailed: root.failureReason !== "" && root.failureSsid === (net ? net.ssid : "")
readonly property bool isPasswordOpen: root.passwordSsid !== "" && root.passwordSsid === (net ? net.ssid : "")
Connections {
target: row.net ? row.net.network : null
function onConnectionFailed(reason) {
root.failNetworkAction(row.net.network, reason)
if (reason === ConnectionFailReason.NoSecrets) root.passwordSsid = row.net.ssid
}
function onConnectedChanged() {
if (row.net) root.checkActionCompletion(row.net.network)
}
function onKnownChanged() {
if (row.net) root.checkActionCompletion(row.net.network)
}
function onStateChangingChanged() {
if (row.net) root.checkActionCompletion(row.net.network)
}
}
readonly property string statusText: {
if (!net) return ""
if (isPasswordOpen) return ""
@@ -1033,28 +1012,11 @@ fi
root.focusSection = "wifi"
root.selectedIndex = row.index
if (row.isConnected) {
root.disconnect(row.net.ssid)
root.disconnect(row.net.network)
return
}
if (row.isProtected && !row.isKnown) {
// Unknown protected network: probe iwd before expanding the inline
// password prompt for this row. Stash the SSID as a string — if we
// held a reference to the row delegate it could be destroyed by a
// model refresh, and a rapid second click would overwrite it and
// misroute the first result.
var quotedSsid = Util.shellQuote(row.net.ssid)
knownCheck.targetSsid = row.net.ssid
knownCheck.command = ["bash", "-c", `
iwctl known-networks list 2>/dev/null \\
| sed -e 's/\\x1b\\[[0-9;]*m//g' \\
| awk -v s=${quotedSsid} 'NR > 3 {
line = $0
sub(/^[[:space:]]+/, "", line)
sub(/[[:space:]]+$/, "", line)
n = split(line, p, /[[:space:]]{2,}/)
if (n >= 1 && p[1] == s) { print "yes"; exit }
}'`]
knownCheck.running = true
root.passwordSsid = row.net.ssid
return
}
root.connectKnown(row.net.ssid)
@@ -1092,7 +1054,7 @@ iwctl known-networks list 2>/dev/null \\
foreground: root.bar.foreground
hoverColor: root.bar.urgent
fontFamily: root.bar.fontFamily
onClicked: if (row.net) root.forget(row.net.ssid)
onClicked: if (row.net) root.forget(row.net)
}
// Shows a lock glyph for protected disconnected networks at the far
@@ -1239,27 +1201,6 @@ iwctl known-networks list 2>/dev/null \\
}
}
// One-shot probe: is the just-clicked SSID in iwd's known-networks?
// If so, skip the passphrase prompt and connect directly. We hold an SSID
// string (not a row delegate) so a model refresh during the probe can't
// leave us pointing at a destroyed object. Rows are globally disabled
// while `knownCheck.running`, so the SSID can't be overwritten mid-flight.
Process {
id: knownCheck
property string targetSsid: ""
stdout: StdioCollector {
id: knownStdout
waitForEnd: true
onStreamFinished: {
var ssid = knownCheck.targetSsid
knownCheck.targetSsid = ""
if (!ssid) return
if (text.indexOf("yes") !== -1) root.connectKnown(ssid)
else root.passwordSsid = ssid
}
}
}
// Poll the wifi/ethernet pill state every 3s. Local to this panel so
// Bar.qml does not need to mirror network state.
Process {