fix(nosignal): hardware-test round 4 — Wi-Fi join, multi-monitor popouts, sudo focus

From the 2026-06-20 Acer muxless-Optimus hardware test:

- F-wifi (NsNetwork.qml): the Wi-Fi panel was display-only — no onClicked,
  no password field — so a secured network could not be joined from the GUI.
  Wire the existing Network service: click to connect; open/saved networks
  connect directly; secured-without-profile expands an inline password field
  (StyledTextField, echoMode Password). Also strip a stray NUL byte that the
  on-box edit left at the `?? " "` sentinel. HW-verified on the test laptop.
- F-multimon (NsShell.qml + NsBar.qml + NsOverlay.qml): bar popouts mirrored
  on every monitor. Add a `screen` identity to NsShell; pills pass their
  output name; each NsOverlay only renders when it owns the open panel ("" =
  unscoped, for the global power modal). HW-verified dual-monitor.
- F-sudo (SudoToggleRow.qml): the passwordless-sudo prompt used the shared
  TUI.float class with no focus rule, so keystrokes missed it and three blank
  tries tripped pam_faillock with no feedback. Use a dedicated `nosignal-sudo`
  class (builder ships float/center/pin/stayfocused rules) and call the new
  `nosignal-sudo-toggle enable-tui` wrapper, which keeps the terminal open on
  failure so the reason is visible.

Add .gitattributes (*.qml/*.conf/*.json text) for clean diffs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-06-20 18:49:18 +01:00
parent 2cc089b41f
commit e42538896f
6 changed files with 173 additions and 52 deletions

3
.gitattributes vendored Normal file
View file

@ -0,0 +1,3 @@
*.qml text
*.conf text
*.json text

View file

@ -46,7 +46,14 @@ ToggleRow {
// --- actions -------------------------------------------------------------- // --- actions --------------------------------------------------------------
Process { Process {
id: enableProc id: enableProc
command: ["kitty", "--class", "TUI.float", "-e", "sudo", "nosignal-sudo-toggle", "enable"] // Dedicated window class (nosignal-sudo) so the shipped hypr windowrules
// (float/center/pin/stayfocused) force this prompt to GRAB keyboard focus.
// With the shared TUI.float class and no focus rule the prompt did not get
// keystrokes -> three blank tries tripped pam_faillock and the switch just
// snapped back with no feedback (hardware bug 2026-06-20). `enable-tui`
// runs the sudo prompt as the user and keeps the terminal open on failure
// so the reason is visible instead of vanishing.
command: ["kitty", "--class", "nosignal-sudo", "-e", "nosignal-sudo-toggle", "enable-tui"]
} }
Process { Process {
id: disableProc id: disableProc

View file

@ -79,7 +79,7 @@ Variants {
id: titlePill id: titlePill
active: NsShell.open === "overview" active: NsShell.open === "overview"
onTriggered: NsShell.toggle("overview", mapToItem(null, width / 2, 0).x) onTriggered: NsShell.toggle("overview", mapToItem(null, width / 2, 0).x, win.modelData.name)
NsIcon { NsIcon {
icon: "desktop_windows" icon: "desktop_windows"
@ -104,7 +104,7 @@ Variants {
anchors.centerIn: parent anchors.centerIn: parent
active: NsShell.open === "calendar" active: NsShell.open === "calendar"
onTriggered: NsShell.toggle("calendar", mapToItem(null, width / 2, 0).x) onTriggered: NsShell.toggle("calendar", mapToItem(null, width / 2, 0).x, win.modelData.name)
BC.Clock {} BC.Clock {}
} }
@ -122,7 +122,7 @@ Variants {
visible: Players.active !== null visible: Players.active !== null
active: NsShell.open === "quicksettings" active: NsShell.open === "quicksettings"
onTriggered: NsShell.toggle("quicksettings", mapToItem(null, width / 2, 0).x) onTriggered: NsShell.toggle("quicksettings", mapToItem(null, width / 2, 0).x, win.modelData.name)
NsIcon { NsIcon {
icon: Players.active?.isPlaying ? "pause" : "music_note" icon: Players.active?.isPlaying ? "pause" : "music_note"
@ -153,7 +153,7 @@ Variants {
id: bellPill id: bellPill
active: NsShell.open === "notifications" active: NsShell.open === "notifications"
onTriggered: NsShell.toggle("notifications", mapToItem(null, width / 2, 0).x) onTriggered: NsShell.toggle("notifications", mapToItem(null, width / 2, 0).x, win.modelData.name)
NsIcon { NsIcon {
icon: Notifs.notClosed.length > 0 ? "notifications" : "notifications_none" icon: Notifs.notClosed.length > 0 ? "notifications" : "notifications_none"
@ -204,7 +204,7 @@ Variants {
readonly property bool laptop: UPower.displayDevice.isLaptopBattery readonly property bool laptop: UPower.displayDevice.isLaptopBattery
active: NsShell.open === "quicksettings" active: NsShell.open === "quicksettings"
onTriggered: NsShell.toggle("quicksettings", mapToItem(null, width / 2, 0).x) onTriggered: NsShell.toggle("quicksettings", mapToItem(null, width / 2, 0).x, win.modelData.name)
NsIcon { NsIcon {
icon: batteryPill.laptop ? Icons.getBatteryIcon(UPower.displayDevice.percentage, [UPowerDeviceState.Charging, UPowerDeviceState.FullyCharged, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state)) : "tune" icon: batteryPill.laptop ? Icons.getBatteryIcon(UPower.displayDevice.percentage, [UPowerDeviceState.Charging, UPowerDeviceState.FullyCharged, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state)) : "tune"
@ -249,7 +249,7 @@ Variants {
property color iconColour: Theme.text property color iconColour: Theme.text
active: NsShell.open === panel active: NsShell.open === panel
onTriggered: NsShell.toggle(panel, mapToItem(null, width / 2, 0).x) onTriggered: NsShell.toggle(panel, mapToItem(null, width / 2, 0).x, win.modelData.name)
NsIcon { NsIcon {
icon: p.icon icon: p.icon

View file

@ -19,8 +19,13 @@ Variants {
required property ShellScreen modelData required property ShellScreen modelData
// This overlay owns the panel only when NsShell.screen names this output
// (or is "" = unscoped, e.g. the global power modal show on all screens).
// Without this, every screen's overlay rendered the panel mirrored popups.
readonly property bool onThisScreen: NsShell.screen === "" || NsShell.screen === modelData.name
screen: modelData screen: modelData
visible: NsShell.open !== "" visible: NsShell.open !== "" && ov.onThisScreen
color: "transparent" color: "transparent"
WlrLayershell.namespace: "nspanels" WlrLayershell.namespace: "nspanels"
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
@ -48,7 +53,7 @@ Variants {
// full-screen modal: Power Menu // full-screen modal: Power Menu
Loader { Loader {
anchors.fill: parent anchors.fill: parent
active: NsShell.open === "power" active: NsShell.open === "power" && ov.onThisScreen
sourceComponent: powerC sourceComponent: powerC
Component { Component {
@ -61,7 +66,7 @@ Variants {
Loader { Loader {
id: loader id: loader
active: NsShell.open !== "" && NsShell.open !== "power" active: NsShell.open !== "" && NsShell.open !== "power" && ov.onThisScreen
y: Theme.size.barHeight + 6 y: Theme.size.barHeight + 6
x: { x: {
if (!item) if (!item)

View file

@ -3,12 +3,22 @@ pragma ComponentBehavior: Bound
// NoSignal Network popout (off the Wi-Fi pill). Title + switch; list of networks // NoSignal Network popout (off the Wi-Fi pill). Title + switch; list of networks
// (signal glyph accent if connected, SSID, Connected label, lock if secured); // (signal glyph accent if connected, SSID, Connected label, lock if secured);
// connected row = accent.soft. Footer "Network settings". Wired to Network/Nmcli. // connected row = accent.soft. Footer "Network settings". Wired to Network/Nmcli.
//
// FIX 2026-06-20 (hardware test): the shipped panel was display-only the row
// MouseArea had no onClicked and there was no password field, so a secured Wi-Fi
// network could not be joined from the GUI (user had to drop to nmcli/nmtui).
// This wires the existing Network service: click a row to connect; open or
// already-saved networks connect directly; a secured network with no saved
// profile expands an inline password field (StyledTextField, echoMode Password).
// NsOverlay grants OnDemand keyboard focus while visible, so the field is typable.
// NOTE: authored on the test machine but NOT yet built/cert'd review in builder.
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import qs.services import qs.services
import qs.utils import qs.utils
import qs.components import qs.components
import qs.components.controls
NsPanel { NsPanel {
id: root id: root
@ -16,6 +26,29 @@ NsPanel {
implicitWidth: 340 implicitWidth: 340
anchorMode: "right" anchorMode: "right"
// ssid of the secured row currently showing its inline password field ("" = none)
property string expandedSsid: ""
// Decide what a click on a network row does.
function activate(m): void {
if (!m)
return;
if (m.active) {
// Already connected -> tapping disconnects.
Network.disconnectFromNetwork();
root.expandedSsid = "";
return;
}
if (!m.isSecure || Network.hasSavedProfile(m.ssid)) {
// Open network, or we already hold the PSK -> connect straight away.
Network.connectToNetworkWithPasswordCheck(m.ssid, m.isSecure, () => {}, m.bssid);
root.expandedSsid = "";
} else {
// Secured + no saved profile -> reveal the inline password field.
root.expandedSsid = (root.expandedSsid === m.ssid) ? "" : m.ssid;
}
}
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
@ -46,61 +79,126 @@ NsPanel {
Repeater { Repeater {
model: Network.networks model: Network.networks
delegate: Rectangle { delegate: ColumnLayout {
id: row id: rowWrap
required property var modelData required property var modelData
readonly property bool connected: modelData?.active ?? false readonly property bool connected: modelData?.active ?? false
readonly property bool expanded: root.expandedSsid === (modelData?.ssid ?? " ")
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 40 spacing: 2
radius: Theme.radius.button
color: connected ? Theme.accentSoft : ma.containsMouse ? Theme.hover : "transparent"
RowLayout { function doConnect(): void {
anchors.fill: parent const m = rowWrap.modelData;
anchors.leftMargin: 10 if (!m || pwField.text.length === 0)
anchors.rightMargin: 10 return;
spacing: 10 Network.connectToNetwork(m.ssid, pwField.text, m.bssid, () => {});
pwField.text = "";
root.expandedSsid = "";
}
NsIcon { Rectangle {
icon: Icons.getNetworkIcon(row.modelData?.strength ?? 0) id: row
color: row.connected ? Theme.accent : Theme.text
}
ColumnLayout { Layout.fillWidth: true
Layout.fillWidth: true implicitHeight: 40
spacing: 0 radius: Theme.radius.button
color: rowWrap.connected ? Theme.accentSoft : ma.containsMouse ? Theme.hover : "transparent"
StyledText { RowLayout {
text: row.modelData?.ssid ?? "?" anchors.fill: parent
color: Theme.text anchors.leftMargin: 10
elide: Text.ElideRight anchors.rightMargin: 10
spacing: 10
NsIcon {
icon: Icons.getNetworkIcon(rowWrap.modelData?.strength ?? 0)
color: rowWrap.connected ? Theme.accent : Theme.text
}
ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
font.family: Theme.font.family spacing: 0
font.pixelSize: Theme.font.bodySmall
StyledText {
text: rowWrap.modelData?.ssid ?? "?"
color: Theme.text
elide: Text.ElideRight
Layout.fillWidth: true
font.family: Theme.font.family
font.pixelSize: Theme.font.bodySmall
}
StyledText {
visible: rowWrap.connected
text: "Connected"
color: Theme.accent
font.family: Theme.font.family
font.pixelSize: Theme.font.meta
}
} }
StyledText {
visible: row.connected NsIcon {
text: "Connected" visible: rowWrap.modelData?.isSecure ?? false
color: Theme.accent icon: "lock"
font.family: Theme.font.family color: Theme.textFaint
font.pixelSize: Theme.font.meta
} }
} }
NsIcon { MouseArea {
visible: row.modelData?.isSecure ?? false id: ma
icon: "lock" anchors.fill: parent
color: Theme.textFaint hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activate(rowWrap.modelData)
} }
} }
MouseArea { // inline password entry only for a secured network with no saved profile
id: ma RowLayout {
anchors.fill: parent Layout.fillWidth: true
hoverEnabled: true Layout.leftMargin: 10
cursorShape: Qt.PointingHandCursor Layout.rightMargin: 10
Layout.bottomMargin: 4
spacing: 8
visible: rowWrap.expanded
onVisibleChanged: {
if (visible)
pwField.forceActiveFocus();
}
StyledTextField {
id: pwField
Layout.fillWidth: true
echoMode: TextField.Password
placeholderText: "Password"
onAccepted: rowWrap.doConnect()
}
Rectangle {
implicitWidth: 72
implicitHeight: 28
radius: Theme.radius.button
color: cma.containsMouse ? Theme.accent : Theme.accentSoft
StyledText {
anchors.centerIn: parent
text: "Connect"
color: Theme.text
font.family: Theme.font.family
font.pixelSize: Theme.font.meta
}
MouseArea {
id: cma
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: rowWrap.doConnect()
}
}
} }
} }
} }

View file

@ -4,7 +4,11 @@ pragma ComponentBehavior: Bound
// NoSignal shell-level popup state. "One panel open at a time" `open` is the // NoSignal shell-level popup state. "One panel open at a time" `open` is the
// id of the active panel ("" = none). `anchorX` is the scene x of the trigger // id of the active panel ("" = none). `anchorX` is the scene x of the trigger
// (a bar pill) center, used to position under-trigger / right-anchored popups. // (a bar pill) center, used to position under-trigger / right-anchored popups.
// The NsBar pills call toggle(); the NsOverlay reads `open` and renders the panel. // `screen` is the name of the output that owns the open panel, so a per-screen
// NsOverlay shows it on ONLY that monitor instead of mirroring on all of them
// (multi-monitor fix, 2026-06-20). "" = no owner show on all screens (used by
// the full-screen power modal opened from a global keybind, which has no screen).
// The NsBar pills call toggle() with their screen; NsOverlay matches on `screen`.
import QtQuick import QtQuick
import Quickshell import Quickshell
@ -14,19 +18,23 @@ Singleton {
property string open: "" property string open: ""
property real anchorX: 0 property real anchorX: 0
property string screen: ""
function toggle(id: string, x: real): void { function toggle(id: string, x: real, screen: string): void {
if (root.open === id) { const scr = screen ?? "";
if (root.open === id && root.screen === scr) {
root.open = ""; root.open = "";
} else { } else {
root.open = id; root.open = id;
root.anchorX = x; root.anchorX = x;
root.screen = scr;
} }
} }
function show(id: string, x: real): void { function show(id: string, x: real, screen: string): void {
root.open = id; root.open = id;
root.anchorX = x; root.anchorX = x;
root.screen = screen ?? "";
} }
function close(): void { function close(): void {