diff --git a/AGENTS.md b/AGENTS.md index 5c7b7a8..dfed8b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -243,6 +243,15 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine `linux-cachyos` is clang-built, `-bore` GCC-built: let DKMS pick the compiler, never pass `LLVM=1`. Test shutdown on the real machine for every new major kernel. +- Add as non-Steam game (`steamgame`, `lib/steam-game.sh`, sub-option of + `launcher`, ticked along with it, 2.5.1): edits Steam's `shortcuts.vdf` + with `patches/steam-shortcuts.py` only while Steam is closed (Steam + rewrites it on exit), then starts Steam again. Never close Steam in gaming + mode or when `SteamGameId` is set (the run is a Steam game). An existing + entry for the start script is updated, not doubled; removal takes every + entry for it. Controller input through Steam's desktop layout arrives as + keys: Enter must select (A), Esc must never quit (B); the app re-execs + without Steam's overlay preload. - Update notifications (`notify`, `lib/update-notifier.sh`, top-level, ticked by default, 2.5.0): a user timer (daily, and the service is wanted by `plasma-workspace.target`) runs `patches/steamify-notifier.py`, which diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec9a6c..1593ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes, per version and per commit. Versions follow `steamify.sh`. Versions before 0.7.0 were numbered afterwards, one per merged pull request. +## 2.5.1 - 2026-09-27 + +- **fix: Add as non-Steam game asks Steam to close again every 15 s (a Steam that was still starting ignored it)** +- `3a0692f` **docs: README screenshot at 2.5.1 with Add as non-Steam game** +- `e86ddcc` **feat: Add as non-Steam game: Steamify in the Steam library, under the shortcut; controller works when started from Steam** +- `389bc33` **fix(ui): B never quits (Steam sends Esc for it), Ctrl+Q does; started from Steam, the hints show the Steam Controller's buttons** +- `c6c1150` **fix(ui): Enter selects (a Steam Controller's A in Steam's desktop layout); Ctrl+Enter reviews and applies** +- `98e2eff` **fix(ui): Started from Steam, the app runs without Steam's overlay (Qt aborted) and stays in the foreground, so Steam Input gives it the controller** +- `431515b` **feat(ui): Review & apply greyed out while nothing would change; the Done screen shows why a run stopped** + ## 2.5.0 - 2026-09-27 - **docs: README: what the VRAM booster says with NVIDIA's closed driver; shorter unsupported text in the app** diff --git a/README.md b/README.md index 703dc05..764a3dc 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ Steam Machine. 5. **Steamify shortcut** - "Steamify CachyOS" on the desktop and in the launcher opens the newest Steamify app, so you don't need the install command again. "Steamify Terminal" in the launcher opens the terminal menu. + With **Add as non-Steam game** (on by default) it's in your Steam library + too: started from Steam it gets your controller (a Steam Controller only + talks to Steam), in gaming mode as well. Steam closes for a moment while + it's added, so that's done from the desktop. 6. **Update notifications** - on by default: a notification and a tray icon when there's a new Steamify, with Open Steamify and Skip this version. It never updates anything by itself diff --git a/TECHNICAL.md b/TECHNICAL.md index f08a416..657ee09 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -236,6 +236,35 @@ Where Steam Machine support was set up before 2.2.0, the menu ticks the power-off fix as a new default sub-option (see feature versions below), so a normal run adds it. +## Add as non-Steam game + +A sub-option of the Steamify shortcut (`steamgame`, `lib/steam-game.sh`, new +in 2.5.1, ticked along with the shortcut): Steamify CachyOS in every Steam +account's non-Steam games (`~/.local/share/Steam/userdata//config/shortcuts.vdf`, +binary VDF, edited by `patches/steam-shortcuts.py`), starting the shortcut's +`run-app`, with the icon rendered as PNG (`~/.local/share/steamify/steamify.png`; +Steam shows no SVG). Why: while Steam runs, a Steam Controller only talks to +Steam, which gives its input to the game it started (Steam Input); on the +desktop through its desktop layout (A = Enter, B = Esc, Y = Space, X = +Steam's keyboard), in gaming mode as a gamepad. + +Steam reads the list only at startup and writes it back on exit, so Steam is +closed (`steam -shutdown`) for the edit and started again (through +`steam-desktop-autostart.service` when it's there). That's refused in gaming +mode and when Steamify itself was started from Steam (`SteamGameId`), since +closing Steam would end it. An entry for the start script that's already +there (added by hand, or with the path from before 2.5.0) is updated, not +added twice; what was done is recorded per account (state `steamgame`: +`= added|updated`) and a copy of each list is kept in the +state directory before the first change. Turning it off (or the shortcut) +removes every entry for the start script, since it can't start anything +without it. + +Started from Steam, the app re-executes itself without Steam's overlay +(`LD_PRELOAD` of `gameoverlayrenderer.so` makes Qt abort) and +`steamify-app.sh` keeps it in the foreground (`SteamGameId`), or Steam +counts the game as ended right away and stops giving it the controller. + ## Update notifications The **Update notifications** option (new in 2.5.0, ticked by default) @@ -414,6 +443,7 @@ immediately, which can turn into a loop - see | `lib/cec.sh` | HDMI-CEC: Valve's `cecd` and friends from its `holo` repository | | `lib/steam-machine.sh` | Steam Machine support: LED driver, LED access, steamos-manager | | `lib/fremont-poweroff.sh` | Steam Machine support: the power-off fix (DKMS module from `patches/`) | +| `lib/steam-game.sh` | Add as non-Steam game: Steamify in the Steam library (`patches/steam-shortcuts.py`) | | `lib/update-notifier.sh` | Update notifications: the notifier from `patches/` and its user timer | | `lib/vram-booster.sh` | VRAM booster (`dmemcg-booster`, `plasma-foreground-booster`) | | `services/` | The systemd units the scripts install (`service_file`, `@KEY@` placeholders); see its README | diff --git a/assets/screenshot-menu.png b/assets/screenshot-menu.png index 76e0a24..ed0ffd1 100644 Binary files a/assets/screenshot-menu.png and b/assets/screenshot-menu.png differ diff --git a/lib/menu.sh b/lib/menu.sh index 21ebef5..e9b6200 100644 --- a/lib/menu.sh +++ b/lib/menu.sh @@ -5,12 +5,12 @@ # Menu order. Components are turned on in this order and off in reverse; # gaming must come first (single user builds on it). -COMPONENTS=(gaming boot theme glyphs single launcher notify vram cec machine poweroff kpin hdmi bios) +COMPONENTS=(gaming boot theme glyphs single launcher steamgame notify vram cec machine poweroff kpin hdmi bios) # One-off actions rather than on/off components: never preselected, never # re-applied, not listed as on or off. ACTIONS=(bios) # Sub-options, shown indented under their parent and only while it's ticked. -declare -A PARENT=([boot]=gaming [poweroff]=machine [kpin]=machine [hdmi]=machine [bios]=machine) +declare -A PARENT=([boot]=gaming [steamgame]=launcher [poweroff]=machine [kpin]=machine [hdmi]=machine [bios]=machine) # Never preselected on a first run: booting into the desktop is a choice, # gamescope is the default; HDMI-CEC is opt-in (it can wake the machine or # upset other devices on the TV, even on SteamOS), except on a Steam Machine, @@ -26,7 +26,7 @@ NO_PRESELECT=(boot cec kpin hdmi) FEATURE_BASELINE=2.1.0 declare -A FEATURE_VERSION=( [gaming]=2.1.0 [boot]=2.1.0 [theme]=2.1.0 [glyphs]=2.1.0 [single]=2.1.0 - [launcher]=2.1.0 [cec]=2.1.0 [machine]=2.2.0 [poweroff]=2.2.0 [vram]=2.3.0 [notify]=2.5.0 + [launcher]=2.1.0 [cec]=2.1.0 [machine]=2.2.0 [poweroff]=2.2.0 [vram]=2.3.0 [notify]=2.5.0 [steamgame]=2.5.1 [kpin]=2.1.0 [hdmi]=2.1.0 ) @@ -37,6 +37,7 @@ declare -A LABEL=( [glyphs]="Install Steam Deck/Machine icons: Deck button icons in gaming mode" [single]="Single user mode: no password, lock screen or log out (SDDM)" [launcher]="Steamify shortcut: the app on the desktop, Steamify Terminal in the launcher" + [steamgame]="Add as non-Steam game: Steamify in your Steam library, for the controller and gaming mode" [notify]="Update notifications: a notification when there's a new Steamify, never updates by itself" [vram]="VRAM booster: the game in front keeps its VRAM, background apps make room" [cec]="HDMI-CEC: use Steam with the TV remote, TV on/off with the PC (experimental)" @@ -52,6 +53,7 @@ component_available() { case "$1" in machine|poweroff) machine_available ;; vram) vram_available ;; + steamgame) steamgame_available ;; kpin) kpin_available ;; hdmi) hdmi_available ;; bios) bios_available ;; @@ -192,6 +194,10 @@ toggle_component() { if [[ "$c" == machine && "${WANTED[machine]}" == 0 ]]; then WANTED[kpin]=0; WANTED[bios]=0; fi if [[ "$c" == kpin && "${WANTED[kpin]}" == 1 ]]; then WANTED[machine]=1; fi if [[ "$c" == bios && "${WANTED[bios]}" == 1 ]]; then WANTED[machine]=1; fi + # Steam starts Steamify through the shortcut's start script. + # Opt-out, like the power-off fix: ticked along with the shortcut. + if [[ "$c" == launcher ]] && component_available steamgame; then WANTED[steamgame]=${WANTED[launcher]}; fi + if [[ "$c" == steamgame && "${WANTED[steamgame]}" == 1 ]]; then WANTED[launcher]=1; fi } show_menu() { diff --git a/lib/steam-game.sh b/lib/steam-game.sh new file mode 100644 index 0000000..c7ea9c8 --- /dev/null +++ b/lib/steam-game.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# "Add as non-Steam game" (steamgame), a sub-option of the Steamify shortcut: +# Steamify CachyOS in the Steam library, so Steam can start it: with Steam +# Input it then gets the controller (a Steam Controller only talks to Steam +# while Steam runs), in gaming mode too. Steam reads its non-Steam games +# (userdata//config/shortcuts.vdf, binary; patches/steam-shortcuts.py) +# only at startup and writes them back on exit, so Steam is closed for the +# edit and started again. That's refused while a Steam session depends on it: +# in gaming mode, or with this run started from Steam itself. What it did is +# recorded per Steam account (state steamgame, "": " added" +# or " updated", for an entry that was there already): Steam rewrites +# the file on exit, so a mark inside the entry could be lost; the start +# script's path in it is the other sign it's ours. +# Sourced by steamify.sh; not meant to be run on its own. + +STEAM_ROOT="${XDG_DATA_HOME:-$HOME/.local/share}/Steam" +STEAMGAME_NAME="Steamify CachyOS" +# Steam shows no SVG icons: the shortcut's icon rendered as PNG. +STEAMGAME_ICON="$STEAMIFY_DATA/steamify.png" + +steamgame_files() { + # Every Steam account's shortcuts.vdf (the file may not exist yet). + local d + for d in "$STEAM_ROOT"/userdata/*/config; do + [[ -d "$d" && "$(basename "$(dirname "$d")")" != 0 ]] && echo "$d/shortcuts.vdf" + done +} + +steamgame_tool() { + python3 <(patch_file steam-shortcuts.py) "$@" +} + +steamgame_available() { [[ -n "$(steamgame_files)" ]]; } + +steamgame_status() { + local f + while IFS= read -r f; do + [[ -f "$f" ]] && steamgame_tool find "$f" "$WIZARD_APP_LAUNCHER" >/dev/null && return 0 + done < <(steamgame_files) + return 1 +} + +steamgame_steam_busy() { + # Closing Steam would end what's running: gaming mode is Steam's own + # session, and a run started from Steam is one of its games. + if [[ -n "${SteamGameId:-}" ]]; then + err "Steamify was started from Steam, which has to close for this. Start it from the desktop icon, or run Steamify Terminal." + return 0 + fi + if pgrep -x gamescope >/dev/null || pgrep -f gamescope-session >/dev/null; then + err "Steam has to close for this, which would end gaming mode. Do it from the desktop." + return 0 + fi + return 1 +} + +steamgame_edit() { + # steamgame_edit : runs it with Steam closed, then starts Steam + # again if it was running (through our autostart unit when that's there). + local was_running=false i + if pgrep -x steam >/dev/null; then + steamgame_steam_busy && return 1 + was_running=true + info "Closing Steam for a moment (it only reads its games list at startup)..." + # A Steam that's still starting ignores the request: ask again. + for ((i = 0; i < 90; i++)); do + ((i % 15 == 0)) && steam -shutdown >/dev/null 2>&1 + pgrep -x steam >/dev/null || break + sleep 1 + done + pgrep -x steam >/dev/null && { err "Steam didn't close; nothing changed."; return 1; } + fi + "$1"; local rc=$? + if [[ "$was_running" == true ]]; then + if systemctl --user is-enabled -q steam-desktop-autostart.service 2>/dev/null; then + systemctl --user start steam-desktop-autostart.service + else + systemd-run --user --collect -q steam -silent >/dev/null 2>&1 + fi + info "Steam is starting again." + fi + return "$rc" +} + +steamgame_icon() { + # The PNG, or the SVG when it can't be rendered (librsvg comes with KDE). + if [[ -f "$WIZARD_ICON" ]] && command -v rsvg-convert >/dev/null && + mkdir -p "$STEAMIFY_DATA" && rsvg-convert -w 256 -h 256 -o "$STEAMGAME_ICON" "$WIZARD_ICON" 2>/dev/null; then + echo "$STEAMGAME_ICON" + else + echo "$WIZARD_ICON" + fi +} + +steamgame_add_all() { + local f out icon + icon="$(steamgame_icon)" + while IFS= read -r f; do + # A copy of each account's list before the first change. + [[ -f "$f" && ! -f "$STATE_DIR/shortcuts-$(basename "$(dirname "$(dirname "$f")")").vdf" ]] && + mkdir -p "$STATE_DIR" && cp -a "$f" "$STATE_DIR/shortcuts-$(basename "$(dirname "$(dirname "$f")")").vdf" + out="$(steamgame_tool add "$f" "$STEAMGAME_NAME" "$WIZARD_APP_LAUNCHER" "$icon" "$WIZARD_APP_ENTRY")" || + { err "Couldn't add Steamify to $f."; return 1; } + state_set steamgame "$(basename "$(dirname "$(dirname "$f")")")" "${out#* } ${out%% *}" + info "Steam library ($(basename "$(dirname "$(dirname "$f")")")): ${out%% *} $STEAMGAME_NAME." + done < <(steamgame_files) +} + +steamgame_remove_all() { + local f id + while IFS= read -r f; do + [[ -f "$f" ]] || continue + # Any entry for our start script: it can't start anything without it. + while id="$(steamgame_tool find "$f" "$WIZARD_APP_LAUNCHER")"; do + steamgame_tool remove "$f" "$id" || return 1 + done + done < <(steamgame_files) +} + +steamgame_enable() { + [[ -x "$WIZARD_APP_LAUNCHER" ]] || { err "The Steamify shortcut isn't set up; turn that on first."; return 1; } + info "Adding $STEAMGAME_NAME to your Steam library..." + steamgame_edit steamgame_add_all || return 1 + ok "Steamify is in your Steam library (non-Steam games): start it from there for the controller, in gaming mode too." +} + +steamgame_disable() { + info "Removing $STEAMGAME_NAME from your Steam library..." + steamgame_edit steamgame_remove_all || return 1 + state_clear steamgame + rm -f "$STEAMGAME_ICON" + ok "Steamify removed from your Steam library." +} diff --git a/patches/README.md b/patches/README.md index bf6c056..ffed613 100644 --- a/patches/README.md +++ b/patches/README.md @@ -11,5 +11,6 @@ read and reviewed on their own. `lib/*.sh` reads them with `patch_file | `ensure-kernel-headers.sh` | `lib/steam-machine.sh` (Steam Machine support) | Run at boot: installs missing headers for every installed kernel | | `leds-valve-dkms.conf` | `lib/steam-machine.sh` (Steam Machine support) | `/etc/dkms` override: build the LED driver for DKMS's target kernel; appended to someone's own override, which is backed up and restored | | `steamify-fremont-poweroff.dkms.conf`, `steamify-cros-ec-cec.dkms.conf` | `lib/fremont-poweroff.sh`, `lib/cec.sh` | The DKMS modules' `dkms.conf` (`fill NAME=... VERSION=...`) | +| `steam-shortcuts.py` | `lib/steam-game.sh` (Add as non-Steam game) | Reads and writes Steam's binary `shortcuts.vdf` (find, add or update, remove) | | `steamify-notifier.py` | `lib/update-notifier.sh` (Update notifications) | The daily check: notification and tray icon when there's a new release | | `cros-ec-cec-single-port.patch` | `lib/cec.sh` (HDMI-CEC) | Makes Valve's Steam Machine CEC driver find amdgpu's HDMI port | diff --git a/patches/steam-shortcuts.py b/patches/steam-shortcuts.py new file mode 100644 index 0000000..368cc35 --- /dev/null +++ b/patches/steam-shortcuts.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# Steamify CachyOS: Steam's non-Steam games list (userdata//config/ +# shortcuts.vdf, binary VDF), for "Add as non-Steam game". Steam reads it only +# at startup and writes it back on exit, so it's edited while Steam is closed. +# steam-shortcuts.py find -> appid of an entry for exe +# steam-shortcuts.py add +# -> "added " / "updated " +# steam-shortcuts.py remove +# An entry counts as ours when its Exe is , or a Steamify start script +# (run-app, also under the pre-2.5.0 path): that one is updated, not doubled. +import os +import struct +import sys +import zlib + +MAP, STR, INT, END = 0, 1, 2, 8 + + +def parse(data): + pos = 0 + + def cstr(): + nonlocal pos + end = data.index(b"\0", pos) + s = data[pos:end].decode("utf-8", "surrogateescape") + pos = end + 1 + return s + + def block(): + nonlocal pos + items = [] + while True: + t = data[pos] + pos += 1 + if t == END: + return items + key = cstr() + if t == MAP: + items.append((key, block())) + elif t == STR: + items.append((key, cstr())) + elif t == INT: + items.append((key, struct.unpack_from(" "wait"|"run"|"ok"|"fail" property var failed: [] property bool restartNeeded: false + // Review & apply only when something would change; "Re-apply what's on" + // is for when nothing did. + readonly property bool canApply: computePlan().length > 0 + onCanApplyChanged: if (!canApply && sel === rows.length) sel = Math.max(0, rows.length - 1) property string runError: "" property bool wrongPassword: false property bool typing: false // a text field has the keys @@ -77,6 +81,8 @@ Item { if (id === "poweroff" && w.poweroff) w.machine = true; if (id === "machine" && !w.machine) w.kpin = false; if (id === "kpin" && w.kpin) w.machine = true; + if (id === "launcher" && items.some(function (i) { return i.id === "steamgame"; })) w.steamgame = w.launcher; + if (id === "steamgame" && w.steamgame) w.launcher = true; want = w; } function computePlan() { @@ -93,7 +99,10 @@ Item { if (want.gaming && boot !== bootNow) p.push({ id: "boot", action: boot === "desktop" ? "desktop" : "gaming" }); return p; } - function goReview(again) { reapply = again; plan = computePlan(); screen = "review"; } + function goReview(again) { + if (!again && !canApply) return; + reapply = again; plan = computePlan(); screen = "review"; + } function wantedIds() { var ids = []; for (var i = 0; i < items.length; i++) @@ -195,7 +204,8 @@ Item { if (screen === "menu") { if (biosChecking) return; if (a === "up") sel = Math.max(0, sel - 1); - else if (a === "down") sel = Math.min(rows.length, sel + 1); + // The greyed-out Apply button can't be selected. + else if (a === "down") sel = Math.min(canApply ? rows.length : rows.length - 1, sel + 1); else if (a === "accept") { if (sel === rows.length) { goReview(false); return; } var r = rows[sel]; @@ -207,7 +217,9 @@ Item { boot = a === "left" ? "gamescope" : "desktop"; else if (a === "apply") goReview(false); else if (a === "reapply") goReview(true); - else if (a === "back") Qt.quit(); + // B/Esc/Back don't quit: Steam's desktop layout sends Esc for a + // Steam Controller's B, which should only ever go back. + else if (a === "quit") Qt.quit(); } else if (screen === "review") { if (a === "accept" || a === "apply") onApplyPressed(); else if (a === "back") { reapply = false; screen = "menu"; } @@ -246,16 +258,21 @@ Item { if (screen === "bios2" && Input.type !== "keyboard" && (e.key === Qt.Key_Return || e.key === Qt.Key_Enter)) { if (!e.isAutoRepeat) act("hold"); e.accepted = true; return; } - if (!fromRemote) Input.type = "keyboard"; + if (!fromRemote) Input.type = Input.fromSteam ? "steam" : "keyboard"; var k = e.key; if (k === Qt.Key_Up) act("up"); else if (k === Qt.Key_Down) act("down"); else if (k === Qt.Key_Left) act("left"); else if (k === Qt.Key_Right) act("right"); else if (k === Qt.Key_Space) act("accept"); - else if (k === Qt.Key_Return || k === Qt.Key_Enter) act(Input.type === "remote" || screen !== "menu" ? "accept" : "apply"); + // Enter selects, like A on a controller: Steam's desktop layout sends + // Enter for a Steam Controller's A (and Space for Y). Ctrl+Enter + // goes straight to Review & apply. + else if ((k === Qt.Key_Return || k === Qt.Key_Enter) && (e.modifiers & Qt.ControlModifier)) act("apply"); + else if (k === Qt.Key_Return || k === Qt.Key_Enter) act("accept"); else if (k === Qt.Key_Escape || k === Qt.Key_Back || k === Qt.Key_Backspace) { if (!e.isAutoRepeat) act("back"); } else if (k === Qt.Key_R) act("reapply"); + else if (k === Qt.Key_Q && (e.modifiers & Qt.ControlModifier)) act("quit"); else return; e.accepted = true; } diff --git a/ui/qml/Btn.qml b/ui/qml/Btn.qml index 8a8533c..7c0d781 100644 --- a/ui/qml/Btn.qml +++ b/ui/qml/Btn.qml @@ -10,11 +10,13 @@ Rectangle { signal clicked height: 48; radius: 12; width: row.implicitWidth + 36 color: primary ? Theme.accent : Theme.button - border.width: focusRing ? 2 : 0; border.color: Theme.textHi + border.width: focusRing && enabled ? 2 : 0; border.color: Theme.textHi + // Greyed out while there's nothing to do. + opacity: enabled ? 1 : 0.4 Row { id: row; anchors.centerIn: parent; spacing: 10 Glyph { k: btn.k; visible: btn.k !== ""; dark: btn.primary; anchors.verticalCenter: parent.verticalCenter } Text { text: btn.text; color: btn.primary ? Theme.ink : Theme.text; font.family: Theme.body; font.pixelSize: 15; font.weight: Font.DemiBold anchors.verticalCenter: parent.verticalCenter } } - MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: btn.clicked() } + MouseArea { anchors.fill: parent; enabled: btn.enabled; cursorShape: Qt.PointingHandCursor; onClicked: btn.clicked() } } diff --git a/ui/qml/DoneScreen.qml b/ui/qml/DoneScreen.qml index 448369d..6f3aeac 100644 --- a/ui/qml/DoneScreen.qml +++ b/ui/qml/DoneScreen.qml @@ -1,5 +1,8 @@ -// The result of a run: all done, a problem, or nothing changed. +// The result of a run: all done, a problem, or nothing changed. With a +// problem, the run's output is shown below it (a BIOS check has no progress +// screen, so this is the only place to read why it stopped). import QtQuick +import QtQuick.Controls.Basic Item { required property AppState app @@ -20,7 +23,14 @@ Item { Text { width: parent.width; horizontalAlignment: Text.AlignHCenter; wrapMode: Text.WordWrap; color: Theme.soft; font.family: Theme.body; font.pixelSize: 17; text: message } Rectangle { visible: app.failed.length > 0; anchors.horizontalCenter: parent.horizontalCenter; width: ft.implicitWidth + 32; height: 44; radius: 12; color: Theme.warnBg Text { id: ft; anchors.centerIn: parent; color: "#f2c27a"; font.family: Theme.body; font.pixelSize: 14 - text: "Had a problem: " + app.failed.map(function (id) { return Texts.label(id); }).join(", ") + ". See the details." } } + text: "Had a problem: " + app.failed.map(function (id) { return Texts.label(id); }).join(", ") + (app.log.count ? ". Details below." : ".") } } + Rectangle { visible: app.failed.length > 0 && app.log.count > 0; width: parent.width; height: 190; radius: 14; color: Theme.card + ListView { id: details; anchors.fill: parent; anchors.margins: 16; clip: true; model: app.log + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + // The end is where it stopped. + onCountChanged: positionViewAtEnd() + onVisibleChanged: if (visible) positionViewAtEnd() + delegate: Text { required property string line; width: details.width - 12; text: line; color: "#aab5c4"; font.family: Theme.mono; font.pixelSize: 13; wrapMode: Text.WrapAnywhere } } } Row { anchors.horizontalCenter: parent.horizontalCenter; spacing: 14; topPadding: 8 Btn { k: Input.g.back; text: "Back to the menu"; height: 52; onClicked: app.act("back") } Btn { visible: app.restartNeeded; k: Input.g.ok; text: "Restart now"; primary: true; height: 52; onClicked: app.backend.restart() } } diff --git a/ui/qml/Glyph.qml b/ui/qml/Glyph.qml index 9f0befb..3bf60aa 100644 --- a/ui/qml/Glyph.qml +++ b/ui/qml/Glyph.qml @@ -6,7 +6,7 @@ Rectangle { property string k property bool dark: false height: 26; width: Math.max(26, gt.implicitWidth + 14) - radius: Input.type === "controller" && k.length === 1 ? 13 : 7 + radius: (Input.type === "controller" || Input.type === "steam") && k.length === 1 ? 13 : 7 color: dark ? Theme.ink : Theme.key Text { id: gt; anchors.centerIn: parent; text: glyph.k; color: glyph.dark ? Theme.accent : Theme.textHi font.family: Theme.body; font.pixelSize: 12; font.weight: Font.DemiBold } diff --git a/ui/qml/Input.qml b/ui/qml/Input.qml index cc30c14..8844060 100644 --- a/ui/qml/Input.qml +++ b/ui/qml/Input.qml @@ -5,10 +5,15 @@ import QtQuick QtObject { property string type: "keyboard" + // Started from Steam: keys come from a Steam Controller through Steam's + // desktop layout (A = Enter, B = Esc, Y = Space, X = Steam's keyboard), + // shown as "steam" with the controller's buttons. + property bool fromSteam: false readonly property var glyphs: ({ - controller: { toggle: "A", choose: "◀ ▶", reapply: "Y", quit: "B", apply: "X", ok: "A", back: "B" }, - keyboard: { toggle: "Space", choose: "← →", reapply: "R", quit: "Esc", apply: "Enter", ok: "Enter", back: "Esc" }, - remote: { toggle: "OK", choose: "◀ ▶", reapply: "Red", quit: "Back", apply: "↓ Apply", ok: "OK", back: "Back" } + controller: { toggle: "A", choose: "◀ ▶", reapply: "Y", quit: "", apply: "X", ok: "A", back: "B" }, + keyboard: { toggle: "Enter", choose: "← →", reapply: "R", quit: "Ctrl+Q", apply: "Ctrl+Enter", ok: "Enter", back: "Esc" }, + steam: { toggle: "A", choose: "◀ ▶", reapply: "", quit: "", apply: "↓ A", ok: "A", back: "B" }, + remote: { toggle: "OK", choose: "◀ ▶", reapply: "Red", quit: "", apply: "↓ Apply", ok: "OK", back: "Back" } }) readonly property var g: glyphs[type] } diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index f60ef7b..41ced9b 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -12,6 +12,8 @@ ApplicationWindow { required property var gamepad required property bool fullscreen required property string iconUrl + required property bool fromSteam + Component.onCompleted: Input.fromSteam = fromSteam width: 1280; height: 720 minimumWidth: 960; minimumHeight: 540 diff --git a/ui/qml/MenuScreen.qml b/ui/qml/MenuScreen.qml index 0b24f54..b368a82 100644 --- a/ui/qml/MenuScreen.qml +++ b/ui/qml/MenuScreen.qml @@ -59,7 +59,10 @@ Item { Row { anchors.left: parent.left; anchors.leftMargin: 40; anchors.verticalCenter: parent.verticalCenter; spacing: 28 Repeater { + // Quit only has a key on a keyboard (Ctrl+Q), and re-apply has + // none through Steam's layout; a controller quits through Steam. model: [[screen.g.toggle, "Toggle"], [screen.g.choose, "Choose"], [screen.g.reapply, "Re-apply what's on"], [screen.g.quit, "Quit"]] + .filter(function (h) { return h[0] !== ""; }) Row { required property var modelData; spacing: 8 Glyph { k: parent.modelData[0]; anchors.verticalCenter: parent.verticalCenter } Text { text: parent.modelData[1]; color: Theme.label; font.family: Theme.body; font.pixelSize: 14; anchors.verticalCenter: parent.verticalCenter } } @@ -70,7 +73,8 @@ Item { Text { readonly property int n: app.computePlan().length text: n === 0 ? "Everything is the way you want it" : n + (n === 1 ? " change" : " changes") color: Theme.faint; font.family: Theme.body; font.pixelSize: 14; anchors.verticalCenter: parent.verticalCenter } - Btn { k: screen.g.apply; text: "Review & apply"; primary: true; focusRing: app.sel === app.rows.length; height: 44; onClicked: app.goReview(false) } + Btn { k: screen.g.apply; text: "Review & apply"; primary: true; enabled: app.canApply + focusRing: app.sel === app.rows.length; height: 44; onClicked: app.goReview(false) } } } } diff --git a/ui/qml/PasswordScreen.qml b/ui/qml/PasswordScreen.qml index 922b198..48e94e3 100644 --- a/ui/qml/PasswordScreen.qml +++ b/ui/qml/PasswordScreen.qml @@ -47,7 +47,7 @@ Rectangle { Text { visible: app.wrongPassword; text: "That password didn't work. Try again."; color: Theme.bad; font.family: Theme.body; font.pixelSize: 14 } Rectangle { width: parent.width; height: 46; radius: 12; color: Theme.note Text { anchors.verticalCenter: parent.verticalCenter; x: 14; color: Theme.soft; font.family: Theme.body; font.pixelSize: 14 - text: ({ controller: "Press Steam + X for the on-screen keyboard.", keyboard: "Type your password and press Enter.", remote: "Select the field for the on-screen keyboard." })[Input.type] } } + text: ({ controller: "Press Steam + X for the on-screen keyboard.", keyboard: "Type your password and press Enter.", steam: "Press X for Steam's on-screen keyboard, type your password and press A.", remote: "Select the field for the on-screen keyboard." })[Input.type] } } Item { width: parent.width; height: 48 Btn { k: Input.g.back; text: "Back"; onClicked: app.act("back") } Btn { anchors.right: parent.right; k: Input.g.ok; text: "Apply"; primary: true; onClicked: screen.submit() } } diff --git a/ui/qml/Texts.qml b/ui/qml/Texts.qml index fc6e536..c92fa6b 100644 --- a/ui/qml/Texts.qml +++ b/ui/qml/Texts.qml @@ -21,6 +21,9 @@ QtObject { launcher: { label: "Steamify shortcut", hint: "The app on the desktop and in the launcher", body: "Opens the newest Steamify app, so you never need the install command again. Steamify Terminal in the launcher opens the terminal menu.", changes: ["Steamify CachyOS on the desktop and in the launcher (the app)", "Steamify Terminal in the launcher"] }, + steamgame: { label: "Add as non-Steam game", hint: "Steamify in your Steam library", + body: "Adds Steamify to your Steam library as a non-Steam game. Started from Steam it gets your controller (a Steam Controller only talks to Steam while Steam runs), and you can open it in gaming mode. Steam closes for a moment and starts again, since it only reads its games list at startup: so this is done from the desktop, not from gaming mode or with Steamify started from Steam.", + changes: ["Steamify CachyOS in your Steam library, with its icon", "An entry that's already there is updated, not added twice", "Off (or the shortcut off): removed from the library again"] }, notify: { label: "Update notifications", hint: "A notification when there's a new Steamify", body: "Checks once a day and when you log in to the desktop. When there's a new Steamify you get a notification and a tray icon: open the app to see what's new and choose what to update, or skip that version. It never updates anything by itself.", changes: ["A daily check (systemd user timer), only on the desktop", "Tray icon only while an update is waiting", "Skip this version: quiet until the next one"] }, diff --git a/ui/steamify-app.sh b/ui/steamify-app.sh index c77f21c..95f428b 100755 --- a/ui/steamify-app.sh +++ b/ui/steamify-app.sh @@ -71,4 +71,9 @@ EOF command -v update-desktop-database >/dev/null && update-desktop-database "$APPS" 2>/dev/null || true say "Starting Steamify..." +# Started from Steam: stay in the foreground, or Steam counts the game as +# ended right away and stops giving it the controller. +if [[ -n "${SteamGameId:-}" ]]; then + exec "$DEST/ui/steamify-ui" < /dev/null +fi setsid "$DEST/ui/steamify-ui" >/dev/null 2>&1 < /dev/null & diff --git a/ui/steamify-ui b/ui/steamify-ui index 8fa4310..2fefa95 100755 --- a/ui/steamify-ui +++ b/ui/steamify-ui @@ -20,6 +20,14 @@ import tempfile import threading import time +# Started from Steam (a non-Steam game, so Steam Input hands it the +# controller): Steam preloads its overlay into every game, and Qt aborts with +# it loaded. Start again without it, before Qt is imported. +if "gameoverlayrenderer" in os.environ.get("LD_PRELOAD", ""): + os.environ["LD_PRELOAD"] = ":".join(p for p in os.environ["LD_PRELOAD"].split(":") + if p and "gameoverlayrenderer" not in p) + os.execv(sys.executable, [sys.executable] + sys.argv) + HERE = os.path.dirname(os.path.realpath(__file__)) try: @@ -365,6 +373,9 @@ def main(): engine = QQmlApplicationEngine() engine.setInitialProperties({"backend": backend, "gamepad": pad, "fullscreen": fullscreen, + # Started from Steam: its keys are a Steam + # Controller's buttons (desktop layout). + "fromSteam": bool(os.environ.get("SteamGameId")), "iconUrl": QUrl.fromLocalFile(os.path.realpath(icon)).toString()}) engine.load(os.path.join(HERE, "qml", "Main.qml")) if not engine.rootObjects():