From b9c4646dda1e1fc4f830548e9b67c8dbdc47a740 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:18:11 +0200 Subject: [PATCH 1/3] fix(panel): restore keyboard focus lifecycle --- hancore.shibumi.ai/ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.audio/ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.battery/ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.bluetooth/ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.brightness/ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.center/ShibumiPanel.qml | 37 +++++++++++++++++-- .../ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.cpu/ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.gpu/ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.media/ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.memory/ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.network/ShibumiPanel.qml | 37 +++++++++++++++++-- .../ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.status/ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.storage/ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.temperature/ShibumiPanel.qml | 37 +++++++++++++++++-- .../ShibumiPanel.qml | 37 +++++++++++++++++-- hancore.shibumi.workspaces/ShibumiPanel.qml | 37 +++++++++++++++++-- shared/presentation/ShibumiPanel.qml | 37 +++++++++++++++++-- tests/contract-regression.sh | 6 +++ widgets/ShibumiPanel.qml | 37 +++++++++++++++++-- 21 files changed, 666 insertions(+), 80 deletions(-) diff --git a/hancore.shibumi.ai/ShibumiPanel.qml b/hancore.shibumi.ai/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.ai/ShibumiPanel.qml +++ b/hancore.shibumi.ai/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.audio/ShibumiPanel.qml b/hancore.shibumi.audio/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.audio/ShibumiPanel.qml +++ b/hancore.shibumi.audio/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.battery/ShibumiPanel.qml b/hancore.shibumi.battery/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.battery/ShibumiPanel.qml +++ b/hancore.shibumi.battery/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.bluetooth/ShibumiPanel.qml b/hancore.shibumi.bluetooth/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.bluetooth/ShibumiPanel.qml +++ b/hancore.shibumi.bluetooth/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.brightness/ShibumiPanel.qml b/hancore.shibumi.brightness/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.brightness/ShibumiPanel.qml +++ b/hancore.shibumi.brightness/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.center/ShibumiPanel.qml b/hancore.shibumi.center/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.center/ShibumiPanel.qml +++ b/hancore.shibumi.center/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.control-center/ShibumiPanel.qml b/hancore.shibumi.control-center/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.control-center/ShibumiPanel.qml +++ b/hancore.shibumi.control-center/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.cpu/ShibumiPanel.qml b/hancore.shibumi.cpu/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.cpu/ShibumiPanel.qml +++ b/hancore.shibumi.cpu/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.gpu/ShibumiPanel.qml b/hancore.shibumi.gpu/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.gpu/ShibumiPanel.qml +++ b/hancore.shibumi.gpu/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.media/ShibumiPanel.qml b/hancore.shibumi.media/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.media/ShibumiPanel.qml +++ b/hancore.shibumi.media/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.memory/ShibumiPanel.qml b/hancore.shibumi.memory/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.memory/ShibumiPanel.qml +++ b/hancore.shibumi.memory/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.network/ShibumiPanel.qml b/hancore.shibumi.network/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.network/ShibumiPanel.qml +++ b/hancore.shibumi.network/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.power-profile/ShibumiPanel.qml b/hancore.shibumi.power-profile/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.power-profile/ShibumiPanel.qml +++ b/hancore.shibumi.power-profile/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.status/ShibumiPanel.qml b/hancore.shibumi.status/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.status/ShibumiPanel.qml +++ b/hancore.shibumi.status/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.storage/ShibumiPanel.qml b/hancore.shibumi.storage/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.storage/ShibumiPanel.qml +++ b/hancore.shibumi.storage/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.temperature/ShibumiPanel.qml b/hancore.shibumi.temperature/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.temperature/ShibumiPanel.qml +++ b/hancore.shibumi.temperature/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.update-center/ShibumiPanel.qml b/hancore.shibumi.update-center/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.update-center/ShibumiPanel.qml +++ b/hancore.shibumi.update-center/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/hancore.shibumi.workspaces/ShibumiPanel.qml b/hancore.shibumi.workspaces/ShibumiPanel.qml index 0928120..6919321 100644 --- a/hancore.shibumi.workspaces/ShibumiPanel.qml +++ b/hancore.shibumi.workspaces/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/shared/presentation/ShibumiPanel.qml b/shared/presentation/ShibumiPanel.qml index 0928120..6919321 100644 --- a/shared/presentation/ShibumiPanel.qml +++ b/shared/presentation/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 diff --git a/tests/contract-regression.sh b/tests/contract-regression.sh index 09cd604..083bda8 100755 --- a/tests/contract-regression.sh +++ b/tests/contract-regression.sh @@ -879,6 +879,12 @@ rg -q 'property int padding: Commons\.Style\.spacing\.popupPadding' \ || fail "Shibumi panel border changes its V1 content inset" rg -q '^PanelWindow \{' widgets/ShibumiPanel.qml \ || fail "Shibumi panel does not own its visible surface" +rg -Fq 'function requestKeyboardFocus(target)' widgets/ShibumiPanel.qml \ + || fail "Shibumi panel cannot reacquire keyboard focus for inline editors" +rg -Fq 'focusPrimed ? WlrKeyboardFocus.OnDemand' widgets/ShibumiPanel.qml \ + || fail "Shibumi panel does not settle its keyboard-focus prime" +rg -q 'id: focusPrimeTimer' widgets/ShibumiPanel.qml \ + || fail "Shibumi panel does not bound its exclusive focus prime" if rg -q '^Ui\.KeyboardPanel|shibumiSurfaceBleed' widgets/ShibumiPanel.qml; then fail "Shibumi panel still paints host and custom panel layers together" fi diff --git a/widgets/ShibumiPanel.qml b/widgets/ShibumiPanel.qml index 0928120..6919321 100644 --- a/widgets/ShibumiPanel.qml +++ b/widgets/ShibumiPanel.qml @@ -29,6 +29,7 @@ PanelWindow { property bool popoutSwitching: false property bool popoutSwitchClosing: false property Item focusTarget: null + property bool focusPrimed: false property bool surfaceOverrideEnabled: false property color surfaceColorOverride: "transparent" property color surfaceBorderColorOverride: "transparent" @@ -135,6 +136,19 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + + function requestKeyboardFocus(target) { + if (!open || !target) return + focusPrimed = false + beginFocusPrime() + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } + screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 || popoutSwitching color: "transparent" @@ -143,7 +157,11 @@ PanelWindow { WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: open - ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + ? (focusPrimed ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() anchors { top: true @@ -262,9 +280,14 @@ PanelWindow { } onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) requestKeyboardFocus(focusTarget) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return const activeOwner = typeof bar.activePopoutForScreen === "function" ? bar.activePopoutForScreen(popoutScreenName) : bar.activePopout @@ -289,6 +312,12 @@ PanelWindow { bar.clearConnectedPanel(coordinatorKey) } + Timer { + id: focusPrimeTimer + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 From bb8c86a48a29bbdff9cd18f7125baeb1a3a500e1 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:18:17 +0200 Subject: [PATCH 2/3] fix(network): handle primitive credential rows --- hancore.shibumi.network/NetworkPanel.qml | 179 ++++- hancore.shibumi.network/Service.qml | 218 ++++-- services/NetworkService.qml | 218 ++++-- tests/fixtures/NetworkTestPanel.qml | 53 +- tests/fixtures/ShibumiPanelTest.qml | 9 + tests/network-credentials-smoke.qml | 939 +++++++++++++++++++++++ tests/network-plugin-regression.sh | 107 ++- widgets/NetworkPanel.qml | 179 ++++- 8 files changed, 1755 insertions(+), 147 deletions(-) create mode 100644 tests/network-credentials-smoke.qml diff --git a/hancore.shibumi.network/NetworkPanel.qml b/hancore.shibumi.network/NetworkPanel.qml index 62ded60..8371d8c 100644 --- a/hancore.shibumi.network/NetworkPanel.qml +++ b/hancore.shibumi.network/NetworkPanel.qml @@ -18,11 +18,21 @@ ShibumiPanel { property string passwordKey: "" property string passwordText: "" property string identityText: "" + property var credentialDisplayNetworks: [] + property string credentialSsid: "" + property var credentialSecurity: null + property string credentialError: "" + property Item credentialEditor: null property string pendingForgetKey: "" property int cursorIndex: -1 readonly property bool wifiControlsVisible: networkService && networkService.wifiAvailable === true readonly property var displayNetworks: filteredNetworks() + readonly property var presentedNetworks: passwordKey !== "" + ? credentialDisplayNetworks : displayNetworks + readonly property bool credentialEditorFocused: !!(credentialEditor + && credentialEditor.activeFocus) + readonly property bool panelKeyboardFocusActive: keyCatcher.activeFocus readonly property int savedCount: { let count = 0 const rows = networkService ? networkService.networks : [] @@ -126,38 +136,109 @@ ShibumiPanel { } function clearPassword() { + const restorePanelFocus = passwordKey !== "" && open passwordKey = "" passwordText = "" identityText = "" + credentialDisplayNetworks = [] + credentialSsid = "" + credentialSecurity = null + credentialError = "" + credentialEditor = null + if (restorePanelFocus) requestPanelKeyboardFocus(keyCatcher) } function openPassword(entry) { + credentialDisplayNetworks = displayNetworks.slice() + credentialSsid = String(entry && entry.ssid || "") + credentialSecurity = entry ? entry.security : null + credentialError = "" expandedKey = entryKey(entry) passwordKey = expandedKey passwordText = "" identityText = "" } + function currentCredentialRow() { + let result = null + let count = 0 + const rows = displayNetworks + for (let i = 0; i < rows.length; i++) { + const row = rows[i] + if (!row || row.visible === false + || String(row.ssid || "") !== credentialSsid + || row.security !== credentialSecurity) continue + result = row + count++ + } + return count === 1 ? result : null + } + + function evaluateCredentialCompletion() { + if (passwordKey === "") return + const current = currentCredentialRow() + if (current && current.connected === true) clearPassword() + } + + function requestPanelKeyboardFocus(target) { + if (!open || !target) return + if (typeof requestKeyboardFocus === "function") { + requestKeyboardFocus(target) + return + } + Qt.callLater(function() { + if (panel.open && target) target.forceActiveFocus() + }) + } + + function registerCredentialEditor(editor) { + if (!editor) return + credentialEditor = editor + requestPanelKeyboardFocus(editor) + } + + function credentialEditorFocusChanged(editor, active) { + if (!editor) return + if (active) { + credentialEditor = editor + return + } + Qt.callLater(function() { + if (panel.open && panel.passwordKey !== "" + && panel.credentialEditor === editor && editor.visible + && !editor.activeFocus && keyCatcher.activeFocus) + panel.requestPanelKeyboardFocus(editor) + }) + } + function openNetworkSettings() { ownerWidget.close() if (bar && typeof bar.run === "function") bar.run("omarchy-launch-or-focus-tui nmtui") } + function needsCredentials(entry) { + return !!entry && !entry.known + && (entry.securityKind === "psk" + || entry.securityKind === "enterprise") + } + + function needsNetworkSettings(entry) { + return !!entry && !entry.known && !needsCredentials(entry) + && entry.securityKind !== "open" + } + function runPrimary(entry) { if (!entry || networkService.busy) return if (entry.connected) { networkService.disconnect(entry) return } - if (entry.network && !entry.known - && (entry.securityKind === "psk" - || entry.securityKind === "enterprise")) { + if (needsCredentials(entry)) { openPassword(entry) return } - if (entry.network && !entry.known - && entry.securityKind !== "open") { + if (needsNetworkSettings(entry)) { openNetworkSettings() return } @@ -165,13 +246,18 @@ ShibumiPanel { } function submitPassword(entry) { - if (!entry || !passwordText || networkService.busy) return + if (!entry || !passwordText || networkService.busy) return false + let accepted = false if (entry.securityKind === "enterprise") { - if (!identityText) return - networkService.connectEnterprise(entry, identityText, passwordText) + if (!identityText) return false + accepted = networkService.connectEnterprise( + entry, identityText, passwordText) } else { - networkService.connectWithPassphrase(entry, passwordText) + accepted = networkService.connectWithPassphrase(entry, passwordText) } + if (!accepted) + credentialError = "Network changed. Select it again." + return accepted } function requestForget(entry) { @@ -211,6 +297,8 @@ ShibumiPanel { onDisplayNetworksChanged: { if (cursorIndex >= displayNetworks.length) cursorIndex = displayNetworks.length - 1 if (displayNetworks.length === 0) cursorIndex = -1 + if (passwordKey !== "") + Qt.callLater(function() { panel.evaluateCredentialCompletion() }) } onOpenChanged: { @@ -231,16 +319,9 @@ ShibumiPanel { target: panel.networkService function onActionKindChanged() { - if (!panel.networkService || panel.networkService.actionKind !== "") return - const key = panel.passwordKey - if (!key) return - const rows = panel.displayNetworks - for (let i = 0; i < rows.length; i++) { - if (panel.entryKey(rows[i]) === key && rows[i].connected) { - panel.clearPassword() - return - } - } + if (!panel.networkService || panel.networkService.actionKind !== "" + || panel.passwordKey === "") return + Qt.callLater(function() { panel.evaluateCredentialCompletion() }) } } @@ -254,6 +335,14 @@ ShibumiPanel { id: keyCatcher anchors.fill: parent blocked: panel.passwordKey !== "" + onActiveFocusChanged: { + if (activeFocus && panel.passwordKey !== "" + && panel.credentialEditor) Qt.callLater(function() { + if (keyCatcher.activeFocus && panel.open + && panel.passwordKey !== "" && panel.credentialEditor) + panel.requestPanelKeyboardFocus(panel.credentialEditor) + }) + } onCloseRequested: panel.ownerWidget.close() onTabRequested: function(direction) { panel.ownerWidget.switchPanel(direction) } onMoveRequested: function(_dx, dy) { @@ -605,7 +694,10 @@ ShibumiPanel { visible: panel.wifiControlsVisible Repeater { - model: panel.displayNetworks + // Scanner/signal updates replace the service's JS array. Keep the + // delegate containing an active credential editor alive until the + // editor closes, while actions still revalidate against live rows. + model: panel.presentedNetworks Ui.CursorSurface { id: networkRow @@ -723,10 +815,7 @@ ShibumiPanel { label: networkRow.actionRunning ? (networkRow.modelData.connected ? "Disconnecting..." : "Connecting...") : networkRow.modelData.connected ? "Disconnect" - : networkRow.modelData.network && !networkRow.modelData.known - && networkRow.modelData.securityKind !== "open" - && networkRow.modelData.securityKind !== "psk" - && networkRow.modelData.securityKind !== "enterprise" + : panel.needsNetworkSettings(networkRow.modelData) ? "Open network settings" : "Connect" enabled: !panel.networkService.busy onClicked: panel.runPrimary(networkRow.modelData) @@ -752,7 +841,8 @@ ShibumiPanel { id: identityField width: parent.width height: Commons.Style.space(30) - visible: networkRow.enterpriseCredentials + visible: networkRow.passwordOpen + && networkRow.enterpriseCredentials text: panel.identityText placeholderText: "Identity (user@domain)" color: panel.bar ? panel.bar.foreground : Commons.Color.foreground @@ -765,11 +855,18 @@ ShibumiPanel { onTextChanged: panel.identityText = text onAccepted: passwordField.forceActiveFocus() Keys.onEscapePressed: panel.clearPassword() + onActiveFocusChanged: + panel.credentialEditorFocusChanged(identityField, + activeFocus) onVisibleChanged: { - if (visible) Qt.callLater(function() { - identityField.forceActiveFocus() - }) + if (visible) panel.registerCredentialEditor(identityField) + else if (panel.credentialEditor === identityField) + panel.credentialEditor = null } + Component.onCompleted: if (visible) + panel.registerCredentialEditor(identityField) + Component.onDestruction: if (panel.credentialEditor === identityField) + panel.credentialEditor = null background: Rectangle { radius: panel.controlRadius color: identityField.activeFocus @@ -781,6 +878,17 @@ ShibumiPanel { } } + Text { + width: parent.width + visible: panel.credentialError !== "" + text: panel.credentialError + color: panel.bar ? panel.bar.urgent : Commons.Color.accent + font.family: panel.bar + ? panel.bar.fontFamily : Commons.Style.font.family + font.pixelSize: Commons.Style.font.caption + wrapMode: Text.Wrap + } + Row { width: parent.width spacing: Commons.Style.space(5) @@ -790,6 +898,7 @@ ShibumiPanel { width: parent.width - connectPassword.width - cancelPassword.width - 2 * parent.spacing height: Commons.Style.space(30) + visible: networkRow.passwordOpen text: panel.passwordText placeholderText: networkRow.enterpriseCredentials ? "Enterprise password" : "Wi-Fi password" @@ -804,10 +913,21 @@ ShibumiPanel { onTextChanged: panel.passwordText = text onAccepted: panel.submitPassword(networkRow.modelData) Keys.onEscapePressed: panel.clearPassword() + onActiveFocusChanged: + panel.credentialEditorFocusChanged(passwordField, + activeFocus) onVisibleChanged: { if (visible && !networkRow.enterpriseCredentials) - Qt.callLater(function() { passwordField.forceActiveFocus() }) + panel.registerCredentialEditor(passwordField) + else if (!visible + && panel.credentialEditor === passwordField) + panel.credentialEditor = null } + Component.onCompleted: if (visible + && !networkRow.enterpriseCredentials) + panel.registerCredentialEditor(passwordField) + Component.onDestruction: if (panel.credentialEditor === passwordField) + panel.credentialEditor = null background: Rectangle { radius: panel.controlRadius color: passwordField.activeFocus @@ -826,6 +946,7 @@ ShibumiPanel { enabled: panel.passwordText !== "" && (!networkRow.enterpriseCredentials || panel.identityText !== "") + && panel.credentialError === "" && !panel.networkService.busy onClicked: panel.submitPassword(networkRow.modelData) } diff --git a/hancore.shibumi.network/Service.qml b/hancore.shibumi.network/Service.qml index e783e34..55948e7 100644 --- a/hancore.shibumi.network/Service.qml +++ b/hancore.shibumi.network/Service.qml @@ -235,9 +235,9 @@ Item { case WifiSecurityType.StaticWep: case WifiSecurityType.DynamicWep: return "wep" case WifiSecurityType.WpaEap: - case WifiSecurityType.Wpa2Eap: + case WifiSecurityType.Wpa2Eap: return "enterprise" case WifiSecurityType.Wpa3SuiteB192: - case WifiSecurityType.Leap: return "enterprise" + case WifiSecurityType.Leap: return "unsupported" default: return "unknown" } } @@ -259,35 +259,78 @@ Item { } } - function profileSecurityLabel(keyManagement) { + function profileSecurityLabel(keyManagement, authAlgorithm) { switch (String(keyManagement || "").toLowerCase()) { case "wpa-psk": return "WPA Personal profile" case "sae": return "WPA3 Personal profile" case "owe": return "Enhanced Open profile" case "wpa-eap": return "WPA Enterprise profile" - case "ieee8021x": return "802.1X profile" - case "none": return "Open or WEP profile" + case "wpa-eap-suite-b-192": return "WPA3 Suite B profile" + case "ieee8021x": return String(authAlgorithm || "").toLowerCase() + === "leap" ? "LEAP profile" : "802.1X profile" + case "open": return "Open profile" + case "none": return "WEP profile" default: return "Saved Wi-Fi profile" } } + function profileMatchesSecurity(profile, security) { + const keyManagement = String(profile && profile.keyManagement || "") + .toLowerCase() + switch (keyManagement) { + case "wpa-psk": + return security === WifiSecurityType.WpaPsk + || security === WifiSecurityType.Wpa2Psk + case "sae": return security === WifiSecurityType.Sae + case "owe": return security === WifiSecurityType.Owe + case "wpa-eap": + return security === WifiSecurityType.WpaEap + || security === WifiSecurityType.Wpa2Eap + case "wpa-eap-suite-b-192": + return security === WifiSecurityType.Wpa3SuiteB192 + case "ieee8021x": + return String(profile.authAlgorithm || "").toLowerCase() === "leap" + ? security === WifiSecurityType.Leap + : security === WifiSecurityType.DynamicWep + case "open": return security === WifiSecurityType.Open + case "none": return security === WifiSecurityType.StaticWep + default: return false + } + } + + function profileVisibleMatchCount(profile, visible) { + if (!profile || !Array.isArray(visible)) return 0 + let count = 0 + for (let i = 0; i < visible.length; i++) { + const candidate = visible[i] + if (candidate && candidate.ssid === profile.ssid + && profileMatchesSecurity(profile, candidate.security)) count++ + } + return count + } + function mergedNetworks(visible, profiles) { const rows = [] const visibleRows = Array.isArray(visible) ? visible : [] const savedRows = Array.isArray(profiles) ? profiles : [] + const representedProfileUuids = ({}) for (let i = 0; i < visibleRows.length; i++) { const source = visibleRows[i] if (!source) continue - let profile = null + const matchingProfiles = [] for (let p = 0; p < savedRows.length; p++) { - if (savedRows[p] && savedRows[p].ssid === source.ssid) { - profile = savedRows[p] - break - } + const candidate = savedRows[p] + if (candidate && candidate.ssid === source.ssid + && profileMatchesSecurity(candidate, source.security)) + matchingProfiles.push(candidate) } + const profile = matchingProfiles.length === 1 + && profileVisibleMatchCount(matchingProfiles[0], visibleRows) === 1 + ? matchingProfiles[0] : null + if (profile && String(profile.uuid || "")) + representedProfileUuids[String(profile.uuid)] = true rows.push({ - network: source.network || null, connected: source.connected === true, known: source.known === true || profile !== null, ssid: String(source.ssid || ""), @@ -305,15 +348,8 @@ Item { for (let p = 0; p < savedRows.length; p++) { const profile = savedRows[p] - if (!profile) continue - let represented = false - for (let i = 0; i < rows.length; i++) { - if (rows[i].ssid === profile.ssid) { - represented = true - break - } - } - if (represented) continue + if (!profile || representedProfileUuids[String(profile.uuid || "")]) + continue rows.push({ network: null, connected: false, @@ -322,7 +358,8 @@ Item { signal: 0, security: null, securityKind: "saved", - securityLabel: profileSecurityLabel(profile.keyManagement), + securityLabel: profileSecurityLabel(profile.keyManagement, + profile.authAlgorithm), visible: false, profileUuid: String(profile.uuid || ""), lastSuccessful: Number(profile.lastSuccessful || 0), @@ -339,49 +376,143 @@ Item { return rows } + function visibleIdentityMatchCount(entry) { + if (!entry || entry.visible === false) return 0 + const ssid = String(entry.ssid || "") + if (!ssid) return 0 + const expectedKind = String(entry.securityKind + || securityKind(entry.security)) + const hasExactSecurity = entry.security !== undefined + && entry.security !== null + const rows = Array.isArray(visibleNetworks) ? visibleNetworks : [] + let count = 0 + for (let i = 0; i < rows.length; i++) { + const candidate = rows[i] + if (!candidate || String(candidate.ssid || "") !== ssid) continue + if (hasExactSecurity && candidate.security !== entry.security) continue + if (!hasExactSecurity + && securityKind(candidate.security) !== expectedKind) continue + count++ + } + return count + } + + function hasUniqueVisibleIdentity(entry) { + return visibleIdentityMatchCount(entry) === 1 + } + + function currentVisibleEntry(entry) { + if (!entry || entry.visible === false) return null + const ssid = String(entry.ssid || "") + const hasExactSecurity = entry.security !== undefined + && entry.security !== null + const expectedKind = String(entry.securityKind + || securityKind(entry.security)) + const rows = mergedNetworks(visibleNetworks, savedProfiles) + let result = null + let count = 0 + for (let i = 0; i < rows.length; i++) { + const candidate = rows[i] + if (!candidate || candidate.visible === false + || String(candidate.ssid || "") !== ssid) continue + if (hasExactSecurity && candidate.security !== entry.security) continue + if (!hasExactSecurity && candidate.securityKind !== expectedKind) continue + result = candidate + count++ + } + return count === 1 ? result : null + } + + function savedProfileMatchCount(entry) { + if (!entry || !Array.isArray(savedProfiles)) return 0 + let count = 0 + for (let i = 0; i < savedProfiles.length; i++) { + const profile = savedProfiles[i] + if (profile && profile.ssid === entry.ssid + && profileMatchesSecurity(profile, entry.security)) count++ + } + return count + } + + function visibleSsidMatchCount(entry) { + const ssid = String(entry && entry.ssid || "") + if (!ssid || !Array.isArray(visibleNetworks)) return 0 + let count = 0 + for (let i = 0; i < visibleNetworks.length; i++) { + const candidate = visibleNetworks[i] + if (candidate && String(candidate.ssid || "") === ssid) count++ + } + return count + } + + function hasUnambiguousVisibleSsid(entry) { + return hasUniqueVisibleIdentity(entry) + && visibleSsidMatchCount(entry) === 1 + } + function connect(entry) { if (!entry || busy) return false profileError = "" - if (!entry.network && entry.profileUuid) + if (entry.profileUuid && entry.visible === false) return runProfileAction("connect", entry.profileUuid) - if (!entry.network || !ready) return false + if (!ready || !hasUniqueVisibleIdentity(entry)) return false if (entry.connected) return disconnect(entry) - if (entry.known || entry.securityKind === "open") { - backend.connectKnown(entry.ssid) + if (entry.profileUuid) + return runProfileAction("connect", entry.profileUuid) + if (hasUnambiguousVisibleSsid(entry) + && (entry.known || entry.securityKind === "open") + && typeof backend.connectKnown === "function") { + backend.connectKnown(String(entry.ssid || "")) return true } return false } function connectWithPassphrase(entry, passphrase) { - if (!entry || !entry.network || entry.securityKind !== "psk" - || !String(passphrase || "") || !ready || busy) return false + const current = currentVisibleEntry(entry) + if (!entry || entry.securityKind !== "psk" + || !String(passphrase || "") || !ready || busy + || !hasUnambiguousVisibleSsid(entry) || !current + || current.connected === true || current.known === true + || savedProfileMatchCount(entry) > 0 + || typeof backend.connectWithPassphrase !== "function") return false profileError = "" - backend.connectWithPassphrase(entry.ssid, String(passphrase)) + backend.connectWithPassphrase(String(entry.ssid || ""), String(passphrase)) return true } function connectEnterprise(entry, identity, passphrase) { - if (!entry || !entry.network || entry.securityKind !== "enterprise" + const current = currentVisibleEntry(entry) + if (!entry || entry.securityKind !== "enterprise" || !String(identity || "") || !String(passphrase || "") - || !ready || busy) return false + || !ready || busy || !hasUnambiguousVisibleSsid(entry) || !current + || current.connected === true || current.known === true + || savedProfileMatchCount(entry) > 0 + || typeof backend.connectEnterprise !== "function") return false profileError = "" - backend.connectEnterprise(entry.ssid, String(identity), String(passphrase)) + backend.connectEnterprise(String(entry.ssid || ""), + String(identity), String(passphrase)) return true } function disconnect(entry) { - if (!ready || busy) return false - backend.disconnect(entry && entry.network ? entry.network : null) + if (!entry || entry.connected !== true || !ready || busy + || !hasUnambiguousVisibleSsid(entry) + || typeof backend.disconnectRow !== "function") return false + backend.disconnectRow(String(entry.ssid || "")) return true } function forget(entry) { if (!entry || !entry.known || busy) return false profileError = "" - if (!entry.network && entry.profileUuid) + if (entry.profileUuid && entry.visible === false) + return runProfileAction("forget", entry.profileUuid) + if (!ready || !hasUniqueVisibleIdentity(entry)) return false + if (entry.profileUuid) return runProfileAction("forget", entry.profileUuid) - if (!entry.network || !ready) return false + if (!hasUnambiguousVisibleSsid(entry) + || typeof backend.forget !== "function") return false backend.forget(entry) return true } @@ -642,12 +773,12 @@ Item { + "printf '__READY__\\n'; " + "while IFS=: read -r uuid type; do " + "case \"$type\" in 802-11-wireless|wifi) ;; *) continue ;; esac; " - + "mapfile -t details < <(nmcli --escape no -g 802-11-wireless.ssid,connection.timestamp,802-11-wireless-security.key-mgmt connection show uuid \"$uuid\"); " - + "ssid=${details[0]-}; timestamp=${details[1]:-0}; key_mgmt=${details[2]:-unknown}; " + + "mapfile -t details < <(nmcli --escape no -g 802-11-wireless.ssid,connection.timestamp,802-11-wireless-security.key-mgmt,802-11-wireless-security.auth-alg connection show uuid \"$uuid\"); " + + "ssid=${details[0]-}; timestamp=${details[1]:-0}; key_mgmt=${details[2]-}; auth_alg=${details[3]-}; " + "[ -n \"$ssid\" ] || continue; " + "case \"$timestamp\" in ''|*[!0-9]*) timestamp=0 ;; esac; " - + "[ -n \"$key_mgmt\" ] || key_mgmt=unknown; " - + "printf '%s\\t%s\\t%s\\t%s\\n' \"$uuid\" \"$ssid\" \"$timestamp\" \"$key_mgmt\"; " + + "[ -n \"$key_mgmt\" ] || key_mgmt=open; [ -n \"$auth_alg\" ] || auth_alg=unknown; " + + "printf '%s\\t%s\\t%s\\t%s\\t%s\\n' \"$uuid\" \"$ssid\" \"$timestamp\" \"$key_mgmt\" \"$auth_alg\"; " + "done <<< \"$connections\""] stdout: StdioCollector { waitForEnd: true @@ -663,12 +794,13 @@ Item { for (let i = 1; i < lines.length; i++) { if (!lines[i]) continue const fields = lines[i].split("\t") - if (fields.length < 4) continue + if (fields.length < 5) continue profiles.push({ uuid: fields[0], - ssid: fields.slice(1, fields.length - 2).join("\t"), - lastSuccessful: parseInt(fields[fields.length - 2]) || 0, - keyManagement: fields[fields.length - 1] + ssid: fields.slice(1, fields.length - 3).join("\t"), + lastSuccessful: parseInt(fields[fields.length - 3]) || 0, + keyManagement: fields[fields.length - 2], + authAlgorithm: fields[fields.length - 1] }) } root.profileError = "" diff --git a/services/NetworkService.qml b/services/NetworkService.qml index 41d669d..d4354a3 100644 --- a/services/NetworkService.qml +++ b/services/NetworkService.qml @@ -198,9 +198,9 @@ Item { case WifiSecurityType.StaticWep: case WifiSecurityType.DynamicWep: return "wep" case WifiSecurityType.WpaEap: - case WifiSecurityType.Wpa2Eap: + case WifiSecurityType.Wpa2Eap: return "enterprise" case WifiSecurityType.Wpa3SuiteB192: - case WifiSecurityType.Leap: return "enterprise" + case WifiSecurityType.Leap: return "unsupported" default: return "unknown" } } @@ -222,35 +222,78 @@ Item { } } - function profileSecurityLabel(keyManagement) { + function profileSecurityLabel(keyManagement, authAlgorithm) { switch (String(keyManagement || "").toLowerCase()) { case "wpa-psk": return "WPA Personal profile" case "sae": return "WPA3 Personal profile" case "owe": return "Enhanced Open profile" case "wpa-eap": return "WPA Enterprise profile" - case "ieee8021x": return "802.1X profile" - case "none": return "Open or WEP profile" + case "wpa-eap-suite-b-192": return "WPA3 Suite B profile" + case "ieee8021x": return String(authAlgorithm || "").toLowerCase() + === "leap" ? "LEAP profile" : "802.1X profile" + case "open": return "Open profile" + case "none": return "WEP profile" default: return "Saved Wi-Fi profile" } } + function profileMatchesSecurity(profile, security) { + const keyManagement = String(profile && profile.keyManagement || "") + .toLowerCase() + switch (keyManagement) { + case "wpa-psk": + return security === WifiSecurityType.WpaPsk + || security === WifiSecurityType.Wpa2Psk + case "sae": return security === WifiSecurityType.Sae + case "owe": return security === WifiSecurityType.Owe + case "wpa-eap": + return security === WifiSecurityType.WpaEap + || security === WifiSecurityType.Wpa2Eap + case "wpa-eap-suite-b-192": + return security === WifiSecurityType.Wpa3SuiteB192 + case "ieee8021x": + return String(profile.authAlgorithm || "").toLowerCase() === "leap" + ? security === WifiSecurityType.Leap + : security === WifiSecurityType.DynamicWep + case "open": return security === WifiSecurityType.Open + case "none": return security === WifiSecurityType.StaticWep + default: return false + } + } + + function profileVisibleMatchCount(profile, visible) { + if (!profile || !Array.isArray(visible)) return 0 + let count = 0 + for (let i = 0; i < visible.length; i++) { + const candidate = visible[i] + if (candidate && candidate.ssid === profile.ssid + && profileMatchesSecurity(profile, candidate.security)) count++ + } + return count + } + function mergedNetworks(visible, profiles) { const rows = [] const visibleRows = Array.isArray(visible) ? visible : [] const savedRows = Array.isArray(profiles) ? profiles : [] + const representedProfileUuids = ({}) for (let i = 0; i < visibleRows.length; i++) { const source = visibleRows[i] if (!source) continue - let profile = null + const matchingProfiles = [] for (let p = 0; p < savedRows.length; p++) { - if (savedRows[p] && savedRows[p].ssid === source.ssid) { - profile = savedRows[p] - break - } + const candidate = savedRows[p] + if (candidate && candidate.ssid === source.ssid + && profileMatchesSecurity(candidate, source.security)) + matchingProfiles.push(candidate) } + const profile = matchingProfiles.length === 1 + && profileVisibleMatchCount(matchingProfiles[0], visibleRows) === 1 + ? matchingProfiles[0] : null + if (profile && String(profile.uuid || "")) + representedProfileUuids[String(profile.uuid)] = true rows.push({ - network: source.network || null, connected: source.connected === true, known: source.known === true || profile !== null, ssid: String(source.ssid || ""), @@ -268,15 +311,8 @@ Item { for (let p = 0; p < savedRows.length; p++) { const profile = savedRows[p] - if (!profile) continue - let represented = false - for (let i = 0; i < rows.length; i++) { - if (rows[i].ssid === profile.ssid) { - represented = true - break - } - } - if (represented) continue + if (!profile || representedProfileUuids[String(profile.uuid || "")]) + continue rows.push({ network: null, connected: false, @@ -285,7 +321,8 @@ Item { signal: 0, security: null, securityKind: "saved", - securityLabel: profileSecurityLabel(profile.keyManagement), + securityLabel: profileSecurityLabel(profile.keyManagement, + profile.authAlgorithm), visible: false, profileUuid: String(profile.uuid || ""), lastSuccessful: Number(profile.lastSuccessful || 0), @@ -302,49 +339,143 @@ Item { return rows } + function visibleIdentityMatchCount(entry) { + if (!entry || entry.visible === false) return 0 + const ssid = String(entry.ssid || "") + if (!ssid) return 0 + const expectedKind = String(entry.securityKind + || securityKind(entry.security)) + const hasExactSecurity = entry.security !== undefined + && entry.security !== null + const rows = Array.isArray(visibleNetworks) ? visibleNetworks : [] + let count = 0 + for (let i = 0; i < rows.length; i++) { + const candidate = rows[i] + if (!candidate || String(candidate.ssid || "") !== ssid) continue + if (hasExactSecurity && candidate.security !== entry.security) continue + if (!hasExactSecurity + && securityKind(candidate.security) !== expectedKind) continue + count++ + } + return count + } + + function hasUniqueVisibleIdentity(entry) { + return visibleIdentityMatchCount(entry) === 1 + } + + function currentVisibleEntry(entry) { + if (!entry || entry.visible === false) return null + const ssid = String(entry.ssid || "") + const hasExactSecurity = entry.security !== undefined + && entry.security !== null + const expectedKind = String(entry.securityKind + || securityKind(entry.security)) + const rows = mergedNetworks(visibleNetworks, savedProfiles) + let result = null + let count = 0 + for (let i = 0; i < rows.length; i++) { + const candidate = rows[i] + if (!candidate || candidate.visible === false + || String(candidate.ssid || "") !== ssid) continue + if (hasExactSecurity && candidate.security !== entry.security) continue + if (!hasExactSecurity && candidate.securityKind !== expectedKind) continue + result = candidate + count++ + } + return count === 1 ? result : null + } + + function savedProfileMatchCount(entry) { + if (!entry || !Array.isArray(savedProfiles)) return 0 + let count = 0 + for (let i = 0; i < savedProfiles.length; i++) { + const profile = savedProfiles[i] + if (profile && profile.ssid === entry.ssid + && profileMatchesSecurity(profile, entry.security)) count++ + } + return count + } + + function visibleSsidMatchCount(entry) { + const ssid = String(entry && entry.ssid || "") + if (!ssid || !Array.isArray(visibleNetworks)) return 0 + let count = 0 + for (let i = 0; i < visibleNetworks.length; i++) { + const candidate = visibleNetworks[i] + if (candidate && String(candidate.ssid || "") === ssid) count++ + } + return count + } + + function hasUnambiguousVisibleSsid(entry) { + return hasUniqueVisibleIdentity(entry) + && visibleSsidMatchCount(entry) === 1 + } + function connect(entry) { if (!entry || busy) return false profileError = "" - if (!entry.network && entry.profileUuid) + if (entry.profileUuid && entry.visible === false) return runProfileAction("connect", entry.profileUuid) - if (!entry.network || !ready) return false + if (!ready || !hasUniqueVisibleIdentity(entry)) return false if (entry.connected) return disconnect(entry) - if (entry.known || entry.securityKind === "open") { - backend.connectKnown(entry.ssid) + if (entry.profileUuid) + return runProfileAction("connect", entry.profileUuid) + if (hasUnambiguousVisibleSsid(entry) + && (entry.known || entry.securityKind === "open") + && typeof backend.connectKnown === "function") { + backend.connectKnown(String(entry.ssid || "")) return true } return false } function connectWithPassphrase(entry, passphrase) { - if (!entry || !entry.network || entry.securityKind !== "psk" - || !String(passphrase || "") || !ready || busy) return false + const current = currentVisibleEntry(entry) + if (!entry || entry.securityKind !== "psk" + || !String(passphrase || "") || !ready || busy + || !hasUnambiguousVisibleSsid(entry) || !current + || current.connected === true || current.known === true + || savedProfileMatchCount(entry) > 0 + || typeof backend.connectWithPassphrase !== "function") return false profileError = "" - backend.connectWithPassphrase(entry.ssid, String(passphrase)) + backend.connectWithPassphrase(String(entry.ssid || ""), String(passphrase)) return true } function connectEnterprise(entry, identity, passphrase) { - if (!entry || !entry.network || entry.securityKind !== "enterprise" + const current = currentVisibleEntry(entry) + if (!entry || entry.securityKind !== "enterprise" || !String(identity || "") || !String(passphrase || "") - || !ready || busy) return false + || !ready || busy || !hasUnambiguousVisibleSsid(entry) || !current + || current.connected === true || current.known === true + || savedProfileMatchCount(entry) > 0 + || typeof backend.connectEnterprise !== "function") return false profileError = "" - backend.connectEnterprise(entry.ssid, String(identity), String(passphrase)) + backend.connectEnterprise(String(entry.ssid || ""), + String(identity), String(passphrase)) return true } function disconnect(entry) { - if (!ready || busy) return false - backend.disconnect(entry && entry.network ? entry.network : null) + if (!entry || entry.connected !== true || !ready || busy + || !hasUnambiguousVisibleSsid(entry) + || typeof backend.disconnectRow !== "function") return false + backend.disconnectRow(String(entry.ssid || "")) return true } function forget(entry) { if (!entry || !entry.known || busy) return false profileError = "" - if (!entry.network && entry.profileUuid) + if (entry.profileUuid && entry.visible === false) + return runProfileAction("forget", entry.profileUuid) + if (!ready || !hasUniqueVisibleIdentity(entry)) return false + if (entry.profileUuid) return runProfileAction("forget", entry.profileUuid) - if (!entry.network || !ready) return false + if (!hasUnambiguousVisibleSsid(entry) + || typeof backend.forget !== "function") return false backend.forget(entry) return true } @@ -603,12 +734,12 @@ Item { + "printf '__READY__\\n'; " + "while IFS=: read -r uuid type; do " + "case \"$type\" in 802-11-wireless|wifi) ;; *) continue ;; esac; " - + "mapfile -t details < <(nmcli --escape no -g 802-11-wireless.ssid,connection.timestamp,802-11-wireless-security.key-mgmt connection show uuid \"$uuid\"); " - + "ssid=${details[0]-}; timestamp=${details[1]:-0}; key_mgmt=${details[2]:-unknown}; " + + "mapfile -t details < <(nmcli --escape no -g 802-11-wireless.ssid,connection.timestamp,802-11-wireless-security.key-mgmt,802-11-wireless-security.auth-alg connection show uuid \"$uuid\"); " + + "ssid=${details[0]-}; timestamp=${details[1]:-0}; key_mgmt=${details[2]-}; auth_alg=${details[3]-}; " + "[ -n \"$ssid\" ] || continue; " + "case \"$timestamp\" in ''|*[!0-9]*) timestamp=0 ;; esac; " - + "[ -n \"$key_mgmt\" ] || key_mgmt=unknown; " - + "printf '%s\\t%s\\t%s\\t%s\\n' \"$uuid\" \"$ssid\" \"$timestamp\" \"$key_mgmt\"; " + + "[ -n \"$key_mgmt\" ] || key_mgmt=open; [ -n \"$auth_alg\" ] || auth_alg=unknown; " + + "printf '%s\\t%s\\t%s\\t%s\\t%s\\n' \"$uuid\" \"$ssid\" \"$timestamp\" \"$key_mgmt\" \"$auth_alg\"; " + "done <<< \"$connections\""] stdout: StdioCollector { waitForEnd: true @@ -624,12 +755,13 @@ Item { for (let i = 1; i < lines.length; i++) { if (!lines[i]) continue const fields = lines[i].split("\t") - if (fields.length < 4) continue + if (fields.length < 5) continue profiles.push({ uuid: fields[0], - ssid: fields.slice(1, fields.length - 2).join("\t"), - lastSuccessful: parseInt(fields[fields.length - 2]) || 0, - keyManagement: fields[fields.length - 1] + ssid: fields.slice(1, fields.length - 3).join("\t"), + lastSuccessful: parseInt(fields[fields.length - 3]) || 0, + keyManagement: fields[fields.length - 2], + authAlgorithm: fields[fields.length - 1] }) } root.profileError = "" diff --git a/tests/fixtures/NetworkTestPanel.qml b/tests/fixtures/NetworkTestPanel.qml index c811f7b..4d3c6e5 100644 --- a/tests/fixtures/NetworkTestPanel.qml +++ b/tests/fixtures/NetworkTestPanel.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick +import Quickshell.Networking Item { property var bar: null @@ -15,11 +16,40 @@ Item { property bool scanning: false property bool busy: false property var wifiDevice: null + property bool wifiStationAvailable: true property var info: ({ iface: "wlan0", ssid: "Details Fallback" }) property var wifiNetworks: [ - { ssid: "Fixture Network", connected: true } + { + ssid: "Fixture Network", + connected: true, + known: true, + signal: 67, + security: WifiSecurityType.Open + }, + { + ssid: "Fixture Enterprise", + connected: false, + known: false, + signal: 61, + security: WifiSecurityType.Wpa2Eap + } ] + property string dnsProvider: "DHCP" + property var dnsProviders: ["DHCP"] + property string actionSsid: "" + property string actionKind: "" + property string failureSsid: "" + property string failureReason: "" + property int knownConnectCount: 0 + property string knownConnectSsid: "" + property int pskConnectCount: 0 + property string pskSsid: "" + property string pskPassphrase: "" property int enterpriseConnectCount: 0 + property int disconnectRowCount: 0 + property string disconnectSsid: "" + property int forgetCount: 0 + property string forgetSsid: "" // Deliberate legacy trap: Shibumi must not delegate its inline speed test // back to these formerly host-owned fields or function. property bool speedTestRunning: false @@ -35,12 +65,33 @@ Item { function open() { opened = true } function close() { opened = false } function refresh(_scanWifi) { return true } + function syncWifiNetworks() {} + function connectKnown(ssid) { + knownConnectCount++ + knownConnectSsid = ssid + } + function connectWithPassphrase(ssid, passphrase) { + pskConnectCount++ + pskSsid = ssid + pskPassphrase = passphrase + } function connectEnterprise(ssid, identity, passphrase) { enterpriseConnectCount++ enterpriseSsid = ssid enterpriseIdentity = identity enterprisePassphrase = passphrase } + function disconnectRow(ssid) { + disconnectRowCount++ + disconnectSsid = ssid + } + function forget(entry) { + forgetCount++ + forgetSsid = entry ? String(entry.ssid || "") : "" + } + function setDns(_provider) {} + function formatRate(_value) { return "0 B/s" } + function formatPingLatency(_value) { return "--" } function runSpeedTest() { speedTestRunCount++ speedTestRunning = true diff --git a/tests/fixtures/ShibumiPanelTest.qml b/tests/fixtures/ShibumiPanelTest.qml index 79009a5..1880722 100644 --- a/tests/fixtures/ShibumiPanelTest.qml +++ b/tests/fixtures/ShibumiPanelTest.qml @@ -9,6 +9,7 @@ Item { property var owner: null property bool open: false property Item focusTarget: null + property int focusRequestCount: 0 property bool centerOnBar: false property real centerOnBarOffset: 0 property int padding: 0 @@ -30,6 +31,7 @@ Item { readonly property real controlBorderWidth: 0 readonly property real controlRadius: 6 readonly property var shibumiTokens: ({ + paper: "#181818", separator: "#404040", fillIdle: "#202020", fillHover: "#282828", @@ -47,6 +49,13 @@ Item { const desired = (Number(value) || 0) + padding * 2 return cap ? Math.min(desired, Number(cap)) : desired } + function requestKeyboardFocus(target) { + if (!open || !target) return + focusRequestCount++ + Qt.callLater(function() { + if (root.open && target) target.forceActiveFocus() + }) + } function syncPopout() { if (!bar || !owner) return if (open) bar.requestPopout(owner) diff --git a/tests/network-credentials-smoke.qml b/tests/network-credentials-smoke.qml new file mode 100644 index 0000000..8a6278c --- /dev/null +++ b/tests/network-credentials-smoke.qml @@ -0,0 +1,939 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Window +import Quickshell +import Quickshell.Networking +import "network" as Network +import "fixtures" as Fixtures + +ShellRoot { + id: root + + property int phase: 0 + property int ticks: 0 + property int focusProbeStage: 0 + property int focusRequestCountBeforeLoss: 0 + property var focusEditor: null + property var stalePskEntry: null + property var staleEnterpriseEntry: null + property var staleProfileEntry: null + + function fail(message) { + console.error("network-credentials-smoke:", message) + Qt.exit(1) + } + + function entryFor(ssid) { + return entryForService(service, ssid, "") + } + + function entryForService(targetService, ssid, kind) { + const rows = targetService ? targetService.networks : [] + for (let i = 0; i < rows.length; i++) { + if (rows[i] && rows[i].visible !== false + && rows[i].ssid === ssid + && (!kind || rows[i].securityKind === kind)) return rows[i] + } + return null + } + + function savedProfileRow(rows, uuid) { + for (let i = 0; i < rows.length; i++) { + if (rows[i] && rows[i].visible === false + && rows[i].profileUuid === uuid) return rows[i] + } + return null + } + + function visibleProfileRows(rows, ssid) { + const result = [] + for (let i = 0; i < rows.length; i++) { + if (rows[i] && rows[i].visible !== false && rows[i].ssid === ssid) + result.push(rows[i]) + } + return result + } + + function visibleProfileRow(rows, ssid, kind) { + const visibleRows = visibleProfileRows(rows, ssid) + for (let i = 0; i < visibleRows.length; i++) { + if (visibleRows[i].securityKind === kind) return visibleRows[i] + } + return null + } + + Item { + id: fakeBar + visible: false + width: 0 + height: 0 + property bool vertical: false + property int barSize: 35 + property int sizeHorizontal: 35 + property string position: "top" + property string fontFamily: "monospace" + property color foreground: "#eeeeee" + property color barForeground: foreground + property color background: "#111111" + property color urgent: "#88bbee" + property bool foregroundAnimationEnabled: false + property var activePopout: null + property var shell: null + property var visualTokens: null + property string lastRun: "" + + function registeredWidgetSource(_id) { return "" } + function registeredWidgetComponent(_id) { return null } + function widgetSettings(_group, _module) { return ({}) } + function requestPopout(owner) { activePopout = owner } + function releasePopout(owner) { + if (activePopout === owner) activePopout = null + } + function publishConnectedPanel(_owner, _screenName, _x, _reveal) {} + function switchPanelFrom(_owner, _direction) { return false } + function targetBelongsToWindow(_target, _window) { return false } + function run(command) { lastRun = String(command || "") } + } + + Item { id: anchor; width: 24; height: 24 } + + Item { + id: ownerWidget + property bool opened: false + property int closeCount: 0 + function close() { opened = false; closeCount++ } + function switchPanel(_direction) { return false } + } + + Component { + id: backendComponent + Fixtures.NetworkTestPanel {} + } + + Network.Service { + id: service + bar: fakeBar + panelComponent: backendComponent + } + + Network.Service { + id: unavailableProfileConnectService + bar: fakeBar + panelComponent: null + } + + Network.Service { + id: unavailableProfileForgetService + bar: fakeBar + panelComponent: null + } + + Network.Service { + id: profiledVisibleConnectService + bar: fakeBar + panelComponent: backendComponent + } + + Network.Service { + id: profiledVisibleForgetService + bar: fakeBar + panelComponent: backendComponent + } + + Window { + id: focusWindow + width: 640 + height: 720 + visible: true + + Network.NetworkPanel { + id: panel + anchorItem: anchor + bar: fakeBar + ownerWidget: ownerWidget + networkService: service + } + + Item { + id: focusThief + width: 1 + height: 1 + } + } + + Timer { + interval: 25 + repeat: true + running: true + onTriggered: { + root.ticks++ + + if (root.phase === 0) { + if (!service.ready || !service.backend || root.ticks < 2) return + service.backend.wifiNetworks = [ + { + connected: false, + known: false, + ssid: "Open Fixture", + signal: 52, + security: WifiSecurityType.Open + }, + { + connected: false, + known: false, + ssid: "PSK Fixture", + signal: 71, + security: WifiSecurityType.Wpa2Psk + }, + { + connected: false, + known: false, + ssid: "SAE Fixture", + signal: 69, + security: WifiSecurityType.Sae + }, + { + connected: false, + known: false, + ssid: "Enterprise Fixture", + signal: 65, + security: WifiSecurityType.Wpa2Eap + }, + { + connected: false, + known: false, + ssid: "OWE Fixture", + signal: 63, + security: WifiSecurityType.Owe + }, + { + connected: false, + known: false, + ssid: "Suite B Fixture", + signal: 62, + security: WifiSecurityType.Wpa3SuiteB192 + }, + { + connected: false, + known: false, + ssid: "LEAP Fixture", + signal: 60, + security: WifiSecurityType.Leap + }, + { + connected: true, + known: true, + ssid: "Connected Fixture", + signal: 82, + security: WifiSecurityType.Wpa2Psk + }, + { + connected: false, + known: true, + ssid: "Known Fixture", + signal: 58, + security: WifiSecurityType.Wpa2Psk + } + ] + root.phase = 1 + root.ticks = 0 + return + } + + if (root.phase === 1) { + const open = root.entryFor("Open Fixture") + const psk = root.entryFor("PSK Fixture") + const sae = root.entryFor("SAE Fixture") + const enterprise = root.entryFor("Enterprise Fixture") + const owe = root.entryFor("OWE Fixture") + const suiteB = root.entryFor("Suite B Fixture") + const leap = root.entryFor("LEAP Fixture") + const connected = root.entryFor("Connected Fixture") + const known = root.entryFor("Known Fixture") + if (!open || !psk || !sae || !enterprise || !owe || !suiteB || !leap + || !connected || !known) return + if (open.network !== undefined || psk.network !== undefined + || sae.network !== undefined || enterprise.network !== undefined + || owe.network !== undefined || suiteB.network !== undefined + || leap.network !== undefined || connected.network !== undefined + || known.network !== undefined) + return root.fail("view snapshots retain backend network objects") + + if (root.focusProbeStage === 0) { + if (!service.connect(open) + || service.backend.knownConnectCount !== 1 + || service.backend.knownConnectSsid !== "Open Fixture" + || service.connect(psk)) + return root.fail("primitive open/protected connect routing") + ownerWidget.opened = true + panel.runPrimary(psk) + if (panel.passwordKey !== psk.entryKey + || panel.expandedKey !== psk.entryKey) + return root.fail("PSK row did not open the existing password editor") + root.focusProbeStage = 1 + root.ticks = 0 + return + } + + if (root.focusProbeStage === 1) { + if (!panel.credentialEditor || root.ticks < 1) return + if (!panel.credentialEditorFocused) + return root.fail("credential editor did not acquire active focus") + root.focusEditor = panel.credentialEditor + const credentialRows = panel.presentedNetworks + panel.passwordText = "focus-probe" + const refreshedRows = service.backend.wifiNetworks.slice() + refreshedRows[1] = { + connected: false, + known: false, + ssid: "PSK Fixture", + signal: 70, + security: WifiSecurityType.Wpa2Psk + } + service.backend.wifiNetworks = refreshedRows + if (panel.presentedNetworks !== credentialRows) + return root.fail("credential row model was replaced during editing") + root.focusProbeStage = 2 + root.ticks = 0 + return + } + + if (root.focusProbeStage === 2) { + if (root.ticks < 1) return + if (panel.credentialEditor !== root.focusEditor + || !panel.credentialEditorFocused + || panel.passwordText !== "focus-probe" + || panel.passwordKey !== psk.entryKey) + return root.fail("credential editor lost identity or focus on refresh") + root.focusRequestCountBeforeLoss = panel.focusRequestCount + focusThief.forceActiveFocus() + root.focusProbeStage = 3 + root.ticks = 0 + return + } + + if (root.focusProbeStage === 3) { + if (root.ticks < 1) return + if (!focusThief.activeFocus || panel.credentialEditorFocused + || panel.focusRequestCount + !== root.focusRequestCountBeforeLoss) + return root.fail("credential editor stole intentional focus") + panel.focusTarget.forceActiveFocus() + root.focusProbeStage = 4 + root.ticks = 0 + return + } + + if (root.focusProbeStage === 4) { + if (root.ticks < 1) return + if (panel.credentialEditor !== root.focusEditor + || !panel.credentialEditorFocused + || panel.focusRequestCount + <= root.focusRequestCountBeforeLoss) + return root.fail("credential editor did not recover panel focus") + panel.passwordText = "psk-secret" + if (!panel.submitPassword(psk) + || service.backend.pskConnectCount !== 1 + || service.backend.pskSsid !== "PSK Fixture" + || service.backend.pskPassphrase !== "psk-secret") + return root.fail("primitive PSK credential forwarding") + panel.clearPassword() + root.focusProbeStage = 5 + root.ticks = 0 + return + } + + if (root.ticks < 1) return + if (root.focusProbeStage !== 5 || !panel.panelKeyboardFocusActive) + return root.fail("panel keyboard focus was not restored after credentials") + + panel.runPrimary(sae) + panel.passwordText = "sae-secret" + panel.submitPassword(sae) + if (panel.passwordKey !== sae.entryKey + || service.backend.pskConnectCount !== 2 + || service.backend.pskSsid !== "SAE Fixture" + || service.backend.pskPassphrase !== "sae-secret") + return root.fail("primitive WPA3/SAE credential forwarding") + + panel.clearPassword() + panel.runPrimary(enterprise) + panel.identityText = "user@example.test" + panel.passwordText = "enterprise-secret" + panel.submitPassword(enterprise) + if (panel.passwordKey !== enterprise.entryKey + || service.backend.enterpriseConnectCount !== 1 + || service.backend.enterpriseSsid !== "Enterprise Fixture" + || service.backend.enterpriseIdentity !== "user@example.test" + || service.backend.enterprisePassphrase !== "enterprise-secret") + return root.fail("primitive enterprise credential forwarding") + + if (!service.disconnect(connected) + || service.backend.disconnectRowCount !== 1 + || service.backend.disconnectSsid !== "Connected Fixture") + return root.fail("primitive connected-row disconnect routing") + if (!service.forget(known) + || service.backend.forgetCount !== 1 + || service.backend.forgetSsid !== "Known Fixture") + return root.fail("primitive known-row forget routing") + + panel.runPrimary(owe) + if (ownerWidget.closeCount !== 1 + || fakeBar.lastRun !== "omarchy-launch-or-focus-tui nmtui" + || !panel.needsNetworkSettings(owe) + || suiteB.securityKind !== "unsupported" + || leap.securityKind !== "unsupported" + || !panel.needsNetworkSettings(suiteB) + || !panel.needsNetworkSettings(leap)) + return root.fail("unsupported primitive security settings routing") + + const personalUuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + const enterpriseUuid = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + const profileSet = [ + { + uuid: personalUuid, + ssid: "Dual Profile", + lastSuccessful: 20, + keyManagement: "wpa-psk" + }, + { + uuid: enterpriseUuid, + ssid: "Dual Profile", + lastSuccessful: 10, + keyManagement: "wpa-eap" + } + ] + const mappedPersonal = service.mergedNetworks([{ + connected: false, + known: true, + ssid: "Dual Profile", + signal: 70, + security: WifiSecurityType.Wpa2Psk + }], profileSet) + const mappedEnterprise = service.mergedNetworks([{ + connected: false, + known: true, + ssid: "Dual Profile", + signal: 68, + security: WifiSecurityType.Wpa2Eap + }], profileSet) + if (!mappedPersonal[0] || mappedPersonal[0].profileUuid !== personalUuid + || !root.savedProfileRow(mappedPersonal, enterpriseUuid) + || !mappedEnterprise[0] + || mappedEnterprise[0].profileUuid !== enterpriseUuid + || !root.savedProfileRow(mappedEnterprise, personalUuid)) + return root.fail("heterogeneous saved profiles matched by SSID only") + const ambiguousProfiles = service.mergedNetworks([{ + connected: false, + known: true, + ssid: "Ambiguous Profile", + signal: 66, + security: WifiSecurityType.Wpa2Psk + }], [ + { + uuid: "cccccccc-cccc-cccc-cccc-cccccccccccc", + ssid: "Ambiguous Profile", + keyManagement: "wpa-psk" + }, + { + uuid: "dddddddd-dddd-dddd-dddd-dddddddddddd", + ssid: "Ambiguous Profile", + keyManagement: "wpa-psk" + } + ]) + if (!ambiguousProfiles[0] || ambiguousProfiles[0].profileUuid !== "" + || !root.savedProfileRow(ambiguousProfiles, + "cccccccc-cccc-cccc-cccc-cccccccccccc") + || !root.savedProfileRow(ambiguousProfiles, + "dddddddd-dddd-dddd-dddd-dddddddddddd")) + return root.fail("ambiguous saved profiles were assigned or hidden") + + const suiteUuid = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" + const genericEapUuid = "ffffffff-ffff-ffff-ffff-ffffffffffff" + const suiteProfiles = service.mergedNetworks([{ + connected: false, + known: true, + ssid: "Suite Profile", + signal: 64, + security: WifiSecurityType.Wpa3SuiteB192 + }], [ + { + uuid: genericEapUuid, + ssid: "Suite Profile", + keyManagement: "wpa-eap" + }, + { + uuid: suiteUuid, + ssid: "Suite Profile", + keyManagement: "wpa-eap-suite-b-192" + } + ]) + const leapUuid = "12121212-1212-1212-1212-121212121212" + const dynamicWepUuid = "34343434-3434-3434-3434-343434343434" + const leapProfiles = service.mergedNetworks([{ + connected: false, + known: true, + ssid: "LEAP Profile", + signal: 59, + security: WifiSecurityType.Leap + }], [ + { + uuid: dynamicWepUuid, + ssid: "LEAP Profile", + keyManagement: "ieee8021x", + authAlgorithm: "open" + }, + { + uuid: leapUuid, + ssid: "LEAP Profile", + keyManagement: "ieee8021x", + authAlgorithm: "leap" + } + ]) + if (!suiteProfiles[0] || suiteProfiles[0].profileUuid !== suiteUuid + || !root.savedProfileRow(suiteProfiles, genericEapUuid) + || !leapProfiles[0] || leapProfiles[0].profileUuid !== leapUuid + || !root.savedProfileRow(leapProfiles, dynamicWepUuid)) + return root.fail("Suite B or LEAP profiles matched a generic mode") + + const openUuid = "45454545-4545-4545-4545-454545454545" + const wepUuid = "67676767-6767-6767-6767-676767676767" + const openWepProfiles = service.mergedNetworks([ + { + connected: false, + known: true, + ssid: "Open WEP Profile", + signal: 61, + security: WifiSecurityType.Open + }, + { + connected: false, + known: true, + ssid: "Open WEP Profile", + signal: 57, + security: WifiSecurityType.StaticWep + } + ], [ + { + uuid: openUuid, + ssid: "Open WEP Profile", + keyManagement: "open" + }, + { + uuid: wepUuid, + ssid: "Open WEP Profile", + keyManagement: "none" + } + ]) + const openProfile = root.visibleProfileRow(openWepProfiles, + "Open WEP Profile", "open") + const wepProfile = root.visibleProfileRow(openWepProfiles, + "Open WEP Profile", "wep") + const mismatchedWep = service.mergedNetworks([{ + connected: false, + known: false, + ssid: "Open Only Profile", + signal: 55, + security: WifiSecurityType.Open + }], [{ + uuid: wepUuid, + ssid: "Open Only Profile", + keyManagement: "none" + }]) + const mismatchedOpen = root.visibleProfileRow(mismatchedWep, + "Open Only Profile", "open") + if (!openProfile || openProfile.profileUuid !== openUuid + || !wepProfile || wepProfile.profileUuid !== wepUuid + || !mismatchedOpen || mismatchedOpen.profileUuid !== "" + || !root.savedProfileRow(mismatchedWep, wepUuid) + || service.profileSecurityLabel("open", "") !== "Open profile" + || service.profileSecurityLabel("none", "") !== "WEP profile") + return root.fail("open and static WEP profiles were conflated") + + const personalVariantUuid = + "78787878-7878-7878-7878-787878787878" + const personalVariantProfiles = service.mergedNetworks([ + { + connected: false, + known: true, + ssid: "Personal Variants", + signal: 72, + security: WifiSecurityType.WpaPsk + }, + { + connected: false, + known: true, + ssid: "Personal Variants", + signal: 68, + security: WifiSecurityType.Wpa2Psk + } + ], [{ + uuid: personalVariantUuid, + ssid: "Personal Variants", + keyManagement: "wpa-psk" + }]) + const enterpriseVariantUuid = + "89898989-8989-8989-8989-898989898989" + const enterpriseVariantProfiles = service.mergedNetworks([ + { + connected: false, + known: true, + ssid: "Enterprise Variants", + signal: 67, + security: WifiSecurityType.WpaEap + }, + { + connected: false, + known: true, + ssid: "Enterprise Variants", + signal: 64, + security: WifiSecurityType.Wpa2Eap + } + ], [{ + uuid: enterpriseVariantUuid, + ssid: "Enterprise Variants", + keyManagement: "wpa-eap" + }]) + const personalVariants = root.visibleProfileRows( + personalVariantProfiles, "Personal Variants") + const enterpriseVariants = root.visibleProfileRows( + enterpriseVariantProfiles, "Enterprise Variants") + if (personalVariants.length !== 2 + || personalVariants[0].profileUuid !== "" + || personalVariants[1].profileUuid !== "" + || !root.savedProfileRow(personalVariantProfiles, + personalVariantUuid) + || enterpriseVariants.length !== 2 + || enterpriseVariants[0].profileUuid !== "" + || enterpriseVariants[1].profileUuid !== "" + || !root.savedProfileRow(enterpriseVariantProfiles, + enterpriseVariantUuid)) + return root.fail("one saved profile was assigned to multiple variants") + + const duplicateVisibleUuid = + "56565656-5656-5656-5656-565656565656" + const duplicateVisibleProfiles = service.mergedNetworks([ + { + connected: false, + known: true, + ssid: "Duplicate Visible", + signal: 70, + security: WifiSecurityType.Wpa2Psk + }, + { + connected: false, + known: true, + ssid: "Duplicate Visible", + signal: 45, + security: WifiSecurityType.Wpa2Psk + } + ], [{ + uuid: duplicateVisibleUuid, + ssid: "Duplicate Visible", + keyManagement: "wpa-psk" + }]) + const duplicateVisibleRows = root.visibleProfileRows( + duplicateVisibleProfiles, "Duplicate Visible") + if (duplicateVisibleRows.length !== 2 + || duplicateVisibleRows[0].profileUuid !== "" + || duplicateVisibleRows[1].profileUuid !== "" + || !root.savedProfileRow(duplicateVisibleProfiles, + duplicateVisibleUuid)) + return root.fail("duplicate visible identity consumed its saved profile") + + if (unavailableProfileConnectService.ready + || unavailableProfileForgetService.ready + || !unavailableProfileConnectService.connect({ + profileUuid: "11111111-1111-1111-1111-111111111111", + ssid: "Saved Fixture", + visible: false + }) + || !unavailableProfileForgetService.forget({ + profileUuid: "22222222-2222-2222-2222-222222222222", + ssid: "Saved Fixture", + known: true, + visible: false + })) + return root.fail("saved profile actions require the visual backend") + + root.stalePskEntry = psk + root.staleEnterpriseEntry = enterprise + root.staleProfileEntry = { + connected: false, + known: true, + ssid: "PSK Fixture", + signal: 71, + security: WifiSecurityType.Wpa2Psk, + securityKind: "psk", + visible: true, + profileUuid: "33333333-3333-3333-3333-333333333333" + } + service.backend.wifiNetworks = [ + { + connected: false, + known: false, + ssid: "PSK Fixture", + signal: 71, + security: WifiSecurityType.Wpa2Psk + }, + { + connected: false, + known: false, + ssid: "PSK Fixture", + signal: 42, + security: WifiSecurityType.Wpa2Psk + } + ] + root.phase = 2 + root.ticks = 0 + return + } + + if (root.phase === 2) { + if (root.ticks < 2) return + const pskCount = service.backend.pskConnectCount + if (service.connectWithPassphrase(root.stalePskEntry, "ambiguous") + || service.backend.pskConnectCount !== pskCount) + return root.fail("duplicate SSID/security identity was not rejected") + service.backend.wifiNetworks = [ + { + connected: false, + known: false, + ssid: "PSK Fixture", + signal: 71, + security: WifiSecurityType.Wpa2Psk + }, + { + connected: false, + known: false, + ssid: "PSK Fixture", + signal: 63, + security: WifiSecurityType.Wpa2Eap + } + ] + root.phase = 3 + root.ticks = 0 + return + } + + if (root.phase === 3) { + if (root.ticks < 2) return + const pskCount = service.backend.pskConnectCount + if (service.connectWithPassphrase(root.stalePskEntry, "heterogeneous") + || service.backend.pskConnectCount !== pskCount) + return root.fail("heterogeneous duplicate SSID was not rejected") + service.backend.wifiNetworks = [{ + connected: false, + known: false, + ssid: "PSK Fixture", + signal: 71, + security: WifiSecurityType.Wpa2Eap + }] + root.phase = 4 + root.ticks = 0 + return + } + + if (root.phase === 4) { + if (root.ticks < 2) return + const pskCount = service.backend.pskConnectCount + if (service.connectWithPassphrase(root.stalePskEntry, "changed") + || service.backend.pskConnectCount !== pskCount + || service.connectWithPassphrase(root.stalePskEntry, "") + || service.connect(root.staleProfileEntry) + || service.forget(root.staleProfileEntry)) + return root.fail("stale changed-security identity was not rejected") + service.backend.wifiNetworks = [{ + connected: false, + known: true, + ssid: "PSK Fixture", + signal: 70, + security: WifiSecurityType.Wpa2Psk + }] + root.phase = 5 + root.ticks = 0 + return + } + + if (root.phase === 5) { + if (root.ticks < 2) return + const pskCount = service.backend.pskConnectCount + panel.openPassword(root.stalePskEntry) + panel.passwordText = "obsolete" + if (service.connectWithPassphrase(root.stalePskEntry, "known") + || service.backend.pskConnectCount !== pskCount + || panel.submitPassword(root.stalePskEntry) + || panel.credentialError === "") + return root.fail("newly known credential target was not rejected") + panel.clearPassword() + service.backend.wifiNetworks = [ + { + connected: false, + known: false, + ssid: "PSK Fixture", + signal: 70, + security: WifiSecurityType.Wpa2Psk + }, + { + connected: false, + known: false, + ssid: "Enterprise Fixture", + signal: 65, + security: WifiSecurityType.Wpa2Eap + } + ] + service.savedProfiles = [ + { + uuid: "90909090-9090-9090-9090-909090909090", + ssid: "PSK Fixture", + keyManagement: "wpa-psk" + }, + { + uuid: "abababab-abab-abab-abab-abababababab", + ssid: "Enterprise Fixture", + keyManagement: "wpa-eap" + } + ] + const enterpriseCount = service.backend.enterpriseConnectCount + panel.openPassword(root.stalePskEntry) + panel.passwordText = "profile-loaded" + if (service.connectWithPassphrase(root.stalePskEntry, "profile-loaded") + || service.connectEnterprise(root.staleEnterpriseEntry, + "user@example.test", "profile-loaded") + || service.backend.pskConnectCount !== pskCount + || service.backend.enterpriseConnectCount !== enterpriseCount + || panel.submitPassword(root.stalePskEntry) + || panel.credentialError === "") + return root.fail("newly loaded saved profiles did not reject credentials") + panel.clearPassword() + service.savedProfiles = [] + service.backend.wifiNetworks = [{ + connected: true, + known: true, + ssid: "Enterprise Fixture", + signal: 65, + security: WifiSecurityType.Wpa2Eap + }] + root.phase = 6 + root.ticks = 0 + return + } + + if (root.phase === 6) { + if (root.ticks < 2) return + const enterpriseCount = service.backend.enterpriseConnectCount + if (service.connectEnterprise(root.staleEnterpriseEntry, + "user@example.test", "obsolete") + || service.backend.enterpriseConnectCount !== enterpriseCount) + return root.fail("newly connected enterprise target was not rejected") + service.backend.wifiNetworks = [{ + connected: false, + known: false, + ssid: "Completion Fixture", + signal: 69, + security: WifiSecurityType.Wpa2Psk + }] + root.phase = 7 + root.ticks = 0 + return + } + + if (root.phase === 7) { + if (root.ticks < 2) return + const completion = root.entryFor("Completion Fixture") + if (!completion) return + panel.openPassword(completion) + panel.passwordText = "completion-secret" + service.backend.actionSsid = "Completion Fixture" + service.backend.actionKind = "connect" + service.backend.actionKind = "" + if (panel.passwordKey === "") + return root.fail("credential editor closed before refreshed success state") + root.phase = 8 + root.ticks = 0 + return + } + + if (root.phase === 8) { + if (root.ticks < 2) return + if (panel.passwordKey === "") + return root.fail("out-of-order action completion closed stale editor") + service.backend.wifiNetworks = [{ + connected: true, + known: true, + ssid: "Completion Fixture", + signal: 69, + security: WifiSecurityType.Wpa2Psk + }] + root.phase = 9 + root.ticks = 0 + return + } + + if (root.phase === 9) { + if (root.ticks < 2) return + if (panel.passwordKey !== "" || !panel.panelKeyboardFocusActive) + return root.fail("refreshed connection did not close and restore focus") + const dualRows = [ + { + connected: false, + known: true, + ssid: "Dual Profile", + signal: 71, + security: WifiSecurityType.Wpa2Psk + }, + { + connected: false, + known: true, + ssid: "Dual Profile", + signal: 68, + security: WifiSecurityType.Wpa2Eap + } + ] + const dualProfiles = [ + { + uuid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + ssid: "Dual Profile", + keyManagement: "wpa-psk" + }, + { + uuid: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + ssid: "Dual Profile", + keyManagement: "wpa-eap" + } + ] + if (!profiledVisibleConnectService.ready + || !profiledVisibleConnectService.backend + || !profiledVisibleForgetService.ready + || !profiledVisibleForgetService.backend) return + profiledVisibleConnectService.savedProfiles = dualProfiles + profiledVisibleForgetService.savedProfiles = dualProfiles + profiledVisibleConnectService.backend.wifiNetworks = dualRows + profiledVisibleForgetService.backend.wifiNetworks = dualRows + root.phase = 10 + root.ticks = 0 + return + } + + if (root.phase === 10) { + if (root.ticks < 2) return + const personal = root.entryForService(profiledVisibleConnectService, + "Dual Profile", "psk") + const enterprise = root.entryForService(profiledVisibleForgetService, + "Dual Profile", "enterprise") + if (!personal || !enterprise + || personal.profileUuid + !== "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + || enterprise.profileUuid + !== "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + || !profiledVisibleConnectService.connect(personal) + || !profiledVisibleForgetService.forget(enterprise)) + return root.fail("security-matched visible profile UUID actions failed") + console.log("network credentials smoke passed") + stop() + Qt.quit() + } + } + } +} diff --git a/tests/network-plugin-regression.sh b/tests/network-plugin-regression.sh index 5ec80ea..9e71d9a 100755 --- a/tests/network-plugin-regression.sh +++ b/tests/network-plugin-regression.sh @@ -75,6 +75,36 @@ for pid_log in "$tmpdir/speedtest-pids" "$tmpdir/speedtest-child-pids" \ done <"$pid_log" done +install -m 0644 "$repo_root/tests/network-credentials-smoke.qml" \ + "$tmpdir/shell.qml" +# The credential contract exercises NetworkPanel's real decision and submission +# logic without mapping a production layer-shell surface in the offscreen test. +install -m 0644 "$repo_root/tests/fixtures/ShibumiPanelTest.qml" \ + "$tmpdir/network/ShibumiPanel.qml" +mkdir -p "$tmpdir/credentials-runtime" "$tmpdir/credentials-home" +chmod 700 "$tmpdir/credentials-runtime" +set +e +credentials_output=$(timeout 12 env \ + HOME="$tmpdir/credentials-home" \ + PATH="$tmpdir/bin:$PATH" \ + QT_QPA_PLATFORM=offscreen \ + WAYLAND_DISPLAY= \ + XDG_RUNTIME_DIR="$tmpdir/credentials-runtime" \ + QML_IMPORT_PATH="$omarchy_path/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \ + QML2_IMPORT_PATH="$omarchy_path/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \ + "$quickshell_bin" -p "$tmpdir" 2>&1) +credentials_rc=$? +set -e +printf '%s\n' "$credentials_output" +[[ $credentials_rc -eq 0 ]] \ + || fail "network credentials smoke exited $credentials_rc" +grep -F 'network credentials smoke passed' <<<"$credentials_output" >/dev/null \ + || fail "network credentials success marker missing" +if grep -Eq 'TypeError|ReferenceError|Binding loop|Unable to assign' \ + <<<"$credentials_output"; then + fail "network credentials smoke produced a QML runtime error" +fi + install -m 0644 "$repo_root/tests/network-scanner-lifecycle-smoke.qml" \ "$tmpdir/shell.qml" for scanner_mode in plugin canonical; do @@ -237,12 +267,85 @@ rg -Fq 'connectedVisibleLabel(visibleNetworks)' \ || fail "network service lacks the connected official-row SSID fallback" rg -Fq 'function connectEnterprise(entry, identity, passphrase)' "$service" \ || fail "network service lacks Quattro enterprise forwarding" -rg -Fq 'backend.connectEnterprise(entry.ssid, String(identity), String(passphrase))' \ +rg -Fq 'backend.connectEnterprise(String(entry.ssid || ""),' \ "$service" || fail "network service does not delegate 802.1X to the official owner" +rg -Fq 'function profileMatchesSecurity(profile, security)' "$service" \ + || fail "saved Wi-Fi profiles are associated by SSID without security" +rg -Fq 'wpa-eap-suite-b-192' "$service" \ + || fail "WPA3 Suite B profiles are conflated with generic WPA-EAP" +rg -Fq 'profile.authAlgorithm' "$service" \ + || fail "LEAP profiles are conflated with generic IEEE 802.1X" +rg -Fq '802-11-wireless-security.auth-alg' "$service" \ + || fail "saved profile inventory omits authentication mode metadata" +rg -Fq 'matchingProfiles.length === 1' "$service" \ + || fail "ambiguous saved Wi-Fi profiles can be assigned to a visible row" +rg -Fq 'function profileVisibleMatchCount(profile, visible)' "$service" \ + || fail "saved profiles are not matched globally across visible variants" +rg -Fq 'profileVisibleMatchCount(matchingProfiles[0], visibleRows) === 1' \ + "$service" \ + || fail "one saved profile can be assigned to multiple visible rows" +rg -Fq 'case "open": return security === WifiSecurityType.Open' "$service" \ + || fail "open saved profiles are not distinguished from WEP" +rg -Fq 'case "none": return security === WifiSecurityType.StaticWep' \ + "$service" \ + || fail "static WEP profiles can be assigned to open networks" +rg -Fq 'key_mgmt=open' "$service" \ + || fail "profiles without a wireless security setting are not inventoried as open" +rg -Fq 'representedProfileUuids' "$service" \ + || fail "unmatched saved Wi-Fi profiles disappear behind visible SSIDs" +rg -Fq 'function currentVisibleEntry(entry)' "$service" \ + || fail "credential actions do not resolve the current merged row" +rg -Fq 'const rows = mergedNetworks(visibleNetworks, savedProfiles)' "$service" \ + || fail "credential actions ignore newly associated saved profiles" +rg -Fq 'function savedProfileMatchCount(entry)' "$service" \ + || fail "credential actions ignore ambiguous compatible saved profiles" +rg -Fq 'current.connected === true || current.known === true' "$service" \ + || fail "credential actions accept newly connected or known rows" +rg -Fq 'function visibleIdentityMatchCount(entry)' "$service" \ + || fail "network service does not revalidate primitive visible identities" +rg -Fq 'function hasUniqueVisibleIdentity(entry)' "$service" \ + || fail "network service cannot reject ambiguous visible identities" +rg -Fq 'function hasUnambiguousVisibleSsid(entry)' "$service" \ + || fail "SSID-only backend actions accept heterogeneous duplicate SSIDs" +rg -Fq 'entry.profileUuid && entry.visible === false' "$service" \ + || fail "saved-only profile routing is not separated from visible rows" +if rg -q 'entry\.network|modelData\.network|network: source\.network' \ + "$service" "$repo_root/hancore.shibumi.network/NetworkPanel.qml"; then + fail "network view/action contract still depends on a live WifiNetwork QObject" +fi +rg -Fq 'function needsCredentials(entry)' \ + "$repo_root/hancore.shibumi.network/NetworkPanel.qml" \ + || fail "network panel lacks a primitive credential decision" +rg -Fq 'credentialDisplayNetworks = displayNetworks.slice()' \ + "$repo_root/hancore.shibumi.network/NetworkPanel.qml" \ + || fail "live network refreshes can rebuild the active credential editor" +rg -Fq 'model: panel.presentedNetworks' \ + "$repo_root/hancore.shibumi.network/NetworkPanel.qml" \ + || fail "credential rows do not preserve delegate identity while editing" +rg -Fq 'function evaluateCredentialCompletion()' \ + "$repo_root/hancore.shibumi.network/NetworkPanel.qml" \ + || fail "credential completion does not wait for refreshed network state" +rg -Fq 'credentialError = "Network changed. Select it again."' \ + "$repo_root/hancore.shibumi.network/NetworkPanel.qml" \ + || fail "rejected stale credentials have no explicit panel feedback" +rg -Fq 'requestPanelKeyboardFocus(keyCatcher)' \ + "$repo_root/hancore.shibumi.network/NetworkPanel.qml" \ + || fail "closing credentials does not restore panel keyboard focus" +rg -Fq 'function credentialEditorFocusChanged(editor, active)' \ + "$repo_root/hancore.shibumi.network/NetworkPanel.qml" \ + || fail "credential editor does not recover unexpected focus loss" +rg -Fq 'Component.onCompleted: if (visible' \ + "$repo_root/hancore.shibumi.network/NetworkPanel.qml" \ + || fail "new credential fields do not acquire initial focus" +rg -Fq 'panel.needsNetworkSettings(networkRow.modelData)' \ + "$repo_root/hancore.shibumi.network/NetworkPanel.qml" \ + || fail "network action label disagrees with primitive action routing" +rg -Fq 'typeof backend.disconnectRow !== "function"' "$service" \ + || fail "network disconnect does not resolve the current row by primitive identity" rg -Fq 'placeholderText: "Identity (user@domain)"' \ "$repo_root/hancore.shibumi.network/NetworkPanel.qml" \ || fail "network panel lacks the enterprise identity field" -rg -Fq 'networkService.connectEnterprise(entry, identityText, passwordText)' \ +rg -Fq 'accepted = networkService.connectEnterprise(' \ "$repo_root/hancore.shibumi.network/NetworkPanel.qml" \ || fail "network panel does not submit enterprise credentials" if rg -q 'nmcli.*(802-1x|wpa-eap|password|identity)' \ diff --git a/widgets/NetworkPanel.qml b/widgets/NetworkPanel.qml index f2bd616..4bebb09 100644 --- a/widgets/NetworkPanel.qml +++ b/widgets/NetworkPanel.qml @@ -18,11 +18,21 @@ ShibumiPanel { property string passwordKey: "" property string passwordText: "" property string identityText: "" + property var credentialDisplayNetworks: [] + property string credentialSsid: "" + property var credentialSecurity: null + property string credentialError: "" + property Item credentialEditor: null property string pendingForgetKey: "" property int cursorIndex: -1 readonly property bool wifiControlsVisible: networkService && networkService.wifiAvailable === true readonly property var displayNetworks: filteredNetworks() + readonly property var presentedNetworks: passwordKey !== "" + ? credentialDisplayNetworks : displayNetworks + readonly property bool credentialEditorFocused: !!(credentialEditor + && credentialEditor.activeFocus) + readonly property bool panelKeyboardFocusActive: keyCatcher.activeFocus readonly property int savedCount: { let count = 0 const rows = networkService ? networkService.networks : [] @@ -125,38 +135,109 @@ ShibumiPanel { } function clearPassword() { + const restorePanelFocus = passwordKey !== "" && open passwordKey = "" passwordText = "" identityText = "" + credentialDisplayNetworks = [] + credentialSsid = "" + credentialSecurity = null + credentialError = "" + credentialEditor = null + if (restorePanelFocus) requestPanelKeyboardFocus(keyCatcher) } function openPassword(entry) { + credentialDisplayNetworks = displayNetworks.slice() + credentialSsid = String(entry && entry.ssid || "") + credentialSecurity = entry ? entry.security : null + credentialError = "" expandedKey = entryKey(entry) passwordKey = expandedKey passwordText = "" identityText = "" } + function currentCredentialRow() { + let result = null + let count = 0 + const rows = displayNetworks + for (let i = 0; i < rows.length; i++) { + const row = rows[i] + if (!row || row.visible === false + || String(row.ssid || "") !== credentialSsid + || row.security !== credentialSecurity) continue + result = row + count++ + } + return count === 1 ? result : null + } + + function evaluateCredentialCompletion() { + if (passwordKey === "") return + const current = currentCredentialRow() + if (current && current.connected === true) clearPassword() + } + + function requestPanelKeyboardFocus(target) { + if (!open || !target) return + if (typeof requestKeyboardFocus === "function") { + requestKeyboardFocus(target) + return + } + Qt.callLater(function() { + if (panel.open && target) target.forceActiveFocus() + }) + } + + function registerCredentialEditor(editor) { + if (!editor) return + credentialEditor = editor + requestPanelKeyboardFocus(editor) + } + + function credentialEditorFocusChanged(editor, active) { + if (!editor) return + if (active) { + credentialEditor = editor + return + } + Qt.callLater(function() { + if (panel.open && panel.passwordKey !== "" + && panel.credentialEditor === editor && editor.visible + && !editor.activeFocus && keyCatcher.activeFocus) + panel.requestPanelKeyboardFocus(editor) + }) + } + function openNetworkSettings() { ownerWidget.close() if (bar && typeof bar.run === "function") bar.run("omarchy-launch-or-focus-tui nmtui") } + function needsCredentials(entry) { + return !!entry && !entry.known + && (entry.securityKind === "psk" + || entry.securityKind === "enterprise") + } + + function needsNetworkSettings(entry) { + return !!entry && !entry.known && !needsCredentials(entry) + && entry.securityKind !== "open" + } + function runPrimary(entry) { if (!entry || networkService.busy) return if (entry.connected) { networkService.disconnect(entry) return } - if (entry.network && !entry.known - && (entry.securityKind === "psk" - || entry.securityKind === "enterprise")) { + if (needsCredentials(entry)) { openPassword(entry) return } - if (entry.network && !entry.known - && entry.securityKind !== "open") { + if (needsNetworkSettings(entry)) { openNetworkSettings() return } @@ -164,13 +245,18 @@ ShibumiPanel { } function submitPassword(entry) { - if (!entry || !passwordText || networkService.busy) return + if (!entry || !passwordText || networkService.busy) return false + let accepted = false if (entry.securityKind === "enterprise") { - if (!identityText) return - networkService.connectEnterprise(entry, identityText, passwordText) + if (!identityText) return false + accepted = networkService.connectEnterprise( + entry, identityText, passwordText) } else { - networkService.connectWithPassphrase(entry, passwordText) + accepted = networkService.connectWithPassphrase(entry, passwordText) } + if (!accepted) + credentialError = "Network changed. Select it again." + return accepted } function requestForget(entry) { @@ -210,6 +296,8 @@ ShibumiPanel { onDisplayNetworksChanged: { if (cursorIndex >= displayNetworks.length) cursorIndex = displayNetworks.length - 1 if (displayNetworks.length === 0) cursorIndex = -1 + if (passwordKey !== "") + Qt.callLater(function() { panel.evaluateCredentialCompletion() }) } onOpenChanged: { @@ -230,16 +318,9 @@ ShibumiPanel { target: panel.networkService function onActionKindChanged() { - if (!panel.networkService || panel.networkService.actionKind !== "") return - const key = panel.passwordKey - if (!key) return - const rows = panel.displayNetworks - for (let i = 0; i < rows.length; i++) { - if (panel.entryKey(rows[i]) === key && rows[i].connected) { - panel.clearPassword() - return - } - } + if (!panel.networkService || panel.networkService.actionKind !== "" + || panel.passwordKey === "") return + Qt.callLater(function() { panel.evaluateCredentialCompletion() }) } } @@ -253,6 +334,14 @@ ShibumiPanel { id: keyCatcher anchors.fill: parent blocked: panel.passwordKey !== "" + onActiveFocusChanged: { + if (activeFocus && panel.passwordKey !== "" + && panel.credentialEditor) Qt.callLater(function() { + if (keyCatcher.activeFocus && panel.open + && panel.passwordKey !== "" && panel.credentialEditor) + panel.requestPanelKeyboardFocus(panel.credentialEditor) + }) + } onCloseRequested: panel.ownerWidget.close() onTabRequested: function(direction) { panel.ownerWidget.switchPanel(direction) } onMoveRequested: function(_dx, dy) { @@ -604,7 +693,10 @@ ShibumiPanel { visible: panel.wifiControlsVisible Repeater { - model: panel.displayNetworks + // Scanner/signal updates replace the service's JS array. Keep the + // delegate containing an active credential editor alive until the + // editor closes, while actions still revalidate against live rows. + model: panel.presentedNetworks Ui.CursorSurface { id: networkRow @@ -722,10 +814,7 @@ ShibumiPanel { label: networkRow.actionRunning ? (networkRow.modelData.connected ? "Disconnecting..." : "Connecting...") : networkRow.modelData.connected ? "Disconnect" - : networkRow.modelData.network && !networkRow.modelData.known - && networkRow.modelData.securityKind !== "open" - && networkRow.modelData.securityKind !== "psk" - && networkRow.modelData.securityKind !== "enterprise" + : panel.needsNetworkSettings(networkRow.modelData) ? "Open network settings" : "Connect" enabled: !panel.networkService.busy onClicked: panel.runPrimary(networkRow.modelData) @@ -752,7 +841,8 @@ ShibumiPanel { id: identityField width: parent.width height: Commons.Style.space(30) - visible: networkRow.enterpriseCredentials + visible: networkRow.passwordOpen + && networkRow.enterpriseCredentials text: panel.identityText placeholderText: "Identity (user@domain)" color: panel.bar ? panel.bar.foreground : Commons.Color.foreground @@ -765,11 +855,18 @@ ShibumiPanel { onTextChanged: panel.identityText = text onAccepted: passwordField.forceActiveFocus() Keys.onEscapePressed: panel.clearPassword() + onActiveFocusChanged: + panel.credentialEditorFocusChanged(identityField, + activeFocus) onVisibleChanged: { - if (visible) Qt.callLater(function() { - identityField.forceActiveFocus() - }) + if (visible) panel.registerCredentialEditor(identityField) + else if (panel.credentialEditor === identityField) + panel.credentialEditor = null } + Component.onCompleted: if (visible) + panel.registerCredentialEditor(identityField) + Component.onDestruction: if (panel.credentialEditor === identityField) + panel.credentialEditor = null background: Rectangle { radius: panel.controlRadius color: identityField.activeFocus @@ -781,6 +878,17 @@ ShibumiPanel { } } + Text { + width: parent.width + visible: panel.credentialError !== "" + text: panel.credentialError + color: panel.bar ? panel.bar.urgent : Commons.Color.accent + font.family: panel.bar + ? panel.bar.fontFamily : Commons.Style.font.family + font.pixelSize: Commons.Style.font.caption + wrapMode: Text.Wrap + } + Row { width: parent.width spacing: Commons.Style.space(5) @@ -790,6 +898,7 @@ ShibumiPanel { width: parent.width - connectPassword.width - cancelPassword.width - 2 * parent.spacing height: Commons.Style.space(30) + visible: networkRow.passwordOpen text: panel.passwordText placeholderText: networkRow.enterpriseCredentials ? "Enterprise password" : "Wi-Fi password" @@ -804,10 +913,21 @@ ShibumiPanel { onTextChanged: panel.passwordText = text onAccepted: panel.submitPassword(networkRow.modelData) Keys.onEscapePressed: panel.clearPassword() + onActiveFocusChanged: + panel.credentialEditorFocusChanged(passwordField, + activeFocus) onVisibleChanged: { if (visible && !networkRow.enterpriseCredentials) - Qt.callLater(function() { passwordField.forceActiveFocus() }) + panel.registerCredentialEditor(passwordField) + else if (!visible + && panel.credentialEditor === passwordField) + panel.credentialEditor = null } + Component.onCompleted: if (visible + && !networkRow.enterpriseCredentials) + panel.registerCredentialEditor(passwordField) + Component.onDestruction: if (panel.credentialEditor === passwordField) + panel.credentialEditor = null background: Rectangle { radius: panel.controlRadius color: passwordField.activeFocus @@ -826,6 +946,7 @@ ShibumiPanel { enabled: panel.passwordText !== "" && (!networkRow.enterpriseCredentials || panel.identityText !== "") + && panel.credentialError === "" && !panel.networkService.busy onClicked: panel.submitPassword(networkRow.modelData) } From db53fd06a82b86ba44541017aa4bda7e3c8eb3a3 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:21:35 +0200 Subject: [PATCH 3/3] docs: require phased delivery approvals --- AGENTS.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 80422ac..778893f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,32 @@ SHIBUMI_AGENTS_OMARCHY_PATH=/tmp/omarchy-agents-b99fd91 \ Live acceptance must record what was actually exercised and must not claim unavailable hardware, credentials, multi-monitor, nested-compositor, or clean-chroot evidence as passed. Physical hardware gates require raw setup details, relevant command output, screenshots or video, and sanitized logs; fixtures cannot replace them. +## Change delivery approvals + +Treat each delivery phase as a separate authorization boundary. Approval for one +phase never authorizes a later phase, and a bundled request must still be +confirmed immediately before each phase begins. + +1. **Branch or worktree preparation:** show the intended branch and worktree + path, then obtain explicit approval before creating, deleting, or resetting + either one. +2. **Commit:** show the files or staged diff, validation result, and proposed + English commit message, then obtain explicit approval before creating the + local commit. +3. **Push:** report the exact commit hashes, source branch, and destination + remote branch, then obtain a new explicit approval before pushing. +4. **Pull request:** show the proposed base, head, title, body summary, and any + issue-closing keywords, then obtain a new explicit approval before creating, + editing, commenting on, or otherwise mutating the PR. +5. **Merge:** wait for the required checks and reviews, report their results and + the intended merge method, then obtain a new explicit approval before + merging. Do not treat PR approval or a general request to finish as merge + authorization. + +Issue comments, labels, and closure are independent GitHub writes and require +separate explicit approval. Read-only status and CI inspection do not require +approval. If authorization is unclear, stop at the current phase and ask. + ## Release safety - Do not push, tag, publish a release, update Omarchy, log out, or reboot unless explicitly authorized.