From b19dc7eae206896620180d59c89c24f767537b24 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 21 May 2026 10:19:36 +0200 Subject: [PATCH] Switch Wi-Fi setup to NetworkManager --- bin/omarchy-restart-wifi | 4 + install/config/hardware/network.sh | 5 +- install/omarchy-base.packages | 2 +- migrations/1779350210.sh | 12 ++ shell/plugins/panels/Network.qml | 259 +++++++++++------------------ 5 files changed, 120 insertions(+), 162 deletions(-) create mode 100644 migrations/1779350210.sh diff --git a/bin/omarchy-restart-wifi b/bin/omarchy-restart-wifi index 1a177c33..64f85a72 100755 --- a/bin/omarchy-restart-wifi +++ b/bin/omarchy-restart-wifi @@ -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 diff --git a/install/config/hardware/network.sh b/install/config/hardware/network.sh index 65f9fdbb..93f3e9e5 100644 --- a/install/config/hardware/network.sh +++ b/install/config/hardware/network.sh @@ -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 diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index ca207a09..0242453c 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -62,7 +62,7 @@ imagemagick imv inetutils inxi -iwd +networkmanager jq kdenlive kernel-modules-hook diff --git a/migrations/1779350210.sh b/migrations/1779350210.sh new file mode 100644 index 00000000..d0e1fa43 --- /dev/null +++ b/migrations/1779350210.sh @@ -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 diff --git a/shell/plugins/panels/Network.qml b/shell/plugins/panels/Network.qml index efbe542a..93900d53 100644 --- a/shell/plugins/panels/Network.qml +++ b/shell/plugins/panels/Network.qml @@ -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 {