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:
co-authored by
Claude Fable 5
parent
474d9dd4eb
commit
018f881840
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user