diff --git a/bin/omarchy-network-qr b/bin/omarchy-network-qr index 0ecac863..9502c25c 100755 --- a/bin/omarchy-network-qr +++ b/bin/omarchy-network-qr @@ -2,11 +2,21 @@ # omarchy:summary=Generate a Wi-Fi QR matrix for the shell # omarchy:group=network -# omarchy:args=[interface] +# omarchy:args=[--meta] [interface] set -euo pipefail -interface=${1:-} +# --meta is opt-in so pre-existing consumers of the bare matrix (cloned +# network widgets from before the share card became its own plugin) keep +# parsing this output. +interface="" +emit_meta=false +for arg in "$@"; do + case "$arg" in + --meta) emit_meta=true ;; + *) interface=$arg ;; + esac +done if [[ -z $interface ]]; then # Prefer the default-route device: it is the connection the panel and the # menu's visibility gate describe. Fall back to the first connected Wi-Fi @@ -65,6 +75,12 @@ payload="WIFI:T:$security;S:$(escape_wifi_qr "$ssid");P:$(escape_wifi_qr "$passw [[ $hidden == "yes" ]] && payload+="H:true;" payload+=";" +# Metadata header ahead of the matrix: the interface that was shared, the +# security type, and the SSID last so it may contain tabs. The share card +# renders its title and password row from this line, and a self-detected +# summon learns which interface to fetch the password for. +[[ $emit_meta == "true" ]] && printf 'meta\t%s\t%s\t%s\n' "$interface" "$security" "$ssid" + # 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 diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index af79fb48..f0ab83b6 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -117,7 +117,7 @@ "setup.network.dns.cloudflare": {"icon":"󰅟","label":"Cloudflare","checked":"[[ \"$(omarchy-dns)\" == \"Cloudflare\" ]]","action":"omarchy-dns Cloudflare"}, "setup.network.dns.google": {"icon":"󰊭","label":"Google","checked":"[[ \"$(omarchy-dns)\" == \"Google\" ]]","action":"omarchy-dns Google"}, "setup.network.dns.custom": {"icon":"","label":"Custom","checked":"[[ \"$(omarchy-dns)\" == \"Custom\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-dns Custom'"}, - "setup.network.qr": {"icon":"󰐲","label":"QR Code","aliases":["wifi-qr"],"when":"[[ $(omarchy-network-status) == wifi* ]]","action":"omarchy-shell omarchy.network showQr"}, + "setup.network.qr": {"icon":"󰐲","label":"QR Code","aliases":["wifi-qr"],"when":"[[ $(omarchy-network-status) == wifi* ]]","action":"omarchy-shell shell summon omarchy.wifiqr"}, "setup.network.speedtest": {"icon":"󰓅","label":"Speed Test","aliases":["speedtest","speed-test"],"action":"omarchy-shell shell summon omarchy.speedtest"}, "setup.default": {"icon":"","label":"Defaults","aliases":["default","defaults"]}, "setup.default.agent": {"icon":"󰚩","label":"Agent"}, diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index c083aeaf..e9b0594a 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -299,20 +299,6 @@ 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 @@ -361,7 +347,6 @@ 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 4058eddb..af9f144a 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -17,26 +17,9 @@ Panel { manageIpc: false // Centralized close so callers can't forget to drop the passphrase prompt. - readonly property bool overlayVisible: qrVisible - - // Shadows the base open(): a summon or toggle while a centered card is up - // dismisses the card instead of opening the compact panel behind an - // exclusive overlay. The base toggle() dispatches here, so the keybind, - // the bar icon, and every IPC route all get this behavior. - function open() { - if (overlayVisible) { - hideWifiQr() - return - } - root.controller.show() - } - function close() { root.controller.hide() cancelPasswordPrompt() - // The centered card outlives the compact panel, but the widget's - // canonical close must not leave an overlay behind. - hideWifiQr() } function cancelPasswordPrompt() { @@ -115,18 +98,6 @@ 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 bool pendingQrShow: false - property bool pendingQrDetect: 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. @@ -235,16 +206,14 @@ Panel { function hide() { root.close() } function toggle() { root.toggle() } function toggleNetwork() { root.toggleNetwork() } - // Menu routes: summon the centered cards directly, panel open or not. - function showQr() { - root.refresh() - root.showWifiQr(true) - } + // Compat routes for configs that summon the centered cards through the + // network target; both cards are their own plugins now. + function showQr() { root.summonWifiQr(true) } function speedTest() { root.summonSpeedTest() } } function activateHeader() { - if (headerIndex === qrHeaderIndex) showWifiQr() + if (headerIndex === qrHeaderIndex) summonWifiQr() else if (headerIndex === speedHeaderIndex) summonSpeedTest() else if (headerIndex === toggleHeaderIndex) toggleNetwork() } @@ -443,63 +412,20 @@ Panel { readonly property string icon: Model.connectionIcon(kind, signalStrength) - function showWifiQr(forceDetect) { - if (qrProc.running) { - // A dismissal's SIGTERM is still in flight; Process.running stays true - // until the child exits, so queue the reopen for onExited. - if (qrExpectedStop) { - pendingQrShow = true - pendingQrDetect = !!forceDetect - } - return - } - qrSize = 0 - qrRows = [] - qrError = "" - qrLoading = true - qrExpectedStop = false - // The panel's own button shares the interface it is showing. The IPC - // route forces self-detection instead: details polling stops while the - // panel is closed, so its cached interface can be stale. - qrProc.command = !forceDetect && info.type === "wifi" && info.iface - ? ["omarchy-network-qr", info.iface] - : ["omarchy-network-qr"] - qrProc.running = true - - // Leave the compact network panel behind while the centered share card is open. + // The share card is its own panel plugin (omarchy.wifiqr) so a replacement + // design can take it over; summon() routes to whichever implementation is + // enabled. The panel's own button pins the interface it is showing. The + // IPC route forces self-detection instead: details polling stops while the + // panel is closed, so its cached interface can be stale. + function summonWifiQr(forceDetect) { controller.hide() cancelPasswordPrompt() - } - - function hideWifiQr() { - pendingQrShow = false - if (qrProc.running) { - qrExpectedStop = true - qrProc.running = false + var payload = {} + if (!forceDetect && info.type === "wifi" && info.iface) { + payload.iface = info.iface + if (info.ssid) payload.ssid = info.ssid } - 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 + bar.shell.summon("omarchy.wifiqr", JSON.stringify(payload)) } function refresh(scanWifi) { @@ -858,55 +784,6 @@ 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.pendingQrShow) { - root.pendingQrShow = false - root.qrExpectedStop = false - Qt.callLater(function() { root.showWifiQr(root.pendingQrDetect) }) - return - } - 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 { @@ -1184,7 +1061,7 @@ Panel { hasCursor: root.qrHeaderHasCursor Layout.alignment: Qt.AlignVCenter onHovered: function(on) { if (on) root.setHeaderCursor(root.qrHeaderIndex) } - onClicked: root.showWifiQr() + onClicked: root.summonWifiQr() } Button { @@ -1587,23 +1464,6 @@ 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 diff --git a/shell/plugins/panels/network/WifiQrPanel.qml b/shell/plugins/panels/network/WifiQrPanel.qml deleted file mode 100644 index 6493da8a..00000000 --- a/shell/plugins/panels/network/WifiQrPanel.qml +++ /dev/null @@ -1,197 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import Quickshell -import Quickshell.Wayland -import qs.Commons -import qs.Ui - -// Centered Wi-Fi share overlay, presented like the speed test: no card, -// just the QR code floating on a heavy scrim. Esc or the scrim dismiss it. -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 === "" - - // The scrim below is a fixed near-black regardless of theme, so text on - // it needs a fixed light palette, not the themed bar.foreground. - readonly property color onScrim: "white" - readonly property color onScrimDim: Qt.rgba(1, 1, 1, 0.55) - readonly property color onScrimUrgent: "#ff6b6b" - - 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 - - // Deep scrim: the floating code needs the backdrop to carry the contrast - // on any wallpaper. - Rectangle { - anchors.fill: parent - color: Qt.rgba(0, 0, 0, 0.78) - - MouseArea { - anchors.fill: parent - onClicked: root.closeRequested() - } - } - - Item { - id: keyCatcher - anchors.fill: parent - focus: true - - Keys.onEscapePressed: root.closeRequested() - - Item { - anchors.centerIn: parent - width: content.implicitWidth - height: content.implicitHeight - // Narrow or heavily scaled outputs: shrink the whole card rather than - // clipping it at the screen edge. - scale: Math.min(1, - (keyCatcher.width - Style.space(32)) / Math.max(1, width), - (keyCatcher.height - Style.space(32)) / Math.max(1, height)) - - // Swallow clicks so only the scrim outside the content dismisses. - MouseArea { anchors.fill: parent; onClicked: {} } - - ColumnLayout { - id: content - anchors.fill: parent - spacing: Style.space(16) - - Text { - text: (root.ssid || "Wi-Fi").toUpperCase() - color: root.onScrimDim - font.family: root.bar.fontFamily - font.pixelSize: Style.font.caption - font.bold: true - font.letterSpacing: 2 - elide: Text.ElideRight - Layout.maximumWidth: Style.space(320) - Layout.alignment: Qt.AlignHCenter - horizontalAlignment: Text.AlignHCenter - } - - // Render every QR module as an integer-sized native rectangle. This - // stays crisp and avoids temporary images and file-cache races. Only - // the dark modules paint, so the white canvas can keep its rounded - // corners; the spec quiet zone baked into the matrix keeps the code - // itself clear of them. - 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" - radius: Style.cornerRadius - 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" : "transparent" - } - } - } - } - - Text { - visible: root.loading - text: "Generating QR code…" - color: root.onScrimDim - font.family: root.bar.fontFamily - font.pixelSize: Style.font.bodySmall - Layout.fillWidth: true - horizontalAlignment: Text.AlignHCenter - } - - Text { - visible: root.error !== "" - text: root.error - color: root.onScrimUrgent - font.family: root.bar.fontFamily - font.pixelSize: Style.font.bodySmall - wrapMode: Text.Wrap - Layout.fillWidth: true - Layout.maximumWidth: Style.space(320) - horizontalAlignment: Text.AlignHCenter - } - - Text { - visible: root.showingQr - text: "Scan to join this network" - color: root.onScrimDim - font.family: root.bar.fontFamily - font.pixelSize: Style.font.bodySmall - 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.onScrimUrgent : root.onScrim - opacity: root.passwordVisible || root.passwordError !== "" ? 1 : 0.6 - font.family: root.bar.fontFamily - font.pixelSize: Style.font.bodySmall - wrapMode: Text.WrapAnywhere - Layout.fillWidth: true - Layout.maximumWidth: Style.space(320) - 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 4ea64ef6..576bdc9c 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, connection state, and QR sharing", + "description": "Wi-Fi list and connection state", "kinds": [ "bar-widget" ], @@ -13,7 +13,7 @@ }, "barWidget": { "displayName": "Network", - "description": "Wi-Fi list, connection state, and QR sharing", + "description": "Wi-Fi list and connection state", "category": "Network", "allowMultiple": false } diff --git a/shell/plugins/panels/wifiqr/Model.js b/shell/plugins/panels/wifiqr/Model.js new file mode 100644 index 00000000..b76e4fa2 --- /dev/null +++ b/shell/plugins/panels/wifiqr/Model.js @@ -0,0 +1,37 @@ +// Parses omarchy-network-qr output: a "meta\t\t\t" +// header, then a square 0/1 module matrix. The SSID sits last so it may +// contain tabs. A malformed matrix returns empty rather than rendering a +// code that cannot scan. +function parseQrOutput(raw) { + var lines = String(raw || "").trim().split(/\r?\n/).filter(function(line) { return line !== "" }) + var meta = { iface: "", security: "", ssid: "" } + + if (lines.length > 0 && lines[0].indexOf("meta\t") === 0) { + var fields = lines.shift().split("\t") + meta.iface = fields[1] || "" + meta.security = fields[2] || "" + meta.ssid = fields.slice(3).join("\t") + } + + return { meta: meta, matrix: parseQrMatrix(lines) } +} + +function parseQrMatrix(lines) { + 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 } +} + +if (typeof module !== "undefined") { + module.exports = { + parseQrOutput: parseQrOutput, + parseQrMatrix: parseQrMatrix + } +} diff --git a/shell/plugins/panels/wifiqr/Panel.qml b/shell/plugins/panels/wifiqr/Panel.qml new file mode 100644 index 00000000..276434a8 --- /dev/null +++ b/shell/plugins/panels/wifiqr/Panel.qml @@ -0,0 +1,366 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import qs.Commons +import qs.Ui +import "Model.js" as Model + +// Centered Wi-Fi share overlay: no card, just the QR code floating on a +// heavy scrim. Esc or the scrim dismiss it. +// +// Standalone panel plugin: each summon regenerates the code via +// omarchy-network-qr, which emits the interface, security, and SSID it +// shared ahead of the module matrix — so a bare summon self-detects the +// connection. The payload may pin the interface and pre-title the card: +// {"iface": "wlan0", "ssid": "MyWifi"}. +Item { + id: root + + property string omarchyPath: Quickshell.env("OMARCHY_PATH") + property var shell: null + property var manifest: null + + property bool opened: false + property string iface: "" + property string ssid: "" + property bool secured: false + + property var qrRows: [] + property int qrSize: 0 + property string error: "" + property bool loading: false + property bool expectedStop: false + property bool pendingShow: false + property string pendingIface: "" + property string password: "" + property bool passwordVisible: false + property string passwordError: "" + property bool pwExpectedStop: false + + readonly property bool showingQr: qrSize > 0 && !loading && error === "" + + // The scrim below is a fixed near-black regardless of theme, so text on + // it needs a fixed light palette, not the themed foreground. + readonly property color onScrim: "white" + readonly property color onScrimDim: Qt.rgba(1, 1, 1, 0.55) + readonly property color onScrimUrgent: "#ff6b6b" + readonly property string fontFamily: Style.font.family + + function open(payloadJson) { + var payload = {} + try { payload = JSON.parse(payloadJson || "{}") || {} } catch (e) {} + // The payload SSID titles the card during generation; the meta line the + // generator emits is authoritative and overwrites it. A payload without + // one clears the title: a re-summon may be sharing a different + // connection, so the previous card's name must not label this one. + root.ssid = payload.ssid !== undefined ? String(payload.ssid) : "" + generate(String(payload.iface || "")) + root.opened = true + // 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. + Qt.callLater(function() { + if (root.opened) keyCatcher.forceActiveFocus() + }) + } + + function close() { + root.opened = false + root.pendingShow = false + if (qrProc.running) { + root.expectedStop = true + qrProc.running = false + } + if (pwProc.running) pwProc.running = false + root.qrSize = 0 + root.qrRows = [] + root.error = "" + root.loading = false + root.iface = "" + root.ssid = "" + root.secured = false + // The Wi-Fi password only enters shell memory while the card is up. + root.password = "" + root.passwordVisible = false + root.passwordError = "" + } + + function dismiss() { + if (root.shell && typeof root.shell.hide === "function") + root.shell.hide((root.manifest && root.manifest.id) || "omarchy.wifiqr") + else close() + } + + function generate(requestedIface) { + if (qrProc.running) { + // Whether the run in flight is a dismissal's SIGTERM still landing or + // a live generation for an earlier summon, the latest request wins: + // queue it for onExited and stop the old process. + pendingShow = true + pendingIface = requestedIface + if (!expectedStop) { + expectedStop = true + qrProc.running = false + } + return + } + qrSize = 0 + qrRows = [] + error = "" + loading = true + expectedStop = false + // A re-summon while the card is still loaded reaches here without a + // close() in between, and may be sharing a different connection now: + // neither the previous reveal's password nor a reveal still in flight + // may survive onto the new card. + iface = "" + secured = false + password = "" + passwordVisible = false + passwordError = "" + if (pwProc.running) { + pwExpectedStop = true + pwProc.running = false + } + qrProc.command = requestedIface + ? ["omarchy-network-qr", "--meta", requestedIface] + : ["omarchy-network-qr", "--meta"] + qrProc.running = true + } + + function updateQr(raw) { + var parsed = Model.parseQrOutput(raw) + qrRows = parsed.matrix.rows + qrSize = parsed.matrix.size + if (parsed.meta.ssid !== "") ssid = parsed.meta.ssid + if (parsed.meta.iface !== "") iface = parsed.meta.iface + secured = parsed.meta.security !== "" && parsed.meta.security !== "nopass" + // Good output settles the run: a canceled predecessor's stderr may have + // landed after this generation started, and must not shadow its result. + if (qrSize > 0) error = "" + } + + function togglePassword() { + if (passwordVisible) { passwordVisible = false; return } + if (password !== "") { passwordVisible = true; return } + if (pwProc.running || !iface) return + passwordError = "" + // Only a deliberate new lookup lowers the canceled-fetch guard, right as + // it launches -- see the pwProc comment. + pwExpectedStop = false + pwProc.command = ["omarchy-network-password", iface] + pwProc.running = true + } + + Process { + id: qrProc + // Both collectors check expectedStop: 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 (generate resets it) because the exit and + // stream-finished signals have no guaranteed order. + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: if (!root.expectedStop) root.updateQr(text) + } + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: if (!root.expectedStop) root.error = String(text || "").trim() + } + onExited: function(exitCode) { + root.loading = false + if (root.pendingShow) { + root.pendingShow = false + // expectedStop stays set until generate() launches the replacement: + // the canceled run's collectors may fire between here and then, and + // must keep being dropped. + Qt.callLater(function() { root.generate(root.pendingIface) }) + return + } + if (root.expectedStop) return + if (exitCode !== 0 || root.qrSize === 0) { + root.qrSize = 0 + root.qrRows = [] + if (root.error === "") root.error = "Could not generate the Wi-Fi QR code" + } + } + } + + // The Wi-Fi password only enters shell memory when the user clicks to + // reveal it, and close() drops it again. 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, and check pwExpectedStop so a fetch + // that a regeneration killed can't reveal the previous network's password + // under the new card. The exit and stream-finished signals have no + // guaranteed order, so the flag survives onExited; only togglePassword + // lowers it, as it launches the next deliberate lookup. + Process { + id: pwProc + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: if (root.opened && !root.pwExpectedStop) root.password = String(text || "").trim() + } + onExited: function(exitCode) { + if (root.pwExpectedStop) return + if (!root.opened) return + if (exitCode === 0 && root.password !== "") root.passwordVisible = true + else root.passwordError = "Could not read the Wi-Fi password" + } + } + + PanelWindow { + visible: root.opened + 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 + + // Deep scrim: the floating code needs the backdrop to carry the contrast + // on any wallpaper. + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.78) + + MouseArea { + anchors.fill: parent + onClicked: root.dismiss() + } + } + + Item { + id: keyCatcher + anchors.fill: parent + focus: true + + Keys.onEscapePressed: root.dismiss() + + Item { + anchors.centerIn: parent + width: content.implicitWidth + height: content.implicitHeight + // Narrow or heavily scaled outputs: shrink the whole card rather than + // clipping it at the screen edge. + scale: Math.min(1, + (keyCatcher.width - Style.space(32)) / Math.max(1, width), + (keyCatcher.height - Style.space(32)) / Math.max(1, height)) + + // Swallow clicks so only the scrim outside the content dismisses. + MouseArea { anchors.fill: parent; onClicked: {} } + + ColumnLayout { + id: content + anchors.fill: parent + spacing: Style.space(16) + + Text { + text: (root.ssid || "Wi-Fi").toUpperCase() + color: root.onScrimDim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + font.letterSpacing: 2 + elide: Text.ElideRight + Layout.maximumWidth: Style.space(320) + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + } + + // Render every QR module as an integer-sized native rectangle. This + // stays crisp and avoids temporary images and file-cache races. Only + // the dark modules paint, so the white canvas can keep its rounded + // corners; the spec quiet zone baked into the matrix keeps the code + // itself clear of them. + 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" + radius: Style.cornerRadius + 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" : "transparent" + } + } + } + } + + Text { + visible: root.loading + text: "Generating QR code…" + color: root.onScrimDim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + + Text { + visible: root.error !== "" + text: root.error + color: root.onScrimUrgent + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.Wrap + Layout.fillWidth: true + Layout.maximumWidth: Style.space(320) + horizontalAlignment: Text.AlignHCenter + } + + Text { + visible: root.showingQr + text: "Scan to join this network" + color: root.onScrimDim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + 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.onScrimUrgent : root.onScrim + opacity: root.passwordVisible || root.passwordError !== "" ? 1 : 0.6 + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WrapAnywhere + Layout.fillWidth: true + Layout.maximumWidth: Style.space(320) + horizontalAlignment: Text.AlignHCenter + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.togglePassword() + } + } + } + } + } + } +} diff --git a/shell/plugins/panels/wifiqr/manifest.json b/shell/plugins/panels/wifiqr/manifest.json new file mode 100644 index 00000000..10ab984b --- /dev/null +++ b/shell/plugins/panels/wifiqr/manifest.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "omarchy.wifiqr", + "name": "Wi-Fi QR", + "version": "1.0.0", + "author": "Omarchy", + "description": "Share the connected Wi-Fi network as a scannable QR code", + "kinds": [ + "panel" + ], + "entryPoints": { + "panel": "Panel.qml" + } +} diff --git a/test/shell.d/network-qr-test.sh b/test/shell.d/network-qr-test.sh index 65e49a92..7c64ef28 100644 --- a/test/shell.d/network-qr-test.sh +++ b/test/shell.d/network-qr-test.sh @@ -33,13 +33,34 @@ chmod +x "$tmp/bin/nmcli" "$tmp/bin/qrencode" run_success_case() { local description=$1 fields=$2 expected_payload=$3 shift 3 - local expected output payload + local output meta matrix payload arg with_meta=false + local expected_matrix expected_security expected_ssid expected_iface="*" + + for arg in "$@"; do + [[ $arg == "--meta" ]] && with_meta=true || expected_iface=$arg + done export QR_NMCLI_FIELDS=$fields export QR_PAYLOAD_FILE="$tmp/payload" output=$(PATH="$tmp/bin:$PATH" "$ROOT/bin/omarchy-network-qr" "$@") - expected=$'100\n010\n001' - [[ $output == "$expected" ]] || fail "$description emits a compact module matrix" "expected: $expected\nactual: $output" + + expected_matrix=$'100\n010\n001' + if [[ $with_meta == "true" ]]; then + meta=$(head -n1 <<<"$output") + matrix=$(tail -n +2 <<<"$output") + + # The meta line leads with the shared interface, security, and SSID. With + # no interface argument the helper detects one from the live host, so that + # field is only pinned when the case pinned it. + expected_security=${expected_payload#WIFI:T:} + expected_security=${expected_security%%;*} + expected_ssid=$(head -n1 <<<"$fields") + [[ $meta == meta$'\t'$expected_iface$'\t'"$expected_security"$'\t'"$expected_ssid" ]] \ + || fail "$description leads with the interface, security, and SSID" "actual: $meta" + else + matrix=$output + fi + [[ $matrix == "$expected_matrix" ]] || fail "$description emits a compact module matrix" "expected: $expected_matrix\nactual: $matrix" payload=$(<"$QR_PAYLOAD_FILE") [[ $payload == "$expected_payload" ]] || fail "$description generates the Wi-Fi payload" "expected: $expected_payload\nactual: $payload" @@ -50,25 +71,34 @@ 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;;' \ + --meta wlan0 + +# Without --meta the output stays a bare matrix, which pre-plugin clones of +# the network widget still parse. +run_success_case \ + "network QR helper keeps the bare matrix without --meta" \ + $'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;;' \ wlan0 # With no interface argument the helper finds the connected Wi-Fi device. run_success_case \ "network QR helper detects the Wi-Fi interface" \ $'Cafe Detected\nwpa-psk\nsecret\nno\n' \ - 'WIFI:T:WPA;S:Cafe Detected;P:secret;;' + 'WIFI:T:WPA;S:Cafe Detected;P:secret;;' \ + --meta run_success_case \ "network QR helper supports open networks" \ $'Cafe Open\nnone\n\nno\n' \ 'WIFI:T:nopass;S:Cafe Open;P:;;' \ - wlan0 + --meta wlan0 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;;' \ - wlan0 + --meta wlan0 # NetworkManager models WEP as key-mgmt "none" plus a wep-key, which must not # be mistaken for an open network. @@ -76,7 +106,7 @@ 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;;' \ - wlan0 + --meta wlan0 export QR_NMCLI_FIELDS=$'Enterprise\nwpa-eap\nsecret\nno\n' export QR_PAYLOAD_FILE="$tmp/enterprise-payload" diff --git a/test/shell.d/network-test.sh b/test/shell.d/network-test.sh index a24e4e5e..2d17d256 100644 --- a/test/shell.d/network-test.sh +++ b/test/shell.d/network-test.sh @@ -105,15 +105,6 @@ 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') diff --git a/test/shell.d/wifiqr-test.sh b/test/shell.d/wifiqr-test.sh new file mode 100644 index 00000000..539cd8ce --- /dev/null +++ b/test/shell.d/wifiqr-test.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const wifiqr = requireFromRoot('shell/plugins/panels/wifiqr/Model.js') + +assertDeepEqual( + wifiqr.parseQrOutput('meta\twlan0\tWPA\tCafe WiFi\n010\n111\n010\n'), + { meta: { iface: 'wlan0', security: 'WPA', ssid: 'Cafe WiFi' }, matrix: { rows: ['010', '111', '010'], size: 3 } }, + 'wifiqr parses the meta header and a square matrix' +) +assertDeepEqual( + wifiqr.parseQrOutput('meta\twlan0\tnopass\tTab\tName\n010\n111\n010\n').meta.ssid, + 'Tab\tName', + 'wifiqr keeps tabs inside the SSID field' +) +assertDeepEqual( + wifiqr.parseQrOutput('010\n111\n010\n'), + { meta: { iface: '', security: '', ssid: '' }, matrix: { rows: ['010', '111', '010'], size: 3 } }, + 'wifiqr tolerates output without a meta header' +) +assertDeepEqual(wifiqr.parseQrOutput('meta\twlan0\tWPA\tCafe\n01\n111\n').matrix, { rows: [], size: 0 }, 'wifiqr rejects ragged QR rows') +assertDeepEqual(wifiqr.parseQrOutput('010\n101\n').matrix, { rows: [], size: 0 }, 'wifiqr rejects a non-square QR matrix') +assertDeepEqual(wifiqr.parseQrOutput('010\n1x1\n010\n').matrix, { rows: [], size: 0 }, 'wifiqr rejects invalid QR modules') +JS