Harden gaming mode and fix startup crash

Strut windows committed zero height to layer-shell during
early init, causing a protocol error and session exit. Wrap
height and exclusionZone in Math.max(1, ...) for top bar
and dock.

Persisted gamingModeEnabled=true with hardcoded
gameCenterOpen=false hid panels with no overlay visible.
Initialize gameCenterOpen from the stored setting.

Fix HUD visibility regression from an emergency visible:true
override. Various null-guard and positioning fixes from
static analysis across the gaming shell QML.
This commit is contained in:
Marco Allegretti 2026-04-20 08:57:03 +02:00
parent 50ea5976ce
commit 70fa861b30
18 changed files with 129 additions and 52 deletions

7
Nebra.code-workspace Normal file
View file

@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}

View file

@ -86,13 +86,15 @@ QQuickItem *AppletHost::fullRepresentationFor(const QString &pluginId)
m_containment->addApplet(applet);
auto *item = PlasmaQuick::AppletQuickItem::itemForApplet(applet);
m_items.insert(pluginId, item);
if (!item) {
qWarning() << "AppletHost: no AppletQuickItem for" << pluginId;
applet->deleteLater();
return nullptr;
}
m_items.insert(pluginId, item);
item->setPreloadFullRepresentation(true);
auto *fullRepItem = item->fullRepresentationItem();

View file

@ -165,6 +165,7 @@ Item {
shrinkSize: 0
onClicked: {
if (!root.taskModel) return;
root.taskModel.requestActivate(root.taskModel.makeModelIndex(taskDelegate.index));
}
@ -180,18 +181,27 @@ Item {
Controls.MenuItem {
text: taskDelegate.model.IsMinimized ? i18n("Restore") : i18n("Minimize")
icon.name: taskDelegate.model.IsMinimized ? "window-restore" : "window-minimize"
onTriggered: root.taskModel.requestToggleMinimized(root.taskModel.makeModelIndex(taskDelegate.index))
onTriggered: {
if (!root.taskModel) return;
root.taskModel.requestToggleMinimized(root.taskModel.makeModelIndex(taskDelegate.index))
}
}
Controls.MenuItem {
text: taskDelegate.model.IsMaximized ? i18n("Restore") : i18n("Maximize")
icon.name: taskDelegate.model.IsMaximized ? "window-restore" : "window-maximize"
onTriggered: root.taskModel.requestToggleMaximized(root.taskModel.makeModelIndex(taskDelegate.index))
onTriggered: {
if (!root.taskModel) return;
root.taskModel.requestToggleMaximized(root.taskModel.makeModelIndex(taskDelegate.index))
}
}
Controls.MenuSeparator {}
Controls.MenuItem {
text: i18n("Close")
icon.name: "window-close"
onTriggered: root.taskModel.requestClose(root.taskModel.makeModelIndex(taskDelegate.index))
onTriggered: {
if (!root.taskModel) return;
root.taskModel.requestClose(root.taskModel.makeModelIndex(taskDelegate.index))
}
}
}

View file

@ -706,12 +706,16 @@ MouseArea {
x: {
if (!targetDelegate) return 0
var delegateGlobal = targetDelegate.mapToGlobal(0, 0)
return Math.max(0, Math.min(Screen.width - width, delegateGlobal.x + (targetDelegate.width - width) / 2))
var win = targetDelegate.Window.window
var scrW = win && win.screen ? win.screen.width : Screen.width
return Math.max(0, Math.min(scrW - width, delegateGlobal.x + (targetDelegate.width - width) / 2))
}
y: {
if (!targetDelegate) return 0
var delegateGlobal = targetDelegate.mapToGlobal(0, 0)
return Math.max(0, Math.min(Screen.height - height, delegateGlobal.y - height - Kirigami.Units.smallSpacing))
var win = targetDelegate.Window.window
var scrH = win && win.screen ? win.screen.height : Screen.height
return Math.max(0, Math.min(scrH - height, delegateGlobal.y - height - Kirigami.Units.smallSpacing))
}
onShowingChanged: {

View file

@ -94,6 +94,7 @@ Window {
text: i18n("Exit Gaming Mode")
display: QQC2.AbstractButton.TextBesideIcon
Keys.onReturnPressed: clicked()
Keys.onEnterPressed: clicked()
onClicked: root.requestExitGamingMode()
}
}
@ -183,7 +184,10 @@ Window {
},
Kirigami.Action {
text: i18n("Leave")
onTriggered: ShellSettings.Settings.gamingModeEnabled = false
onTriggered: {
ShellSettings.Settings.gamingModeEnabled = false
theExitDialog.close()
}
}
]
onClosed: exitGamingDialog.active = false

View file

@ -27,7 +27,7 @@ QQC2.ItemDelegate {
onClicked: {
if (!application) return
if (application.icon !== "" && !application.running) {
if (application.icon && typeof application.icon === "string" && application.icon.length > 0 && !application.running) {
MobileShellState.ShellDBusClient.openAppLaunchAnimationWithPosition(
Plasmoid.screen,
application.icon,

View file

@ -14,8 +14,11 @@ Window {
signal openRequested()
width: Kirigami.Units.gridUnit * 4
height: Kirigami.Units.gridUnit * 2
// Guard against startup timing where Kirigami units may briefly be 0/NaN.
// LayerShell surfaces must never be committed with zero size.
readonly property real safeGridUnit: ((Kirigami.Units.gridUnit || 0) > 0) ? Kirigami.Units.gridUnit : 16
width: safeGridUnit * 4
height: safeGridUnit * 2
color: "transparent"
flags: Qt.FramelessWindowHint
@ -25,7 +28,11 @@ Window {
LayerShell.Window.exclusionZone: 0
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
opacity: visible ? 1 : 0
// Driven by the Loader in folio/qml/main.qml set false to fade out
// before the Loader destroys the window.
property bool showing: true
opacity: showing ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad }
}

View file

@ -181,6 +181,7 @@ Item {
id: resumeArea
anchors.fill: parent
hoverEnabled: true
z: -1
// Leave room for the close button
onClicked: {
taskList.currentIndex = index

View file

@ -34,8 +34,9 @@ ContainmentItem {
property Folio.HomeScreen folio: root.plasmoid
// Tracks whether the Game Center grid is visible within gaming mode.
// Starts true when gaming mode turns on; set to false by a game launch.
property bool gameCenterOpen: false
// If gaming mode is already enabled at startup, open it immediately so
// the user is never left without controls.
property bool gameCenterOpen: ShellSettings.Settings.gamingModeEnabled
property bool showGameCenterHint: false
Timer {
@ -52,6 +53,7 @@ ContainmentItem {
}
Component.onCompleted: {
root.gameCenterOpen = ShellSettings.Settings.gamingModeEnabled
folio.FolioSettings.load();
folio.FavouritesModel.load();
folio.PageListModel.load();
@ -698,12 +700,13 @@ ContainmentItem {
// Small persistent button at the top-right corner of the screen that lets
// the user return to the Game Center after launching a game.
// Wrapped in a Loader so the LayerShell Window is destroyed (not merely
// hidden) when not needed hiding a LayerShell window with AnchorTop|Right
// but no fixed height causes a Wayland protocol error (height=0).
// Keep the Loader active for the full duration of gaming mode so the
// opacity Behavior in GamingHUD can animate both fade-in and fade-out.
Loader {
active: ShellSettings.Settings.gamingModeEnabled && !root.gameCenterOpen
active: ShellSettings.Settings.gamingModeEnabled
sourceComponent: GamingHUD {
visible: showing
showing: !root.gameCenterOpen
onOpenRequested: root.gameCenterOpen = true
}
}

View file

@ -131,6 +131,7 @@ Item {
context: Qt.ApplicationShortcut
onActivated: {
if (drawer.actionDrawer.intendedToBeVisible) {
drawer.actionDrawer.intendedToBeVisible = false;
drawer.actionDrawer.close();
} else {
drawer.actionDrawer.openToPinnedMode = false;

View file

@ -132,7 +132,7 @@ ContainmentItem {
function onGamingModeEnabledChanged() {
root.setWindowProperties();
MobileShellState.ShellDBusClient.panelState = ShellSettings.Settings.gamingModeEnabled ? "hidden" : "default";
MobileShellState.ShellDBusClient.panelState = ShellSettings.Settings.gamingModeEnabled ? "hidden" : (fullscreen ? "hidden" : "default");
}
}
@ -150,13 +150,13 @@ ContainmentItem {
visible: ShellSettings.Settings.convergenceModeEnabled && !ShellSettings.Settings.gamingModeEnabled
color: "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput
height: root.panelHeight
height: Math.max(1, root.panelHeight)
width: 1
LayerShell.Window.scope: "topbar-space"
LayerShell.Window.layer: LayerShell.Window.LayerBottom
LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight
LayerShell.Window.exclusionZone: root.panelHeight
LayerShell.Window.exclusionZone: Math.max(1, root.panelHeight)
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
}

View file

@ -141,7 +141,7 @@ ContainmentItem {
function onGamingModeEnabledChanged() {
root.setWindowProperties();
navigationPanel.offset = ShellSettings.Settings.gamingModeEnabled ? root.navigationPanelHeight : 0;
navigationPanel.offset = ShellSettings.Settings.gamingModeEnabled ? MobileShell.Constants.navigationPanelThickness : 0;
}
}
@ -166,13 +166,13 @@ ContainmentItem {
color: "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput
// height is set by layer-shell anchoring; provide a fallback.
height: Kirigami.Units.gridUnit * 3
height: Math.max(1, MobileShell.Constants.navigationPanelThickness)
width: 1 // layer-shell stretches it via AnchorLeft|AnchorRight
LayerShell.Window.scope: "dock-space"
LayerShell.Window.layer: LayerShell.Window.LayerBottom
LayerShell.Window.anchors: LayerShell.Window.AnchorBottom | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight
LayerShell.Window.exclusionZone: Kirigami.Units.gridUnit * 3
LayerShell.Window.exclusionZone: Math.max(1, MobileShell.Constants.navigationPanelThickness)
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
}

View file

@ -11,9 +11,9 @@ Loader {
property var currentWindow
// Window that needs geometry clamping after un-maximize in convergence
// mode. Set in onMaximizedChanged and consumed by the timer below.
property var pendingConstrainWindow: null
// Windows awaiting geometry clamping after un-maximize in convergence
// mode. Using an array so concurrent un-maximizes are not lost.
property var pendingConstrainWindows: []
// After a window is un-maximized in convergence mode, the dockSpaceReserver
// LayerShell surface needs one Wayland roundtrip to (re)commit its exclusive
@ -24,27 +24,30 @@ Loader {
id: constrainAfterRestoreTimer
interval: 200
onTriggered: {
const window = root.pendingConstrainWindow
root.pendingConstrainWindow = null
if (!window || window.deleted || !window.normalWindow) return
if (!ShellSettings.Settings.convergenceModeEnabled) return
const windows = root.pendingConstrainWindows.slice()
root.pendingConstrainWindows = []
for (const window of windows) {
if (!window || window.deleted || !window.normalWindow) continue
if (!ShellSettings.Settings.convergenceModeEnabled) continue
if (ShellSettings.Settings.gamingModeEnabled) continue
const output = window.output
const desktop = window.desktops[0]
if (!output) return
if (!desktop) return
const output = window.output
const desktop = window.desktops[0]
if (!output) continue
if (!desktop) continue
const maxRect = KWinComponents.Workspace.clientArea(
KWinComponents.Workspace.MaximizeArea, output, desktop)
const geo = window.frameGeometry
const maxBottom = maxRect.y + maxRect.height
const maxRect = KWinComponents.Workspace.clientArea(
KWinComponents.Workspace.MaximizeArea, output, desktop)
const geo = window.frameGeometry
const maxBottom = maxRect.y + maxRect.height
if (geo.y + geo.height > maxBottom) {
// Clip the bottom edge to MaximizeArea; preserve top position
// and width. Ensure height is at least 100px to avoid
// pathological cases where the window starts above maxRect.
const newH = Math.max(100, maxBottom - geo.y)
window.frameGeometry = Qt.rect(geo.x, geo.y, geo.width, newH)
if (geo.y + geo.height > maxBottom) {
// Clip the bottom edge to MaximizeArea; preserve top position
// and width. Ensure height is at least 100px to avoid
// pathological cases where the window starts above maxRect.
const newH = Math.max(100, maxBottom - geo.y)
window.frameGeometry = Qt.rect(geo.x, geo.y, geo.width, newH)
}
}
}
}
@ -97,6 +100,9 @@ Loader {
target: currentWindow
function onFullScreenChanged() {
if (!currentWindow) {
return;
}
currentWindow.interactiveMoveResizeFinished.connect((currentWindow) => {
root.run(currentWindow);
});
@ -104,6 +110,9 @@ Loader {
}
function onMaximizedChanged() {
if (!currentWindow) {
return;
}
if (!currentWindow.maximizable) {
return;
}
@ -116,7 +125,7 @@ Loader {
// zone is re-committed over a Wayland roundtrip.
if (ShellSettings.Settings.convergenceModeEnabled
&& ShellSettings.Settings.autoHidePanelsEnabled) {
root.pendingConstrainWindow = currentWindow
root.pendingConstrainWindows.push(currentWindow)
constrainAfterRestoreTimer.restart()
}
}
@ -150,6 +159,9 @@ Loader {
target: KWinComponents.Workspace
function onWindowAdded(window) {
if (!window) {
return;
}
if (window.normalWindow) {
window.interactiveMoveResizeFinished.connect((window) => {
root.run(window);
@ -159,6 +171,9 @@ Loader {
}
function onWindowActivated(window) {
if (!window) {
return;
}
if (window.normalWindow) {
currentWindow = window;
window.interactiveMoveResizeFinished.connect((window) => {

View file

@ -3,4 +3,14 @@
# SPDX-FileCopyrightText: 2026 Marco Allegretti
# SPDX-License-Identifier: EUPL-1.2
$XGETTEXT `find . -name \*.js -o -name \*.qml -o -name \*.cpp` -o $podir/plasma_org.kde.plasma.quicksetting.gaming.pot
set -e
: "${XGETTEXT:?XGETTEXT is not set}"
: "${podir:?podir is not set}"
mapfile -t files < <(find . \( -name '*.js' -o -name '*.qml' -o -name '*.cpp' \) -print)
if [[ ${#files[@]} -eq 0 ]]; then
echo "Messages.sh: no input files found" >&2
exit 1
fi
"$XGETTEXT" "${files[@]}" -o "$podir/plasma_org.kde.plasma.quicksetting.gaming.pot"

View file

@ -18,7 +18,6 @@ QS.QuickSetting {
function requestDisable() {
confirmDisableDialog.active = true;
confirmDisableDialog.item.open();
}
function toggle() {
@ -33,6 +32,7 @@ QS.QuickSetting {
Loader {
id: confirmDisableDialog
active: false
onLoaded: item.open()
sourceComponent: Kirigami.PromptDialog {
id: theConfirmDialog
@ -46,7 +46,10 @@ QS.QuickSetting {
},
Kirigami.Action {
text: i18n("Leave")
onTriggered: ShellSettings.Settings.gamingModeEnabled = false
onTriggered: {
ShellSettings.Settings.gamingModeEnabled = false
theConfirmDialog.close()
}
}
]
onClosed: confirmDisableDialog.active = false

View file

@ -2,4 +2,14 @@
# SPDX-FileCopyrightText: 2026 Marco Allegretti
# SPDX-License-Identifier: EUPL-1.2
$XGETTEXT *.json contents/ui/*.qml -o $podir/plasma_mobile_qt.pot
set -e
: "${XGETTEXT:?XGETTEXT is not set}"
: "${podir:?podir is not set}"
shopt -s nullglob
files=(*.json contents/ui/*.qml)
if [[ ${#files[@]} -eq 0 ]]; then
echo "Messages.sh: no input files found" >&2
exit 1
fi
"$XGETTEXT" "${files[@]}" -o "$podir/plasma_mobile_qt.pot"

View file

@ -9,8 +9,8 @@ import org.kde.plasma.private.mobileshell.quicksettingsplugin as QS
QS.QuickSetting {
text: i18n("Launch Hint")
icon: "dialog-information"
status: ""
enabled: ShellSettings.Settings.gamingDismissHintEnabled
status: ShellSettings.Settings.gamingDismissHintEnabled ? i18n("On") : i18n("Off")
enabled: true
available: ShellSettings.Settings.gamingModeEnabled
function toggle() {

View file

@ -1,2 +1,2 @@
SPDX-FileCopyrightText: 2026 Marco Allegretti.
SPDX-FileCopyrightText: 2026 Marco Allegretti
SPDX-License-Identifier: EUPL-1.2