Files
omarchycn/shell/plugins/panels/wifiqr/Panel.qml
T
018f881840 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>
2026-08-07 12:50:04 +02:00

367 lines
12 KiB
QML

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()
}
}
}
}
}
}
}