Extract the Wi-Fi QR share card into its own omarchy.wifiqr panel plugin (#6598)

* Extract the Wi-Fi QR share card into its own omarchy.wifiqr panel plugin

omarchy-network-qr now leads with an iface/security/ssid meta line, so a
bare summon self-detects the connection and the plugin owns the whole
share flow. The network panel loses its overlay lifecycle: with no
centered card left inside it, the shadowed open/close collapses back to
the stock panel behavior, and the QR button just summons the plugin --
which a clone or third-party plugin can replace, like the speed test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep canceled QR and password runs from leaking into their replacements

Copilot review: the cancellation guards dropped in onExited while the
canceled run's collectors were still allowed to fire, so a stale stderr
could shadow a successful regeneration and a stale password could be
revealed under a new network's card. The guards now stay up until the
next run launches, good output settles any earlier error, and a bare
re-summon no longer inherits the previous card's SSID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-08-07 12:50:04 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 474d9dd4eb
commit 018f881840
12 changed files with 519 additions and 389 deletions
+18 -2
View File
@@ -2,11 +2,21 @@
# omarchy:summary=Generate a Wi-Fi QR matrix for the shell # omarchy:summary=Generate a Wi-Fi QR matrix for the shell
# omarchy:group=network # omarchy:group=network
# omarchy:args=[interface] # omarchy:args=[--meta] [interface]
set -euo pipefail 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 if [[ -z $interface ]]; then
# Prefer the default-route device: it is the connection the panel and the # 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 # 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;" [[ $hidden == "yes" ]] && payload+="H:true;"
payload+=";" 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 # 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. # 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 # Margin 4 is the spec quiet zone; the card surround is dark, so this white
+1 -1
View File
@@ -117,7 +117,7 @@
"setup.network.dns.cloudflare": {"icon":"󰅟","label":"Cloudflare","checked":"[[ \"$(omarchy-dns)\" == \"Cloudflare\" ]]","action":"omarchy-dns Cloudflare"}, "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.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.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.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": {"icon":"","label":"Defaults","aliases":["default","defaults"]},
"setup.default.agent": {"icon":"󰚩","label":"Agent"}, "setup.default.agent": {"icon":"󰚩","label":"Agent"},
-15
View File
@@ -299,20 +299,6 @@ function isProtected(security, openSecurity) {
return 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 // The password arrives on stdin and reaches nmcli through the scriptable
// `connection edit` editor -- argv is world-readable in /proc, so the secret // `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 // must never be an argument (printf is a bash builtin, so no process spawns
@@ -361,7 +347,6 @@ if (typeof module !== "undefined") {
sortWifiRows: sortWifiRows, sortWifiRows: sortWifiRows,
wifiSectionTitle: wifiSectionTitle, wifiSectionTitle: wifiSectionTitle,
isProtected: isProtected, isProtected: isProtected,
parseQrMatrix: parseQrMatrix,
enterpriseConnectScript: enterpriseConnectScript, enterpriseConnectScript: enterpriseConnectScript,
networkFailureReason: networkFailureReason networkFailureReason: networkFailureReason
} }
+15 -155
View File
@@ -17,26 +17,9 @@ Panel {
manageIpc: false manageIpc: false
// Centralized close so callers can't forget to drop the passphrase prompt. // 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() { function close() {
root.controller.hide() root.controller.hide()
cancelPasswordPrompt() cancelPasswordPrompt()
// The centered card outlives the compact panel, but the widget's
// canonical close must not leave an overlay behind.
hideWifiQr()
} }
function cancelPasswordPrompt() { function cancelPasswordPrompt() {
@@ -115,18 +98,6 @@ Panel {
property string passwordText: "" property string passwordText: ""
property string identityText: "" 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 // True while any wifi action is mid-flight. Rows
// disable themselves on this so clicks on the other rows don't silently // disable themselves on this so clicks on the other rows don't silently
// no-op against runNetworkAction's serialized guard. // no-op against runNetworkAction's serialized guard.
@@ -235,16 +206,14 @@ Panel {
function hide() { root.close() } function hide() { root.close() }
function toggle() { root.toggle() } function toggle() { root.toggle() }
function toggleNetwork() { root.toggleNetwork() } function toggleNetwork() { root.toggleNetwork() }
// Menu routes: summon the centered cards directly, panel open or not. // Compat routes for configs that summon the centered cards through the
function showQr() { // network target; both cards are their own plugins now.
root.refresh() function showQr() { root.summonWifiQr(true) }
root.showWifiQr(true)
}
function speedTest() { root.summonSpeedTest() } function speedTest() { root.summonSpeedTest() }
} }
function activateHeader() { function activateHeader() {
if (headerIndex === qrHeaderIndex) showWifiQr() if (headerIndex === qrHeaderIndex) summonWifiQr()
else if (headerIndex === speedHeaderIndex) summonSpeedTest() else if (headerIndex === speedHeaderIndex) summonSpeedTest()
else if (headerIndex === toggleHeaderIndex) toggleNetwork() else if (headerIndex === toggleHeaderIndex) toggleNetwork()
} }
@@ -443,63 +412,20 @@ Panel {
readonly property string icon: Model.connectionIcon(kind, signalStrength) readonly property string icon: Model.connectionIcon(kind, signalStrength)
function showWifiQr(forceDetect) { // The share card is its own panel plugin (omarchy.wifiqr) so a replacement
if (qrProc.running) { // design can take it over; summon() routes to whichever implementation is
// A dismissal's SIGTERM is still in flight; Process.running stays true // enabled. The panel's own button pins the interface it is showing. The
// until the child exits, so queue the reopen for onExited. // IPC route forces self-detection instead: details polling stops while the
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. // panel is closed, so its cached interface can be stale.
qrProc.command = !forceDetect && info.type === "wifi" && info.iface function summonWifiQr(forceDetect) {
? ["omarchy-network-qr", info.iface]
: ["omarchy-network-qr"]
qrProc.running = true
// Leave the compact network panel behind while the centered share card is open.
controller.hide() controller.hide()
cancelPasswordPrompt() cancelPasswordPrompt()
var payload = {}
if (!forceDetect && info.type === "wifi" && info.iface) {
payload.iface = info.iface
if (info.ssid) payload.ssid = info.ssid
} }
bar.shell.summon("omarchy.wifiqr", JSON.stringify(payload))
function hideWifiQr() {
pendingQrShow = false
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) { function refresh(scanWifi) {
@@ -858,55 +784,6 @@ Panel {
onTriggered: root.syncWifiNetworks() 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 { Process {
id: dnsProc id: dnsProc
stdout: StdioCollector { stdout: StdioCollector {
@@ -1184,7 +1061,7 @@ Panel {
hasCursor: root.qrHeaderHasCursor hasCursor: root.qrHeaderHasCursor
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
onHovered: function(on) { if (on) root.setHeaderCursor(root.qrHeaderIndex) } onHovered: function(on) { if (on) root.setHeaderCursor(root.qrHeaderIndex) }
onClicked: root.showWifiQr() onClicked: root.summonWifiQr()
} }
Button { 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 // 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 // `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 // pinned, so only the live band lights up and the two can no longer read as
@@ -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()
}
}
}
}
}
}
+2 -2
View File
@@ -4,7 +4,7 @@
"name": "Network", "name": "Network",
"version": "1.0.0", "version": "1.0.0",
"author": "Omarchy", "author": "Omarchy",
"description": "Wi-Fi list, connection state, and QR sharing", "description": "Wi-Fi list and connection state",
"kinds": [ "kinds": [
"bar-widget" "bar-widget"
], ],
@@ -13,7 +13,7 @@
}, },
"barWidget": { "barWidget": {
"displayName": "Network", "displayName": "Network",
"description": "Wi-Fi list, connection state, and QR sharing", "description": "Wi-Fi list and connection state",
"category": "Network", "category": "Network",
"allowMultiple": false "allowMultiple": false
} }
+37
View File
@@ -0,0 +1,37 @@
// Parses omarchy-network-qr output: a "meta\t<iface>\t<security>\t<ssid>"
// 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
}
}
+366
View File
@@ -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()
}
}
}
}
}
}
}
+14
View File
@@ -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"
}
}
+37 -7
View File
@@ -33,13 +33,34 @@ chmod +x "$tmp/bin/nmcli" "$tmp/bin/qrencode"
run_success_case() { run_success_case() {
local description=$1 fields=$2 expected_payload=$3 local description=$1 fields=$2 expected_payload=$3
shift 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_NMCLI_FIELDS=$fields
export QR_PAYLOAD_FILE="$tmp/payload" export QR_PAYLOAD_FILE="$tmp/payload"
output=$(PATH="$tmp/bin:$PATH" "$ROOT/bin/omarchy-network-qr" "$@") 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=$(<"$QR_PAYLOAD_FILE")
[[ $payload == "$expected_payload" ]] || fail "$description generates the Wi-Fi payload" "expected: $expected_payload\nactual: $payload" [[ $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" \ "network QR helper escapes WPA credentials through stdin" \
$'Cafe;Guest\\5G\nwpa-psk\np,a:ss;word\\42\nno\n' \ $'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;;' \ '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 wlan0
# With no interface argument the helper finds the connected Wi-Fi device. # With no interface argument the helper finds the connected Wi-Fi device.
run_success_case \ run_success_case \
"network QR helper detects the Wi-Fi interface" \ "network QR helper detects the Wi-Fi interface" \
$'Cafe Detected\nwpa-psk\nsecret\nno\n' \ $'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 \ run_success_case \
"network QR helper supports open networks" \ "network QR helper supports open networks" \
$'Cafe Open\nnone\n\nno\n' \ $'Cafe Open\nnone\n\nno\n' \
'WIFI:T:nopass;S:Cafe Open;P:;;' \ 'WIFI:T:nopass;S:Cafe Open;P:;;' \
wlan0 --meta wlan0
run_success_case \ run_success_case \
"network QR helper marks hidden networks" \ "network QR helper marks hidden networks" \
$'Hidden Network\nwpa-psk\nsecret\nyes\n' \ $'Hidden Network\nwpa-psk\nsecret\nyes\n' \
'WIFI:T:WPA;S:Hidden Network;P:secret;H:true;;' \ '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 # NetworkManager models WEP as key-mgmt "none" plus a wep-key, which must not
# be mistaken for an open network. # be mistaken for an open network.
@@ -76,7 +106,7 @@ run_success_case \
"network QR helper encodes WEP networks" \ "network QR helper encodes WEP networks" \
$'Old Router\nnone\n\nno\nwep-secret\n' \ $'Old Router\nnone\n\nno\nwep-secret\n' \
'WIFI:T:WEP;S:Old Router;P:wep-secret;;' \ 'WIFI:T:WEP;S:Old Router;P:wep-secret;;' \
wlan0 --meta wlan0
export QR_NMCLI_FIELDS=$'Enterprise\nwpa-eap\nsecret\nno\n' export QR_NMCLI_FIELDS=$'Enterprise\nwpa-eap\nsecret\nno\n'
export QR_PAYLOAD_FILE="$tmp/enterprise-payload" export QR_PAYLOAD_FILE="$tmp/enterprise-payload"
-9
View File
@@ -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, 0), 'KNOWN NETWORKS', 'network labels known wifi section')
assertEqual(network.wifiSectionTitle(rows, 2), 'OTHER NETWORKS', 'network labels other 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 } 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(1, reasons), 'Passphrase required', 'network maps missing passphrase failures')
assertEqual(network.networkFailureReason(2, reasons), 'Wrong password', 'network maps auth timeout failures') assertEqual(network.networkFailureReason(2, reasons), 'Wrong password', 'network maps auth timeout failures')
+28
View File
@@ -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