* 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>
367 lines
12 KiB
QML
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|