From 70fa861b307f066355099bb3e377148b05807d94 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 20 Apr 2026 08:57:03 +0200 Subject: [PATCH] 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. --- Nebra.code-workspace | 7 +++ .../mobileshell/components/applethost.cpp | 4 +- .../qml/navigationpanel/NavigationPanel.qml | 16 ++++- .../homescreens/folio/qml/FavouritesBar.qml | 8 ++- .../folio/qml/gaming/GameCenterOverlay.qml | 6 +- .../homescreens/folio/qml/gaming/GameTile.qml | 2 +- .../folio/qml/gaming/GamingHUD.qml | 13 +++- .../folio/qml/gaming/RunningGamesView.qml | 1 + containments/homescreens/folio/qml/main.qml | 15 +++-- containments/panel/qml/StatusPanel.qml | 1 + containments/panel/qml/main.qml | 6 +- containments/taskpanel/qml/main.qml | 6 +- .../convergentwindows/contents/ui/main.qml | 59 ++++++++++++------- quicksettings/gaming/Messages.sh | 12 +++- quicksettings/gaming/contents/ui/main.qml | 7 ++- quicksettings/gaminghint/Messages.sh | 12 +++- quicksettings/gaminghint/contents/ui/main.qml | 4 +- screenshots/SHIFT-touch.png.license | 2 +- 18 files changed, 129 insertions(+), 52 deletions(-) create mode 100644 Nebra.code-workspace diff --git a/Nebra.code-workspace b/Nebra.code-workspace new file mode 100644 index 00000000..ef9f5d27 --- /dev/null +++ b/Nebra.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/components/mobileshell/components/applethost.cpp b/components/mobileshell/components/applethost.cpp index 9fe7cf16..5968e343 100644 --- a/components/mobileshell/components/applethost.cpp +++ b/components/mobileshell/components/applethost.cpp @@ -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(); diff --git a/components/mobileshell/qml/navigationpanel/NavigationPanel.qml b/components/mobileshell/qml/navigationpanel/NavigationPanel.qml index 80e0b13a..758aaff5 100644 --- a/components/mobileshell/qml/navigationpanel/NavigationPanel.qml +++ b/components/mobileshell/qml/navigationpanel/NavigationPanel.qml @@ -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)) + } } } diff --git a/containments/homescreens/folio/qml/FavouritesBar.qml b/containments/homescreens/folio/qml/FavouritesBar.qml index 3e338c08..485d9173 100644 --- a/containments/homescreens/folio/qml/FavouritesBar.qml +++ b/containments/homescreens/folio/qml/FavouritesBar.qml @@ -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: { diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index ebe6a6c4..5caf136e 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -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 diff --git a/containments/homescreens/folio/qml/gaming/GameTile.qml b/containments/homescreens/folio/qml/gaming/GameTile.qml index 2a65ce4d..ef821540 100644 --- a/containments/homescreens/folio/qml/gaming/GameTile.qml +++ b/containments/homescreens/folio/qml/gaming/GameTile.qml @@ -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, diff --git a/containments/homescreens/folio/qml/gaming/GamingHUD.qml b/containments/homescreens/folio/qml/gaming/GamingHUD.qml index fd52aeac..6c04e3b8 100644 --- a/containments/homescreens/folio/qml/gaming/GamingHUD.qml +++ b/containments/homescreens/folio/qml/gaming/GamingHUD.qml @@ -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 } } diff --git a/containments/homescreens/folio/qml/gaming/RunningGamesView.qml b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml index 469a4d7b..97f1c113 100644 --- a/containments/homescreens/folio/qml/gaming/RunningGamesView.qml +++ b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml @@ -181,6 +181,7 @@ Item { id: resumeArea anchors.fill: parent hoverEnabled: true + z: -1 // Leave room for the close button onClicked: { taskList.currentIndex = index diff --git a/containments/homescreens/folio/qml/main.qml b/containments/homescreens/folio/qml/main.qml index e645c4d9..15e7dabd 100644 --- a/containments/homescreens/folio/qml/main.qml +++ b/containments/homescreens/folio/qml/main.qml @@ -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 } } diff --git a/containments/panel/qml/StatusPanel.qml b/containments/panel/qml/StatusPanel.qml index 091db624..11f78247 100644 --- a/containments/panel/qml/StatusPanel.qml +++ b/containments/panel/qml/StatusPanel.qml @@ -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; diff --git a/containments/panel/qml/main.qml b/containments/panel/qml/main.qml index 2e151540..7effa258 100644 --- a/containments/panel/qml/main.qml +++ b/containments/panel/qml/main.qml @@ -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 } diff --git a/containments/taskpanel/qml/main.qml b/containments/taskpanel/qml/main.qml index f9f4bb87..31cde951 100644 --- a/containments/taskpanel/qml/main.qml +++ b/containments/taskpanel/qml/main.qml @@ -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 } diff --git a/kwin/scripts/convergentwindows/contents/ui/main.qml b/kwin/scripts/convergentwindows/contents/ui/main.qml index ce00568b..deb73722 100644 --- a/kwin/scripts/convergentwindows/contents/ui/main.qml +++ b/kwin/scripts/convergentwindows/contents/ui/main.qml @@ -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) => { diff --git a/quicksettings/gaming/Messages.sh b/quicksettings/gaming/Messages.sh index 37df498c..f1cdd746 100644 --- a/quicksettings/gaming/Messages.sh +++ b/quicksettings/gaming/Messages.sh @@ -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" diff --git a/quicksettings/gaming/contents/ui/main.qml b/quicksettings/gaming/contents/ui/main.qml index 6d022f1c..a99e9660 100644 --- a/quicksettings/gaming/contents/ui/main.qml +++ b/quicksettings/gaming/contents/ui/main.qml @@ -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 diff --git a/quicksettings/gaminghint/Messages.sh b/quicksettings/gaminghint/Messages.sh index 73c7528f..51cc9cbd 100644 --- a/quicksettings/gaminghint/Messages.sh +++ b/quicksettings/gaminghint/Messages.sh @@ -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" diff --git a/quicksettings/gaminghint/contents/ui/main.qml b/quicksettings/gaminghint/contents/ui/main.qml index fbf548b1..54073fa1 100644 --- a/quicksettings/gaminghint/contents/ui/main.qml +++ b/quicksettings/gaminghint/contents/ui/main.qml @@ -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() { diff --git a/screenshots/SHIFT-touch.png.license b/screenshots/SHIFT-touch.png.license index d31163d3..e1e3a62d 100644 --- a/screenshots/SHIFT-touch.png.license +++ b/screenshots/SHIFT-touch.png.license @@ -1,2 +1,2 @@ -SPDX-FileCopyrightText: 2026 Marco Allegretti. +SPDX-FileCopyrightText: 2026 Marco Allegretti SPDX-License-Identifier: EUPL-1.2