From aabb8d0f4b31a72533f7718c7ff81c91792e7f80 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Fri, 30 Jan 2026 11:02:28 +0100 Subject: [PATCH] Couch Mode: add diagnostics overlay --- src/CMakeLists.txt | 1 + src/gamelauncher.cpp | 153 +++++++++++++++++++++++++++ src/gamelauncher.h | 2 + src/qml/DiagnosticsSheet.qml | 126 ++++++++++++++++++++++ src/qml/GameDetailsSheet.qml | 9 ++ src/qml/Main.qml | 52 +++++++-- src/qml/components/BottomHintBar.qml | 14 ++- 7 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 src/qml/DiagnosticsSheet.qml diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7493b46..4b23883 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -64,6 +64,7 @@ ecm_add_qml_module(alakarte URI org.kde.alakarte qml/LibraryView.qml qml/GameCard.qml qml/GameDetailsSheet.qml + qml/DiagnosticsSheet.qml qml/SettingsPage.qml qml/SidebarView.qml qml/GameEditDialog.qml diff --git a/src/gamelauncher.cpp b/src/gamelauncher.cpp index 8afe8e6..eeb9c5b 100644 --- a/src/gamelauncher.cpp +++ b/src/gamelauncher.cpp @@ -132,6 +132,159 @@ bool GameLauncher::hasRunningGames() const return !m_runningGames.isEmpty(); } +QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const +{ + QVariantMap info; + info.insert(QStringLiteral("ok"), false); + + if (!game) { + info.insert(QStringLiteral("error"), tr("No game selected")); + return info; + } + + info.insert(QStringLiteral("gameId"), game->id()); + info.insert(QStringLiteral("gameName"), game->name()); + info.insert(QStringLiteral("isRunning"), m_runningGames.contains(game->id())); + + const QString command = game->launchCommand(); + info.insert(QStringLiteral("launchCommand"), command); + if (command.isEmpty()) { + info.insert(QStringLiteral("error"), tr("No launch command configured")); + return info; + } + + const QString runner = game->launchRunner().trimmed(); + const QString runnerPath = game->launchRunnerPath().trimmed(); + const QString prefixPath = game->launchPrefixPath().trimmed(); + const QVariantMap launchEnv = game->launchEnv(); + const bool hasLaunchOverrides = !runner.isEmpty() || !runnerPath.isEmpty() || !prefixPath.isEmpty() || !launchEnv.isEmpty(); + + info.insert(QStringLiteral("runner"), runner); + info.insert(QStringLiteral("runnerPath"), runnerPath); + info.insert(QStringLiteral("prefixPath"), prefixPath); + info.insert(QStringLiteral("workingDirectory"), game->workingDirectory()); + info.insert(QStringLiteral("hasLaunchOverrides"), hasLaunchOverrides); + + QVariantMap envOverrides; + for (auto it = launchEnv.constBegin(); it != launchEnv.constEnd(); ++it) { + const QString key = it.key(); + if (key.isEmpty() || key.contains(QLatin1Char('='))) { + continue; + } + envOverrides.insert(key, it.value().toString()); + } + info.insert(QStringLiteral("envOverrides"), envOverrides); + + if (!hasLaunchOverrides && command.startsWith(QLatin1String("steam://"))) { + info.insert(QStringLiteral("ok"), true); + info.insert(QStringLiteral("launchType"), QStringLiteral("url")); + info.insert(QStringLiteral("url"), command); + info.insert(QStringLiteral("usesDesktopServices"), true); + return info; + } + + if (!hasLaunchOverrides && command.startsWith(QLatin1String("lutris "))) { + info.insert(QStringLiteral("ok"), true); + info.insert(QStringLiteral("launchType"), QStringLiteral("url")); + info.insert(QStringLiteral("url"), command.mid(7)); + info.insert(QStringLiteral("usesDesktopServices"), true); + return info; + } + + QStringList parts; + if (command.startsWith(QLatin1String("steam://"))) { + parts = {QStringLiteral("xdg-open"), command}; + } else if (command.startsWith(QLatin1String("lutris "))) { + parts = {QStringLiteral("xdg-open"), command.mid(7)}; + } else { + parts = QProcess::splitCommand(command); + } + + if (parts.isEmpty()) { + info.insert(QStringLiteral("error"), tr("Invalid launch command")); + return info; + } + + const QString program = parts.takeFirst(); + info.insert(QStringLiteral("program"), program); + info.insert(QStringLiteral("args"), parts); + + QString finalProgram = program; + QStringList finalArgs = parts; + + QVariantMap effectiveEnv = envOverrides; + + if ((runner == QLatin1String("wine") || runner == QLatin1String("proton")) && (program == QLatin1String("xdg-open"))) { + info.insert(QStringLiteral("error"), tr("This runner cannot be used with URL-based launch commands")); + return info; + } + + if (runner == QLatin1String("custom")) { + if (runnerPath.isEmpty()) { + info.insert(QStringLiteral("error"), tr("Custom runner is enabled but no runner path is configured")); + return info; + } + finalProgram = runnerPath; + finalArgs = {program}; + finalArgs.append(parts); + } else if (runner == QLatin1String("wine")) { + QString wineExe = runnerPath; + if (wineExe.isEmpty()) { + wineExe = QStandardPaths::findExecutable(QStringLiteral("wine")); + } + if (wineExe.isEmpty()) { + info.insert(QStringLiteral("error"), tr("Wine runner is enabled but Wine was not found")); + return info; + } + + QString winePrefix = prefixPath; + if (winePrefix.isEmpty()) { + winePrefix = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id(); + } + + effectiveEnv.insert(QStringLiteral("WINEPREFIX"), winePrefix); + info.insert(QStringLiteral("resolvedPrefixPath"), winePrefix); + + finalProgram = wineExe; + finalArgs = {program}; + finalArgs.append(parts); + } else if (runner == QLatin1String("proton")) { + QString protonExe = runnerPath; + if (protonExe.isEmpty()) { + protonExe = discoverDefaultProtonExecutable(); + } + if (protonExe.isEmpty()) { + info.insert(QStringLiteral("error"), tr("Proton runner is enabled but no Proton installation was found")); + return info; + } + + const QString steamInstallPath = findSteamClientInstallPathFromProton(protonExe); + info.insert(QStringLiteral("resolvedSteamInstallPath"), steamInstallPath); + + QString compatDataPath = prefixPath; + if (compatDataPath.isEmpty()) { + compatDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id(); + } + + effectiveEnv.insert(QStringLiteral("STEAM_COMPAT_DATA_PATH"), compatDataPath); + info.insert(QStringLiteral("resolvedPrefixPath"), compatDataPath); + + if (!steamInstallPath.isEmpty()) { + effectiveEnv.insert(QStringLiteral("STEAM_COMPAT_CLIENT_INSTALL_PATH"), steamInstallPath); + } + + finalProgram = protonExe; + finalArgs = {QStringLiteral("run"), program}; + finalArgs.append(parts); + } + + info.insert(QStringLiteral("finalProgram"), finalProgram); + info.insert(QStringLiteral("finalArgs"), finalArgs); + info.insert(QStringLiteral("effectiveEnv"), effectiveEnv); + info.insert(QStringLiteral("ok"), true); + return info; +} + void GameLauncher::launchGame(Game *game) { if (!game) { diff --git a/src/gamelauncher.h b/src/gamelauncher.h index 94e14a0..972ceae 100644 --- a/src/gamelauncher.h +++ b/src/gamelauncher.h @@ -7,6 +7,7 @@ #include #include #include +#include #include "game.h" @@ -24,6 +25,7 @@ public: bool hasRunningGames() const; Q_INVOKABLE void launchGame(Game *game); + Q_INVOKABLE QVariantMap resolveLaunchInfo(Game *game) const; Q_INVOKABLE void stopGame(Game *game); Q_INVOKABLE bool isGameRunning(Game *game) const; diff --git a/src/qml/DiagnosticsSheet.qml b/src/qml/DiagnosticsSheet.qml new file mode 100644 index 0000000..6e3184a --- /dev/null +++ b/src/qml/DiagnosticsSheet.qml @@ -0,0 +1,126 @@ +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts +import org.kde.kirigami as Kirigami +import org.kde.alakarte + +Kirigami.OverlaySheet { + id: diagnosticsSheet + + property var game + property var launchInfo: ({}) + + title: i18n("Diagnostics") + parent: applicationWindow().overlay + + closePolicy: QQC2.Popup.CloseOnEscape | QQC2.Popup.CloseOnPressOutside + + implicitWidth: { + let w = applicationWindow() + if (!w) return Kirigami.Units.gridUnit * 30 + if (w.isMobile) return w.width + if (w.isNarrowScreen) return w.width - Kirigami.Units.largeSpacing * 2 + return Math.min(w.width - Kirigami.Units.gridUnit * 2, Kirigami.Units.gridUnit * 34) + } + + implicitHeight: { + let w = applicationWindow() + if (!w) return Kirigami.Units.gridUnit * 30 + if (w.isMobile) return w.height + return Math.min(w.height - Kirigami.Units.gridUnit * 2, Kirigami.Units.gridUnit * 42) + } + + onOpened: { + if (game) { + launchInfo = App.launcher.resolveLaunchInfo(game) + } else { + launchInfo = ({ "ok": false, "error": i18n("No game selected") }) + } + closeButton.forceActiveFocus() + } + + header: Kirigami.ShadowedRectangle { + implicitHeight: headerRow.implicitHeight + Kirigami.Units.largeSpacing * 2 + radius: Kirigami.Units.mediumSpacing + color: Kirigami.Theme.backgroundColor + + RowLayout { + id: headerRow + anchors.fill: parent + anchors.margins: Kirigami.Units.largeSpacing + spacing: Kirigami.Units.mediumSpacing + + Kirigami.Heading { + text: diagnosticsSheet.title + level: 2 + Layout.fillWidth: true + elide: Text.ElideRight + } + + QQC2.ToolButton { + id: closeButton + text: i18n("Close") + icon.name: "dialog-close" + display: QQC2.AbstractButton.IconOnly + onClicked: diagnosticsSheet.close() + } + } + } + + contentItem: QQC2.ScrollView { + id: scroll + clip: true + + ColumnLayout { + width: scroll.availableWidth + spacing: Kirigami.Units.largeSpacing + + Kirigami.InlineMessage { + Layout.fillWidth: true + visible: launchInfo && launchInfo.ok === false + type: Kirigami.MessageType.Error + text: launchInfo && launchInfo.error ? launchInfo.error : i18n("Unknown error") + } + + Kirigami.FormLayout { + Layout.fillWidth: true + + QQC2.Label { + Kirigami.FormData.label: i18n("Launch command:") + text: launchInfo && launchInfo.launchCommand ? launchInfo.launchCommand : "" + wrapMode: Text.WrapAnywhere + } + + QQC2.Label { + Kirigami.FormData.label: i18n("Runner:") + text: launchInfo && launchInfo.runner ? launchInfo.runner : i18n("None") + wrapMode: Text.WrapAnywhere + } + + QQC2.Label { + Kirigami.FormData.label: i18n("Program:") + text: launchInfo && launchInfo.finalProgram ? launchInfo.finalProgram : "" + wrapMode: Text.WrapAnywhere + } + + QQC2.Label { + Kirigami.FormData.label: i18n("Args:") + text: launchInfo && launchInfo.finalArgs ? launchInfo.finalArgs.join(" ") : "" + wrapMode: Text.WrapAnywhere + } + + QQC2.Label { + Kirigami.FormData.label: i18n("Working directory:") + text: launchInfo && launchInfo.workingDirectory ? launchInfo.workingDirectory : i18n("Not specified") + wrapMode: Text.WrapAnywhere + } + + QQC2.Label { + Kirigami.FormData.label: i18n("Prefix:") + text: launchInfo && launchInfo.resolvedPrefixPath ? launchInfo.resolvedPrefixPath : (launchInfo && launchInfo.prefixPath ? launchInfo.prefixPath : "") + wrapMode: Text.WrapAnywhere + } + } + } + } +} diff --git a/src/qml/GameDetailsSheet.qml b/src/qml/GameDetailsSheet.qml index b68e734..9f0c68d 100644 --- a/src/qml/GameDetailsSheet.qml +++ b/src/qml/GameDetailsSheet.qml @@ -48,6 +48,7 @@ Kirigami.OverlaySheet { signal launchRequested() signal editRequested() + signal diagnosticsRequested() signal removeRequested() title: game ? game.name : "" @@ -417,6 +418,14 @@ Kirigami.OverlaySheet { onClicked: detailsSheet.editRequested() } + QQC2.Button { + icon.name: "dialog-information" + text: i18n("Diagnostics") + display: detailsSheet.useCompactLayout ? QQC2.AbstractButton.TextUnderIcon : QQC2.AbstractButton.TextBesideIcon + enabled: !!game + onClicked: detailsSheet.diagnosticsRequested() + } + QQC2.Button { icon.name: "edit-delete" text: i18n("Remove") diff --git a/src/qml/Main.qml b/src/qml/Main.qml index dc4bc6a..a879bc4 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -34,6 +34,7 @@ Kirigami.ApplicationWindow { if (settingsLayerOpen && layerContent && layerContent.anyConfirmOpen) return "confirm" } if (settingsSheet.opened && settingsContent.anyConfirmOpen) return "confirm" + if (diagnosticsSheet.opened) return "diagnostics" if (detailsSheet.opened) return "details" if (importSheet.opened) return "import" if (settingsSheet.opened) return "settings" @@ -169,6 +170,10 @@ Kirigami.ApplicationWindow { settingsSheet.close() return true } + if (diagnosticsSheet.opened) { + diagnosticsSheet.close() + return true + } if (detailsSheet.opened) { detailsSheet.close() return true @@ -206,6 +211,7 @@ Kirigami.ApplicationWindow { function canOpenSettings() { return !gameEditDialog.visible && !detailsSheet.opened + && !diagnosticsSheet.opened && !importSheet.opened && !aboutSheet.opened && !settingsSheet.opened @@ -397,7 +403,7 @@ Kirigami.ApplicationWindow { sequence: "Ctrl+F" onActivated: { if (root.currentConfirmDialog()) return - if (detailsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return + if (detailsSheet.opened || diagnosticsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return if (sidebar.modal && sidebar.opened) return searchActive = true Qt.callLater(function() { libraryView.focusSearch() }) @@ -433,6 +439,11 @@ Kirigami.ApplicationWindow { root.closeSettings() return } + if (diagnosticsSheet.opened) { + diagnosticsSheet.close() + Qt.callLater(function() { root.openSettings() }) + return + } if (root.canOpenSettings()) { root.openSettings() return @@ -467,7 +478,7 @@ Kirigami.ApplicationWindow { sequence: "Ctrl+H" onActivated: { if (root.currentConfirmDialog()) return - if (detailsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return + if (detailsSheet.opened || diagnosticsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return if (root.hintContext !== "library" && root.hintContext !== "sidebar") return let target = (root.currentSource === "hidden") ? "all" : "hidden" sidebarView.suppressAutoClose = true @@ -480,7 +491,7 @@ Kirigami.ApplicationWindow { sequence: "Ctrl+PgUp" onActivated: { if (root.currentConfirmDialog()) return - if (detailsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return + if (detailsSheet.opened || diagnosticsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return if (root.hintContext !== "library" && root.hintContext !== "sidebar") return sidebarView.cycleSource(-1) } @@ -490,7 +501,7 @@ Kirigami.ApplicationWindow { sequence: "Ctrl+PgDown" onActivated: { if (root.currentConfirmDialog()) return - if (detailsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return + if (detailsSheet.opened || diagnosticsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return if (root.hintContext !== "library" && root.hintContext !== "sidebar") return sidebarView.cycleSource(1) } @@ -500,7 +511,7 @@ Kirigami.ApplicationWindow { sequence: "Ctrl+D" onActivated: { if (root.currentConfirmDialog()) return - if (detailsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return + if (detailsSheet.opened || diagnosticsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return if (sidebar.modal && sidebar.opened) return if (root.selectedGame) { detailsSheet.open() @@ -528,19 +539,19 @@ Kirigami.ApplicationWindow { } function onLeftBumperPressed() { if (root.currentConfirmDialog()) return - if (detailsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return + if (detailsSheet.opened || diagnosticsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return if (root.hintContext !== "library" && root.hintContext !== "sidebar") return sidebarView.cycleSource(-1) } function onRightBumperPressed() { if (root.currentConfirmDialog()) return - if (detailsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return + if (detailsSheet.opened || diagnosticsSheet.opened || gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened) return if (root.hintContext !== "library" && root.hintContext !== "sidebar") return sidebarView.cycleSource(1) } function onDetailsPressed() { if (root.currentConfirmDialog()) return - if (gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened || (sidebar.modal && sidebar.opened)) return + if (gameEditDialog.visible || diagnosticsSheet.opened || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened || (sidebar.modal && sidebar.opened)) return if (root.hintContext !== "library" && root.hintContext !== "details") return if (detailsSheet.opened) { if (root.selectedGame) { @@ -610,6 +621,11 @@ Kirigami.ApplicationWindow { aboutSheet.close() return } + if (diagnosticsSheet.opened) { + root.pendingSidebarOpen = true + diagnosticsSheet.close() + return + } if (detailsSheet.opened) { root.pendingSidebarOpen = true detailsSheet.close() @@ -650,7 +666,7 @@ Kirigami.ApplicationWindow { detailsSheet.editRequested() return } - if (gameEditDialog.visible || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened || (sidebar.modal && sidebar.opened)) return + if (gameEditDialog.visible || diagnosticsSheet.opened || importSheet.opened || settingsLayerOpen || settingsSheet.opened || aboutSheet.opened || (sidebar.modal && sidebar.opened)) return searchActive = true Qt.callLater(function() { libraryView.focusSearch() }) } @@ -872,6 +888,11 @@ Kirigami.ApplicationWindow { gameEditDialog.open() } + onDiagnosticsRequested: { + diagnosticsSheet.game = root.selectedGame + diagnosticsSheet.open() + } + onRemoveRequested: { if (!root.selectedGame) return root.pendingRemoveGame = root.selectedGame @@ -883,6 +904,19 @@ Kirigami.ApplicationWindow { } } + DiagnosticsSheet { + id: diagnosticsSheet + game: root.selectedGame + + onClosed: { + if (detailsSheet.opened) { + detailsSheet.forceActiveFocus() + return + } + libraryView.restoreFocus() + } + } + Kirigami.PromptDialog { id: removeGameConfirmDialog title: i18n("Remove Game") diff --git a/src/qml/components/BottomHintBar.qml b/src/qml/components/BottomHintBar.qml index 9bfc68e..63f65b9 100644 --- a/src/qml/components/BottomHintBar.qml +++ b/src/qml/components/BottomHintBar.qml @@ -152,7 +152,7 @@ RowLayout { } } - if (root.context === "settings" || root.context === "import" || root.context === "sidebar" || root.context === "about") { + if (root.context === "settings" || root.context === "import" || root.context === "sidebar" || root.context === "about" || root.context === "diagnostics") { switch (action) { case "navigate": return "Tab" case "confirm": return "Enter" @@ -199,6 +199,16 @@ RowLayout { } } + if (root.context === "diagnostics") { + switch (action) { + case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : "" + case "confirm": return i18n("Select") + case "back": return i18n("Back") + case "menu": return (useGamepadHints && uiMode === Config.Couch) ? i18n("Menu") : i18n("Close") + default: return "" + } + } + if (root.context === "edit") { switch (action) { case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : "" @@ -364,7 +374,7 @@ RowLayout { label: actionLabel("menu") iconSource: (useGamepadHints && uiMode === Config.Couch) ? iconForAux("menu") - : ((root.context === "library" || root.context === "details" || root.context === "sidebar" || root.context === "settings") ? iconForAux("menu") : "") + : ((root.context === "library" || root.context === "details" || root.context === "sidebar" || root.context === "settings" || root.context === "diagnostics") ? iconForAux("menu") : "") keyLabel: keyboardLabel("menu") } }