diff --git a/bin/omarchy-network-password b/bin/omarchy-network-password new file mode 100755 index 00000000..55dd54c4 --- /dev/null +++ b/bin/omarchy-network-password @@ -0,0 +1,34 @@ +#!/bin/bash + +# omarchy:summary=Print the active Wi-Fi connection's password +# omarchy:group=network +# omarchy:args= + +set -euo pipefail + +interface=${1:?Usage: omarchy-network-password } +uuid=$(nmcli --get-values GENERAL.CON-UUID device show "$interface" | head -n 1) +[[ -n $uuid && $uuid != "--" ]] || { echo "No active Wi-Fi connection" >&2; exit 1; } + +mapfile -t fields < <(nmcli --show-secrets --escape no --get-values \ + 802-11-wireless-security.key-mgmt,802-11-wireless-security.psk,802-11-wireless-security.wep-key0 \ + connection show uuid "$uuid") + +key_management=${fields[0]:-} +password=${fields[1]:-} +wep_key=${fields[2]:-} + +[[ $key_management != *eap* && $key_management != *ieee8021x* ]] || { + echo "Enterprise Wi-Fi has no shareable password" >&2 + exit 1 +} +if [[ -z $key_management || $key_management == "none" ]]; then + # NetworkManager models WEP as key-mgmt "none" plus a wep-key. + password=$wep_key + [[ -n $password ]] || { echo "This network has no password" >&2; exit 1; } +fi +[[ -n $password ]] || { echo "Could not read the Wi-Fi password" >&2; exit 1; } + +# Stdout is a private pipe to the caller; the secret is never an argument, so +# it never shows up in /proc cmdlines. +printf '%s\n' "$password" diff --git a/bin/omarchy-network-qr b/bin/omarchy-network-qr new file mode 100755 index 00000000..92af3dd9 --- /dev/null +++ b/bin/omarchy-network-qr @@ -0,0 +1,65 @@ +#!/bin/bash + +# omarchy:summary=Generate a Wi-Fi QR matrix for the shell +# omarchy:group=network +# omarchy:args= + +set -euo pipefail + +interface=${1:?Usage: omarchy-network-qr } +uuid=$(nmcli --get-values GENERAL.CON-UUID device show "$interface" | head -n 1) +[[ -n $uuid && $uuid != "--" ]] || { echo "No active Wi-Fi connection" >&2; exit 1; } + +mapfile -t fields < <(nmcli --show-secrets --escape no --get-values \ + 802-11-wireless.ssid,802-11-wireless-security.key-mgmt,802-11-wireless-security.psk,802-11-wireless.hidden,802-11-wireless-security.wep-key0 \ + connection show uuid "$uuid") + +ssid=${fields[0]:-} +key_management=${fields[1]:-} +password=${fields[2]:-} +hidden=${fields[3]:-no} +wep_key=${fields[4]:-} + +[[ -n $ssid ]] || { echo "Could not read the Wi-Fi name" >&2; exit 1; } +[[ $key_management != *eap* && $key_management != *ieee8021x* ]] || { + echo "Enterprise Wi-Fi cannot be shared with a password QR code" >&2 + exit 1 +} + +escape_wifi_qr() { + local value=$1 + value=${value//\\/\\\\} + value=${value//;/\\;} + value=${value//,/\\,} + value=${value//:/\\:} + printf '%s' "$value" +} + +if [[ -n $key_management && $key_management != "none" ]]; then + [[ -n $password ]] || { echo "Could not read the Wi-Fi password" >&2; exit 1; } + security=WPA +elif [[ -n $wep_key ]]; then + # NetworkManager models WEP as key-mgmt "none" plus a wep-key; encoding it + # as an open network would produce a QR that silently fails to join. + password=$wep_key + security=WEP +else + security=nopass +fi + +payload="WIFI:T:$security;S:$(escape_wifi_qr "$ssid");P:$(escape_wifi_qr "$password");" +[[ $hidden == "yes" ]] && payload+="H:true;" +payload+=";" + +# ASCII uses two characters per module. Collapse each pair to one 0/1 value +# so the shell can render a square matrix directly with native QML rectangles. +# Margin 4 is the spec quiet zone; the card surround is dark, so this white +# border is all the scanner gets. +ascii=$(printf '%s' "$payload" | qrencode --type ASCII --margin 4 --output -) +while IFS= read -r line; do + row= + for ((column = 0; column < ${#line}; column += 2)); do + [[ ${line:column:2} == *#* ]] && row+=1 || row+=0 + done + printf '%s\n' "$row" +done <<<"$ascii" diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 13ee9a91..2e01c3a1 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -104,6 +104,7 @@ python-gobject python-poetry-core python-terminaltexteffects qemu-user-static-binfmt +qrencode quickshell-git ripgrep ruby diff --git a/migrations/1785511354.sh b/migrations/1785511354.sh new file mode 100644 index 00000000..3fe689cb --- /dev/null +++ b/migrations/1785511354.sh @@ -0,0 +1,3 @@ +echo "Install qrencode for Wi-Fi QR sharing" + +omarchy-pkg-add qrencode diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index 8b46db39..bf5fa41d 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -40,13 +40,11 @@ function formatHeaderFreq(mhz) { return ghz.toFixed(ghz % 1 === 0 ? 0 : 1) + "ghz" } -// `hideWifiBand` is set when the band toggle is on screen: the band is already -// spelled out there, so repeating it in "York (5ghz)" is noise. A single-band -// network hides the toggle, and then the header stays the only place it shows. -function headerDetail(info, hideWifiBand) { +// Wi-Fi band state belongs in the selector section, not beside the hero name. +// Ethernet has no equivalent selector, so keep its negotiated link speed here. +function headerDetail(info) { var value = info || {} if (value.type === "ethernet") return formatHeaderSpeed(value.speed || "") - if (value.type === "wifi") return hideWifiBand ? "" : formatHeaderFreq(value.freq || "") return "" } @@ -284,6 +282,20 @@ function isProtected(security, openSecurity) { return security !== openSecurity } +function parseQrMatrix(raw) { + var lines = String(raw || "").trim().split(/\r?\n/).filter(function(line) { return line !== "" }) + if (lines.length === 0) return { rows: [], size: 0 } + + var size = lines[0].length + if (size !== lines.length) return { rows: [], size: 0 } + + for (var i = 0; i < lines.length; i++) { + if (lines[i].length !== size || !/^[01]+$/.test(lines[i])) return { rows: [], size: 0 } + } + + return { rows: lines, size: size } +} + // The password arrives on stdin and reaches nmcli through the scriptable // `connection edit` editor -- argv is world-readable in /proc, so the secret // must never be an argument (printf is a bash builtin, so no process spawns @@ -332,6 +344,7 @@ if (typeof module !== "undefined") { sortWifiRows: sortWifiRows, wifiSectionTitle: wifiSectionTitle, isProtected: isProtected, + parseQrMatrix: parseQrMatrix, enterpriseConnectScript: enterpriseConnectScript, networkFailureReason: networkFailureReason } diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index ac48950c..d117dbf6 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -103,6 +103,16 @@ Panel { property string passwordText: "" property string identityText: "" + property var qrRows: [] + property int qrSize: 0 + property string qrError: "" + property bool qrLoading: false + property bool qrExpectedStop: false + property string qrPassword: "" + property bool qrPasswordVisible: false + property string qrPasswordError: "" + readonly property bool qrVisible: qrLoading || qrSize > 0 || qrError !== "" + // True while any wifi action is mid-flight. Rows // disable themselves on this so clicks on the other rows don't silently // no-op against runNetworkAction's serialized guard. @@ -120,15 +130,16 @@ Panel { property int headerIndex: 0 readonly property bool canDisconnect: !!connectedWifiNetwork readonly property bool headerHasDisconnect: false - // The hero switch is the Wi-Fi radio and nothing else, so it only exists - // when there is a radio to switch. A click carried no state, but a switch - // asserts one: on a wired box it would otherwise sit there reading "off" - // beside a perfectly live Ethernet connection. + readonly property bool canShareWifi: info.type === "wifi" && canShareNetwork(connectedWifiNetwork) + // The hero switch is the Wi-Fi radio, so it only exists when there is a + // radio to switch. On a wired box it would otherwise sit there reading + // "off" beside a perfectly live Ethernet connection. readonly property bool canToggleWifi: networkManagerAvailable && wifiStationAvailable - readonly property int headerActionCount: canToggleWifi ? 1 : 0 - // Only claim the header cursor when the switch is actually on screen — - // "header" stays navigable, but a machine with no radio has nothing to highlight. - readonly property bool headerHasCursor: cursorActive && focusSection === "header" && canToggleWifi + readonly property int qrHeaderIndex: canShareWifi ? 0 : -1 + readonly property int toggleHeaderIndex: canToggleWifi ? (canShareWifi ? 1 : 0) : -1 + readonly property int headerActionCount: (canShareWifi ? 1 : 0) + (canToggleWifi ? 1 : 0) + readonly property bool qrHeaderHasCursor: cursorActive && focusSection === "header" && headerIndex === qrHeaderIndex + readonly property bool toggleHeaderHasCursor: cursorActive && focusSection === "header" && headerIndex === toggleHeaderIndex readonly property string toggleHint: Networking.wifiEnabled ? "Turn Wi-Fi off" : "Turn Wi-Fi on" readonly property var dnsProviders: ["DHCP", "Cloudflare", "Google", "Custom"] property int dnsIndex: 0 @@ -204,13 +215,14 @@ Panel { } function activateHeader() { - toggleNetwork() + if (headerIndex === qrHeaderIndex) showWifiQr() + else if (headerIndex === toggleHeaderIndex) toggleNetwork() } - function setHeaderCursor() { + function setHeaderCursor(index) { cursorActive = true focusSection = "header" - headerIndex = 0 + headerIndex = index } function selectDnsByDelta(delta) { @@ -355,6 +367,11 @@ Panel { return !!(net && net.known && isProtected(net.security) && !net.connected) } + function canShareNetwork(net) { + if (!net || !net.connected) return false + return net.security !== WifiSecurityType.Wpa2Eap && net.security !== WifiSecurityType.WpaEap + } + function selectWifiActionByDelta(delta) { if (selectedIndex < 0 || selectedIndex >= wifiNetworks.length) return if (!canForgetNetwork(wifiNetworks[selectedIndex])) { @@ -398,6 +415,51 @@ Panel { readonly property string icon: Model.connectionIcon(kind, signalStrength) + function showWifiQr() { + if (qrProc.running || !info.iface || info.type !== "wifi") return + qrSize = 0 + qrRows = [] + qrError = "" + qrLoading = true + qrExpectedStop = false + qrProc.command = ["omarchy-network-qr", info.iface] + qrProc.running = true + + // Leave the compact network panel behind while the centered share card is open. + controller.hide() + cancelPasswordPrompt() + } + + function hideWifiQr() { + if (qrProc.running) { + qrExpectedStop = true + qrProc.running = false + } + if (pwProc.running) pwProc.running = false + qrSize = 0 + qrRows = [] + qrError = "" + qrLoading = false + qrPassword = "" + qrPasswordVisible = false + qrPasswordError = "" + } + + function updateQr(raw) { + var matrix = Model.parseQrMatrix(raw) + qrRows = matrix.rows + qrSize = matrix.size + } + + function toggleQrPassword() { + if (qrPasswordVisible) { qrPasswordVisible = false; return } + if (qrPassword !== "") { qrPasswordVisible = true; return } + if (pwProc.running || !info.iface) return + qrPasswordError = "" + pwProc.command = ["omarchy-network-password", info.iface] + pwProc.running = true + } + function refresh(scanWifi) { if (scanWifi === undefined) scanWifi = false if (!detailsProc.running) detailsProc.running = true @@ -430,7 +492,7 @@ Panel { } function headerDetail() { - return Model.headerDetail(info, canSelectBand) + return Model.headerDetail(info) } function updateDetails(raw) { @@ -792,6 +854,49 @@ Panel { onTriggered: root.syncWifiNetworks() } + Process { + id: qrProc + // Both collectors check qrExpectedStop: a dismissal mid-generation kills + // the process, but buffered output still arrives afterwards and would + // repopulate qrSize -- reopening the card the user just closed. The flag + // stays set through onExited (showWifiQr resets it) because the exit and + // stream-finished signals have no guaranteed order. + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: if (!root.qrExpectedStop) root.updateQr(text) + } + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: if (!root.qrExpectedStop) root.qrError = String(text || "").trim() + } + onExited: function(exitCode) { + root.qrLoading = false + if (root.qrExpectedStop) return + if (exitCode !== 0 || root.qrSize === 0) { + root.qrSize = 0 + root.qrRows = [] + if (root.qrError === "") root.qrError = "Could not generate the Wi-Fi QR code" + } + } + } + + // The Wi-Fi password only enters shell memory when the user clicks to + // reveal it, and hideWifiQr drops it again when the share card closes. + // Both handlers bail when the card is gone so a fetch that was in flight + // during dismissal can't stash the secret into a closed panel's state. + Process { + id: pwProc + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: if (root.qrVisible) root.qrPassword = String(text || "").trim() + } + onExited: function(exitCode) { + if (!root.qrVisible) return + if (exitCode === 0 && root.qrPassword !== "") root.qrPasswordVisible = true + else root.qrPasswordError = "Could not read the Wi-Fi password" + } + } + Process { id: dnsProc stdout: StdioCollector { @@ -1074,7 +1179,7 @@ Panel { // ---------- Hero: network icon · SSID + state · actions ---------- Item { width: parent.width - implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight, powerSwitch.implicitHeight) + implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight, heroActions.implicitHeight) // Status only — the switch owns toggling, mouse and keyboard alike. Text { @@ -1088,23 +1193,45 @@ Panel { anchors.verticalCenter: parent.verticalCenter } - // Compact on/off switch on the trailing edge of the hero, and the - // header's only cursor target. - ToggleSwitch { - id: powerSwitch - visible: root.canToggleWifi - checked: Networking.wifiEnabled - hasCursor: root.headerHasCursor - foreground: root.bar.foreground + // Sharing belongs to the connected-network hero rather than the scan + // result row. The radio switch remains beside it as the other hero action. + RowLayout { + id: heroActions + spacing: Style.space(8) anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - onHovered: function(on) { if (on) root.setHeaderCursor() } - onToggled: root.toggleNetwork() - PanelToolTip { - visible: powerSwitch.containsMouse - text: root.toggleHint + Button { + id: qrAction + visible: root.canShareWifi + iconText: "󰐲" + tooltipText: "Show QR code" + foreground: root.bar.foreground fontFamily: root.bar.fontFamily + iconSize: Style.font.subtitle * 1.5 + horizontalPadding: Style.space(5) + verticalPadding: Style.space(2) + hasCursor: root.qrHeaderHasCursor + Layout.alignment: Qt.AlignVCenter + onHovered: function(on) { if (on) root.setHeaderCursor(root.qrHeaderIndex) } + onClicked: root.showWifiQr() + } + + ToggleSwitch { + id: powerSwitch + visible: root.canToggleWifi + checked: Networking.wifiEnabled + hasCursor: root.toggleHeaderHasCursor + foreground: root.bar.foreground + Layout.alignment: Qt.AlignVCenter + onHovered: function(on) { if (on) root.setHeaderCursor(root.toggleHeaderIndex) } + onToggled: root.toggleNetwork() + + PanelToolTip { + visible: powerSwitch.containsMouse + text: root.toggleHint + fontFamily: root.bar.fontFamily + } } } @@ -1113,7 +1240,7 @@ Panel { anchors.left: heroIcon.right anchors.leftMargin: Style.space(14) anchors.right: parent.right - anchors.rightMargin: powerSwitch.visible ? powerSwitch.width + Style.space(12) : 0 + anchors.rightMargin: heroActions.width > 0 ? heroActions.width + Style.space(12) : 0 anchors.verticalCenter: parent.verticalCenter spacing: Style.space(2) @@ -1552,6 +1679,23 @@ Panel { } } + WifiQrPanel { + anchorItem: button + bar: root.bar + qrRows: root.qrRows + qrSize: root.qrSize + loading: root.qrLoading + error: root.qrError + ssid: root.info.ssid || "" + secured: root.connectedWifiNetwork ? root.isProtected(root.connectedWifiNetwork.security) : false + password: root.qrPassword + passwordVisible: root.qrPasswordVisible + passwordError: root.qrPasswordError + open: root.qrVisible + onCloseRequested: root.hideWifiQr() + onPasswordToggleRequested: root.toggleQrPassword() + } + // One Wi-Fi band pill. `active` (fill) is the band actually in use and // `selected` (bold) is the pinned choice; with Automatic on nothing is // pinned, so only the live band lights up and the two can no longer read as @@ -1797,8 +1941,7 @@ Panel { spacing: Style.space(1) anchors.left: networkIcon.right anchors.leftMargin: Style.space(10) - anchors.right: rightAction.visible ? rightAction.left - : parent.right + anchors.right: rightAction.visible ? rightAction.left : parent.right anchors.rightMargin: rightAction.visible ? Style.space(8) : 0 anchors.verticalCenter: parent.verticalCenter diff --git a/shell/plugins/panels/network/WifiQrPanel.qml b/shell/plugins/panels/network/WifiQrPanel.qml new file mode 100644 index 00000000..119b9225 --- /dev/null +++ b/shell/plugins/panels/network/WifiQrPanel.qml @@ -0,0 +1,171 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import qs.Commons +import qs.Ui + +PanelWindow { + id: root + + required property Item anchorItem + required property QtObject bar + required property var qrRows + required property int qrSize + required property bool loading + required property string error + required property string ssid + required property bool secured + required property string password + required property bool passwordVisible + required property string passwordError + property bool open: false + + readonly property bool showingQr: qrSize > 0 && !loading && error === "" + + signal closeRequested() + signal passwordToggleRequested() + + visible: open + // The window is instantiated hidden, so the content's `focus: true` is + // evaluated before the surface is mapped and Escape would land nowhere. + // Re-acquire after mapping, as KeyboardPanel does. + onOpenChanged: { + if (open) Qt.callLater(function() { + if (root.open) keyCatcher.forceActiveFocus() + }) + } + screen: anchorItem.QsWindow.window ? anchorItem.QsWindow.window.screen : null + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "omarchy-network-qr" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.45) + + MouseArea { + anchors.fill: parent + onClicked: root.closeRequested() + } + } + + BorderSurface { + width: Style.space(320) + height: content.implicitHeight + Style.space(48) + anchors.centerIn: parent + radius: Style.cornerRadius + color: Color.menu.background + borderSpec: Border.surfaceSpec("menu", "border", Color.popups.border, Math.max(1, Style.space(2))) + + MouseArea { anchors.fill: parent; onClicked: {} } + + Item { + id: keyCatcher + anchors.fill: parent + anchors.margins: Style.space(24) + focus: true + + Keys.onEscapePressed: root.closeRequested() + + ColumnLayout { + id: content + anchors.fill: parent + spacing: Style.space(12) + + Text { + text: "Share " + (root.ssid || "Wi-Fi") + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.title + font.bold: true + elide: Text.ElideRight + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + + // Render every QR module as an integer-sized native rectangle. This + // stays crisp and avoids temporary images and file-cache races. + Rectangle { + id: qrCanvas + readonly property int moduleSize: root.qrSize > 0 + ? Math.max(4, Math.floor(Style.space(240) / root.qrSize)) + : 0 + + visible: root.showingQr + width: root.qrSize * moduleSize + height: width + color: "white" + Layout.alignment: Qt.AlignHCenter + + Grid { + anchors.fill: parent + columns: root.qrSize + + Repeater { + model: root.qrSize * root.qrSize + + Rectangle { + required property int index + readonly property int matrixRow: Math.floor(index / root.qrSize) + readonly property int matrixColumn: index % root.qrSize + + width: qrCanvas.moduleSize + height: qrCanvas.moduleSize + color: root.qrRows[matrixRow].charAt(matrixColumn) === "1" ? "#111111" : "white" + } + } + } + } + + Text { + visible: root.loading + text: "Generating QR code…" + color: root.bar.foreground + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + + Text { + visible: root.error !== "" + text: root.error + color: root.bar.urgent + wrapMode: Text.Wrap + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + + Text { + visible: root.showingQr + text: "Scan to join this network" + color: root.bar.foreground + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + + Text { + visible: root.showingQr && root.secured + text: root.passwordError !== "" ? root.passwordError + : root.passwordVisible ? root.password + : "Show password" + color: root.passwordError !== "" ? root.bar.urgent : root.bar.foreground + opacity: root.passwordVisible || root.passwordError !== "" ? 1 : 0.6 + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WrapAnywhere + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.passwordToggleRequested() + } + } + } + } + } +} diff --git a/shell/plugins/panels/network/manifest.json b/shell/plugins/panels/network/manifest.json index 576bdc9c..4ea64ef6 100644 --- a/shell/plugins/panels/network/manifest.json +++ b/shell/plugins/panels/network/manifest.json @@ -4,7 +4,7 @@ "name": "Network", "version": "1.0.0", "author": "Omarchy", - "description": "Wi-Fi list and connection state", + "description": "Wi-Fi list, connection state, and QR sharing", "kinds": [ "bar-widget" ], @@ -13,7 +13,7 @@ }, "barWidget": { "displayName": "Network", - "description": "Wi-Fi list and connection state", + "description": "Wi-Fi list, connection state, and QR sharing", "category": "Network", "allowMultiple": false } diff --git a/test/shell.d/network-password-test.sh b/test/shell.d/network-password-test.sh new file mode 100644 index 00000000..2dec5af1 --- /dev/null +++ b/test/shell.d/network-password-test.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" + +cat >"$tmp/bin/nmcli" <<'EOF' +#!/bin/bash +if [[ $* == *GENERAL.CON-UUID* ]]; then + echo test-uuid +else + printf '%s' "$PW_NMCLI_FIELDS" +fi +EOF +chmod +x "$tmp/bin/nmcli" + +run_failure_case() { + local description=$1 fields=$2 expected_error=$3 + local error + + export PW_NMCLI_FIELDS=$fields + if PATH="$tmp/bin:$PATH" "$ROOT/bin/omarchy-network-password" wlan0 >"$tmp/output" 2>"$tmp/error"; then + fail "$description" "helper unexpectedly succeeded" + fi + error=$(<"$tmp/error") + [[ $error == "$expected_error" ]] || fail "$description" "expected: $expected_error\nactual: $error" + pass "$description" +} + +# The password comes back raw -- no QR escaping -- because it is shown to a +# human, not embedded in a WIFI: payload. +export PW_NMCLI_FIELDS=$'wpa-psk\np,a:ss;word\\42\n' +output=$(PATH="$tmp/bin:$PATH" "$ROOT/bin/omarchy-network-password" wlan0) +[[ $output == 'p,a:ss;word\42' ]] || fail "network password helper prints the raw password" "expected: p,a:ss;word\\42\nactual: $output" +pass "network password helper prints the raw password" + +run_failure_case \ + "network password helper refuses open networks" \ + $'none\n\n' \ + "This network has no password" + +# WEP looks like an open network (key-mgmt "none") but carries a wep-key. +export PW_NMCLI_FIELDS=$'none\n\nwep-secret\n' +output=$(PATH="$tmp/bin:$PATH" "$ROOT/bin/omarchy-network-password" wlan0) +[[ $output == "wep-secret" ]] || fail "network password helper prints WEP keys" "expected: wep-secret\nactual: $output" +pass "network password helper prints WEP keys" + +run_failure_case \ + "network password helper refuses enterprise networks" \ + $'wpa-eap\nsecret\n' \ + "Enterprise Wi-Fi has no shareable password" diff --git a/test/shell.d/network-qr-test.sh b/test/shell.d/network-qr-test.sh new file mode 100644 index 00000000..a203ef27 --- /dev/null +++ b/test/shell.d/network-qr-test.sh @@ -0,0 +1,77 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" + +cat >"$tmp/bin/nmcli" <<'EOF' +#!/bin/bash +if [[ $* == *GENERAL.CON-UUID* ]]; then + echo test-uuid +else + printf '%s' "$QR_NMCLI_FIELDS" +fi +EOF + +cat >"$tmp/bin/qrencode" <<'EOF' +#!/bin/bash +for arg in "$@"; do + [[ $arg != WIFI:* ]] || exit 97 +done +payload=$("$QR_PAYLOAD_FILE" +printf '## \n ## \n ##\n' +EOF +chmod +x "$tmp/bin/nmcli" "$tmp/bin/qrencode" + +run_success_case() { + local description=$1 fields=$2 expected_payload=$3 + local expected output payload + + export QR_NMCLI_FIELDS=$fields + export QR_PAYLOAD_FILE="$tmp/payload" + output=$(PATH="$tmp/bin:$PATH" "$ROOT/bin/omarchy-network-qr" wlan0) + expected=$'100\n010\n001' + [[ $output == "$expected" ]] || fail "$description emits a compact module matrix" "expected: $expected\nactual: $output" + + payload=$(<"$QR_PAYLOAD_FILE") + [[ $payload == "$expected_payload" ]] || fail "$description generates the Wi-Fi payload" "expected: $expected_payload\nactual: $payload" + pass "$description" +} + +run_success_case \ + "network QR helper escapes WPA credentials through stdin" \ + $'Cafe;Guest\\5G\nwpa-psk\np,a:ss;word\\42\nno\n' \ + 'WIFI:T:WPA;S:Cafe\;Guest\\5G;P:p\,a\:ss\;word\\42;;' + +run_success_case \ + "network QR helper supports open networks" \ + $'Cafe Open\nnone\n\nno\n' \ + 'WIFI:T:nopass;S:Cafe Open;P:;;' + +run_success_case \ + "network QR helper marks hidden networks" \ + $'Hidden Network\nwpa-psk\nsecret\nyes\n' \ + 'WIFI:T:WPA;S:Hidden Network;P:secret;H:true;;' + +# NetworkManager models WEP as key-mgmt "none" plus a wep-key, which must not +# be mistaken for an open network. +run_success_case \ + "network QR helper encodes WEP networks" \ + $'Old Router\nnone\n\nno\nwep-secret\n' \ + 'WIFI:T:WEP;S:Old Router;P:wep-secret;;' + +export QR_NMCLI_FIELDS=$'Enterprise\nwpa-eap\nsecret\nno\n' +export QR_PAYLOAD_FILE="$tmp/enterprise-payload" +if PATH="$tmp/bin:$PATH" "$ROOT/bin/omarchy-network-qr" wlan0 >"$tmp/enterprise-output" 2>"$tmp/enterprise-error"; then + fail "network QR helper rejects enterprise networks" "helper unexpectedly succeeded" +fi +enterprise_error=$(<"$tmp/enterprise-error") +expected_error="Enterprise Wi-Fi cannot be shared with a password QR code" +[[ $enterprise_error == "$expected_error" ]] || fail "network QR helper rejects enterprise networks" "expected: $expected_error\nactual: $enterprise_error" +[[ ! -e $QR_PAYLOAD_FILE ]] || fail "network QR helper rejects enterprise networks" "qrencode unexpectedly ran" +pass "network QR helper rejects enterprise networks" diff --git a/test/shell.d/network-test.sh b/test/shell.d/network-test.sh index a6838097..2c8d9067 100644 --- a/test/shell.d/network-test.sh +++ b/test/shell.d/network-test.sh @@ -90,6 +90,15 @@ assertDeepEqual(rows.map(row => row.ssid), ['Connected', 'Known', 'Open'], 'netw assertEqual(network.wifiSectionTitle(rows, 0), 'KNOWN NETWORKS', 'network labels known wifi section') assertEqual(network.wifiSectionTitle(rows, 2), 'OTHER NETWORKS', 'network labels other wifi section') +assertDeepEqual( + network.parseQrMatrix('010\n111\n010\n'), + { rows: ['010', '111', '010'], size: 3 }, + 'network parses a square QR matrix' +) +assertDeepEqual(network.parseQrMatrix('01\n111\n'), { rows: [], size: 0 }, 'network rejects ragged QR rows') +assertDeepEqual(network.parseQrMatrix('010\n101\n'), { rows: [], size: 0 }, 'network rejects a non-square QR matrix') +assertDeepEqual(network.parseQrMatrix('010\n1x1\n010\n'), { rows: [], size: 0 }, 'network rejects invalid QR modules') + const reasons = { NoSecrets: 1, WifiAuthTimeout: 2, WifiNetworkLost: 3, WifiClientDisconnected: 4, WifiClientFailed: 5 } assertEqual(network.networkFailureReason(1, reasons), 'Passphrase required', 'network maps missing passphrase failures') assertEqual(network.networkFailureReason(2, reasons), 'Wrong password', 'network maps auth timeout failures') @@ -118,7 +127,6 @@ assertDeepEqual( -assertEqual(network.headerDetail({ type: 'wifi', freq: '5745' }), '5ghz', 'network header shows the wifi band when the toggle is hidden') -assertEqual(network.headerDetail({ type: 'wifi', freq: '5745' }, true), '', 'network header drops the wifi band when the toggle shows it') -assertEqual(network.headerDetail({ type: 'ethernet', speed: '100' }, true), '100mbit', 'network header keeps ethernet speed regardless of the band toggle') +assertEqual(network.headerDetail({ type: 'wifi', freq: '5745' }), '', 'network keeps wifi band state out of the hero') +assertEqual(network.headerDetail({ type: 'ethernet', speed: '100' }), '100mbit', 'network keeps ethernet speed in the hero') JS