Add Wi-Fi QR sharing to network panel (#6463)

* Add Wi-Fi QR sharing to network panel

* Refine Wi-Fi QR sharing

* Use QR glyph for Wi-Fi sharing

* Add click-to-reveal password to the Wi-Fi share card

Scanning the QR is the fast path, but the person typing on a laptop needs
the actual password. A dimmed "Show password" hint under the QR toggles
the secret in place.

The password stays out of the shell until asked for: a click runs the new
omarchy-network-password helper (a private pipe, never an argument), and
closing the card drops it again. Open and enterprise networks never show
the control.

The card loses its Close button -- Escape and clicking outside already
cover it -- and now sizes itself to its content.

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

* Keep a dismissed Wi-Fi share card closed and make Escape reliable

Closing the card mid-generation killed the helper, but its buffered
stdout still arrived and repopulated the matrix, reopening the card the
user just closed. Both collectors now honor qrExpectedStop, and the flag
survives onExited because exit and stream-finished have no guaranteed
order. The password fetch gets the same treatment so a reveal in flight
during dismissal can't stash the secret into a closed card's state.

The content's focus was claimed while the window was still unmapped, so
Escape could land nowhere. Re-acquire it after mapping, the way
KeyboardPanel does.

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

* Share WEP networks correctly and restore the QR quiet zone

NetworkManager models WEP as key-mgmt "none" plus a wep-key, so the QR
helper encoded WEP networks as open -- a QR that scans fine and then
silently fails to join. Encode them as T:WEP and let the password helper
print the key.

Also widen qrencode's margin from 2 to the spec's 4-module quiet zone;
the card surround is dark, so that white border is all a scanner gets.

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

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Tobias Lütke
2026-07-31 17:18:42 -04:00
committed by GitHub
co-authored by Claude Fable 5 David Heinemeier Hansson
parent 1ea910f662
commit a79d1dc8da
11 changed files with 609 additions and 39 deletions
+18 -5
View File
@@ -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
}
+172 -29
View File
@@ -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
@@ -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()
}
}
}
}
}
}
+2 -2
View File
@@ -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
}