#!/bin/bash # # steamify.sh - Steamify CachyOS # # Wizard that makes a CachyOS (KDE Plasma) install behave like SteamOS: # boot into a Steam Deck-style gamescope session, switch to the Plasma # desktop and back, and reset to gamescope on the next boot/logout. # # It opens with a menu that detects which components are on and turns # them on or off to match what the user picks; turning one off restores # what was there before (system files from .bak-gamescope-wizard backups, # KDE settings from the undo journal in lib/state.sh). Components live in # lib/, see TECHNICAL.md. Safe to re-run. set -uo pipefail # Release version, see CHANGELOG.md. VERSION=2.12.0 # Single-file build (v2.12.0, 7926e8b), generated by .github/tools/bundle.sh - edit the # sources in the repository instead: https://github.com/theupriser/steamify-cachyos # ----- lib/common.sh ----- # Output helpers, prompts and small shared utilities. c_reset="\033[0m"; c_bold="\033[1m"; c_green="\033[32m"; c_yellow="\033[33m"; c_red="\033[31m"; c_cyan="\033[36m"; c_dim="\033[2m" info() { echo -e "${c_cyan}[INFO]${c_reset} $*"; } ok() { echo -e "${c_green}[OK]${c_reset} $*"; } # patch_file : a file from patches/ (module sources, patches). The # single-file build replaces this with one that has them embedded. patch_file() { cat "$SCRIPT_DIR/patches/$1"; } # service_file [KEY=value...]: a systemd unit from services/, filled # in. The single-file build replaces service_raw like patch_file. service_raw() { cat "$SCRIPT_DIR/services/$1"; } service_file() { local name="$1"; shift; service_raw "$name" | fill "$@"; } # fill [KEY=value...]: stdin with each @KEY@ replaced by its value, taken # literally (a home folder may contain sed's "&", "\" or "|"). fill() { local kv v local -a subst=(-e '') for kv in "$@"; do v="${kv#*=}"; v="${v//\\/\\\\}"; v="${v//&/\\&}"; v="${v//|/\\|}" subst+=(-e "s|@${kv%%=*}@|$v|g") done sed "${subst[@]}" } warn() { echo -e "${c_yellow}[WARN]${c_reset} $*"; } err() { echo -e "${c_red}[ERROR] $*${c_reset}" >&2; } ask_yn() { local prompt="$1" default="${2:-y}" reply local hint="[Y/n]"; [[ "$default" == "n" ]] && hint="[y/N]" read -rp "$(echo -e "${c_bold}${prompt}${c_reset} ${hint} ")" reply reply="${reply:-$default}" [[ "$reply" =~ ^[Yy]$ ]] } require_root_helper() { # Re-exec any privileged step through sudo rather than requiring the # whole script to run as root, so $HOME/whoami stay correct for the # target user detection below. if [[ $EUID -eq 0 && -z "${SUDO_USER:-}" ]]; then err "Please run this script as your normal user (it will call sudo itself), not directly as root." exit 1 fi } user_session() { # A running user systemd and session bus. Not in the installer's chroot, # where the ISO applies Steamify for a user who has never logged in. [[ -S "${XDG_RUNTIME_DIR:-/nonexistent}/bus" ]] } user_systemctl() { # systemctl --user, also without a session: then only the unit files # change (enable/disable through --root); starting and stopping is left # to the first login, which starts what's enabled anyway. if user_session; then systemctl --user "$@"; return; fi local a verb="" args=() for a in "$@"; do [[ "$a" == --now ]] && continue [[ -z "$verb" && "$a" != -* ]] && verb="$a" args+=("$a") done case "$verb" in enable|disable|is-enabled|mask|unmask) systemctl --user --root=/ "${args[@]}" ;; is-active) return 1 ;; *) return 0 ;; esac } plasma_applets() { # Print "containment:applet" for every applet of plugin $1 in the # user's Plasma layout, for kwriteconfig6 --group paths. local conf="$HOME/.config/plasma-org.kde.plasma.desktop-appletsrc" [[ -f "$conf" ]] || return 0 awk -v plugin="$1" ' /^\[Containments\]\[[0-9]+\]\[Applets\]\[[0-9]+\]$/ { split($0, p, /[][]+/); sect = p[3] ":" p[5] } /^\[/ && !/\]\[Applets\]\[[0-9]+\]$/ { sect = "" } $0 == "plugin=" plugin && sect != "" { print sect } ' "$conf" } backup_file() { local f="$1" if [[ -f "$f" && ! -f "${f}.bak-gamescope-wizard" ]]; then sudo cp -a "$f" "${f}.bak-gamescope-wizard" info "Backed up $f -> ${f}.bak-gamescope-wizard" fi } # plasmashell keeps its config in memory and writes it back on exit, so # edits to its files (panels, applets, wallpaper) must be made while it is # stopped. It is restarted through systemd so it keeps the session's # environment (platform theme etc.); a plain --replace from this shell # may not, which gives a light-themed desktop. stop_plasmashell_for_edit() { PLASMASHELL_WAS_RUNNING=false if pgrep -u "$USER" -x plasmashell >/dev/null; then PLASMASHELL_WAS_RUNNING=true systemctl --user stop plasma-plasmashell.service 2>/dev/null pkill -u "$USER" -x plasmashell && sleep 2 fi } restart_plasmashell_if_stopped() { if [[ "${PLASMASHELL_WAS_RUNNING:-false}" == true ]]; then rm -rf ~/.cache/plasmashell* ~/.cache/org.kde.dirmodel-qml.kcache systemctl --user start plasma-plasmashell.service 2>/dev/null || { setsid plasmashell >/dev/null 2>&1 & } fi } # ----- lib/state.sh ----- # Undo journal for per-user KDE settings, so every component can be turned # off again exactly: the first time a component changes a key, the key's # previous value is recorded, and reverting the component puts it back (or # deletes the key if it didn't exist before). STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/steamify" # Steamify's own files in the home folder: the app (app/, from # steamify-app.sh) and the shortcut's and notifier's scripts (bin/). STEAMIFY_DATA="${XDG_DATA_HOME:-$HOME/.local/share}/steamify" STEAMIFY_BIN="$STEAMIFY_DATA/bin" UNSET=$'\x01' kset() { # kset # kwriteconfig6, recording the old value in the component's journal. # "--delete" removes the key. local component="$1" file="$2" groups="$3" key="$4" value="$5" local journal="$STATE_DIR/$component.journal" old id local -a gargs=() g local groups_item IFS='|' read -ra g <<< "$groups" for groups_item in "${g[@]}"; do gargs+=(--group "$groups_item"); done mkdir -p "$STATE_DIR" id="$(printf '%s\t%s\t%s' "$file" "$groups" "$key")" if ! grep -qxF -- "$id" <(cut -f1-3 "$journal" 2>/dev/null); then old="$(kreadconfig6 --file "$file" "${gargs[@]}" --key "$key" --default "$UNSET")" printf '%s\t%s\n' "$id" "$(printf '%s' "$old" | base64 -w0)" >> "$journal" fi if [[ "$value" == "--delete" ]]; then kwriteconfig6 --file "$file" "${gargs[@]}" --key "$key" --delete else kwriteconfig6 --file "$file" "${gargs[@]}" --key "$key" "$value" fi } krevert() { # krevert : restore every key the component changed, newest # first, then forget the journal. local component="$1" local journal="$STATE_DIR/$component.journal" local file groups key old groups_item local -a g [[ -f "$journal" ]] || return 0 while IFS=$'\t' read -r file groups key old; do local -a gargs=() IFS='|' read -ra g <<< "$groups" for groups_item in "${g[@]}"; do gargs+=(--group "$groups_item"); done old="$(printf '%s' "$old" | base64 -d)" if [[ "$old" == "$UNSET" ]]; then kwriteconfig6 --file "$file" "${gargs[@]}" --key "$key" --delete else kwriteconfig6 --file "$file" "${gargs[@]}" --key "$key" "$old" fi done < <(tac "$journal") rm -f "$journal" } has_journal() { [[ -s "$STATE_DIR/$1.journal" ]] } state_set() { # state_set : remember a value for later. mkdir -p "$STATE_DIR" kwriteconfig6 --file "$STATE_DIR/$1.state" --group State --key "$2" "$3" } state_get() { # state_get [default] kreadconfig6 --file "$STATE_DIR/$1.state" --group State --key "$2" --default "${3:-}" } state_clear() { rm -f "$STATE_DIR/$1.state" } current_display_manager() { systemctl show -p Id --value display-manager 2>/dev/null | sed 's/\.service$//' } migrate_dir() { # migrate_dir : moves to and leaves a symlink. # When both exist (an older release ran after a newer one), what # lacks is moved over; a file in both keeps 's copy and stays # as it is. Returns 0 when something moved. local old="$1" new="$2" f moved=1 [[ -d "$old" && ! -L "$old" ]] || return 1 if [[ ! -e "$new" ]]; then mkdir -p "$(dirname "$new")" mv "$old" "$new" && ln -s "$new" "$old" return 0 fi for f in "$old"/* "$old"/.[!.]*; do [[ -e "$f" || -L "$f" ]] || continue [[ -e "$new/${f##*/}" ]] && continue mv "$f" "$new/" && moved=0 done rmdir "$old" 2>/dev/null && ln -s "$new" "$old" return "$moved" } migrate_layout() { # Before 2.5.0 the state and scripts were under the project's old name # (cachyos-gamescope-boot). Moved once; the old names stay as symlinks, # so an older release that still runs finds the same state. local old_state="${XDG_STATE_HOME:-$HOME/.local/state}/cachyos-gamescope-boot" local old_bin="${XDG_DATA_HOME:-$HOME/.local/share}/cachyos-gamescope-boot" local old_icon="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps/cachyos-gamescope-boot-wizard.svg" local f # Links left pointing at nothing once their component was turned off. for f in "$old_state" "$old_bin"; do [[ -L "$f" && ! -e "$f" ]] && rm -f "$f"; done migrate_dir "$old_state" "$STATE_DIR" if migrate_dir "$old_bin" "$STEAMIFY_BIN"; then # The notifier had the old paths built in; its unit and the # shortcut's entries point at the scripts. [[ -f "$STEAMIFY_BIN/steamify-notifier" ]] && patch_file steamify-notifier.py | install_executable "$STEAMIFY_BIN/steamify-notifier" 755 for f in "$NOTIFY_UNITS/$NOTIFY_NAME.service" "$WIZARD_APP_ENTRY" "$WIZARD_TERMINAL_ENTRY" \ "$(wizard_desktop_dir)/$WIZARD_DESKTOP_NAME"; do [[ -f "$f" ]] && sed -i "s|$old_bin/|$STEAMIFY_BIN/|g" "$f" done user_systemctl daemon-reload 2>/dev/null fi if [[ -f "$old_icon" ]]; then if [[ -e "$WIZARD_ICON" ]]; then rm -f "$old_icon"; else mv "$old_icon" "$WIZARD_ICON"; fi gtk-update-icon-cache -q -f -t "$(dirname "$(dirname "$(dirname "$WIZARD_ICON")")")" 2>/dev/null || true fi for f in "$WIZARD_APP_ENTRY" "$WIZARD_TERMINAL_ENTRY" "$(wizard_desktop_dir)/$WIZARD_DESKTOP_NAME"; do [[ -f "$f" ]] && grep -q '^Icon=cachyos-gamescope-boot-wizard$' "$f" && sed -i 's|^Icon=cachyos-gamescope-boot-wizard$|Icon=steamify|' "$f" done return 0 } # ----- lib/packages.sh ----- # Required packages and AUR helper handling. aur_noninteractive_flags() { # Fully non-interactive install flags for the given AUR helper: no # PKGBUILD diff/edit/cleanbuild prompts, no provider-selection prompts # (e.g. picking kernel headers for DKMS), and makepkg itself silenced. case "$1" in yay) echo --needed --noconfirm --answerclean None --answerdiff None \ --answeredit None --mflags "--noconfirm" ;; paru) echo --needed --noconfirm --skipreview --mflags "--noconfirm" ;; esac } find_aur_helper() { if command -v yay >/dev/null 2>&1; then echo yay elif command -v paru >/dev/null 2>&1; then echo paru fi } bootstrap_yay() { # Builds and installs yay from the AUR using only makepkg + base-devel # (both from the official repos), so we have an AUR helper available # without assuming the user already set one up. info "Installing base-devel and git (needed to build an AUR helper)..." sudo pacman -S --needed --noconfirm base-devel git || return 1 local tmp_dir tmp_dir="$(mktemp -d)" info "Cloning yay-bin from the AUR..." if ! git clone --depth 1 https://aur.archlinux.org/yay-bin.git "$tmp_dir/yay-bin"; then err "Failed to clone yay-bin from the AUR." rm -rf "$tmp_dir" return 1 fi # Build only, then install it ourselves: makepkg -i runs `sudo -k pacman`, # which forgets the cached sudo password and asks for it again. info "Building and installing yay (this runs makepkg as your user, not root)..." local -a pkgs if (cd "$tmp_dir/yay-bin" && makepkg --noconfirm) && mapfile -t pkgs < <(cd "$tmp_dir/yay-bin" && makepkg --packagelist | grep -v -- '-debug-') && sudo pacman -U --noconfirm "${pkgs[@]}"; then ok "yay installed." rm -rf "$tmp_dir" return 0 else err "Building yay failed. See errors above." rm -rf "$tmp_dir" return 1 fi } python_hid_shim_ensure() { # jupiter-hw-support (a dependency of gamescope-session-cachyos) wants python-hid, which conflicts with python-hidapi, and # a PC can need python-hidapi (liquidctl does). Only Valve's Steam Deck controller firmware updater uses python-hid, which # a PC doesn't run: so a placeholder package that provides python-hid keeps python-hidapi and satisfies the dependency # (also at later updates, which a one-time --assume-installed wouldn't). It stays when the conversion goes off: # jupiter-hw-support, which stays, depends on it. pacman -Q python-hidapi >/dev/null 2>&1 || return 0 pacman -Q python-hid steamify-python-hid-shim >/dev/null 2>&1 && return 0 local dir pkg cache="${XDG_CACHE_HOME:-$HOME/.cache}/steamify" warn "python-hidapi is installed (liquidctl and others need it) and conflicts with python-hid, which Steam Deck's tools ask for: keeping python-hidapi, with a small placeholder package for python-hid." sudo pacman -S --needed --noconfirm base-devel >/dev/null || { err "Installing the build tools failed."; return 1; } mkdir -p "$cache" && dir="$(mktemp -d -p "$cache" hid-shim.XXXXXX)" || return 1 patch_file python-hid-shim.PKGBUILD > "$dir/PKGBUILD" || { rm -rf "$dir"; return 1; } if ! (cd "$dir" && makepkg -f --noconfirm >/dev/null) || ! pkg="$(ls "$dir"/steamify-python-hid-shim-*.pkg.tar.* 2>/dev/null | head -1)" || ! sudo pacman -U --noconfirm "$pkg"; then rm -rf "$dir" err "Couldn't set up the python-hid placeholder: python-hidapi and python-hid conflict, so the packages can't be installed." return 1 fi rm -rf "$dir" ok "python-hidapi kept; python-hid is covered by a placeholder." } install_required_packages() { # Step 2: gamescope/Steam and the desktop helpers the rest of the setup uses. local -a MISSING info "Checking required packages: gamescope-session-cachyos, steam, mangohud, xterm, ttf-liberation, wqy-zenhei, plasma-keyboard" mapfile -t MISSING < <(pacman -T gamescope-session-cachyos steam mangohud xterm ttf-liberation wqy-zenhei plasma-keyboard) if [[ ${#MISSING[@]} -gt 0 ]]; then python_hid_shim_ensure || return 1 info "Installing missing packages: ${MISSING[*]}" sudo pacman -S --needed --noconfirm "${MISSING[@]}" || { err "Package installation failed."; return 1; } else ok "All required packages already installed." fi } ensure_aur_helper() { # yay or paru, bootstrapping yay from the AUR when neither is installed. if [[ -n "$(find_aur_helper)" ]]; then ok "AUR helper already present." else info "No AUR helper (yay/paru) found. Installing yay..." bootstrap_yay fi } # ----- lib/login-manager.sh ----- # Booting into gaming mode: autologin into gamescope with SDDM or # plasma-login-manager (plus the session sync bridge the latter needs). BASE_CONF="/etc/plasmalogin.conf" # The "SteamOS conversion" menu item: gaming mode boot, the Return to # Gaming Mode shortcut and Steam on the desktop (lib/steam-desktop.sh). gaming_status() { # Autologin into gamescope is configured for this user. case "$(current_display_manager)" in sddm) [[ -f /etc/sddm.conf.d/10-gamescope-autologin.conf ]] ;; plasmalogin) sed -n '/^\[Autologin\]/,/^\[/p' "$BASE_CONF" 2>/dev/null | grep -qx "User=$USER" && [[ -f /etc/systemd/system/sync-steamos-session.path ]] ;; *) return 1 ;; esac } # What Steam shows in its System settings besides the OS name (which stays # CachyOS's, for legal clarity and because limine-snapper-sync finds the boot # entries by it): the SteamOS release as the OS version, steam-machine as the # codename, Steamify with its version as the variant (os-release VERSION_ID, # VERSION_CODENAME, VARIANT_ID; see patches/steamify-os-release.sh). OS_NAME_SCRIPT=/usr/local/libexec/steamify-os-release OS_NAME_HOOK=/etc/pacman.d/hooks/zz-steamify-os-release.hook # limine's tools take the OS name from TARGET_OS_NAME, else os-release's # PRETTY_NAME: pinned to CachyOS, the boot entries never depend on os-release. LIMINE_DEFAULTS=/etc/default/limine steamos_release() { # The SteamOS release Steamify follows: Valve's newest jupiter-X.Y repo # (where the SteamOS extras come from), as an os-release ID (no spaces or # capitals: steamos-3.9). Offline (e.g. in the installer): the one set # before, if any. local v v="$(curl -fsL --max-time 15 "$VALVE_MIRROR/" 2>/dev/null | grep -oE 'jupiter-[0-9]+\.[0-9]+/' | tr -d / | sort -V | tail -n 1)" if [[ -n "$v" ]]; then echo "steamos-${v#jupiter-}" else sed -n 's/^VERSION_ID=\(steamos-.*\)$/\1/p' /etc/os-release 2>/dev/null; fi } os_name_enable() { # 2.9.0 pre-releases renamed the OS itself; the name stays CachyOS's. if grep -q '^DISTRIB_DESCRIPTION=.*with Steamify' /etc/lsb-release 2>/dev/null && [[ -x /usr/share/libalpm/scripts/cachyos-branding ]]; then sudo /usr/share/libalpm/scripts/cachyos-branding lsb-release fi [[ -n "$(kreadconfig6 --file kcm-about-distrorc --group General --key Name 2>/dev/null)" ]] && kset gaming kcm-about-distrorc General Name --delete # About this System: os-release's VERSION (CachyOS has none) instead of # VERSION_ID, which is the SteamOS release now. kset gaming kcm-about-distrorc General UseOSReleaseVersion true patch_file steamify-os-release.sh | fill VERSION="$VERSION" STEAMOS="$(steamos_release)" | sudo install -Dm755 /dev/stdin "$OS_NAME_SCRIPT" && patch_file steamify-os-release.hook | fill SCRIPT="$OS_NAME_SCRIPT" | sudo install -Dm644 /dev/stdin "$OS_NAME_HOOK" && sudo "$OS_NAME_SCRIPT" || { err "Setting Steamify's version for Steam failed."; return 1; } if [[ -f "$LIMINE_DEFAULTS" ]] && ! grep -q '^TARGET_OS_NAME=' "$LIMINE_DEFAULTS"; then backup_file "$LIMINE_DEFAULTS" printf '%s\n' 'TARGET_OS_NAME="CachyOS"' | sudo tee -a "$LIMINE_DEFAULTS" > /dev/null state_set gaming limine_target 1 fi return 0 } os_name_disable() { sudo rm -f "$OS_NAME_HOOK" "$OS_NAME_SCRIPT" [[ -f /etc/os-release ]] && sudo sed -i -e '/^VARIANT=/d' -e '/^VARIANT_ID=/d' -e '/^VERSION_ID=/d' -e '/^VERSION_CODENAME=/d' /etc/os-release # CachyOS's own values back, the way its hooks write them. if [[ -x /usr/share/libalpm/scripts/cachyos-branding ]]; then sudo /usr/share/libalpm/scripts/cachyos-branding os-release # Undoes 2.9.0 pre-releases, which changed lsb-release's name too. sudo /usr/share/libalpm/scripts/cachyos-branding lsb-release fi if [[ -n "$(state_get gaming limine_target)" ]]; then sudo sed -i '/^TARGET_OS_NAME="CachyOS"$/d' "$LIMINE_DEFAULTS" state_set gaming limine_target "" fi return 0 } os_version_refresh() { # After every run that changed something (sudo is at hand then): Steam's # OS version follows Steamify's, not just the one that set it up. Silent: # the app reads the backend's stdout as JSON. gaming_status 2>/dev/null || return 0 grep -qx "VARIANT_ID=steamify-$VERSION" /etc/os-release 2>/dev/null && return 0 os_name_enable > /dev/null 2>&1 return 0 } gaming_enable() { # LOGIN_MANAGER (sddm with single user, else plasmalogin) is set by the # menu. Re-running with the other value switches over cleanly. install_required_packages || return 1 # NVIDIA: gamescope's picture is corrupted unless it is the patched one (no-op elsewhere). gsn_enable || return 1 case "$LOGIN_MANAGER" in sddm) switch_to_sddm || return 1 configure_sddm_autologin remove_session_sync ;; plasmalogin) remove_sddm_autologin switch_to_plasmalogin || return 1 configure_autologin install_session_sync ;; esac # (NVIDIA with single user mode from before the conversion: its desktop autologin would pin the Plasma session.) single_login_release create_desktop_shortcut steam_enable os_name_enable || return 1 } gaming_disable() { # Back to a normal desktop boot with CachyOS's default login manager. # Packages (steam, gamescope-session-cachyos, ...) are kept. info "Turning off booting into gaming mode..." remove_sddm_autologin remove_session_sync restore_plasmalogin_config switch_to_plasmalogin remove_desktop_shortcut steam_disable gsn_disable krevert gaming os_name_disable # NVIDIA with single user mode still on: nothing else logs in by itself any more. nvidia_present && single_status && single_login_enable ok "The PC boots to the normal login screen again (from the next boot)." } switch_to_plasmalogin() { [[ "$(current_display_manager)" == plasmalogin ]] && return 0 info "Switching the login manager to plasma-login-manager..." sudo pacman -S --needed --noconfirm plasma-login-manager || { err "Installing plasma-login-manager failed."; return 1; } local current current="$(systemctl show -p Id --value display-manager 2>/dev/null)" [[ -n "$current" && "$current" != plasmalogin.service ]] && sudo systemctl disable "$current" sudo systemctl enable -f plasmalogin.service || { err "Enabling plasmalogin failed."; return 1; } ok "plasma-login-manager is the login manager from the next boot on." } remove_sddm_autologin() { sudo rm -f /etc/sddm.conf.d/10-gamescope-autologin.conf } restore_plasmalogin_config() { # Put back the original /etc/plasmalogin.conf from the first run's # backup, or at least drop our [Autologin] section. if [[ -f "${BASE_CONF}.bak-gamescope-wizard" ]]; then sudo cp -a "${BASE_CONF}.bak-gamescope-wizard" "$BASE_CONF" elif [[ -f "$BASE_CONF" ]]; then sudo awk '/^\[Autologin\]/ { skip=1; next } /^\[/ { skip=0 } !skip { print }' "$BASE_CONF" | sudo tee "${BASE_CONF}.tmp" > /dev/null && sudo mv "${BASE_CONF}.tmp" "$BASE_CONF" fi } switch_to_sddm() { # Takes effect at the next boot; the running session is left alone. [[ "$(current_display_manager)" == sddm ]] && return 0 info "Installing and enabling SDDM..." sudo pacman -S --needed --noconfirm sddm || { err "Installing sddm failed."; return 1; } local current current="$(systemctl show -p Id --value display-manager 2>/dev/null)" if [[ -n "$current" && "$current" != "sddm.service" ]]; then sudo systemctl disable "$current" fi sudo systemctl enable -f sddm.service || { err "Enabling sddm failed."; return 1; } ok "SDDM is the login manager from the next boot on." } remove_sddm_base_autologin() { # /etc/sddm.conf is read last: an [Autologin] there would override our fragments. if [[ -f /etc/sddm.conf ]] && grep -q '^\[Autologin\]' /etc/sddm.conf; then backup_file /etc/sddm.conf sudo awk ' /^\[Autologin\]/ { skip=1; next } /^\[/ { skip=0 } !skip { print } ' /etc/sddm.conf | sudo tee /etc/sddm.conf.tmp > /dev/null && sudo mv /etc/sddm.conf.tmp /etc/sddm.conf info "Removed [Autologin] from /etc/sddm.conf (backup kept)." fi } configure_sddm_autologin() { # User= and Relogin= live in our own fragment; steam-set-session keeps # Session= up to date in zz-steamos-autologin.conf, which sorts later # and so wins. /etc/sddm.conf is read last, so any [Autologin] there # would override both and is removed. local conf="/etc/sddm.conf.d/10-gamescope-autologin.conf" info "Configuring SDDM autologin for $TARGET_USER into gamescope (Relogin=true)" sudo mkdir -p /etc/sddm.conf.d sudo tee "$conf" > /dev/null << EOF [Autologin] User=$TARGET_USER Session=gamescope-session.desktop Relogin=true EOF remove_sddm_base_autologin # Start in gamescope, through CachyOS's own tool so its file is current. sudo /usr/lib/steamos/steam-set-session gamescope-session.desktop ok "SDDM autologin configured:" cat "$conf" echo } remove_session_sync() { # The plasmalogin sync bridge from an earlier run isn't needed on SDDM. if [[ -f /etc/systemd/system/sync-steamos-session.path ]]; then info "Removing the plasma-login-manager sync bridge (not needed with SDDM)..." sudo systemctl disable --now sync-steamos-session.path 2>/dev/null sudo rm -f /etc/systemd/system/sync-steamos-session.path \ /etc/systemd/system/sync-steamos-session.service \ /usr/local/bin/sync-steamos-session.sh sudo systemctl daemon-reload fi } configure_autologin() { # Steps 3-5: plasmalogin.conf.d, the base [Autologin] section, stray fragments. # 3. Ensure plasmalogin.conf.d exists (fixes Switch-to-Desktop crash) info "Ensuring /etc/plasmalogin.conf.d exists (fixes the 'Switching to Desktop' hang bug)" sudo mkdir -p /etc/plasmalogin.conf.d ok "Directory present." echo # 4. Fix the base /etc/plasmalogin.conf info "Configuring $BASE_CONF (Session=gamescope-session.desktop, User=$TARGET_USER, Relogin=true)" backup_file "$BASE_CONF" sudo touch "$BASE_CONF" sudo awk ' BEGIN { in_autologin=0 } /^\[Autologin\]/ { in_autologin=1; next } /^\[/ { in_autologin=0 } !in_autologin { print } ' "$BASE_CONF" | sudo tee "${BASE_CONF}.tmp" > /dev/null || { err "Failed to rewrite $BASE_CONF."; exit 1; } sudo mv "${BASE_CONF}.tmp" "$BASE_CONF" sudo tee -a "$BASE_CONF" > /dev/null << EOF [Autologin] Session=gamescope-session.desktop User=$TARGET_USER Relogin=true EOF ok "Base autologin config written:" sudo sed -n '/^\[Autologin\]/,$p' "$BASE_CONF" echo # 5. Clean up any stray conf.d autologin fragments from manual attempts info "Removing any leftover manual conf.d autologin overrides (base config now handles it)" sudo rm -f /etc/plasmalogin.conf.d/zzz-steamos-autologin*.conf ok "Cleaned." echo } install_session_sync() { # Steps 6-8: sync bridge conf.d -> base config, its systemd units, first run. # 6. Install the sync bridge: conf.d -> base config local SYNC_SCRIPT="/usr/local/bin/sync-steamos-session.sh" info "Installing session sync bridge at $SYNC_SCRIPT" patch_file sync-steamos-session.sh | sudo tee "$SYNC_SCRIPT" > /dev/null sudo chmod +x "$SYNC_SCRIPT" ok "Sync script installed." echo # 7. systemd path watcher + service info "Installing systemd path watcher so session switches take effect immediately" service_file sync-steamos-session.path | sudo tee /etc/systemd/system/sync-steamos-session.path > /dev/null service_file sync-steamos-session.service | sudo tee /etc/systemd/system/sync-steamos-session.service > /dev/null sudo systemctl daemon-reload sudo systemctl enable --now sync-steamos-session.path ok "Path watcher enabled." echo # 8. Run it once now so current state is in sync info "Running the sync once now to align current state" sudo systemctl start sync-steamos-session.service ok "Done. Current base config:" sudo cat "$BASE_CONF" echo } # ----- lib/single-user.sh ----- # Single user, no password, like SteamOS: no lock screen, user switching or # log out, since typing a password with a controller is miserable. Goes # together with SDDM (see lib/login-manager.sh). All changes are journaled # per user (lib/state.sh), so turning it off restores KDE's previous state. single_status() { [[ "$(kreadconfig6 --file kdeglobals --group "KDE Action Restrictions" --key action/lock_screen)" == false ]] } WALLET_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/kwalletd" # The user's own wallet while single user mode is on, and ours after. WALLET_BACKUP=".bak-steamify" WALLET_OURS=".steamify-single-user" stop_kwallet() { # The wallet daemon keeps the file open and would write it back. local d for d in kwalletd6 ksecretd; do pkill -u "$USER" -x "$d" 2>/dev/null && sleep 1 done return 0 } single_wallet_enable() { # Like SteamOS: an empty, password-less wallet (Valve's own file), so # nothing asks for a wallet password (e.g. Brave at start): with # autologin nobody typed the login password that unlocks the usual one. # The user's wallet is moved aside and comes back when this is off. The # wallet from an earlier time in single user mode is reused, with # whatever was saved in it then. local tmp f if [[ -f "$WALLET_DIR/kdewallet.kwl$WALLET_OURS" ]]; then stop_kwallet for f in kdewallet.kwl kdewallet.salt; do [[ -f "$WALLET_DIR/$f" && ! -e "$WALLET_DIR/$f$WALLET_BACKUP" ]] && mv "$WALLET_DIR/$f" "$WALLET_DIR/$f$WALLET_BACKUP" [[ -f "$WALLET_DIR/$f$WALLET_OURS" ]] && mv -f "$WALLET_DIR/$f$WALLET_OURS" "$WALLET_DIR/$f" done kset single kwalletrc Wallet "First Use" false ok "Single user mode's wallet (no password) is back; your own is kept as kdewallet.kwl$WALLET_BACKUP." return 0 fi tmp="$(mktemp -d)" if ! fetch_valve_presets "$tmp"; then rm -rf "$tmp" warn "Couldn't get Valve's empty wallet; apps may still ask for the wallet password." return 0 fi if [[ -f "$WALLET_DIR/kdewallet.kwl" ]] && cmp -s "$WALLET_DIR/kdewallet.kwl" "$tmp/usr/share/kwalletd/kdewallet.kwl"; then rm -rf "$tmp" return 0 fi stop_kwallet mkdir -p "$WALLET_DIR" for f in kdewallet.kwl kdewallet.salt; do # Never overwrite an earlier backup: that one is the user's. [[ -f "$WALLET_DIR/$f" && ! -e "$WALLET_DIR/$f$WALLET_BACKUP" ]] && mv "$WALLET_DIR/$f" "$WALLET_DIR/$f$WALLET_BACKUP" install -m 600 "$tmp/usr/share/kwalletd/$f" "$WALLET_DIR/$f" done rm -rf "$tmp" kset single kwalletrc Wallet "First Use" false [[ -f "$WALLET_DIR/kdewallet.kwl$WALLET_BACKUP" ]] && info "Your wallet is kept as $WALLET_DIR/kdewallet.kwl$WALLET_BACKUP and comes back when single user mode is off." ok "Empty wallet without a password: apps no longer ask for the wallet password." } single_wallet_disable() { # The user's wallet back; ours (maybe with passwords saved since) is # kept next to it. [[ -f "$WALLET_DIR/kdewallet.kwl$WALLET_BACKUP" ]] || return 0 stop_kwallet local f for f in kdewallet.kwl kdewallet.salt; do [[ -f "$WALLET_DIR/$f" ]] && mv -f "$WALLET_DIR/$f" "$WALLET_DIR/$f$WALLET_OURS" [[ -f "$WALLET_DIR/$f$WALLET_BACKUP" ]] && mv "$WALLET_DIR/$f$WALLET_BACKUP" "$WALLET_DIR/$f" done ok "Your own wallet is back (the empty one is kept as kdewallet.kwl$WALLET_OURS)." } reload_screen_locker() { # The screen locker only reads kscreenlockerrc when the session starts: # without this it keeps locking after idling until the next login. qdbus6 org.freedesktop.ScreenSaver /ScreenSaver org.kde.screensaver.configure >/dev/null 2>&1 || busctl --user call org.freedesktop.ScreenSaver /ScreenSaver org.kde.screensaver configure >/dev/null 2>&1 || true } single_launcher() { # Launcher: only Sleep / Restart / Shut Down, no Session dropdown (where # Log Out lives). Restricting action/logout instead would also hide # Restart and Shut Down. Separate so the theme can re-apply it after # replacing the Plasma layout. Edit only while plasmashell is stopped. local applet grp for applet in $(plasma_applets org.kde.plasma.kickoff); do grp="Containments|${applet%%:*}|Applets|${applet#*:}|Configuration|General" kset single plasma-org.kde.plasma.desktop-appletsrc "$grp" primaryActions 3 kset single plasma-org.kde.plasma.desktop-appletsrc "$grp" systemFavorites 'suspend,reboot,shutdown' done } single_launcher_drop() { # The theme's layout backup is taken when it's turned on: with single # user on then, it holds single's launcher keys, and restoring it after # single was turned off brings them back. Drop exactly single's values # (a launcher set up differently by the user is left alone). local applet local -a kick_args for applet in $(plasma_applets org.kde.plasma.kickoff); do kick_args=(--file plasma-org.kde.plasma.desktop-appletsrc --group Containments --group "${applet%%:*}" --group Applets --group "${applet#*:}" --group Configuration --group General) [[ "$(kreadconfig6 "${kick_args[@]}" --key primaryActions)" == 3 ]] && kwriteconfig6 "${kick_args[@]}" --key primaryActions --delete [[ "$(kreadconfig6 "${kick_args[@]}" --key systemFavorites)" == 'suspend,reboot,shutdown' ]] && kwriteconfig6 "${kick_args[@]}" --key systemFavorites --delete done return 0 } # Without the SteamOS conversion (NVIDIA PCs: gamescope's session is broken there) nothing logs in by itself, so single user # mode does it: SDDM, autologin into the Plasma session. Named to sort after steam-set-session's zz-steamos-autologin.conf, # which may still say gamescope from an earlier conversion. SINGLE_AUTOLOGIN=${SINGLE_AUTOLOGIN:-/etc/sddm.conf.d/zzz-steamify-autologin.conf} single_login_needed() { nvidia_present && ! gaming_status; } single_login_enable() { single_login_needed || return 0 switch_to_sddm || return 1 info "Logging in to the desktop by itself (SDDM autologin for $TARGET_USER)..." sudo mkdir -p "$(dirname "$SINGLE_AUTOLOGIN")" sudo tee "$SINGLE_AUTOLOGIN" > /dev/null << EOF [Autologin] User=$TARGET_USER Session=plasma.desktop Relogin=true EOF remove_sddm_base_autologin } single_login_release() { # The SteamOS conversion logs in by itself, and picks the session (gamescope, or the desktop with "Boot into"): the # desktop autologin above, which sorts last, would pin the Plasma session and "Return to Gaming Mode" would open KDE # again. Called when the conversion is turned on; turning it off sets it up again (gaming_disable). [[ -f "$SINGLE_AUTOLOGIN" ]] || return 0 info "The SteamOS conversion logs in by itself: removing the desktop autologin of single user mode." sudo rm -f "$SINGLE_AUTOLOGIN" } single_login_disable() { [[ -f "$SINGLE_AUTOLOGIN" ]] || return 0 sudo rm -f "$SINGLE_AUTOLOGIN" # The way back is CachyOS's own login manager, unless the conversion is on (it keeps SDDM). gaming_status || switch_to_plasmalogin } single_enable() { single_login_enable || return 1 info "Turning off the lock screen, user switching and logging out..." stop_plasmashell_for_edit # Hides Lock / Switch User in menus. local action for action in lock_screen switch_user start_new_session; do kset single kdeglobals "KDE Action Restrictions" "action/$action" false done # Never lock on idle or resume. kset single kscreenlockerrc Daemon Autolock false kset single kscreenlockerrc Daemon LockOnResume false kset single kscreenlockerrc Daemon Timeout 0 # The restrictions don't cover the shortcuts: unbind Meta+L and the # Ctrl+Alt+Del logout screen (format: current,default,description). kset single kglobalshortcutsrc ksmserver "Lock Session" $'none,Screensaver\tMeta+L,Lock Session' kset single kglobalshortcutsrc ksmserver "Log Out" 'none,Ctrl+Alt+Del,Show Logout Screen' single_launcher restart_plasmashell_if_stopped reload_screen_locker single_wallet_enable ok "Single user: no lock screen, user switching or log out." } single_disable() { single_login_disable info "Restoring the lock screen, user switching and logging out..." stop_plasmashell_for_edit if has_journal single; then krevert single else # Set up by a version of this script without the undo journal: # fall back to KDE's defaults for everything it changes. local action applet key for action in lock_screen switch_user start_new_session; do kwriteconfig6 --file kdeglobals --group "KDE Action Restrictions" --key "action/$action" --delete done for key in Autolock LockOnResume Timeout; do kwriteconfig6 --file kscreenlockerrc --group Daemon --key "$key" --delete done kwriteconfig6 --file kglobalshortcutsrc --group ksmserver --key "Lock Session" $'Screensaver\tMeta+L,Screensaver\tMeta+L,Lock Session' kwriteconfig6 --file kglobalshortcutsrc --group ksmserver --key "Log Out" 'Ctrl+Alt+Del,Ctrl+Alt+Del,Show Logout Screen' for applet in $(plasma_applets org.kde.plasma.kickoff); do kwriteconfig6 --file plasma-org.kde.plasma.desktop-appletsrc --group Containments --group "${applet%%:*}" \ --group Applets --group "${applet#*:}" --group Configuration --group General --key primaryActions --delete kwriteconfig6 --file plasma-org.kde.plasma.desktop-appletsrc --group Containments --group "${applet%%:*}" \ --group Applets --group "${applet#*:}" --group Configuration --group General --key systemFavorites --delete done fi restart_plasmashell_if_stopped reload_screen_locker single_wallet_disable ok "KDE's normal lock screen and user switching are back." } # ----- lib/steam-desktop.sh ----- # Steam in the Plasma desktop session: gamepad UI args, virtual keyboard, # autostart. Part of the SteamOS conversion (gaming_enable/gaming_disable). steam_enable() { info "Applying non-optional SteamOS environment & keyboard fixes..." local steam_env_dir steam_env_dir="$HOME/.config/environment.d" mkdir -p "$steam_env_dir" # 2. Prevent KDE Wayland from blocking the virtual keyboard overlay echo "KWIN_IM_SHOW_ALWAYS=1" > "$steam_env_dir/99-kde-virtual-keyboard.conf" ok "Steam UI parameters and keyboard fix deployed successfully." } # Sub-option of the SteamOS conversion: Steam starts silently (in the tray, # no window) on the Plasma desktop. It is Steam's own autostart entry, the file # Steam's setting "Run Steam when my computer starts" creates and removes, so # that setting shows what Steamify did (and the other way around). Steam's copy # is the system steam.desktop; ours has -silent in its Exec line. STEAM_AUTOSTART="${XDG_CONFIG_HOME:-$HOME/.config}/autostart/steam.desktop" STEAM_SYSTEM_DESKTOP=/usr/share/applications/steam.desktop silent_status() { # The old systemd unit counts as on too (until it is migrated by an update of this option). grep -qs '^Exec=.*-silent' "$STEAM_AUTOSTART" || [[ -f "$HOME/.config/systemd/user/steam-desktop-autostart.service" ]] } silent_old_unit_remove() { # Before 2.11.0 the option was a systemd user unit: it would start Steam a second time. local unit="$HOME/.config/systemd/user/steam-desktop-autostart.service" [[ -f "$unit" ]] || return 0 user_systemctl disable --now steam-desktop-autostart.service 2>/dev/null rm -f "$unit" user_systemctl daemon-reload } silent_enable() { silent_old_unit_remove mkdir -p "$(dirname "$STEAM_AUTOSTART")" if [[ -f "$STEAM_SYSTEM_DESKTOP" ]]; then sed 's|^Exec=/usr/bin/steam %U$|Exec=/usr/bin/steam -silent %U|' "$STEAM_SYSTEM_DESKTOP" > "$STEAM_AUTOSTART" fi silent_status || printf '%s\n' '[Desktop Entry]' 'Name=Steam' 'Exec=/usr/bin/steam -silent %U' \ 'Icon=steam' 'Terminal=false' 'Type=Application' 'Categories=Network;FileTransfer;Game;' > "$STEAM_AUTOSTART" ok "Steam starts silently on the desktop from the next login." } silent_disable() { silent_old_unit_remove silent_status && rm -f "$STEAM_AUTOSTART" ok "Steam no longer starts by itself on the desktop." } steam_disable() { info "Removing Steam desktop settings..." # The autostart entry is the `silent` option's: removed with it, and here # too for when the conversion goes off by itself. silent_status && silent_disable silent_old_unit_remove rm -f "$HOME/.config/environment.d/99-kde-virtual-keyboard.conf" ok "Steam desktop settings removed (takes full effect at next login)." } # "Steam Deck/Machine icons" menu item: Steam's gamepad UI in SteamOS 3 # mode, which shows Steam Deck button glyphs in gaming mode. glyphs_status() { [[ -f "$HOME/.config/environment.d/99-gamescope-steam-glyphs.conf" ]] } glyphs_enable() { local gs_env="$HOME/.config/gamescope-session/environment" mkdir -p "$HOME/.config/environment.d" echo 'STEAM_GAMEPADUI_ARGS="-gamepadui -steamos3"' > "$HOME/.config/environment.d/99-gamescope-steam-glyphs.conf" if [[ -d "$HOME/.config/gamescope-session" ]]; then touch "$gs_env" sed -i '/^STEAM_GAMEPADUI_ARGS=/d' "$gs_env" echo 'STEAM_GAMEPADUI_ARGS="-gamepadui -steamos3"' >> "$gs_env" fi ok "Steam Deck/Machine icons on (from the next gaming mode start)." } glyphs_disable() { rm -f "$HOME/.config/environment.d/99-gamescope-steam-glyphs.conf" [[ -f "$HOME/.config/gamescope-session/environment" ]] && sed -i '/^STEAM_GAMEPADUI_ARGS=/d' "$HOME/.config/gamescope-session/environment" ok "Steam's default button icons are back (from the next gaming mode start)." } # ----- lib/steam-machine.sh ----- # "Steam Machine support" menu item, only on Valve Fremont hardware: the # front LED bar driver, LED access for Steam and steamos-manager. Its # sub-option "Power-off fix" is lib/fremont-poweroff.sh. detect_valve_fremont() { # Valve Steam Machine (Fremont) boards. The leds-valve driver's own DMI # table also matches the OEM/F7F strings some early units report, so # accept both -- otherwise we'd skip hardware the driver supports. local vendor product vendor="$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || true)" product="$(cat /sys/class/dmi/id/product_name 2>/dev/null || true)" [[ "$vendor" == "Valve" && "$product" == "Fremont" ]] || [[ "$vendor" == "OEM" && "$product" == "F7F" ]] } # Kernel pinned on a Steam Machine (optional): newer linux-cachyos releases # rebooted it instead of shutting down, which the power-off fix now handles. The packages are kept in # PINNED_KERNEL_DIR, so re-applying (or reinstalling after an update slipped # through) needs no download. PINNED_KERNEL_VER="7.1.6-1" PINNED_KERNEL_KVER="7.1.6-1-cachyos" PINNED_KERNEL_PKGS=(linux-cachyos linux-cachyos-headers) PINNED_KERNEL_DIR="${PINNED_KERNEL_DIR:-/var/cache/steamify/kernel}" # SHA-256 of each package, so a file from any source is the one reviewed # here; its CachyOS signature is checked as well. declare -A PINNED_KERNEL_SHA256=( [linux-cachyos]=417fcd07102192b86e78e92ed7171d5a49378e9f57faea3fa2c7c541e9f68d08 [linux-cachyos-headers]=d069866a11d9092746e1d5133cefd8924f027da2c9bf5af38d30b3aed6ded9bf ) # Tried in order, after the kernel dir and pacman's cache. Our own release # always has these files; the archive keeps every release; the mirror only # the current one. PINNED_KERNEL_URL puts another source (a directory # holding the files) in front. PINNED_KERNEL_SOURCES=( ${PINNED_KERNEL_URL:+"$PINNED_KERNEL_URL"} "https://github.com/theupriser/steamify-cachyos/releases/download/kernel-$PINNED_KERNEL_VER" "https://archive.cachyos.org/archive/cachyos" "https://mirror.cachyos.org/repo/x86_64/cachyos" ) pinned_kernel_installed() { local p for p in "${PINNED_KERNEL_PKGS[@]}"; do [[ "$(pacman -Q "$p" 2>/dev/null)" == "$p $PINNED_KERNEL_VER" ]] || return 1 done } fetch_pinned_kernel_file() { # $1 = file name. Local kernel dir first, then pacman's cache, then the # download sources. Downloads go to a .part file so a broken one is never # mistaken for a finished one. local f="$1" dest="$PINNED_KERNEL_DIR/$1" src [[ -s "$dest" ]] && return 0 if [[ -s "/var/cache/pacman/pkg/$f" ]]; then sudo cp "/var/cache/pacman/pkg/$f" "$dest" && return 0 fi for src in "${PINNED_KERNEL_SOURCES[@]}"; do if sudo curl -fL --retry 2 --connect-timeout 15 -o "$dest.part" "$src/$f" 2>/dev/null; then sudo mv "$dest.part" "$dest" return 0 fi warn "Couldn't get $f from $src, trying the next source..." done sudo rm -f "$dest.part" return 1 } verify_pinned_kernel_pkg() { # $1 = package name, $2 = file. Both checks must pass: the SHA-256 from # this script, and the CachyOS signature (pacman on its own installs a # local file without a .sig: LocalFileSigLevel = Optional). local sum sum="$(sha256sum "$2" | cut -d' ' -f1)" if [[ "$sum" != "${PINNED_KERNEL_SHA256[$1]}" ]]; then err "$(basename "$2") doesn't match its expected checksum." return 1 fi if ! sudo pacman-key --verify "$2.sig" "$2" >/dev/null 2>&1; then err "$(basename "$2") doesn't have a valid CachyOS signature." return 1 fi } pin_kernel_in_pacman_conf() { # Adds our packages to IgnorePkg in [options], keeping what's there. local p for p in "${PINNED_KERNEL_PKGS[@]}"; do grep -Eq "^IgnorePkg\s*=.*(\s|=)$p(\s|$)" /etc/pacman.conf && continue if grep -Eq '^IgnorePkg\s*=' /etc/pacman.conf; then sudo sed -i -E "0,/^IgnorePkg\s*=/s/^(IgnorePkg\s*=.*)$/\1 $p/" /etc/pacman.conf else sudo sed -i -E "0,/^\[options\]/s//[options]\nIgnorePkg = $p/" /etc/pacman.conf fi done } unpin_kernel_in_pacman_conf() { local p for p in "${PINNED_KERNEL_PKGS[@]}"; do sudo sed -i -E "/^IgnorePkg\s*=/s/\s$p(\s|$)/\1/" /etc/pacman.conf done # Drop the line if nothing is left on it. sudo sed -i -E '/^IgnorePkg\s*=\s*$/d' /etc/pacman.conf } install_pinned_kernel() { sudo mkdir -p "$PINNED_KERNEL_DIR" pin_kernel_in_pacman_conf if pinned_kernel_installed; then ok "Kernel $PINNED_KERNEL_VER already installed and pinned." return 0 fi local p f files=() info "Getting kernel $PINNED_KERNEL_VER (kept in $PINNED_KERNEL_DIR)..." for p in "${PINNED_KERNEL_PKGS[@]}"; do f="$p-$PINNED_KERNEL_VER-x86_64.pkg.tar.zst" fetch_pinned_kernel_file "$f" && fetch_pinned_kernel_file "$f.sig" || { err "Couldn't download $f from any source."; return 1; } if ! verify_pinned_kernel_pkg "$p" "$PINNED_KERNEL_DIR/$f"; then # Removed, so the next run fetches it again. sudo rm -f "$PINNED_KERNEL_DIR/$f" "$PINNED_KERNEL_DIR/$f.sig" err "Removed it; run the wizard again to download it once more." return 1 fi files+=("$PINNED_KERNEL_DIR/$f") done # pacman checks each package against the .sig next to it. info "Installing kernel $PINNED_KERNEL_VER..." if ! sudo pacman -U --noconfirm "${files[@]}"; then err "Installing kernel $PINNED_KERNEL_VER failed." return 1 fi [[ "$(uname -r)" != "$PINNED_KERNEL_KVER" ]] && RESTART_FOR_LOGIN=true ok "Kernel $PINNED_KERNEL_VER installed and pinned (restart to use it)." } remove_kernel_pin() { # Back to CachyOS's current kernel. The packages stay in # PINNED_KERNEL_DIR, so turning support on again doesn't download. unpin_kernel_in_pacman_conf pinned_kernel_installed || return 0 info "Updating the kernel back to CachyOS's current version..." sudo pacman -Syu --noconfirm || { warn "Updating the kernel failed; run: sudo pacman -Syu"; return 0; } RESTART_FOR_LOGIN=true } # "Pin the kernel" menu item, a sub-option of Steam Machine support. The # power-off fix made it unnecessary: only shown while on, to remove it. kpin_available() { detect_valve_fremont && kpin_status; } kpin_status() { pinned_kernel_installed && grep -Eq '^IgnorePkg\s*=.*\slinux-cachyos(\s|$)' /etc/pacman.conf } kpin_enable() { install_pinned_kernel || return 1 # The DKMS pacman hook built leds-valve for the new headers; make sure. if pacman -Qi leds-valve-dkms-git >/dev/null 2>&1 && ! dkms status -k "$PINNED_KERNEL_KVER" leds-valve-dkms 2>/dev/null | grep -q installed; then sudo dkms install leds-valve-dkms/0.1 -k "$PINNED_KERNEL_KVER" || warn "Building the LED driver for $PINNED_KERNEL_KVER failed." fi } kpin_disable() { # Saved HDMI refresh boost EDIDs were for the pinned kernel only (newer # ones read the whole EDID), also those of displays not connected now. hdmi_status && hdmi_forget_all remove_kernel_pin } install_kernel_headers() { # DKMS can only build leds-valve against kernels whose headers are # installed. CachyOS ships several kernels (linux-cachyos, -lts, -bore, # ...); install the matching -headers package for every one present. local -a kernels headers=() local k mapfile -t kernels < <(pacman -Qqo /usr/lib/modules/*/pkgbase 2>/dev/null | sort -u) # Installed headers are left alone: -S would update them past a pinned # kernel (linux-cachyos-headers on a Steam Machine). local have=0 for k in "${kernels[@]}"; do pacman -Q "${k}-headers" >/dev/null 2>&1 && { have=$((have + 1)); continue; } pacman -Si "${k}-headers" >/dev/null 2>&1 && headers+=("${k}-headers") done if [[ ${#headers[@]} -eq 0 && $have -gt 0 ]]; then ok "Kernel headers already installed." return 0 fi if [[ ${#headers[@]} -eq 0 ]]; then warn "Couldn't work out which kernel headers package you need." warn "Install the -headers package for your kernel (e.g. linux-cachyos-headers) yourself." return 1 fi info "Making sure kernel headers are installed: ${headers[*]}" sudo pacman -S --needed --noconfirm "${headers[@]}" } install_valve_led_driver() { # Installs the leds-valve DKMS driver from the AUR so the front LED # bar is exposed under /sys/class/leds/valve-leds[N]. local aur_helper aur_helper="$(find_aur_helper)" if [[ -z "$aur_helper" ]]; then warn "No AUR helper available - can't install leds-valve-dkms-git." warn "Install one yourself, then run: yay -S leds-valve-dkms-git" return 1 fi local -a flags read -ra flags <<< "$(aur_noninteractive_flags "$aur_helper")" # Headers first: without them the package installs fine but DKMS # silently builds nothing, so modprobe later finds no module. install_kernel_headers || return 1 # The upstream Makefile builds against `uname -r` instead of the kernel # DKMS targets, so every other installed kernel (e.g. linux-cachyos-lts) # got the running kernel's tree and failed (wrong kernel, and gcc against # a clang-built tree). DKMS reads this override after the package's # dkms.conf; written before the install so its own build works too. # On a fresh system dkms isn't installed yet, so /etc/dkms doesn't exist. # Someone's own override is backed up and kept, with ours after it: it's # sourced as bash, so our MAKE[0] wins. sudo mkdir -p "$(dirname "$LED_DKMS_OVERRIDE")" if ! led_dkms_override | sudo tee "$LED_DKMS_OVERRIDE.new" >/dev/null || ! sudo mv -f "$LED_DKMS_OVERRIDE.new" "$LED_DKMS_OVERRIDE"; then err "Couldn't write $LED_DKMS_OVERRIDE; without it the LED driver only builds for the running kernel." return 1 fi if ! pacman -Qi leds-valve-dkms-git >/dev/null 2>&1; then info "Installing leds-valve-dkms-git from the AUR via $aur_helper (non-interactive)..." if ! "$aur_helper" -S "${flags[@]}" leds-valve-dkms-git; then err "Installing leds-valve-dkms-git failed." return 1 fi else ok "leds-valve-dkms-git already installed." fi # Build for every installed kernel that has headers, so booting another # one (e.g. the LTS kernel) still has the driver. local kdir kver for kdir in /usr/lib/modules/*/build; do [[ -d "$kdir" ]] || continue kver="$(basename "$(dirname "$kdir")")" dkms status -k "$kver" leds-valve-dkms 2>/dev/null | grep -q installed && continue info "Building leds-valve for kernel $kver..." if ! sudo dkms install leds-valve-dkms/0.1 -k "$kver"; then err "DKMS build for $kver failed. See the build log:" err " /var/lib/dkms/leds-valve-dkms/0.1/build/make.log" return 1 fi done # Load now, and on every boot. echo leds-valve | sudo tee /etc/modules-load.d/leds-valve.conf >/dev/null # The running kernel was just replaced (the kernel pin): its modules are # gone, so the driver can only load after the restart. if [[ ! -d "/usr/lib/modules/$(uname -r)/kernel" ]]; then ok "The LED driver loads after the restart (into the new kernel)." return 0 fi info "Loading the leds-valve kernel module..." if sudo modprobe leds-valve; then ok "Module loaded." else warn "modprobe leds-valve failed. Kernel messages:" sudo dmesg | grep -i 'valve' | tail -n 10 fi if compgen -G "/sys/class/leds/valve-leds*" >/dev/null; then ok "LED bar nodes detected: $(cd /sys/class/leds && echo valve-leds*)" else warn "No valve-leds nodes under /sys/class/leds/ yet. Reboot, then check with:" warn " ls /sys/class/leds/ | grep valve ; sudo dmesg | grep -i valve" fi } LED_UDEV_RULE="/etc/udev/rules.d/70-valve-leds-user.rules" LED_DKMS_OVERRIDE="/etc/dkms/leds-valve-dkms.conf" led_dkms_override() { # The override to write: an existing one's own lines (backed up the first # time), without ours from an earlier run, then ours. Before 2.5.0 the # file was ours alone, with a different comment. local ours ours="$(patch_file leds-valve-dkms.conf)" if [[ -f "$LED_DKMS_OVERRIDE" ]]; then grep -qxF -f <(echo "$ours") "$LED_DKMS_OVERRIDE" || grep -q '^# Written by cachyos-gamescope-boot' "$LED_DKMS_OVERRIDE" || backup_file "$LED_DKMS_OVERRIDE" >&2 grep -vxF -f <(echo "$ours") "$LED_DKMS_OVERRIDE" | grep -v '^# Written by cachyos-gamescope-boot' fi echo "$ours" } led_dkms_override_remove() { # Puts someone's own override back, or removes ours. if [[ -f "$LED_DKMS_OVERRIDE.bak-gamescope-wizard" ]]; then sudo mv -f "$LED_DKMS_OVERRIDE.bak-gamescope-wizard" "$LED_DKMS_OVERRIDE" else sudo rm -f "$LED_DKMS_OVERRIDE" fi } HEADERS_SCRIPT="/usr/local/lib/steamify/ensure-kernel-headers" # Before 2.5.0; removed when it's set up again or turned off. HEADERS_SCRIPT_OLD="/usr/local/lib/cachyos-gamescope-boot/ensure-kernel-headers" HEADERS_UNIT="/etc/systemd/system/ensure-kernel-headers.service" install_headers_boot_check() { # DKMS's pacman hook rebuilds leds-valve for every kernel whose headers # get installed, but a newly added kernel (e.g. linux-cachyos-bore) # comes without them, and a pacman hook can't install packages itself. # So check at every boot and install missing headers, which triggers # that hook and builds the driver for the new kernel. sudo mkdir -p "$(dirname "$HEADERS_SCRIPT")" patch_file ensure-kernel-headers.sh | sudo tee "$HEADERS_SCRIPT" > /dev/null sudo chmod 755 "$HEADERS_SCRIPT" sudo rm -rf "$(dirname "$HEADERS_SCRIPT_OLD")" service_file ensure-kernel-headers.service HEADERS_SCRIPT="$HEADERS_SCRIPT" | sudo tee "$HEADERS_UNIT" > /dev/null sudo systemctl daemon-reload sudo systemctl enable ensure-kernel-headers.service } reload_powerdevil() { qdbus6 org.kde.Solid.PowerManagement /org/kde/Solid/PowerManagement \ org.kde.Solid.PowerManagement.refreshStatus >/dev/null 2>&1 || true } kernel_overview() { # Per installed kernel: headers (needed to build DKMS modules), the # in-kernel Steam controller driver, and, on a Steam Machine, whether the # LED driver and the power-off fix are built for it. Printed in the menu so it's easy to verify # a kernel update or a newly added kernel got everything. local mark="${c_green}yes${c_reset}" miss="${c_red}no ${c_reset}" leds=false detect_valve_fremont && command -v dkms >/dev/null 2>&1 && leds=true local kdir k pkg headers hid led off running line local legend="> = running; controller = Steam controller driver" [[ "$leds" == true ]] && legend+=", LEDs = LED bar driver built, power-off = power-off fix built" echo -e " ${c_bold}Kernels${c_reset} ($legend)" for kdir in /usr/lib/modules/*/; do k="$(basename "$kdir")" [[ -f "$kdir/pkgbase" ]] || continue pkg="$(< "$kdir/pkgbase")" headers="$miss"; [[ -d "$kdir/build" ]] && headers="$mark" hid="$miss"; compgen -G "$kdir/kernel/drivers/hid/hid-steam.ko*" >/dev/null && hid="$mark" running=" "; [[ "$k" == "$(uname -r)" ]] && running="${c_cyan}>${c_reset} " line="$(printf '%-23s %-18s headers %b controller %b' "$k" "$pkg" "$headers" "$hid")" if [[ "$leds" == true ]]; then led="$miss"; dkms status -k "$k" leds-valve-dkms 2>/dev/null | grep -q installed && led="$mark" off="$miss"; dkms status -k "$k" "$POWEROFF_DKMS_NAME" 2>/dev/null | grep -q installed && off="$mark" line+="$(printf ' LEDs %b power-off %b' "$led" "$off")" fi echo -e " ${running}${line}" done cec_installed && { echo; echo -e " $(cec_overview)"; } if [[ "$leds" == true ]]; then local loaded="$miss" nodes lsmod | grep -q '^leds_valve' && loaded="$mark" nodes="$(compgen -G '/sys/class/leds/valve-leds*' | wc -l)" echo echo -e " LED driver loaded: ${loaded} LED nodes: ${nodes} steamos-manager: $(systemctl is-active steamos-manager 2>/dev/null)" fi } machine_available() { detect_valve_fremont; } machine_status() { pacman -Qi leds-valve-dkms-git >/dev/null 2>&1 && pacman -Qi steamos-manager >/dev/null 2>&1 } # Steam (steamwebhelper) reads the serial number for its System settings # straight from DMI, which the kernel makes root-only; SteamOS makes it # readable. A tmpfiles rule does that at every boot. SERIAL_TMPFILES=/etc/tmpfiles.d/steamify-serial.conf # Steam's Wi-Fi backend switch goes through steamos-manager, which reads and # writes the backend in this file (SteamOS ships it); without it, it fails # with "Wi-Fi backend not found in config". wpa_supplicant is what # NetworkManager uses on CachyOS anyway. Not written when someone set a # backend themselves. WIFI_BACKEND_CONF=/etc/NetworkManager/conf.d/99-valve-wifi-backend.conf wifi_backend_enable() { [[ -f "$WIFI_BACKEND_CONF" ]] && return 0 grep -rqs '^wifi.backend=' /etc/NetworkManager/NetworkManager.conf /etc/NetworkManager/conf.d && return 0 printf '%s\n' "# Written by Steamify: Steam's Wi-Fi backend setting (steamos-manager)." \ '[device]' 'wifi.backend=wpa_supplicant' | sudo install -Dm644 /dev/stdin "$WIFI_BACKEND_CONF" || { err "Writing the Wi-Fi backend setting failed."; return 1; } state_set machine wifi_backend 1 } wifi_backend_disable() { [[ -n "$(state_get machine wifi_backend)" ]] || return 0 sudo rm -f "$WIFI_BACKEND_CONF" state_clear machine } serial_enable() { printf '%s\n' "# Written by Steamify: Steam shows the serial number, like on SteamOS." \ 'z /sys/class/dmi/id/product_serial 0444 - - -' | sudo tee "$SERIAL_TMPFILES" > /dev/null || { err "Making the serial number readable for Steam failed."; return 1; } # The file applies at every boot; now too, unless /sys is read-only (the ISO's installer runs in a chroot). sudo systemd-tmpfiles --create "$SERIAL_TMPFILES" 2>/dev/null || info "The serial number becomes readable for Steam at the next boot." return 0 } serial_disable() { sudo rm -f "$SERIAL_TMPFILES" sudo chmod 0400 /sys/class/dmi/id/product_serial 2>/dev/null return 0 } machine_enable() { # The pinned kernel first when it's wanted too, so DKMS builds the LED # driver for it once, instead of for the current kernel and then again. if [[ "${WANTED[kpin]:-0}" == 1 ]]; then install_pinned_kernel || { err "Couldn't set up kernel $PINNED_KERNEL_VER."; return 1; } fi ensure_aur_helper || { warn "Couldn't set up an AUR helper automatically. Install yay or paru, then run the wizard again."; return 1; } install_valve_led_driver || { warn "LED driver setup ran into a problem - see errors above."; return 1; } install_headers_boot_check # The LED files are root-only. Steam runs as the user; let it write them # in case it drives the bar directly (Valve's own privileged-write helper # doesn't cover the Steam Machine's LED bar). info "Letting $TARGET_USER control the LED bar..." sudo tee "$LED_UDEV_RULE" > /dev/null << EOF # Let the console user (and Steam) control the Steam Machine's front LED bar. SUBSYSTEM=="leds", KERNEL=="valve-leds*", RUN+="/usr/bin/find /sys%p -maxdepth 1 -type f -exec /usr/bin/chown $TARGET_USER {} +" EOF sudo udevadm control --reload sudo udevadm trigger --subsystem-match=leds --action=add # steamos-manager and inputplumber are what Steam in gaming mode talks to # for hardware and controller settings; it knows the Steam Machine from its DMI data. info "Installing hardware manager packages (steamos-manager & inputplumber)..." sudo pacman -S --needed --noconfirm steamos-manager inputplumber || { err "Installing hardware manager packages failed."; return 1; } serial_enable || return 1 wifi_backend_enable || return 1 sudo systemctl enable --now inputplumber.service sudo systemctl enable --now steamos-manager.service user_systemctl enable steamos-manager.service 2>/dev/null # HDMI-CEC ran before this and couldn't link cecd to steamos-manager yet. cec_status && cec_link_steamos_manager # Console-like power handling, as SteamOS's powerdevilrc (mains-power # part; there is no battery): the power button sleeps instead of showing # the logout screen, and the machine never suspends on its own (e.g. # during a long download). The launcher's Shut Down still shuts down. kset machine powerdevilrc "AC|SuspendAndShutdown" PowerButtonAction 1 kset machine powerdevilrc "AC|SuspendAndShutdown" AutoSuspendAction 0 kset machine powerdevilrc "AC|Display" LockBeforeTurnOffDisplay false reload_powerdevil ok "Steam Machine support on." } machine_disable() { info "Removing Steam Machine support..." user_systemctl disable --now steamos-manager.service 2>/dev/null sudo systemctl disable --now steamos-manager.service 2>/dev/null sudo systemctl disable --now inputplumber.service 2>/dev/null sudo pacman -Rns --noconfirm steamos-manager inputplumber 2>/dev/null serial_disable wifi_backend_disable sudo rm -f "$LED_UDEV_RULE" /etc/modules-load.d/leds-valve.conf sudo udevadm control --reload sudo modprobe -r leds-valve 2>/dev/null sudo pacman -Rns --noconfirm leds-valve-dkms-git 2>/dev/null krevert machine reload_powerdevil sudo systemctl disable ensure-kernel-headers.service 2>/dev/null led_dkms_override_remove sudo rm -f "$HEADERS_UNIT" "$HEADERS_SCRIPT" sudo rm -rf "$(dirname "$HEADERS_SCRIPT")" "$(dirname "$HEADERS_SCRIPT_OLD")" sudo systemctl daemon-reload ok "Steam Machine support removed (the AUR helper, if installed, is kept)." } # ----- lib/fremont-poweroff.sh ----- # "Power-off fix" menu item (poweroff), a sub-option of Steam Machine support # ticked along with it (opt-out): the Steam Machine powers off instead of # booting up again. Recent kernels (7.2, and the 6.x, 7.0 and 7.1 updates # that got the change backported) keep the S4/S5 wake bit the firmware # leaves set on GPIO pin 18 ("pinctrl-amd: Don't clear S4 wake bits at # probe"), so the machine starts again right after powering off. Valve's kernel clears it at probe on Fremont, in # a patch not meant for upstream, so CachyOS won't get it. A small module # (patches/steamify-fremont-poweroff.c) clears it right before power-off; # DKMS builds it for each installed kernel. On a kernel that clears it # itself it does nothing. POWEROFF_DKMS_NAME=steamify-fremont-poweroff POWEROFF_DKMS_VER=1 POWEROFF_DKMS_SRC="/usr/src/$POWEROFF_DKMS_NAME-$POWEROFF_DKMS_VER" POWEROFF_MODULES_LOAD=/etc/modules-load.d/steamify-fremont-poweroff.conf poweroff_fix_installed() { [[ -f "$POWEROFF_DKMS_SRC/dkms.conf" && -f "$POWEROFF_MODULES_LOAD" ]]; } poweroff_fix_enable() { detect_valve_fremont || return 0 local tmp k if ! pacman -Q dkms >/dev/null 2>&1; then sudo pacman -S --needed --noconfirm dkms || { err "Installing dkms failed."; return 1; } fi install_kernel_headers || return 1 tmp="$(mktemp -d)" patch_file "$POWEROFF_DKMS_NAME.c" > "$tmp/$POWEROFF_DKMS_NAME.c" echo "obj-m += $POWEROFF_DKMS_NAME.o" >"$tmp/Makefile" patch_file "$POWEROFF_DKMS_NAME.dkms.conf" | fill NAME="$POWEROFF_DKMS_NAME" VERSION="$POWEROFF_DKMS_VER" >"$tmp/dkms.conf" sudo dkms remove "$POWEROFF_DKMS_NAME/$POWEROFF_DKMS_VER" --all >/dev/null 2>&1 sudo rm -rf "$POWEROFF_DKMS_SRC" sudo install -d "$POWEROFF_DKMS_SRC" && sudo install -m644 "$tmp"/{"$POWEROFF_DKMS_NAME.c",Makefile,dkms.conf} "$POWEROFF_DKMS_SRC/" rm -rf "$tmp" sudo dkms add "$POWEROFF_DKMS_NAME/$POWEROFF_DKMS_VER" >/dev/null || { err "Adding the power-off fix to DKMS failed."; return 1; } for k in /usr/lib/modules/*/build; do k="$(basename "$(dirname "$k")")" info "Building the power-off fix for $k..." sudo dkms install "$POWEROFF_DKMS_NAME/$POWEROFF_DKMS_VER" -k "$k" >/dev/null || warn "Building the power-off fix for $k failed; with that kernel it may start again after shutting down." done echo "$POWEROFF_DKMS_NAME" | sudo tee "$POWEROFF_MODULES_LOAD" >/dev/null sudo modprobe "$POWEROFF_DKMS_NAME" 2>/dev/null return 0 } poweroff_fix_disable() { sudo rm -f "$POWEROFF_MODULES_LOAD" sudo modprobe -r "$POWEROFF_DKMS_NAME" 2>/dev/null [[ -d "$POWEROFF_DKMS_SRC" ]] || return 0 sudo dkms remove "$POWEROFF_DKMS_NAME/$POWEROFF_DKMS_VER" --all >/dev/null 2>&1 sudo rm -rf "$POWEROFF_DKMS_SRC" } poweroff_status() { poweroff_fix_installed; } poweroff_enable() { info "Installing the power-off fix (the machine stays off after shutting down)..." poweroff_fix_enable || { err "Installing the power-off fix failed."; return 1; } ok "Power-off fix on: the Steam Machine stays off after shutting down." } poweroff_disable() { poweroff_fix_disable ok "Power-off fix removed; with recent kernels the Steam Machine may start again after shutting down." } # ----- lib/vram-booster.sh ----- # "VRAM booster" menu item (vram), ticked by default, like SteamOS 3.9's # dGPU VRAM management: the game in front keeps its VRAM, background apps # are evicted first, so a game that needs most of it doesn't spill into # system RAM and stutter. The kernel side is the dmem cgroup controller # (7.2; amdgpu and xe register their VRAM, NVIDIA's driver doesn't), so it's # only offered when the kernel lists a VRAM region (vram or vidmem) of at least 2 GB. CachyOS packages the userspace # side: dmemcg-booster (a system service that enables the controller and # sets the limits, plus a user service) and plasma-foreground-booster, which # tells it which window is in front on the desktop. The latter only starts # with "autostart" set in kcgroupsrc. VRAM_PKGS=(dmemcg-booster plasma-foreground-booster) VRAM_MIN_BYTES=$((2 * 1024 * 1024 * 1024)) # The regions the kernel's dmem controller lists (a file under /sys/fs/cgroup; # a variable so tests can point it at a copy). VRAM_CAPACITY="${WIZARD_VRAM_CAPACITY:-/sys/fs/cgroup/dmem.capacity}" vram_supported() { # WIZARD_VRAM_FAKE_NVIDIA (tests): as with an NVIDIA card whose driver # doesn't register its VRAM; see vram_nvidia_case. [[ -n "${WIZARD_VRAM_FAKE_NVIDIA:-}" ]] && return 1 # Only for a GPU with its own VRAM: an integrated GPU registers a small # carve-out too, but mostly uses system RAM, so there's little to boost. # By what the driver registers, not the brand: amdgpu and xe name the # region "vram", other drivers "vidmem" (numbered with several), so an # NVIDIA driver that registers its memory is offered right away. awk -v min="$VRAM_MIN_BYTES" '$1 ~ /\/(vram|vidmem)[0-9]*$/ && $2 >= min { found = 1 } END { exit !found }' \ "$VRAM_CAPACITY" 2>/dev/null } vram_nvidia() { # An NVIDIA display controller (PCI vendor 10de, class 03xxxx). local d [[ -n "${WIZARD_VRAM_FAKE_NVIDIA:-}" ]] && return 0 for d in /sys/bus/pci/devices/*; do [[ "$(cat "$d/vendor" 2>/dev/null)" == 0x10de && "$(cat "$d/class" 2>/dev/null)" == 0x03* ]] && return 0 done return 1 } # chwd's lists of NVIDIA cards that need a closed legacy branch (580xx, # 470xx, 390xx); every other NVIDIA card runs the open kernel modules. VRAM_CHWD_IDS=/var/lib/chwd/ids vram_nvidia_legacy_id() { # vram_nvidia_legacy_id : 0 when chwd # lists the card for a closed legacy branch, i.e. it is older than the # RTX 20 series. Also what lib/nvidia.sh uses to decide if a card is supported. grep -qwi "${1#0x}" "$VRAM_CHWD_IDS"/nvidia-*.ids 2>/dev/null } vram_nvidia_case() { # Why an NVIDIA card's VRAM isn't registered, so the menu can say what to # do: "update" (open modules older than 615), "switch " # (closed driver on a card the open one supports), "legacy" (card only # runs a closed legacy branch), "nouveau" (no NVIDIA module). # WIZARD_VRAM_FAKE_NVIDIA=1 fakes "switch nvidia-dkms-580xx", or name a case. local fake="${WIZARD_VRAM_FAKE_NVIDIA:-}" license d id p if [[ -n "$fake" ]]; then [[ "$fake" == 1 ]] && fake="switch nvidia-dkms-580xx" echo "$fake"; return fi license="$(modinfo -F license nvidia 2>/dev/null)" case "$license" in "Dual MIT/GPL") echo update; return ;; "") echo nouveau; return ;; esac for d in /sys/bus/pci/devices/*; do [[ "$(cat "$d/vendor" 2>/dev/null)" == 0x10de && "$(cat "$d/class" 2>/dev/null)" == 0x03* ]] || continue vram_nvidia_legacy_id "$(cat "$d/device")" && { echo legacy; return; } done for p in 580xx 470xx 390xx; do pacman -Q "nvidia-$p-dkms" >/dev/null 2>&1 && { echo "switch nvidia-dkms-$p"; return; } done echo switch } vram_nvidia_hint() { # vram_nvidia_hint : the short reason (terminal menu, the app's row). case "$1" in update) echo "update NVIDIA's driver to 615 or newer (sudo pacman -Syu)" ;; switch*) echo "needs NVIDIA's open driver, which your card supports" ;; legacy) echo "your NVIDIA card's driver doesn't support it (older than RTX 20)" ;; *) echo "the nouveau driver doesn't support it yet" ;; esac } vram_nvidia_note() { # vram_nvidia_note : the full explanation, with what to do. local profile="${1#switch}"; profile="${profile# }" case "$1" in update) echo "NVIDIA's open driver tells Linux how its video memory is used from version 615 on; yours is older ($(cat /sys/module/nvidia/version 2>/dev/null || echo unknown)). Update your system (sudo pacman -Syu), restart, and it's available here." ;; switch*) echo "Your card also runs NVIDIA's open driver, the one CachyOS installs by default, and only that one tells Linux how its video memory is used. You're using NVIDIA's closed driver. Switch in a terminal with: ${profile:+sudo chwd -r $profile && }sudo chwd -i nvidia-open-dkms, then restart: it's available here after that. Steamify doesn't switch it for you: if something goes wrong, the screen stays black after the restart." ;; legacy) echo "Your NVIDIA card is older than the RTX 20 series and only runs NVIDIA's closed driver, which doesn't tell Linux how its video memory is used. Newer NVIDIA cards (RTX 20 series and up), and AMD and Intel graphics cards, support it." ;; *) echo "The open-source nouveau driver doesn't tell Linux how its video memory is used yet (a patch is on its way into the kernel). NVIDIA's own open driver does, from version 615 on (sudo chwd -a installs the right one)." ;; esac } # Shown where it works, and greyed out with an NVIDIA card whose driver # doesn't register its VRAM with the kernel (yet): so it's clear why it # can't be turned on. Hidden elsewhere (integrated graphics, older kernel). vram_available() { vram_supported || vram_status || vram_nvidia; } vram_selectable() { vram_supported || vram_status; } vram_status() { pacman -Q "${VRAM_PKGS[@]}" >/dev/null 2>&1 && systemctl is-enabled -q dmemcg-booster-system.service 2>/dev/null && user_systemctl is-enabled -q dmemcg-booster-user.service 2>/dev/null } vram_enable() { local p missing=() info "Installing the VRAM booster (the game in front keeps its VRAM)..." for p in "${VRAM_PKGS[@]}"; do pacman -Q "$p" >/dev/null 2>&1 || missing+=("$p"); done if ((${#missing[@]})); then sudo pacman -S --needed --noconfirm "${missing[@]}" || { err "Installing ${missing[*]} failed."; return 1; } state_set vram installed_pkgs "${missing[*]}" fi sudo systemctl enable --now dmemcg-booster-system.service >/dev/null 2>&1 || { err "Starting dmemcg-booster failed."; return 1; } user_systemctl enable --now dmemcg-booster-user.service >/dev/null 2>&1 || { err "Starting dmemcg-booster for $USER failed."; return 1; } kset vram kcgroupsrc "Foreground Booster" autostart true # Started by the Plasma session from now on; start it in this one too. [[ "${XDG_CURRENT_DESKTOP:-}" == KDE ]] && user_systemctl start plasma-foreground-booster.service >/dev/null 2>&1 ok "VRAM booster on: the game in front keeps its VRAM." } vram_disable() { local pkgs user_systemctl disable --now plasma-foreground-booster.service dmemcg-booster-user.service >/dev/null 2>&1 sudo systemctl disable --now dmemcg-booster-system.service >/dev/null 2>&1 krevert vram pkgs="$(state_get vram installed_pkgs)" # shellcheck disable=SC2086 # a list of package names [[ -n "$pkgs" ]] && sudo pacman -Rns --noconfirm $pkgs >/dev/null 2>&1 state_clear vram ok "VRAM booster off." } # ----- lib/hdmi-refresh.sh ----- # "HDMI refresh boost" menu item (hdmi), retired in 2.2.0: it saved an EDID # override per display (with a hotplug script loading it) so the pinned # 7.1.6 kernel ran HDMI displays above 60 Hz. Newer kernels read the whole # EDID themselves, and the kernel pin is gone with the power-off fix, so # only its removal is left: shown while anything of it is on the system, # always unticked, so a normal run removes it. HDMI_FW_DIR=/usr/lib/firmware/edid # Before 2.1.0 the override was on the kernel command line. HDMI_INITRAMFS_CONF=/etc/mkinitcpio.conf.d/90-steamify-edid.conf # The saved displays; their EDIDs are $HDMI_FW_DIR/steamify-.bin. HDMI_MAP=/etc/steamify/hdmi-edid.conf HDMI_RUN=/run/steamify-edid HDMI_HOTPLUG=/usr/local/bin/steamify-edid-hotplug HDMI_UNIT_NAME=steamify-edid.service HDMI_UNIT="/etc/systemd/system/$HDMI_UNIT_NAME" HDMI_UDEV_RULE=/etc/udev/rules.d/90-steamify-edid.rules hdmi_connectors() { # Connected HDMI outputs, as "-" (e.g. card1-HDMI-A-1). local d for d in /sys/class/drm/card*-HDMI-A-*; do [[ "$(cat "$d/status" 2>/dev/null)" == connected ]] && basename "$d" done } hdmi_boot_file() { # The file holding the kernel command line, per boot loader. if [[ -f /etc/default/limine ]]; then echo /etc/default/limine elif [[ -f /etc/sdboot-manage.conf ]] && command -v sdboot-manage >/dev/null; then echo /etc/sdboot-manage.conf elif [[ -f /etc/default/grub ]]; then echo /etc/default/grub fi } hdmi_cmdline_param() { # The drm.edid_firmware= value we set, or nothing. local f; f="$(hdmi_boot_file)" [[ -n "$f" ]] && grep -oE 'drm\.edid_firmware=[^ "]*steamify-[^ "]*' "$f" 2>/dev/null | head -n 1 } hdmi_available() { detect_valve_fremont && hdmi_status; } hdmi_status() { # On while anything of it is left: saved displays, the hotplug script, # an EDID file, or the kernel parameter of versions before 2.1.0. [[ -s "$HDMI_MAP" || -f "$HDMI_UNIT" || -n "$(hdmi_cmdline_param)" ]] || compgen -G "$HDMI_FW_DIR/steamify-*.bin" >/dev/null } hdmi_reset_live() { # Puts a connected display back on its own EDID right away. debugfs is # root-only, so the glob runs under sudo; the kernel takes exactly # "reset", without a newline. local d d="$(sudo sh -c 'for d in /sys/kernel/debug/dri/*/"$1"; do [ -e "$d/edid_override" ] && { echo "$d"; break; }; done' _ "${1#card*-}")" [[ -n "$d" ]] || return 1 printf reset | sudo tee "$d/edid_override" >/dev/null && echo 1 | sudo tee "$d/trigger_hotplug" >/dev/null } hdmi_remove_boot_param() { # Removes the drm.edid_firmware= older versions put on the kernel command # line, then rebuilds the initramfs and boot entries. local f [[ -n "$(hdmi_cmdline_param)" ]] || { sudo rm -f "$HDMI_INITRAMFS_CONF"; return 0; } f="$(hdmi_boot_file)" backup_file "$f" sudo sed -i -E 's/drm\.edid_firmware=[^ "]*steamify-[^ "]* ?//' "$f" sudo rm -f "$HDMI_INITRAMFS_CONF" info "Rebuilding the initramfs and boot entries..." case "$f" in /etc/default/limine) sudo limine-mkinitcpio ;; /etc/sdboot-manage.conf) sudo /usr/bin/mkinitcpio -P && sudo sdboot-manage gen ;; /etc/default/grub) sudo /usr/bin/mkinitcpio -P && sudo grub-mkconfig -o /boot/grub/grub.cfg ;; esac } hdmi_forget_all() { # Every saved display and the hotplug script that loaded their EDIDs. local conn sudo rm -f "$HDMI_FW_DIR"/steamify-*.bin sudo systemctl disable "$HDMI_UNIT_NAME" >/dev/null 2>&1 sudo rm -f "$HDMI_UDEV_RULE" "$HDMI_UNIT" "$HDMI_HOTPLUG" "$HDMI_MAP" sudo rm -rf "$HDMI_RUN" sudo systemctl daemon-reload sudo udevadm control --reload for conn in $(hdmi_connectors); do hdmi_reset_live "$conn" 2>/dev/null; done return 0 } hdmi_enable() { err "HDMI refresh boost is no longer offered: current kernels run HDMI displays at their full rate." return 1 } hdmi_disable() { hdmi_forget_all hdmi_remove_boot_param || return 1 state_clear hdmi ok "HDMI refresh boost removed; displays use their own EDID again." } # ----- lib/nvidia.sh ----- # Gaming on NVIDIA PCs: the SteamOS conversion (gamescope session) isn't offered there, because gamescope's DRM session shows a # corrupted picture on NVIDIA (NVIDIA's open bug 5240452: modes above 2560x1440@120). `nvidia` is the replacement: Steam on the # Plasma desktop, installed when missing and started at login; `bigpicture` (a sub-option) makes it start in Big Picture. # On a supported card (RTX 20 series or newer, `nvidia_supported`) it also sets up what NVIDIA's Wayland/DRM stack wants: # nvidia-drm.modeset=1 and fbdev=1 on the kernel command line and the NVIDIA modules in the initramfs. That didn't cure # gamescope's picture (the driver already reported both `Y`), but it is the standard setup and may be needed. NVIDIA_UNIT=steamify-steam-autostart.service NVIDIA_PARAMS="nvidia-drm.modeset=1 nvidia-drm.fbdev=1" NVIDIA_INITRAMFS_CONF=${NVIDIA_INITRAMFS_CONF:-/etc/mkinitcpio.conf.d/90-steamify-nvidia.conf} # The script and pacman hook that keep the initramfs file right at every kernel change. NVIDIA_SCRIPT=${NVIDIA_SCRIPT:-/usr/local/libexec/steamify-nvidia-initramfs} NVIDIA_HOOK=${NVIDIA_HOOK:-/etc/pacman.d/hooks/85-steamify-nvidia-initramfs.hook} # Keeps video memory over suspend (with the nvidia-suspend/resume services). NVIDIA_MODPROBE=${NVIDIA_MODPROBE:-/etc/modprobe.d/90-steamify-nvidia.conf} NVIDIA_SLEEP_UNITS="nvidia-suspend.service nvidia-resume.service nvidia-hibernate.service" # 32-bit parts Steam games and the 32-bit MangoHud preload need. NVIDIA_LIB32_PKGS="lib32-nvidia-utils lib32-libxkbcommon" # The running kernel's command line; the tests point this at a file. NVIDIA_CMDLINE=${NVIDIA_CMDLINE:-/proc/cmdline} # The DRM devices in sysfs; the tests point this at a fake tree. NVIDIA_DRM_DIR=${NVIDIA_DRM_DIR:-/sys/class/drm} nvidia_unit_file() { echo "$HOME/.config/systemd/user/$NVIDIA_UNIT"; } nvidia_present() { # An NVIDIA GPU (PCI vendor 0x10de, display class) whose driver provides # nvidia_drm. nouveau has none, and gamescope is not a problem there. local d for d in "$NVIDIA_DRM_DIR"/card[0-9]*/device; do [[ "$(cat "$d/vendor" 2>/dev/null)" == 0x10de ]] || continue [[ "$(cat "$d/class" 2>/dev/null)" == 0x03* ]] || continue modinfo nvidia_drm >/dev/null 2>&1 && return 0 done return 1 } # Not offered any more (2.12.0): the SteamOS conversion with the patched gamescope (lib/gamescope-nvidia.sh) covers NVIDIA PCs. # A PC that has this on keeps it until the conversion is turned on, which calls nvidia_swap_out. nvidia_available() { return 1; } nvidia_migration_pending() { # "Gaming on NVIDIA" is on (set up before 2.12.0) and the SteamOS conversion that replaces it isn't: the menu ticks the # conversion and shows it as an update, so a normal run moves the PC over (nvidia_swap_out in gsn_enable). nvidia_present && nvidia_status && ! gaming_status 2>/dev/null } nvidia_swap_out() { # The conversion takes over: Steam no longer starts by itself on the desktop at login (both would start it). Steam, the # packages and the NVIDIA settings stay: the conversion needs them too. The state stays for the conversion's own disable. nvidia_status || return 0 info "Gaming on NVIDIA is replaced by the SteamOS conversion: Steam no longer starts on the desktop at login." user_systemctl disable --now "$NVIDIA_UNIT" >/dev/null 2>&1 rm -f "$(nvidia_unit_file)" user_systemctl daemon-reload krevert bigpicture return 0 } bigpicture_available() { nvidia_available; } nvidia_status() { pacman -Q steam >/dev/null 2>&1 && [[ -f "$(nvidia_unit_file)" ]] && user_systemctl is-enabled -q "$NVIDIA_UNIT" 2>/dev/null } nvidia_write_unit() { # nvidia_write_unit [args]: the autostart unit, Steam started with those arguments. local unit; unit="$(nvidia_unit_file)" mkdir -p "$(dirname "$unit")" service_file steamify-steam-autostart.service ARGS="$1" > "$unit" || { err "Writing $unit failed."; return 1; } user_systemctl daemon-reload } nvidia_enable() { info "Gaming on NVIDIA: Steam on the desktop, started at login..." if ! pacman -Q steam >/dev/null 2>&1; then sudo pacman -S --needed --noconfirm steam || { err "Installing Steam failed."; return 1; } state_set nvidia installed_pkgs steam fi # Keeps what the Big Picture option set up; a first run starts Steam normally. local unit_args="" if bigpicture_status; then unit_args=-gamepadui; elif nvsilent_status; then unit_args=-silent; fi nvidia_write_unit "$unit_args" || return 1 user_systemctl enable "$NVIDIA_UNIT" >/dev/null 2>&1 || { err "Enabling $NVIDIA_UNIT failed."; return 1; } nvidia_kernel_enable || return 1 nvidia_extras_enable || return 1 ok "Steam starts at your next login." } nvidia_disable() { local pkgs unit; unit="$(nvidia_unit_file)" user_systemctl disable --now "$NVIDIA_UNIT" >/dev/null 2>&1 rm -f "$unit" user_systemctl daemon-reload # Swapped for the SteamOS conversion (turned on first, in the same run): it needs the same settings, they stay. gaming_status 2>/dev/null || nvidia_extras_disable pkgs="$(state_get nvidia installed_pkgs)" if gaming_status 2>/dev/null; then local p; pkgs=" $pkgs " for p in $NVIDIA_LIB32_PKGS; do pkgs="${pkgs// $p / }"; done fi # Only what Steamify installed; games and the Steam folder stay. # shellcheck disable=SC2086 # a list of package names [[ -n "$pkgs" ]] && sudo pacman -Rns --noconfirm $pkgs >/dev/null 2>&1 state_clear nvidia krevert bigpicture gaming_status 2>/dev/null || nvidia_kernel_disable ok "Steam no longer starts at login." } bigpicture_status() { grep -qs -- '-gamepadui' "$(nvidia_unit_file)"; } bigpicture_enable() { # The unit comes from nvidia_enable (it runs first); without it there is nothing to change. [[ -f "$(nvidia_unit_file)" ]] || return 0 nvidia_write_unit -gamepadui || return 1 # Windows of the last session (Discord, a browser) would be restored on top of Big Picture. kset bigpicture ksmserverrc General loginMode emptySession ok "Steam starts in Big Picture at your next login, on an empty desktop." } bigpicture_disable() { krevert bigpicture [[ -f "$(nvidia_unit_file)" ]] || return 0 nvidia_write_unit "" && ok "Steam starts in its normal window at your next login." } # Sub-option: Steam starts silently (tray only, no window). Big Picture and # a silent start exclude each other (the menus untick one when the other is # ticked); a first run with both gets Big Picture. nvsilent_status() { grep -qs -- '-silent' "$(nvidia_unit_file)"; } nvsilent_enable() { [[ -f "$(nvidia_unit_file)" ]] || return 0 bigpicture_status && return 0 nvidia_write_unit -silent || return 1 ok "Steam starts silently at your next login." } nvsilent_disable() { nvsilent_status || return 0 nvidia_write_unit "" && ok "Steam starts in its normal window at your next login." } # --- Kernel parameters and early modules (a supported card only) nvidia_supported() { # A supported NVIDIA GPU: PCI vendor 0x10de, display class, its kernel # driver installed, and not older than Turing (GTX 16 / RTX 20 series: the cards NVIDIA's open kernel driver supports), decided like the # VRAM booster does (vram_nvidia_legacy_id: chwd's legacy card lists, every # other card runs NVIDIA's open modules). Older cards are left alone. local d for d in "$NVIDIA_DRM_DIR"/card[0-9]*/device; do [[ "$(cat "$d/vendor" 2>/dev/null)" == 0x10de ]] || continue [[ "$(cat "$d/class" 2>/dev/null)" == 0x03* ]] || continue vram_nvidia_legacy_id "$(cat "$d/device" 2>/dev/null)" && continue modinfo nvidia_drm >/dev/null 2>&1 && return 0 done return 1 } nvidia_legacy_gpu() { # 0 when the only NVIDIA GPU(s) are older than RTX 20 (reported, not fixed). local d found=0 for d in "$NVIDIA_DRM_DIR"/card[0-9]*/device; do [[ "$(cat "$d/vendor" 2>/dev/null)" == 0x10de && "$(cat "$d/class" 2>/dev/null)" == 0x03* ]] || continue vram_nvidia_legacy_id "$(cat "$d/device" 2>/dev/null)" || return 1 found=1 done [[ $found == 1 ]] } nvidia_boot_file() { # Same boot loaders as the kernel command line elsewhere (lib/hdmi-refresh.sh). hdmi_boot_file } nvidia_param_set() { # 0 when $1 (e.g. nvidia-drm.modeset=1) is on the kernel command line or # already in the boot loader's file (not booted yet). # The kernel treats "nvidia_drm" and "nvidia-drm" alike: CachyOS' own # setup may already have written the underscore spelling. local f re; f="$(nvidia_boot_file)"; re="${1//nvidia-drm/nvidia[-_]drm}" grep -qwE -- "$re" "$NVIDIA_CMDLINE" 2>/dev/null || { [[ -n "$f" ]] && grep -qwE -- "$re" "$f" 2>/dev/null; } } nvidia_params_missing() { local p for p in $NVIDIA_PARAMS; do nvidia_param_set "$p" || return 0; done return 1 } nvidia_initramfs_ok() { [[ -f "$NVIDIA_INITRAMFS_CONF" ]] || grep -qE '^MODULES=.*nvidia_drm' /etc/mkinitcpio.conf 2>/dev/null } nvidia_modules_everywhere() { # 0 when every installed kernel has the nvidia_drm module (the script's own check). patch_file steamify-nvidia-initramfs.sh | NVIDIA_MODULES_DIR="${NVIDIA_MODULES_DIR:-/usr/lib/modules}" bash -s check } nvidia_hook_install() { # Never installs an empty script or hook (a piped patch_file that failed # still lets install succeed with no input). local script hook script="$(patch_file steamify-nvidia-initramfs.sh)" && [[ -n "$script" ]] && hook="$(patch_file steamify-nvidia-initramfs.hook | fill SCRIPT="$NVIDIA_SCRIPT")" && [[ -n "$hook" ]] || return 1 printf '%s\n' "$script" | sudo install -Dm755 /dev/stdin "$NVIDIA_SCRIPT" && printf '%s\n' "$hook" | sudo install -Dm644 /dev/stdin "$NVIDIA_HOOK" } nvidia_rebuild_boot() { local f; f="$(nvidia_boot_file)" info "Rebuilding the initramfs and boot entries..." case "$f" in /etc/default/limine) sudo limine-mkinitcpio ;; /etc/sdboot-manage.conf) sudo /usr/bin/mkinitcpio -P && sudo sdboot-manage gen ;; /etc/default/grub) sudo /usr/bin/mkinitcpio -P && sudo grub-mkconfig -o /boot/grub/grub.cfg ;; *) sudo /usr/bin/mkinitcpio -P ;; esac } nvidia_boot_has_params() { # 0 when the generated boot entries carry the parameters (or can't be # checked): limine.conf, systemd-boot's entries, grub.cfg. local target case "$1" in */limine) target="${NVIDIA_LIMINE_CONF:-/boot/limine.conf}" ;; */sdboot-manage.conf) target="${NVIDIA_SDBOOT_DIR:-/boot/loader/entries}" ;; */grub) target="${NVIDIA_GRUB_CFG:-/boot/grub/grub.cfg}" ;; *) return 0 ;; esac sudo test -e "$target" || return 0 sudo grep -rqs 'nvidia-drm.modeset=1' "$target" } nvidia_kernel_enable() { nvidia_supported || return 0 local f changed=0 p; f="$(nvidia_boot_file)" if nvidia_params_missing; then if [[ -z "$f" ]]; then warn "NVIDIA GPU found, but no supported boot loader: add '$NVIDIA_PARAMS' to the kernel command line yourself." else info "NVIDIA GPU found: adding $NVIDIA_PARAMS to the kernel command line (fixes a corrupted screen in gaming mode)..." backup_file "$f" case "$f" in */limine) sudo sed -i -E "s/^(KERNEL_CMDLINE\[default\]\+?=\"[^\"]*)\"/\1 $NVIDIA_PARAMS\"/" "$f" ;; */sdboot-manage.conf) sudo sed -i -E "s/^(LINUX_OPTIONS=\"[^\"]*)\"/\1 $NVIDIA_PARAMS\"/" "$f" ;; */grub) sudo sed -i -E "s/^(GRUB_CMDLINE_LINUX_DEFAULT=\"[^\"]*)\"/\1 $NVIDIA_PARAMS\"/" "$f" ;; esac if ! grep -q "nvidia-drm.fbdev=1" "$f"; then err "Couldn't add the NVIDIA parameters to $f (no kernel command line setting in it): add '$NVIDIA_PARAMS' yourself." return 1 fi changed=1 fi fi nvidia_hook_install || { err "Installing the NVIDIA initramfs hook failed."; return 1; } local had=0; [[ -f "$NVIDIA_INITRAMFS_CONF" ]] && had=1 sudo env NVIDIA_INITRAMFS_CONF="$NVIDIA_INITRAMFS_CONF" NVIDIA_MODULES_DIR="${NVIDIA_MODULES_DIR:-/usr/lib/modules}" "$NVIDIA_SCRIPT" sync if [[ -f "$NVIDIA_INITRAMFS_CONF" && $had == 0 ]]; then info "Loading the NVIDIA modules early (initramfs)..." changed=1 elif [[ ! -f "$NVIDIA_INITRAMFS_CONF" && $had == 1 ]]; then warn "A kernel without the NVIDIA modules is installed: not loading them early any more (rebuilding the initramfs without them)." changed=1 elif [[ ! -f "$NVIDIA_INITRAMFS_CONF" ]] && ! nvidia_initramfs_ok; then warn "Not every installed kernel has the NVIDIA modules: not loading them early for now (the kernel parameters still apply; this is checked again at every kernel update)." fi if [[ $changed == 1 ]]; then nvidia_rebuild_boot || { err "Rebuilding the boot entries failed."; return 1; } if ! nvidia_boot_has_params "$f"; then err "The boot entries don't have the NVIDIA parameters: the initramfs or boot entry build failed (see above)." return 1 fi ok "NVIDIA gaming mode fix applied; it takes effect after a reboot." fi return 0 } nvidia_extras_enable() { # Video memory kept over sleep, the matching services and the 32-bit libraries (a supported card only). nvidia_supported || return 0 local pkg u units="" prev missing=() if [[ ! -f "$NVIDIA_MODPROBE" ]]; then info "Keeping video memory over suspend (NVreg_PreserveVideoMemoryAllocations=1)..." printf '%s\n' '# Added by Steamify (Gaming on NVIDIA)' 'options nvidia NVreg_PreserveVideoMemoryAllocations=1' | sudo tee "$NVIDIA_MODPROBE" >/dev/null || { err "Writing $NVIDIA_MODPROBE failed."; return 1; } # The NVIDIA modules load from the initramfs, which carries modprobe.d. nvidia_rebuild_boot || warn "Rebuilding the initramfs failed: the suspend setting applies once it is rebuilt." fi for u in $NVIDIA_SLEEP_UNITS; do systemctl is-enabled -q "$u" 2>/dev/null && continue sudo systemctl enable "$u" >/dev/null 2>&1 && units+="${units:+ }$u" done if [[ -n "$units" ]]; then prev="$(state_get nvidia enabled_units)" state_set nvidia enabled_units "${prev:+$prev }$units" fi for pkg in $NVIDIA_LIB32_PKGS; do pacman -Q "$pkg" >/dev/null 2>&1 || missing+=("$pkg"); done if [[ ${#missing[@]} -gt 0 ]]; then info "Installing the 32-bit libraries games need: ${missing[*]}" if sudo pacman -S --needed --noconfirm "${missing[@]}"; then prev="$(state_get nvidia installed_pkgs)" state_set nvidia installed_pkgs "${prev:+$prev }${missing[*]}" else warn "Installing ${missing[*]} failed (is [multilib] enabled in /etc/pacman.conf?): 32-bit games may not start." fi fi return 0 } nvidia_extras_disable() { # Only what Steamify enabled; the 32-bit packages go with installed_pkgs. local u for u in $(state_get nvidia enabled_units); do sudo systemctl disable "$u" >/dev/null 2>&1; done if [[ -f "$NVIDIA_MODPROBE" ]]; then sudo rm -f "$NVIDIA_MODPROBE" nvidia_rebuild_boot || true fi } nvidia_kernel_disable() { local f changed=0; f="$(nvidia_boot_file)" if [[ -n "$f" ]] && grep -q "$NVIDIA_PARAMS" "$f" 2>/dev/null; then sudo sed -i "s/ \?$NVIDIA_PARAMS//" "$f" changed=1 fi [[ -f "$NVIDIA_INITRAMFS_CONF" ]] && { sudo rm -f "$NVIDIA_INITRAMFS_CONF"; changed=1; } sudo rm -f "$NVIDIA_HOOK" "$NVIDIA_SCRIPT" [[ $changed == 1 ]] && nvidia_rebuild_boot return 0 } # ----- lib/gamescope-nvidia.sh ----- # gamescope's own gaming mode on NVIDIA: a patched gamescope that allocates its scan-out buffers with GBM, which cures # the corrupted picture (https://github.com/ValveSoftware/gamescope/issues/2309). The patch is opt-in: gamescope reads # `gamescope_drm_gbm_scanout=1` from its environment, which is set for the gaming session (gamescope-session's # environment file, and environment.d). # # gamescope changes every few weeks, so the package is not carried but downloaded: the newest release of # https://github.com/theupriser/steamify-gamescope-nvidia (CI builds it in CachyOS's container, a person merges the # version bump), and a newer one than the installed package shows as an update of the SteamOS conversion # (`gsn_update_pending`). What this Steamify release was tested with is the fallback: the pinned package (sha256 below), # a copy that came with the ISO, and last a build here from the recipe and patch in patches/. Simple files that don't change # stay in patches/ and are used as they are. # # Used by the SteamOS conversion (`gaming_enable`/`gaming_disable`) on every NVIDIA PC (`nvidia_present`); the kernel # parameters and the rest of lib/nvidia.sh's setup only on a supported card (`nvidia_supported`): GTX 16 / RTX 20 series or # newer (Turing and later, what NVIDIA's open kernel driver supports). Tested on an RTX 5080 only. GSN_PKG=steamify-gamescope-nvidia GSN_RELEASES="${GSN_RELEASES:-https://github.com/theupriser/steamify-gamescope-nvidia/releases}" # The package this Steamify release was tested with (patches/gamescope-nvidia.PKGBUILD names the same version): the # fallback. A new pin: the release's tag, the package's sha256, and the PKGBUILD's pkgver/pkgrel. GSN_VERSION=3.16.31-3 # One build per CPU level, like CachyOS's repositories: generic is the one every x86-64 PC can run. declare -A GSN_SHA256=( [generic]=e9c7e4d8e1f2a909c25c7e090a86e610234103c6014e65882e84bffe088bafb2 [v3]=0a2e9408d8faea3b7c8dda8a4f8d01f7c7e77eea2374590a311856ee3feb559e [v4]=550c08910617d6e770dfa8ebe742f11d04fa65e51dea8773ce419e1e0244572e [znver4]=ec05ba1ba7e7cbde7c2dbee6959b30f89a96659cd4b7f675837ac425ea028d5f ) # Where the ISO leaves a copy of the pinned package, for installs without GitHub. GSN_BACKUP_DIR="${GSN_BACKUP_DIR:-/usr/local/share/steamify/gamescope}" # How long a look at GitHub for a newer release counts (seconds), so opening the menu doesn't ask every time. GSN_CHECK_EVERY="${GSN_CHECK_EVERY:-21600}" GSN_ENV_LINE='gamescope_drm_gbm_scanout=1' GSN_ENV_D="${GSN_ENV_D:-$HOME/.config/environment.d/99-steamify-gamescope-nvidia.conf}" GSN_SESSION_ENV="${GSN_SESSION_ENV:-$HOME/.config/gamescope-session/environment}" gsn_package_installed() { pacman -Q "$GSN_PKG" >/dev/null 2>&1; } gsn_installed_version() { pacman -Q "$GSN_PKG" 2>/dev/null | awk '{print $2}'; } gsn_env_set() { [[ -f "$GSN_ENV_D" ]] && grep -qsx "$GSN_ENV_LINE" "$GSN_SESSION_ENV"; } gsn_status() { gsn_package_installed && gsn_env_set; } gsn_newer() { # gsn_newer : version a is newer than b (pacman's own comparison). [[ "$(vercmp "$1" "$2" 2>/dev/null)" == 1 ]] } gsn_latest_tag() { # The newest release's tag (-), from GitHub; nothing when it can't be reached or # there is no release. Follows the redirect of /releases/latest: no API, no rate limit, no JSON. gsn_latest_tag [seconds] local url url="$(curl -fsL --connect-timeout 4 --max-time "${1:-10}" -o /dev/null -w '%{url_effective}' "$GSN_RELEASES/latest" 2>/dev/null)" || return 1 url="${url##*/}" [[ "$url" =~ ^[0-9][0-9A-Za-z._]*-[0-9]+$ ]] || return 1 echo "$url" } gsn_update_pending() { # The patched gamescope is installed and GitHub has a newer release: the menu shows the conversion as an update. # Looked up at most every GSN_CHECK_EVERY seconds (the answer is kept in the state), quickly, and silently when # offline: no network is never a reason to show or hide anything. gsn_package_installed || return 1 local now checked latest now="$(date +%s)"; checked="$(state_get gamescope_nvidia checked 0)" if (( now - ${checked:-0} > GSN_CHECK_EVERY )); then state_set gamescope_nvidia checked "$now" latest="$(gsn_latest_tag 5)" && state_set gamescope_nvidia latest "$latest" fi latest="$(state_get gamescope_nvidia latest)" [[ -n "$latest" ]] && gsn_newer "$latest" "$(gsn_installed_version)" } gsn_cpu_variant() { # The best build this CPU can run: znver4 (Ryzen 7000/9000: AMD with AVX-512 BF16), v4, v3, else generic. The # dynamic loader knows the CPU level best ("x86-64-v4 (supported, searched)"). local help; help="$(/lib64/ld-linux-x86-64.so.2 --help 2>/dev/null)" if grep -q 'x86-64-v4 (supported' <<< "$help"; then if grep -q AuthenticAMD /proc/cpuinfo && grep -qw avx512_bf16 /proc/cpuinfo; then echo znver4; else echo v4; fi elif grep -q 'x86-64-v3 (supported' <<< "$help"; then echo v3 else echo generic; fi } gsn_sha_of() { # The hash a release file is expected to have: the pinned one for the pinned version, else the .sha256 next to the # package in the same release (it shows the download wasn't damaged, not who made it). gsn_sha_of local tag="$1" file="$2" variant="$3" if [[ "$tag" == "$GSN_VERSION" ]]; then echo "${GSN_SHA256[$variant]}"; return 0; fi curl -fsL --connect-timeout 10 --max-time 30 "$GSN_RELEASES/download/$tag/$file.sha256" 2>/dev/null | awk 'NR==1 {print $1}' } gsn_download() { # gsn_download : the release's package, verified, in the cache; prints its path. local tag="$1" variant="$2" cache="${XDG_CACHE_HOME:-$HOME/.cache}/steamify" file want got file="$GSN_PKG-$tag-$variant.pkg.tar.zst" mkdir -p "$cache" || return 1 want="$(gsn_sha_of "$tag" "$file" "$variant")" [[ "$want" =~ ^[0-9a-f]{64}$ ]] || return 1 if [[ -f "$cache/$file" ]] && [[ "$(sha256sum "$cache/$file" | awk '{print $1}')" == "$want" ]]; then echo "$cache/$file"; return 0; fi curl -fsL --retry 2 --connect-timeout 15 --max-time 300 -o "$cache/$file.part" "$GSN_RELEASES/download/$tag/$file" || { rm -f "$cache/$file.part"; return 1; } got="$(sha256sum "$cache/$file.part" | awk '{print $1}')" if [[ "$got" != "$want" ]]; then rm -f "$cache/$file.part"; return 1; fi mv -f "$cache/$file.part" "$cache/$file" echo "$cache/$file" } gsn_fetch_package() { # The package to install, as a path: the build for this CPU, then the generic one, each from the newest release on # GitHub (not older than the pinned one), else the pinned release (GitHub, the cache, or the copy that came with the # ISO). Only files with the expected sha256 count. gsn_fetch_package [variant] local tag pkg v cache="${XDG_CACHE_HOME:-$HOME/.cache}/steamify" file copy local -a variants=("${1:-$(gsn_cpu_variant)}"); [[ "${variants[0]}" == generic ]] || variants+=(generic) tag="$(gsn_latest_tag)" || tag="" for v in "${variants[@]}"; do if [[ -n "$tag" && "$tag" != "$GSN_VERSION" ]] && gsn_newer "$tag" "$GSN_VERSION"; then info "Downloading the newest patched gamescope ($tag, $v)..." >&2 if pkg="$(gsn_download "$tag" "$v")"; then echo "$pkg"; return 0; fi warn "Downloading the newest patched gamescope ($tag, $v) failed: trying the version this Steamify was tested with ($GSN_VERSION)." >&2 fi info "Downloading the patched gamescope ($GSN_VERSION, $v)..." >&2 if pkg="$(gsn_download "$GSN_VERSION" "$v")"; then echo "$pkg"; return 0; fi file="$GSN_PKG-$GSN_VERSION-$v.pkg.tar.zst" for copy in "$GSN_BACKUP_DIR/$file" "$cache/$file"; do if [[ -f "$copy" ]] && [[ "$(sha256sum "$copy" | awk '{print $1}')" == "${GSN_SHA256[$v]}" ]]; then info "Using the copy that came with Steamify: $copy" >&2 echo "$copy"; return 0 fi done done warn "Downloading the patched gamescope failed (or its checksum didn't match)." >&2 return 1 } gsn_smoke_test() { # The installed gamescope must start (headless, no screen needed): a build that crashes would leave gaming mode in a loop. # It segfaults while shutting down headless (also unpatched builds), so what counts is that it ran the program. timeout 25 gamescope --backend headless -W 640 -H 360 -r 60 -- true 2>&1 | grep -q 'Primary child shut down' } gsn_build_package() { # Last resort: builds the pinned package from the recipe and patch Steamify carries (patches/), as the user. It # downloads gamescope's sources (Valve's repository at the PKGBUILD's pkgver, and its submodules) and compiles them: a # few minutes. Prints the package's path. local dir cache="${XDG_CACHE_HOME:-$HOME/.cache}/steamify" info "Building the patched gamescope here instead (a few minutes)..." >&2 sudo pacman -S --needed --noconfirm base-devel git >&2 || { err "Installing the build tools failed." >&2; return 1; } # On disk, not /tmp: the installer's chroot has a tmpfs there (RAM), and the build is 1-2 GB. mkdir -p "$cache" && dir="$(mktemp -d -p "$cache" gamescope-build.XXXXXX)" || return 1 patch_file gamescope-nvidia.PKGBUILD > "$dir/PKGBUILD" && patch_file gamescope-gbm-scanout.patch > "$dir/gamescope-gbm-scanout.patch" && patch_file gamescope-ui-native.patch > "$dir/gamescope-ui-native.patch" || { err "Couldn't write the build files." >&2 rm -rf "$dir"; return 1 } if ! (cd "$dir" && makepkg -sf --noconfirm --nocheck >&2); then err "Building the patched gamescope failed (see above)." >&2 rm -rf "$dir"; return 1 fi # Moved out, so the build folder can go. local f built="" for f in "$dir"/"$GSN_PKG"-*.pkg.tar.*; do [[ -e "$f" && "$f" != *-debug-* ]] && { built="$f"; break; } done [[ -n "$built" ]] && mv "$built" "$cache/gamescope-local-build.pkg.tar.zst" || { rm -rf "$dir"; return 1; } rm -rf "$dir" echo "$cache/gamescope-local-build.pkg.tar.zst" } gsn_install_package() { # The patched gamescope, newest first, each step only when the one before failed: the newest release from GitHub, the # pinned package (GitHub, cache or the copy that came with the ISO), a build here. A package that downloaded fine but # doesn't install (its libraries differ from this PC's) goes on to the next step too. Nothing else changes before one of # them is installed, so a failure leaves the PC as it was, with what it had. --ask 4 lets pacman replace the stock # gamescope it conflicts with, and an older patched one. local pkg latest have have="$(gsn_installed_version)" latest="$(gsn_latest_tag)" || latest="" # Already the newest (or, offline, the pinned version or newer): nothing to do. if [[ -n "$have" ]]; then if [[ -n "$latest" ]] && ! gsn_newer "$latest" "$have"; then return 0; fi if [[ -z "$latest" ]] && ! gsn_newer "$GSN_VERSION" "$have"; then return 0; fi fi if pkg="$(gsn_fetch_package)" && sudo pacman -U --noconfirm --ask 4 "$pkg"; then if ! gsn_smoke_test; then warn "The patched gamescope for this CPU doesn't start: installing the generic build instead." pkg="$(gsn_fetch_package generic)" && sudo pacman -U --noconfirm --ask 4 "$pkg" && gsn_smoke_test || { err "The patched gamescope doesn't start on this PC."; return 1; } fi ok "Patched gamescope installed." return 0 fi warn "The ready-made patched gamescope couldn't be installed." [[ -n "$have" ]] && { warn "The installed one ($have) stays."; return 0; } if pkg="$(gsn_build_package)" && sudo pacman -U --noconfirm --ask 4 "$pkg"; then ok "Patched gamescope built and installed." return 0 fi err "The patched gamescope couldn't be installed. The stock gamescope stays." return 1 } gsn_env_enable() { mkdir -p "$(dirname "$GSN_ENV_D")" "$(dirname "$GSN_SESSION_ENV")" echo "$GSN_ENV_LINE" > "$GSN_ENV_D" touch "$GSN_SESSION_ENV" grep -qsx "$GSN_ENV_LINE" "$GSN_SESSION_ENV" || echo "$GSN_ENV_LINE" >> "$GSN_SESSION_ENV" } gsn_env_disable() { rm -f "$GSN_ENV_D" [[ -f "$GSN_SESSION_ENV" ]] && sed -i "/^${GSN_ENV_LINE}\$/d" "$GSN_SESSION_ENV" return 0 } # --- The session starts gamescope on the display's own mode # # gamescope-session starts gamescope without a size or a refresh rate, so it takes the display's *preferred* mode, which # many monitors declare as 60 Hz while the desktop runs them at their highest. patches/gamescope-display-wrapper.sh (put # first in the gaming session's PATH by a drop-in of gamescope-session.service) starts it on the mode KDE has saved for # the connected display, else the biggest mode at the highest refresh rate, read at every session start. GSN_DISPLAY_WRAPPER="${GSN_DISPLAY_WRAPPER:-$STEAMIFY_BIN/gamescope}" GSN_DISPLAY_DROPIN="${GSN_DISPLAY_DROPIN:-$HOME/.config/systemd/user/gamescope-session.service.d/steamify-display.conf}" gsn_display_status() { [[ -x "$GSN_DISPLAY_WRAPPER" && -f "$GSN_DISPLAY_DROPIN" ]]; } gsn_display_pending() { # The patched gamescope is in use but the session still starts it on the display's preferred mode: a normal run sets it up. gsn_package_installed && gsn_env_set && ! gsn_display_status } gsn_display_enable() { patch_file gamescope-display-wrapper.sh | install_executable "$GSN_DISPLAY_WRAPPER" 755 || return 1 mkdir -p "$(dirname "$GSN_DISPLAY_DROPIN")" cat > "$GSN_DISPLAY_DROPIN" << EOF # Steamify: the gaming session starts gamescope on the display's own mode (see lib/gamescope-nvidia.sh). Turning the # SteamOS conversion off removes this file. [Service] ExecStart= ExecStart=/usr/bin/bash -c 'PATH="$(dirname "$GSN_DISPLAY_WRAPPER"):\$PATH" exec /usr/lib/steamos/gamescope-session' EOF user_systemctl daemon-reload } gsn_display_disable() { # Only our own files: the wrapper is recognised by its first comment line. grep -qs "^# Steamify: gamescope-session starts gamescope" "$GSN_DISPLAY_WRAPPER" && rm -f "$GSN_DISPLAY_WRAPPER" [[ -f "$GSN_DISPLAY_DROPIN" ]] && rm -f "$GSN_DISPLAY_DROPIN" && rmdir "$(dirname "$GSN_DISPLAY_DROPIN")" 2>/dev/null user_systemctl daemon-reload return 0 } gsn_preflight() { # Before anything changes: what would make the install fail half way. Prints the reason and fails, or passes. if [[ -e /var/lib/pacman/db.lck ]]; then echo "another package manager is running (/var/lib/pacman/db.lck): finish or close it and try again"; return 1 fi local free; free="$(df --output=avail -BM / 2>/dev/null | tail -1 | tr -dc 0-9)" if [[ -n "$free" ]] && (( free < 1500 )); then echo "less than 1.5 GB free on /: free some space and try again"; return 1 fi return 0 } gsn_fail_mark() { # Remember a failed setup so the menu says so on the next start, and tell the user now (desktop notification). state_set gamescope_nvidia failed "$(date '+%Y-%m-%d %H:%M'): $1" command -v notify-send >/dev/null 2>&1 && notify-send -a Steamify -i dialog-warning "Steamify: gaming mode setup failed" "$1. Open Steamify and apply again." 2>/dev/null || true } gsn_fail_note() { # The menu's reminder of a failed setup (nothing when the last one worked). local f; f="$(state_get gamescope_nvidia failed)" [[ -n "$f" ]] && warn "The last gaming mode setup for NVIDIA failed ($f). Apply the conversion again to retry." return 0 } gsn_enable() { # Only on a supported NVIDIA card; elsewhere the stock gamescope is right. nvidia_present || return 0 local why if ! why="$(gsn_preflight)"; then err "Gaming mode for NVIDIA can't be set up: $why."; gsn_fail_mark "$why"; return 1; fi # A PC that had "Gaming on NVIDIA" on: Steam must not also start on the desktop. nvidia_swap_out # What NVIDIA's DRM stack wants (lib/nvidia.sh; a supported card only, the functions check): modeset/fbdev on the # kernel command line, the modules in the initramfs, video memory kept over suspend, the 32-bit libraries. Takes # effect at the next boot, like the session. nvidia_kernel_enable || return 1 nvidia_extras_enable || return 1 gsn_install_package || { gsn_fail_mark "the patched gamescope couldn't be installed"; return 1; } state_set gamescope_nvidia failed "" gsn_env_enable gsn_display_enable || warn "Couldn't set up the display mode for gaming mode: gamescope takes the display's preferred mode." ok "gamescope draws NVIDIA's picture through GBM (gamescope_drm_gbm_scanout=1) from the next gaming mode start." } gsn_disable() { # The setting goes, and what nvidia_*_enable added; the packages stay (patched gamescope, 32-bit libraries), like # the other packages of the conversion (steam, gamescope-session-cachyos). gsn_env_disable gsn_display_disable nvidia_present || return 0 nvidia_extras_disable nvidia_kernel_disable } # ----- lib/controllers.sh ----- # Extended controller support: the Xbox wireless dongle (xone, with its firmware) and Xbox controllers over Bluetooth (xpadneo). # The kernel has no driver for the dongle, and without xpadneo a Bluetooth Xbox controller works as a plain gamepad (buttons and # sticks) but misses rumble (trigger motors too), proper button mapping and the battery level. Both are DKMS modules from CachyOS's # own repo, built for every installed kernel (so every kernel needs its headers). Sourced by steamify.sh; not meant to be run on # its own. controller_xone_pkg() { # A PC with the AUR's xone-dkms-git already works: the repo's xone-dkms would conflict with it. pacman -Q xone-dkms-git >/dev/null 2>&1 && echo xone-dkms-git || echo xone-dkms } controller_pkgs() { echo "xpadneo-dkms $(controller_xone_pkg) xone-dongle-firmware"; } extended_controller_support_status() { # shellcheck disable=SC2046 # a list of package names pacman -Q $(controller_pkgs) >/dev/null 2>&1 } extended_controller_support_enable() { local p prev missing=() info "Installing extended controller support (Xbox wireless dongle, Xbox Bluetooth)..." install_kernel_headers || return 1 for p in $(controller_pkgs); do pacman -Q "$p" >/dev/null 2>&1 || missing+=("$p"); done if ((${#missing[@]})); then sudo pacman -S --needed --noconfirm "${missing[@]}" || { err "Installing ${missing[*]} failed."; return 1; } # What a first, half-finished run installed stays on the list. prev="$(state_get extended_controller_support installed_pkgs)" state_set extended_controller_support installed_pkgs "$(echo "$prev ${missing[*]}" | xargs)" fi ok "Extended controller support on: plug the dongle in again (or reconnect the controller) to use it." } extended_controller_support_disable() { local pkgs pkgs="$(state_get extended_controller_support installed_pkgs)" # Only what Steamify installed; kernel headers stay. # shellcheck disable=SC2086 # a list of package names [[ -n "$pkgs" ]] && sudo pacman -Rns --noconfirm $pkgs >/dev/null 2>&1 state_clear extended_controller_support ok "Extended controller support off." } # ----- lib/cec.sh ----- # "HDMI-CEC" menu item, on any PC: Valve's CEC daemon (cecd), TV/receiver # volume control (cec-audio-control) and the units that attach USB CEC # adapters (Pulse-Eight, RainShadow) with inputattach. They're only in # Valve's SteamOS (holo) repository, not in CachyOS or the AUR. Works with # any /dev/cec*: a GPU with CEC or a USB adapter. On a Steam Machine it also # builds Valve's CEC driver (see cec_driver_enable), and steamos-manager # configures cecd from Steam's settings. CEC_PKGS=(cecd cec-audio-control inputattach-cec-units) # steamos-manager checks once, at its start, whether cecd runs; order it # after cecd so it never misses it at login. CEC_ORDER_DROPIN=/etc/systemd/user/steamos-manager.service.d/10-steamify-after-cecd.conf # CachyOS's gaming mode script exports STEAM_ENABLE_CEC=0, which hides # Steam's HDMI-CEC settings; Steam reads it from the gamescope environment # file. A later EnvironmentFile= overrides it. CEC_STEAM_ENV=/etc/steamify/steam-cec.env CEC_STEAM_DROPIN=/etc/systemd/user/steam-launcher.service.d/10-steamify-cec.conf # The Steam Machine's CEC runs through its embedded controller (cros_ec_cec). # Mainline's driver doesn't list Fremont, so there's no /dev/cec0; Valve's # kernel does. We build Valve's copy with DKMS for every kernel, pinned to a # reviewed commit. CEC_DKMS_NAME=steamify-cros-ec-cec CEC_DKMS_VER=1 CEC_DKMS_SRC="/usr/src/$CEC_DKMS_NAME-$CEC_DKMS_VER" CEC_DRIVER_URL="https://raw.githubusercontent.com/evlaV/linux-integration/10c8c8800ccd3ae359203b4eefb6479f613b3b8e/drivers/media/cec/platform/cros-ec/cros-ec-cec.c" CEC_DRIVER_SHA256=e89fd4e87fceb32d713ffc59b64794779b3c2b3c012b0e2f2e9f0c40b42f4373 # Downloaded once, then kept: raw.githubusercontent.com rate-limits (HTTP 429) repeated downloads # from one address, and turning CEC off and on again needs no network then. Named after its # checksum, so a new pin (a new commit and checksum above) downloads its file once by itself. CEC_DRIVER_CACHE="/var/cache/steamify/cros-ec-cec-${CEC_DRIVER_SHA256:0:12}.c" cec_link_steamos_manager() { # On a Steam Machine: steamos-manager writes cecd's config from Steam's # CEC settings (its configure-cecd unit, run before cecd), and only # offers Steam those settings when cecd was there at its start. Also # called by Steam Machine support, which installs steamos-manager after # this item ran. pacman -Q steamos-manager >/dev/null 2>&1 || return 0 user_systemctl daemon-reload user_systemctl enable steamos-manager-configure-cecd.service 2>/dev/null user_systemctl start steamos-manager-configure-cecd.service 2>/dev/null user_systemctl restart cecd.service 2>/dev/null user_systemctl restart steamos-manager.service 2>/dev/null return 0 } cec_installed() { pacman -Q "${CEC_PKGS[@]}" >/dev/null 2>&1; } cec_driver_ok() { ! detect_valve_fremont || [[ -d "$CEC_DKMS_SRC" ]]; } # On = installed, Steam told to show its CEC settings and, on a Steam # Machine, the driver built. An install from before 1.1.3 (settings) or # 2.0.3 (driver) lacks one; the menu then ticks it (see cec_repair). cec_status() { cec_installed && [[ -f "$CEC_STEAM_DROPIN" ]] && cec_driver_ok; } cec_reload_driver() { # cecd keeps /dev/cec0 open, so the old module can't be unloaded under it. user_systemctl stop cecd.service 2>/dev/null sudo modprobe -r cros_ec_cec 2>/dev/null sudo modprobe cros_ec_cec 2>/dev/null user_systemctl start cecd.service 2>/dev/null } cec_driver_enable() { detect_valve_fremont || return 0 local tmp k if ! pacman -Q dkms patch >/dev/null 2>&1; then sudo pacman -S --needed --noconfirm dkms patch || { err "Installing dkms failed."; return 1; } fi install_kernel_headers || return 1 tmp="$(mktemp -d)" if [[ -f "$CEC_DRIVER_CACHE" ]] && echo "$CEC_DRIVER_SHA256 $CEC_DRIVER_CACHE" | sha256sum -c --quiet - 2>/dev/null; then cp "$CEC_DRIVER_CACHE" "$tmp/cros-ec-cec.c" else info "Downloading Valve's Steam Machine CEC driver..." if ! curl -fsL --retry 2 --max-time 60 "$CEC_DRIVER_URL" -o "$tmp/cros-ec-cec.c" || ! echo "$CEC_DRIVER_SHA256 $tmp/cros-ec-cec.c" | sha256sum -c --quiet -; then # GitHub is down, rate-limits us, or sent something else: Steamify carries the same file (checked the same way). warn "Downloading Valve's CEC driver failed (or its checksum didn't match): using the copy Steamify carries." patch_file cros-ec-cec.c > "$tmp/cros-ec-cec.c" if ! echo "$CEC_DRIVER_SHA256 $tmp/cros-ec-cec.c" | sha256sum -c --quiet -; then rm -rf "$tmp" err "Steamify's own copy of Valve's CEC driver doesn't match the pinned checksum (a new pin needs a new copy in patches/)." return 1 fi fi # The name carries the checksum: a newer pin gets its own file, the old one goes. sudo find "${CEC_DRIVER_CACHE%/*}" -maxdepth 1 -name 'cros-ec-cec-*.c' ! -name "${CEC_DRIVER_CACHE##*/}" -delete 2>/dev/null sudo install -Dm644 "$tmp/cros-ec-cec.c" "$CEC_DRIVER_CACHE" fi # So it finds amdgpu's HDMI port (see the patch for why). patch_file cros-ec-cec-single-port.patch | patch -s -d "$tmp" -p1 || { rm -rf "$tmp"; err "Patching Valve's CEC driver failed."; return 1; } echo 'obj-m += cros-ec-cec.o' >"$tmp/Makefile" patch_file "$CEC_DKMS_NAME.dkms.conf" | fill NAME="$CEC_DKMS_NAME" VERSION="$CEC_DKMS_VER" >"$tmp/dkms.conf" sudo dkms remove "$CEC_DKMS_NAME/$CEC_DKMS_VER" --all >/dev/null 2>&1 sudo rm -rf "$CEC_DKMS_SRC" sudo install -d "$CEC_DKMS_SRC" && sudo install -m644 "$tmp"/{cros-ec-cec.c,Makefile,dkms.conf} "$CEC_DKMS_SRC/" rm -rf "$tmp" sudo dkms add "$CEC_DKMS_NAME/$CEC_DKMS_VER" >/dev/null || { err "Adding the CEC driver to DKMS failed."; return 1; } # Every kernel with headers; one that fails to build only lacks CEC. for k in /usr/lib/modules/*/build; do k="$(basename "$(dirname "$k")")" info "Building the CEC driver for $k..." sudo dkms install "$CEC_DKMS_NAME/$CEC_DKMS_VER" -k "$k" >/dev/null || warn "Building the CEC driver for $k failed; that kernel has no HDMI-CEC." done cec_reload_driver } cec_driver_disable() { [[ -d "$CEC_DKMS_SRC" ]] || return 0 sudo dkms remove "$CEC_DKMS_NAME/$CEC_DKMS_VER" --all >/dev/null 2>&1 sudo rm -rf "$CEC_DKMS_SRC" cec_reload_driver } cec_repair() { cec_installed && ! cec_status; } fetch_holo_pkg() { # fetch_holo_pkg : download the newest from # Valve's newest holo repository into and print its path. The # SHA-256 comes from Valve's package index and is verified. local dir="$1" name="$2" repo desc file_name sha256 repo="$(curl -fsL "$VALVE_MIRROR/" | grep -oE 'holo-[0-9]+\.[0-9]+/' | tr -d / | sort -V | tail -n 1)" if [[ ! -f "$dir/holo.db" ]]; then [[ -n "$repo" ]] && curl -fsL "$VALVE_MIRROR/$repo/os/x86_64/$repo.db" -o "$dir/holo.db" || { err "Couldn't read Valve's SteamOS package index ($VALVE_MIRROR)."; return 1; } fi desc="$(tar -tf "$dir/holo.db" 2>/dev/null | grep -E "^$name-[0-9][^/]*/desc$" | head -n 1)" [[ -n "$desc" ]] || { err "No $name in Valve's $repo repository."; return 1; } file_name="$(tar -xOf "$dir/holo.db" "$desc" | awk '/^%FILENAME%$/ { getline; print }')" sha256="$(tar -xOf "$dir/holo.db" "$desc" | awk '/^%SHA256SUM%$/ { getline; print }')" info "Downloading $file_name ($repo)..." >&2 curl -fsL "$VALVE_MIRROR/$repo/os/x86_64/$file_name" -o "$dir/$file_name" || { err "Downloading $file_name failed."; return 1; } echo "$sha256 $dir/$file_name" | sha256sum -c --quiet - >&2 || { err "Checksum mismatch for $file_name; not using it."; return 1; } echo "$dir/$file_name" } cec_enable() { local tmp p f files=() # inputattach, needed by inputattach-cec-units. if ! pacman -Q linuxconsole >/dev/null 2>&1; then sudo pacman -S --needed --noconfirm linuxconsole || { err "Installing linuxconsole failed."; return 1; } state_set cec installed_linuxconsole 1 fi tmp="$(mktemp -d)" for p in "${CEC_PKGS[@]}"; do f="$(fetch_holo_pkg "$tmp" "$p")" || { rm -rf "$tmp"; return 1; } files+=("$f") done info "Installing Valve's CEC daemon..." if ! sudo pacman -U --needed --noconfirm "${files[@]}"; then rm -rf "$tmp" err "Installing ${CEC_PKGS[*]} failed." return 1 fi rm -rf "$tmp" cec_driver_enable || return 1 # Its udev rule gives the user /dev/cec*; cecd starts with the graphical # session, after steamos-manager wrote its config from Steam's settings. sudo udevadm control --reload sudo udevadm trigger --subsystem-match=cec --action=add sudo mkdir -p "$(dirname "$CEC_ORDER_DROPIN")" printf '%s\n' "# Written by Steamify: steamos-manager only sees cecd if it runs at its start." \ '[Unit]' 'After=cecd.service' | sudo tee "$CEC_ORDER_DROPIN" >/dev/null sudo mkdir -p "$(dirname "$CEC_STEAM_ENV")" "$(dirname "$CEC_STEAM_DROPIN")" printf '%s\n' "# Written by Steamify: Steam's HDMI-CEC settings in gaming mode." 'STEAM_ENABLE_CEC=1' | sudo tee "$CEC_STEAM_ENV" >/dev/null printf '%s\n' "# Written by Steamify: overrides STEAM_ENABLE_CEC=0 from the gaming mode script." \ '[Service]' "EnvironmentFile=-$CEC_STEAM_ENV" | sudo tee "$CEC_STEAM_DROPIN" >/dev/null user_systemctl daemon-reload # The TV remote's volume keys: SteamOS enables this socket through its # preset, which pacman doesn't apply, so nothing else does on CachyOS. user_systemctl enable --now cec-audio-control.socket 2>/dev/null cec_link_steamos_manager if compgen -G "/dev/cec*" >/dev/null; then user_systemctl restart cecd.service 2>/dev/null ok "HDMI-CEC on ($(cd /dev && echo cec*)); Steam shows its settings from the next gaming mode start." info "Turn on CEC on your TV too (e.g. Sony: BRAVIA Sync, Samsung: Anynet+, LG: SimpLink)." else warn "No CEC device (/dev/cec*) found: your GPU may not support CEC. A USB CEC adapter" warn "(e.g. Pulse-Eight) works too. Check again after a restart with: ls /dev/cec*" fi } cec_disable() { info "Removing HDMI-CEC..." user_systemctl disable --now cecd.service cec-audio-control.socket cec-audio-control.service 2>/dev/null user_systemctl disable steamos-manager-configure-cecd.service 2>/dev/null sudo pacman -Rns --noconfirm "${CEC_PKGS[@]}" 2>/dev/null cec_driver_disable sudo rm -f "$CEC_ORDER_DROPIN" "$CEC_STEAM_DROPIN" "$CEC_STEAM_ENV" sudo rmdir "$(dirname "$CEC_ORDER_DROPIN")" "$(dirname "$CEC_STEAM_DROPIN")" "$(dirname "$CEC_STEAM_ENV")" 2>/dev/null user_systemctl daemon-reload if [[ -n "$(state_get cec installed_linuxconsole)" ]]; then sudo pacman -Rns --noconfirm linuxconsole 2>/dev/null state_clear cec fi sudo udevadm control --reload user_systemctl is-active -q steamos-manager.service 2>/dev/null && user_systemctl restart steamos-manager.service ok "HDMI-CEC removed." } cec_overview() { # For the menu's kernel overview. local dev="none" compgen -G "/dev/cec*" >/dev/null && dev="$(cd /dev && echo cec*)" echo "CEC devices: $dev cecd: $(user_systemctl is-active cecd 2>/dev/null)" } # ----- lib/boot-session.sh ----- # "Boot into: gamescope / desktop" menu item, on top of the SteamOS # conversion. Gamescope is the default (the conversion's autologin, and # CachyOS's cachyos-gamescope-autologin resetting to it after a desktop # session). Desktop: a unit that sets the autologin session to Plasma at # every boot, before the login manager starts. Switching between the two in # a running system (Return to Gaming Mode, Steam's Switch to Desktop) works # the same either way. BOOT_UNIT_NAME="steamify-boot-desktop.service" BOOT_UNIT="/etc/systemd/system/$BOOT_UNIT_NAME" SESSION_SYNC="/usr/local/bin/sync-steamos-session.sh" boot_status() { # On = boots into the desktop. [[ -f "$BOOT_UNIT" ]] && systemctl is-enabled -q "$BOOT_UNIT_NAME" 2>/dev/null } boot_enable() { info "Setting the PC to boot into the desktop..." # The sync bridge (plasma-login-manager only; not there with SDDM) copies # the session into the base config; run it right away rather than rely # on its path unit, which could still be pending when the login manager # starts. service_file "$BOOT_UNIT_NAME" SESSION_SYNC="$SESSION_SYNC" | sudo tee "$BOOT_UNIT" > /dev/null sudo systemctl daemon-reload sudo systemctl enable "$BOOT_UNIT_NAME" || { err "Enabling $BOOT_UNIT_NAME failed."; return 1; } ok "Boots into the desktop from the next boot on (Return to Gaming Mode still works)." } boot_disable() { info "Setting the PC to boot into gaming mode again..." sudo systemctl disable "$BOOT_UNIT_NAME" 2>/dev/null sudo rm -f "$BOOT_UNIT" sudo systemctl daemon-reload # Next boot into gamescope, as the conversion sets it up. sudo /usr/lib/steamos/steam-set-session gamescope-session.desktop [[ -x "$SESSION_SYNC" ]] && sudo "$SESSION_SYNC" ok "Boots into gaming mode from the next boot on." } boot_choice() { # The menu row: the chosen option in brackets. if [[ "$1" == 1 ]]; then echo "Boot into: gamescope [desktop]" else echo "Boot into: [gamescope] desktop" fi } # ----- lib/vapor-theme.sh ----- # SteamOS Vapor desktop theme, from CachyOS's cachyos-vapor package. VAPOR_LOOKANDFEEL=com.valve.vapor.desktop VAPOR_DEFAULTS=/usr/share/plasma/look-and-feel/$VAPOR_LOOKANDFEEL/contents/defaults # Files the "Desktop and window layout" part replaces wholesale (panels, # widgets, wallpaper); backed up as-is rather than journaled key by key. THEME_LAYOUT_FILES=(plasma-org.kde.plasma.desktop-appletsrc plasmashellrc kdedefaults) current_colorscheme() { # CachyOS ships its dark Breeze colors inline in kdeglobals without a # ColorScheme name, so fall back to the scheme matching ColorSchemeHash. local name hash f name="$(kreadconfig6 --file kdeglobals --group General --key ColorScheme)" if [[ -z "$name" ]]; then hash="$(kreadconfig6 --file kdeglobals --group General --key ColorSchemeHash)" for f in ~/.local/share/color-schemes/*.colors /usr/share/color-schemes/*.colors; do [[ -f "$f" && -n "$hash" ]] || continue [[ "$(sha1sum < "$f")" == "$hash "* ]] && { name="$(basename "$f" .colors)"; break; } done fi echo "${name:-BreezeLight}" } journal_vapor_defaults() { # Write every setting from Vapor's defaults file ("[file][group]" + # key=value) through the undo journal, so disable restores the user's # icons, cursor, splash, window decoration etc. The color scheme is # left to plasma-apply-colorscheme. local line file groups key rest while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" =~ ^\[([^]]+)\](.*)$ ]]; then file="${BASH_REMATCH[1]}"; rest="${BASH_REMATCH[2]}"; groups="" while [[ "$rest" =~ ^\[([^]]*)\](.*)$ ]]; do groups+="${groups:+|}${BASH_REMATCH[1]}"; rest="${BASH_REMATCH[2]}" done elif [[ "$line" == *=* && -n "$groups" ]]; then key="${line%%=*}" [[ "$key" == ColorScheme ]] && continue kset theme "$file" "$groups" "$key" "${line#*=}" fi done < "$VAPOR_DEFAULTS" } theme_status() { [[ "$(kreadconfig6 --file kdeglobals --group KDE --key LookAndFeelPackage)" == "$VAPOR_LOOKANDFEEL" ]] } theme_enable() { if ! pacman -Q cachyos-vapor >/dev/null 2>&1; then info "Installing cachyos-vapor..." sudo pacman -S --needed --noconfirm cachyos-vapor || { err "Installing cachyos-vapor failed."; return 1; } # Only remove it again on disable if we were the ones installing it. state_set theme installed_pkg 1 fi # Without a session (the ISO's installer), for a user who has never # logged in: the layout still CachyOS's default from /etc/skel is # removed (after the backup below), and Plasma builds its first layout # from the look-and-feel package, as --resetLayout does below. local offline=false layout=~/.config/plasma-org.kde.plasma.desktop-appletsrc if [[ -z "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]]; then if [[ -e "$layout" ]] && ! cmp -s "$layout" "/etc/skel/.config/${layout##*/}"; then err "Run this from the Plasma desktop: applying the Vapor layout needs a running Plasma session." return 1 fi offline=true fi # Only the first time, so a re-apply doesn't overwrite the originals. local backup="$STATE_DIR/theme-layout" f if [[ ! -d "$backup" ]]; then mkdir -p "$backup" for f in "${THEME_LAYOUT_FILES[@]}"; do [[ -e ~/.config/"$f" ]] && cp -a ~/.config/"$f" "$backup/" done fi # Applying a color scheme or cursor rewrites more than the journal # covers (inline colors, GTK settings): remember what to re-apply. [[ -n "$(state_get theme colorscheme)" ]] || state_set theme colorscheme "$(current_colorscheme)" [[ -n "$(state_get theme cursor)" ]] || state_set theme cursor "$(kreadconfig6 --file kcminputrc --group Mouse --key cursorTheme --default breeze_cursors)" for f in ~/.config/gtk-3.0/settings.ini ~/.config/gtk-4.0/settings.ini; do [[ -f "$f" ]] && kset theme "$f" Settings gtk-cursor-theme-name \ "$(kreadconfig6 --file "$f" --group Settings --key gtk-cursor-theme-name)" done kset theme kdeglobals KDE LookAndFeelPackage "$VAPOR_LOOKANDFEEL" # Vapor is a dark theme: register it as the dark one, or Plasma's Dark # Mode toggle shows "off" and offers to switch to Breeze Dark. kset theme kdeglobals KDE DefaultDarkLookAndFeel "$VAPOR_LOOKANDFEEL" journal_vapor_defaults # Journal the key, then clear it: plasma-apply-colorscheme skips a # scheme that is already named in the config, without writing its colors. kset theme kdeglobals General ColorScheme --delete plasma-apply-colorscheme Vapor >/dev/null 2>&1 || true if [[ "$offline" == true ]]; then rm -f ~/.config/plasma-org.kde.plasma.desktop-appletsrc ~/.config/plasmashellrc extras_enable || warn "SteamOS desktop extras not installed (see above); the theme itself is on." ok "Vapor theme set; Plasma lays out the Vapor desktop at the first login." return 0 fi info "Applying the Vapor theme with its desktop and window layout..." # --resetLayout is "Desktop and window layout" in System Settings: the # SteamOS panel, launcher icon and wallpaper. It needs plasmashell up. lookandfeeltool -a "$VAPOR_LOOKANDFEEL" --resetLayout >/dev/null 2>&1 || { err "lookandfeeltool failed."; return 1; } # The new layout has a new launcher, without single user's settings, and the default taskbar pins. sleep 3 stop_plasmashell_for_edit taskbar_drop_discover single_status && single_launcher restart_plasmashell_if_stopped extras_enable || warn "SteamOS desktop extras not installed (see above); the theme itself is on." ok "Vapor theme active." } taskbar_drop_discover() { # Plasma's default taskbar pins Discover, which CachyOS doesn't install: the pin shows as a # blank icon. Pin what CachyOS's own layout pins (/etc/skel) instead. Not when Discover is # installed, and only on a taskbar that still has the default pins (no launchers key). # Edit only while plasmashell is stopped. [[ -e /usr/share/applications/org.kde.discover.desktop ]] && return 0 local applet grp for applet in $(plasma_applets org.kde.plasma.icontasks); do grp="Containments|${applet%%:*}|Applets|${applet#*:}|Configuration|General" [[ -n "$(kreadconfig6 --file plasma-org.kde.plasma.desktop-appletsrc --group Containments --group "${applet%%:*}" \ --group Applets --group "${applet#*:}" --group Configuration --group General --key launchers)" ]] && continue kset theme plasma-org.kde.plasma.desktop-appletsrc "$grp" launchers \ 'applications:systemsettings.desktop,preferred://filemanager,preferred://browser' done } theme_disable() { extras_disable info "Restoring the previous desktop look and layout..." plasma-apply-colorscheme "$(state_get theme colorscheme BreezeLight)" >/dev/null 2>&1 || true plasma-apply-cursortheme "$(state_get theme cursor breeze_cursors)" >/dev/null 2>&1 || true local backup="$STATE_DIR/theme-layout" f stop_plasmashell_for_edit if [[ -d "$backup" ]]; then for f in "${THEME_LAYOUT_FILES[@]}"; do rm -rf ~/.config/"$f" [[ -e "$backup/$f" ]] && cp -a "$backup/$f" ~/.config/ done rm -rf "$backup" fi # After the tools above, so keys that weren't set before are removed again. krevert theme if single_status; then single_launcher else single_launcher_drop fi restart_plasmashell_if_stopped qdbus6 org.kde.KWin /KWin org.kde.KWin.reconfigure >/dev/null 2>&1 || true if [[ -n "$(state_get theme installed_pkg)" ]]; then # Fails, and stays installed, when another package (e.g. # cachyos-handheld) depends on it. sudo pacman -R --noconfirm cachyos-vapor >/dev/null 2>&1 || info "Keeping cachyos-vapor installed (another package needs it)." fi state_clear theme ok "Previous desktop look restored." } # ----- lib/steamos-extras.sh ----- # SteamOS desktop extras that cachyos-vapor doesn't ship, taken from the # newest Valve steamdeck-kde-presets package: "Add to Steam" (file # right-click menu and launcher), Nested Desktop (Plasma as a game inside # gaming mode), the "Return to Gaming Mode" icon, the Steam Keyboard window # rule. Part of the SteamOS theme component # (lib/vapor-theme.sh). System files go to /usr/local (nothing # package-owned); per-user settings go through the undo journal. VALVE_MIRROR="https://steamdeck-packages.steamos.cloud/archlinux-mirror" NESTED_DIR=/usr/local/share/applications/steam/holo-nested-desktop EXTRAS_FILES=( /usr/local/bin/holo-add-to-steam /usr/local/bin/holo-nested-desktop /usr/local/share/kio/servicemenus/steam.desktop /usr/local/share/plasma/kickeractions/steam.desktop /usr/local/share/icons/hicolor/scalable/actions/gaming-return.svg "$NESTED_DIR" ) # Group name for our rule in kwinrulesrc; KWin accepts any unique id. STEAM_KEYBOARD_RULE=cachyos-gamescope-boot-steam-keyboard fetch_valve_presets() { # fetch_valve_presets : download and extract the newest # steamdeck-kde-presets into . The newest SteamOS release repo # (jupiter-3.9, jupiter-3.10, ...) is found on the mirror; its pacman # database gives the exact file name and SHA-256, which is verified. local dir="$1" repo desc file_name sha256 repo="$(curl -fsL "$VALVE_MIRROR/" | grep -oE 'jupiter-[0-9]+\.[0-9]+/' | tr -d / | sort -V | tail -n 1)" [[ -n "$repo" ]] && curl -fsL "$VALVE_MIRROR/$repo/os/x86_64/$repo.db" -o "$dir/repo.db" || { err "Couldn't read Valve's SteamOS package index ($VALVE_MIRROR)."; return 1; } desc="$(tar -tf "$dir/repo.db" 2>/dev/null | grep -E '^steamdeck-kde-presets-[0-9][^/]*/desc$' | head -n 1)" [[ -n "$desc" ]] || { err "No steamdeck-kde-presets in Valve's $repo repository."; return 1; } file_name="$(tar -xOf "$dir/repo.db" "$desc" | awk '/^%FILENAME%$/ { getline; print }')" sha256="$(tar -xOf "$dir/repo.db" "$desc" | awk '/^%SHA256SUM%$/ { getline; print }')" info "Downloading $file_name ($repo)..." curl -fsL "$VALVE_MIRROR/$repo/os/x86_64/$file_name" -o "$dir/presets.pkg.tar.zst" || { err "Downloading $file_name failed."; return 1; } echo "$sha256 $dir/presets.pkg.tar.zst" | sha256sum -c --quiet - || { err "Checksum mismatch for $file_name; not using it."; return 1; } tar -I unzstd -xf "$dir/presets.pkg.tar.zst" -C "$dir" usr/bin usr/share || { err "Extracting $file_name failed."; return 1; } } extras_enable() { info "Installing SteamOS desktop extras (Add to Steam, Nested Desktop, icons)..." local pkg for pkg in curl zstd kdialog; do # holo-add-to-steam and holo-nested-desktop show errors with kdialog. pacman -Q "$pkg" >/dev/null 2>&1 && continue sudo pacman -S --needed --noconfirm "$pkg" && state_set theme "installed_$pkg" 1 done local tmp_dir src tmp_dir="$(mktemp -d)" if ! fetch_valve_presets "$tmp_dir"; then rm -rf "$tmp_dir" return 1 fi src="$tmp_dir/usr" sudo install -D -m 755 "$src/bin/holo-add-to-steam" /usr/local/bin/holo-add-to-steam sudo install -D -m 755 "$src/bin/holo-nested-desktop" /usr/local/bin/holo-nested-desktop sudo install -D -m 644 "$src/share/kio/servicemenus/steam.desktop" /usr/local/share/kio/servicemenus/steam.desktop sudo install -D -m 644 "$src/share/plasma/kickeractions/steam.desktop" /usr/local/share/plasma/kickeractions/steam.desktop # gaming-return.svg is a symlink in the package; SteamOS uses it for the # Return to Gaming Mode shortcut on non-Deck hardware. sudo install -D -m 644 "$src/share/icons/hicolor/scalable/actions/steam-gaming-return.svg" \ /usr/local/share/icons/hicolor/scalable/actions/gaming-return.svg # Nested Desktop with its Steam library art; the entry points at the # art under /usr/share, which is where Valve puts it. sudo rm -rf "$NESTED_DIR" sudo mkdir -p "$(dirname "$NESTED_DIR")" sudo cp -r "$src/share/applications/steam/holo-nested-desktop" "$NESTED_DIR" sudo sed -i "s|/usr/share/applications/steam/holo-nested-desktop|$NESTED_DIR|" "$NESTED_DIR/holo-nested-desktop.desktop" rm -rf "$tmp_dir" sudo gtk-update-icon-cache -q -f -t /usr/local/share/icons/hicolor 2>/dev/null || true kbuildsycoca6 >/dev/null 2>&1 || true set_shortcut_icon gaming-return # Keep Steam's on-screen keyboard (Steam + X) above other windows and out # of the taskbar, like SteamOS's kwinrulesrc. local rules rule="$STEAM_KEYBOARD_RULE" rules="$(kreadconfig6 --file kwinrulesrc --group General --key rules)" if [[ ",$rules," != *",$rule,"* ]]; then kset theme kwinrulesrc "$rule" Description "Window settings for Steam Keyboard" kset theme kwinrulesrc "$rule" above true kset theme kwinrulesrc "$rule" aboverule 2 kset theme kwinrulesrc "$rule" skiptaskbar true kset theme kwinrulesrc "$rule" skiptaskbarrule 2 kset theme kwinrulesrc "$rule" title "Steam Keyboard" kset theme kwinrulesrc "$rule" titlematch 2 kset theme kwinrulesrc "$rule" type 16 kset theme kwinrulesrc "$rule" typerule 2 kset theme kwinrulesrc "$rule" wmclass steam kset theme kwinrulesrc "$rule" wmclasscomplete true kset theme kwinrulesrc "$rule" wmclassmatch 2 kset theme kwinrulesrc General rules "${rules:+$rules,}$rule" kset theme kwinrulesrc General count "$(( $(kreadconfig6 --file kwinrulesrc --group General --key count --default 0) + 1 ))" qdbus6 org.kde.KWin /KWin org.kde.KWin.reconfigure >/dev/null 2>&1 || true fi } extras_disable() { sudo rm -rf "${EXTRAS_FILES[@]}" sudo gtk-update-icon-cache -q -f -t /usr/local/share/icons/hicolor 2>/dev/null || true kbuildsycoca6 >/dev/null 2>&1 || true set_shortcut_icon steam local pkg for pkg in kdialog zstd curl; do [[ -n "$(state_get theme "installed_$pkg")" ]] && sudo pacman -R --noconfirm "$pkg" >/dev/null 2>&1 done # The keyboard rule is reverted by the theme's journal. return 0 } # ----- lib/bios.sh ----- # "Update BIOS" menu item, only on a Steam Machine and always opt-in: flashes # the newest Steam Machine BIOS from Valve's fremont-hw-support package with # fwupd. It's an action, not an on/off component: never preselected, never # re-applied, and there is nothing to turn off afterwards. BIOS_REPO_PREFIX=holo # WIZARD_BIOS_DRY_RUN=1 walks through the whole BIOS update (download, # checksum, both warnings) but never flashes: fwupd's device check is # skipped and the install is only printed. For testing, e.g. in a VM. BIOS_DRY_RUN="${WIZARD_BIOS_DRY_RUN:-}" bios_available() { detect_valve_fremont; } bios_status() { return 1; } bios_disable() { return 0; } bios_current() { cat /sys/class/dmi/id/bios_version 2>/dev/null || echo unknown } bios_lookup_newest() { # Sets BIOS_REPO, BIOS_PKG, BIOS_SHA256 and BIOS_NEWEST (e.g. F7F0108) # from the newest holo-X.Y repository on Valve's mirror: its .files # database names the firmware file, its .db gives the package checksum. # Only once per run, and with short timeouts so an offline machine # doesn't hold up the menu. [[ -n "${BIOS_LOOKED_UP:-}" ]] && return 0 BIOS_LOOKED_UP=1; BIOS_NEWEST="" local tmp desc BIOS_REPO="$(curl -fsL --max-time 10 "$VALVE_MIRROR/" | grep -oE "$BIOS_REPO_PREFIX-[0-9]+\.[0-9]+/" | tr -d / | sort -uV | tail -n 1)" [[ -n "$BIOS_REPO" ]] || return 1 tmp="$(mktemp -d)" if curl -fsL --max-time 30 "$VALVE_MIRROR/$BIOS_REPO/os/x86_64/$BIOS_REPO.files" -o "$tmp/files" && curl -fsL --max-time 30 "$VALVE_MIRROR/$BIOS_REPO/os/x86_64/$BIOS_REPO.db" -o "$tmp/db"; then desc="$(tar -tf "$tmp/files" 2>/dev/null | grep -E '^fremont-hw-support-[0-9][^/]*/files$' | head -n 1)" [[ -n "$desc" ]] && BIOS_CAB="$(tar -xOf "$tmp/files" "$desc" | grep -E '^usr/share/fwupd/.*\.cab$' | head -n 1)" desc="${desc%/files}/desc" BIOS_PKG="$(tar -xOf "$tmp/db" "$desc" 2>/dev/null | awk '/^%FILENAME%$/ { getline; print }')" BIOS_SHA256="$(tar -xOf "$tmp/db" "$desc" 2>/dev/null | awk '/^%SHA256SUM%$/ { getline; print }')" [[ -n "${BIOS_CAB:-}" && -n "$BIOS_PKG" && -n "$BIOS_SHA256" ]] && BIOS_NEWEST="$(basename "$BIOS_CAB" .cab)" fi rm -rf "$tmp" [[ -n "$BIOS_NEWEST" ]] } bios_selectable() { # Only when Valve has a newer BIOS than the one installed; the menu # greys the item out otherwise (up to date, or newest unknown/offline). [[ -z "${BIOS_NEEDS_RESTART:-}" && -n "${BIOS_NEWEST:-}" && "$BIOS_NEWEST" != "$(bios_current)" ]] } bios_label() { # Menu label with the current and the newest version; kept short so the # row (with "(not available)" when greyed out) fits 80 columns. local current current="$(bios_current)" if [[ -n "${BIOS_NEEDS_RESTART:-}" ]]; then echo "Update BIOS: $BIOS_NEWEST waits for a restart" elif [[ -z "${BIOS_NEWEST:-}" ]]; then echo "Update BIOS: now $current, newest unknown (offline?)" elif [[ "$BIOS_NEWEST" == "$current" ]]; then echo "Update BIOS: $current is up to date" else echo "Update BIOS: now $current, newest $BIOS_NEWEST (own risk)" fi } BIOS_BOX_WIDTH=68 bios_box_line() { # bios_box_line [text]: one line of the warning box, padded to the same # visible width (colour codes don't count) so the right edge lines up. local text="${1:-}" plain pad plain="$(printf '%b' "$text" | sed 's/\x1b\[[0-9;]*m//g')" pad=$(( BIOS_BOX_WIDTH - 6 - ${#plain} )) (( pad < 0 )) && pad=0 printf ' %b██%b %b%*s%b██%b\n' "$c_red" "$c_reset" "$text" "$pad" "" "$c_red" "$c_reset" } bios_box_border() { # Block characters: Konsole draws a long coloured row of # narrower # than the box's other lines, so its right edge wouldn't line up. printf ' %b%s%b\n' "$c_red" "$(printf '█%.0s' $(seq "$BIOS_BOX_WIDTH"))" "$c_reset" } bios_disclaimer() { # bios_disclaimer [line...]: the warning box; extra lines are # shown above the fixed risks. local title="$1" line b="$c_bold" n="$c_reset" shift echo bios_box_border bios_box_line bios_box_line "$c_red$b$title$n" bios_box_line bios_box_border for line in "$@"; do bios_box_line "$line"; done bios_box_line bios_box_line "- A failed or interrupted BIOS update can leave the machine" bios_box_line " unable to start (bricked). This wizard, CachyOS and Valve" bios_box_line " take no responsibility for that." bios_box_line "- ${b}NEVER turn off the power, unplug the machine or press${n}" bios_box_line " ${b}the power button while the update runs${n}, including the" bios_box_line " restart(s) afterwards, when the firmware is written." bios_box_line "- The screen can stay black for several minutes. Wait." bios_box_line "- Only on mains power, with all other programs closed." bios_box_line bios_box_border echo } bios_fits_device() { # bios_fits_device <cab>: does fwupd match this firmware to a device in # this machine? It compares the file's hardware IDs (GUIDs) with the # hardware; for a file that doesn't fit it reports an UpdateError. local details details="$(sudo fwupdmgr get-details --json "$1" 2>/dev/null)" || return 1 grep -q '"Guid"' <<< "$details" && ! grep -q '"UpdateError"' <<< "$details" } # The checked firmware waits here between bios_prepare and bios_flash, so the # app can show its warnings in between (and the terminal flow uses it too). BIOS_STAGE_DIR="${XDG_RUNTIME_DIR:-/tmp}/steamify-bios" bios_prepare() { # Downloads Valve's package, checks its SHA-256 and extracts the .cab to # $BIOS_STAGE_DIR, then asks fwupd whether it fits this machine. Sets # BIOS_COMPATIBLE (yes|dry-run). 0 = ready to flash, 2 = nothing to do # (already the newest), 1 = error (nothing was touched). if ! bios_lookup_newest; then err "Couldn't find the newest Steam Machine BIOS on Valve's mirror ($VALVE_MIRROR)." return 1 fi [[ -n "$BIOS_DRY_RUN" ]] && warn "DRY RUN (WIZARD_BIOS_DRY_RUN): nothing will be flashed." if [[ "$(bios_current)" == "$BIOS_NEWEST" ]]; then ok "The BIOS is already the newest version ($BIOS_NEWEST); nothing to do." return 2 fi pacman -Q fwupd >/dev/null 2>&1 || sudo pacman -S --needed --noconfirm fwupd || { err "Installing fwupd failed."; return 1; } rm -rf "$BIOS_STAGE_DIR"; mkdir -p "$BIOS_STAGE_DIR"; chmod 700 "$BIOS_STAGE_DIR" info "Downloading $BIOS_PKG ($BIOS_REPO)..." if ! curl -fsSL "$VALVE_MIRROR/$BIOS_REPO/os/x86_64/$BIOS_PKG" -o "$BIOS_STAGE_DIR/pkg.tar.zst" || ! echo "$BIOS_SHA256 $BIOS_STAGE_DIR/pkg.tar.zst" | sha256sum -c --quiet - || ! tar -I unzstd -xf "$BIOS_STAGE_DIR/pkg.tar.zst" -C "$BIOS_STAGE_DIR" "$BIOS_CAB"; then err "Downloading or verifying $BIOS_PKG failed; the BIOS was not touched." rm -rf "$BIOS_STAGE_DIR" return 1 fi ok "Checksum OK: this is Valve's $BIOS_PKG." if [[ -n "$BIOS_DRY_RUN" ]]; then warn "Dry run: skipping fwupd's check that the firmware fits this machine." BIOS_COMPATIBLE=dry-run elif bios_fits_device "$BIOS_STAGE_DIR/$BIOS_CAB"; then ok "fwupd confirms BIOS $BIOS_NEWEST is firmware for this machine." BIOS_COMPATIBLE=yes else err "fwupd says BIOS $BIOS_NEWEST is not for this machine's hardware; not installing it." rm -rf "$BIOS_STAGE_DIR" return 1 fi printf '%s\n' "$BIOS_NEWEST" "$BIOS_CAB" > "$BIOS_STAGE_DIR/ready" } bios_flash() { # Hands the firmware bios_prepare checked to fwupd; it is written during # the next restart. Sets BIOS_NEEDS_RESTART. local newest cab { read -r newest; read -r cab; } < "$BIOS_STAGE_DIR/ready" 2>/dev/null if [[ -z "${cab:-}" || ! -f "$BIOS_STAGE_DIR/$cab" ]]; then err "No checked BIOS file waiting; nothing was flashed." return 1 fi if [[ -n "$BIOS_DRY_RUN" ]]; then ok "Dry run: would run: fwupdmgr install -y --no-reboot-check $(basename "$cab")" ok "Dry run finished; nothing was flashed." # Treated as staged, so the restart choices that follow a real # update show up too; restarting only prints (see restart_now). BIOS_NEEDS_RESTART=1 rm -rf "$BIOS_STAGE_DIR" return 0 fi info "Handing BIOS $newest to fwupd. Do NOT turn off the power from now on." # -y: the user confirmed twice; --no-reboot-check: our own restart # question comes at the end. if ! sudo fwupdmgr install -y --no-reboot-check "$BIOS_STAGE_DIR/$cab"; then err "fwupd could not install the BIOS update (see above); the BIOS was not changed." rm -rf "$BIOS_STAGE_DIR" return 1 fi rm -rf "$BIOS_STAGE_DIR" BIOS_NEEDS_RESTART=1 ok "BIOS $newest is staged. It is written during the next restart:" warn "keep the power on and don't touch the machine until it has fully started again." } bios_enable() { local rc current compatible current="$(bios_current)" bios_prepare; rc=$? [[ $rc -eq 2 ]] && return 0 [[ $rc -ne 0 ]] && return 1 local yes="$c_green$c_bold" b="$c_bold" n="$c_reset" if [[ "$BIOS_COMPATIBLE" == dry-run ]]; then compatible="${c_yellow}${b}not checked${n} (dry run)" else compatible="${yes}yes${n} (checked by fwupd)" fi bios_disclaimer "WARNING: BIOS UPDATE - ENTIRELY AT YOUR OWN RISK" \ "Current BIOS: ${b}$current${n}" \ "New BIOS: ${b}$BIOS_NEWEST${n}" \ "Checksum: ${yes}OK${n} (Valve's package)" \ "Compatible: $compatible" if ! ask_yn "Do you understand the risks and want to continue?" n; then info "BIOS update cancelled; nothing was changed."; rm -rf "$BIOS_STAGE_DIR"; return 0 fi bios_disclaimer "LAST CHANCE: THIS FLASHES BIOS $BIOS_NEWEST" \ "After this, keep the power on until the machine has fully" \ "started again. Don't touch it, even if the screen is black." local reply read -rp "$(echo -e "${c_red}${c_bold}Type UPDATE (in capitals) to flash the BIOS, anything else cancels:${c_reset} ")" reply if [[ "$reply" != UPDATE ]]; then info "BIOS update cancelled; nothing was changed."; rm -rf "$BIOS_STAGE_DIR"; return 0 fi bios_flash } # For the app (lib/backend.sh): the flash step alone, after its warnings. bios_flash_only() { bios_flash; } # ----- lib/desktop-shortcut.sh ----- # "Return to Gaming Mode" desktop shortcut and the sudoers rule it needs. create_desktop_shortcut() { # Determine the home directory of the target user local user_home user_home="$(getent passwd "$TARGET_USER" | cut -d: -f6)" # Locate the correct Desktop directory local desktop_dir if [[ -f "$user_home/.config/user-dirs.dirs" ]]; then desktop_dir=$(grep '^XDG_DESKTOP_DIR=' "$user_home/.config/user-dirs.dirs" | cut -d '"' -f 2) desktop_dir="${desktop_dir/\$HOME/$user_home}" fi desktop_dir="${desktop_dir:-$user_home/Desktop}" mkdir -p "$desktop_dir" # On SDDM, steamos-session-select logging out is enough: Relogin=true # logs straight back in to the newly selected session, like SteamOS. # plasma-login-manager needs the sync bridge run and a restart, which # the shortcut can't sudo for interactively (Terminal=false), so it gets # a password-less rule scoped to exactly those two commands. local exec_line="steamos-session-select gamescope" if [[ "$LOGIN_MANAGER" == "plasmalogin" ]]; then exec_line="sh -c 'steamos-session-select gamescope && sudo -n /usr/bin/systemctl start sync-steamos-session.service && sudo -n /usr/bin/systemctl restart plasmalogin'" local sudoers_tmp sudoers_tmp="$(mktemp)" cat > "$sudoers_tmp" << EOF $TARGET_USER ALL=(root) NOPASSWD: /usr/bin/systemctl start sync-steamos-session.service, /usr/bin/systemctl restart plasmalogin EOF if sudo visudo -cf "$sudoers_tmp" >/dev/null; then sudo install -m 0440 -o root -g root "$sudoers_tmp" /etc/sudoers.d/gamescope-session-switch ok "Installed /etc/sudoers.d/gamescope-session-switch for the shortcut." else warn "Generated sudoers rule failed validation; the shortcut will not be able to restart plasmalogin." fi rm -f "$sudoers_tmp" else sudo rm -f /etc/sudoers.d/gamescope-session-switch fi # SteamOS itself uses Valve's "gaming-return" icon (Steam logo with a # return arrow; the Deck-style arrow is only used on Steam Deck # hardware). The theme installs it (lib/steamos-extras.sh) and updates # the shortcut via set_shortcut_icon; without it, use Steam's icon. local shortcut_icon="steam" [[ -f /usr/local/share/icons/hicolor/scalable/actions/gaming-return.svg ]] && shortcut_icon="gaming-return" # 2. Generate the .desktop shortcut with instant session switcher strings local shortcut_path="$desktop_dir/Return to Gaming Mode.desktop" info "Creating 'Return to Gaming Mode' desktop shortcut at: $shortcut_path" cat << EOF > "$shortcut_path" [Desktop Entry] Name=Return to Gaming Mode Comment=Switch session back to Gamescope Exec=$exec_line Icon=$shortcut_icon Terminal=false Type=Application Categories=System; EOF # Fix permissions for the target user and KDE Plasma desktop ecosystem chmod +x "$shortcut_path" ok "Desktop shortcut created successfully." } set_shortcut_icon() { # set_shortcut_icon <icon>: update an existing shortcut, if any. local shortcut shortcut="$(xdg-user-dir DESKTOP 2>/dev/null || echo "$HOME/Desktop")/Return to Gaming Mode.desktop" [[ -f "$shortcut" ]] && sed -i "s/^Icon=.*/Icon=$1/" "$shortcut" return 0 } remove_desktop_shortcut() { local desktop_dir desktop_dir="$(xdg-user-dir DESKTOP 2>/dev/null || echo "$HOME/Desktop")" rm -f "$desktop_dir/Return to Gaming Mode.desktop" sudo rm -f /etc/sudoers.d/gamescope-session-switch } # ----- lib/wizard-shortcut.sh ----- # "Steamify shortcut" menu item: "Steamify CachyOS" on the desktop and in the # app launcher opens the newest release of the app (curl | bash of # steamify-app.sh, like the install command in the README), so it's never out # of date; "Steamify Terminal" in the launcher opens the terminal menu in # Konsole the same way. WIZARD_RELEASE="https://github.com/theupriser/steamify-cachyos/releases/latest/download" WIZARD_URL="$WIZARD_RELEASE/steamify.sh" WIZARD_APP_URL="$WIZARD_RELEASE/steamify-app.sh" # Steam logo with a gear (assets/ in the repo, published with every release). WIZARD_ICON="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps/steamify.svg" WIZARD_LAUNCHER="$STEAMIFY_BIN/run-wizard" WIZARD_APP_LAUNCHER="$STEAMIFY_BIN/run-app" WIZARD_APPS="${XDG_DATA_HOME:-$HOME/.local/share}/applications" # steamify-ui: the id the app gives itself, so its window belongs to this # entry. steamify-app.sh leaves an entry with X-Steamify-Shortcut alone. WIZARD_DESKTOP_NAME="steamify-ui.desktop" WIZARD_APP_ENTRY="$WIZARD_APPS/$WIZARD_DESKTOP_NAME" WIZARD_TERMINAL_ENTRY="$WIZARD_APPS/steamify-terminal.desktop" # Before 2.0.1 the shortcut opened the terminal menu under this name. WIZARD_OLD_NAME="cachyos-gamescope-boot-wizard.desktop" wizard_desktop_dir() { xdg-user-dir DESKTOP 2>/dev/null || echo "$HOME/Desktop" } install_executable() { # install_executable <dest> <mode>: stdin to <dest>, with its mode set # before it appears. Plasma treats a desktop icon it saw while it wasn't # executable yet as untrusted and opens it in an editor. local dest="$1" tmp mkdir -p "$(dirname "$dest")" tmp="$(mktemp "$(dirname "$dest")/.tmp.XXXXXX")" cat > "$tmp" chmod "$2" "$tmp" mv -f "$tmp" "$dest" } launcher_status() { [[ -x "$WIZARD_APP_LAUNCHER" && -x "$WIZARD_LAUNCHER" && -f "$WIZARD_APP_ENTRY" && -f "$WIZARD_TERMINAL_ENTRY" ]] } # A shortcut from before 2.0.1 (terminal only): the menu ticks the item, so a # normal run replaces it. launcher_repair() { [[ -f "$WIZARD_APPS/$WIZARD_OLD_NAME" ]] && ! launcher_status; } launcher_enable() { info "Adding the Steamify shortcut (the app on the desktop and in the launcher, Steamify Terminal in the launcher)..." install_executable "$WIZARD_LAUNCHER" 755 << EOF #!/bin/bash # Installed by Steamify CachyOS: run its newest release. # pipefail: a failed download must count as a failure, not an empty script. set -o pipefail if ! curl -fsSL --max-time 30 "$WIZARD_URL" | bash; then echo echo "Couldn't download or run the wizard. Are you connected to the internet?" echo read -rp "Press Enter to close this window... " _ exit 1 fi # Done: close the window after a countdown (Enter closes it right away). echo for (( i = 10; i > 0; i-- )); do printf '\rClosing this window in %2d seconds (Enter to close now)... ' "\$i" read -rs -t 1 _ && break done echo EOF # The app, without a terminal. Only the first time, when PySide6 still # has to be installed, it runs in Konsole: sudo asks for the password there. install_executable "$WIZARD_APP_LAUNCHER" 755 << EOF #!/bin/bash # Installed by Steamify CachyOS: start the newest release of its app. set -o pipefail if [[ "\${1:-}" != --in-terminal ]] && ! python3 -c 'import PySide6.QtQml' 2>/dev/null; then exec konsole -e "\$0" --in-terminal fi if ! curl -fsSL --max-time 30 "$WIZARD_APP_URL" | bash; then msg="Couldn't download or start Steamify. Are you connected to the internet?" if [[ "\${1:-}" == --in-terminal ]]; then echo; echo "\$msg"; read -rp "Press Enter to close this window... " _ else kdialog --title Steamify --sorry "\$msg" 2>/dev/null; fi exit 1 fi EOF # The icon comes with the release; without it, Steam's own icon. local icon=steam tmp tmp="$(mktemp)" if curl -fsSL --max-time 20 "$WIZARD_RELEASE/steam-gaming-settings.svg" -o "$tmp" && grep -q '<svg' "$tmp"; then install -D -m 644 "$tmp" "$WIZARD_ICON" icon=steamify gtk-update-icon-cache -q -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" 2>/dev/null || true else warn "Couldn't download the shortcut's icon; using Steam's icon instead." fi rm -f "$tmp" local entry entry="[Desktop Entry] Type=Application Name=Steamify CachyOS Comment=Turn the SteamOS-style parts of this PC on or off Exec=$WIZARD_APP_LAUNCHER Icon=$icon Terminal=false Categories=Game;System;Settings; X-Steamify-Shortcut=true" printf '%s\n' "$entry" | install_executable "$WIZARD_APP_ENTRY" 644 printf '%s\n' "$entry" | install_executable "$(wizard_desktop_dir)/$WIZARD_DESKTOP_NAME" 755 printf '%s\n' "[Desktop Entry] Type=Application Name=Steamify Terminal Comment=Steamify CachyOS's menu in a terminal Exec=konsole -e $WIZARD_LAUNCHER Icon=$icon Terminal=false Categories=Game;System;Settings;" | install_executable "$WIZARD_TERMINAL_ENTRY" 644 # The terminal-only shortcut from before 2.0.1. rm -f "$(wizard_desktop_dir)/$WIZARD_OLD_NAME" "$WIZARD_APPS/$WIZARD_OLD_NAME" kbuildsycoca6 >/dev/null 2>&1 || true ok "Steamify shortcut added: \"Steamify CachyOS\" (the app) on the desktop and in the launcher, \"Steamify Terminal\" in the launcher." } launcher_disable() { # The launcher entry only if it's ours: steamify-app.sh writes its own # under the same name when there's no shortcut. grep -q '^X-Steamify-Shortcut=true' "$WIZARD_APP_ENTRY" 2>/dev/null && rm -f "$WIZARD_APP_ENTRY" rm -f "$(wizard_desktop_dir)/$WIZARD_DESKTOP_NAME" "$WIZARD_TERMINAL_ENTRY" \ "$(wizard_desktop_dir)/$WIZARD_OLD_NAME" "$WIZARD_APPS/$WIZARD_OLD_NAME" \ "$WIZARD_LAUNCHER" "$WIZARD_APP_LAUNCHER" "$WIZARD_ICON" rmdir "$(dirname "$WIZARD_LAUNCHER")" 2>/dev/null || true kbuildsycoca6 >/dev/null 2>&1 || true ok "Steamify shortcut removed." } # ----- lib/steam-game.sh ----- # "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/<id>/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, "<account>": "<appid> added" # or "<appid> 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. 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 <function>: runs it with Steam closed, then starts Steam # again if it was running (silently, in the tray). 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 systemd-run --user --collect -q steam -silent >/dev/null 2>&1 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." } # ----- lib/update-notifier.sh ----- # "Update notifications" menu item (notify), ticked by default: a systemd # user timer checks GitHub's newest release once a day and at each Plasma # login. When it's newer than the last Steamify that ran here, it shows a # notification (Open Steamify / Skip this version) and a tray icon until # then; it never updates anything itself (patches/steamify-notifier.py). NOTIFY_SCRIPT="$STEAMIFY_BIN/steamify-notifier" NOTIFY_UNITS="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" NOTIFY_NAME=steamify-update-check notify_status() { [[ -f "$NOTIFY_SCRIPT" ]] && user_systemctl is-enabled -q "$NOTIFY_NAME.timer" 2>/dev/null && user_systemctl is-enabled -q "$NOTIFY_NAME.service" 2>/dev/null } notify_seen() { # Every run records its version: the notifier compares the newest # release with it, so opening Steamify ends a reminder. Also whether it # was the app or the terminal menu: the notification opens the same. notify_status || return 0 state_set notify seen "$VERSION" if [[ "$BACKEND" == true ]]; then state_set notify frontend app; else state_set notify frontend terminal; fi } notify_enable() { local p missing=() info "Adding update notifications..." # PySide6 for the tray icon, notify-send for the notification's buttons. python3 -c 'import PySide6.QtWidgets' 2>/dev/null || missing+=(pyside6) command -v notify-send >/dev/null || missing+=(libnotify) if ((${#missing[@]})); then sudo pacman -S --needed --noconfirm "${missing[@]}" || { err "Installing ${missing[*]} failed."; return 1; } fi patch_file steamify-notifier.py | install_executable "$NOTIFY_SCRIPT" 755 || { err "Couldn't write $NOTIFY_SCRIPT."; return 1; } mkdir -p "$NOTIFY_UNITS" service_file "$NOTIFY_NAME.service" NOTIFY_SCRIPT="$NOTIFY_SCRIPT" > "$NOTIFY_UNITS/$NOTIFY_NAME.service" service_file "$NOTIFY_NAME.timer" > "$NOTIFY_UNITS/$NOTIFY_NAME.timer" user_systemctl daemon-reload user_systemctl enable "$NOTIFY_NAME.service" >/dev/null 2>&1 && user_systemctl enable --now "$NOTIFY_NAME.timer" >/dev/null 2>&1 || { err "Enabling the update check failed."; return 1; } state_set notify seen "$VERSION" ok "Update notifications on: you'll get a notification when there's a new Steamify." } notify_disable() { user_systemctl disable --now "$NOTIFY_NAME.timer" "$NOTIFY_NAME.service" >/dev/null 2>&1 rm -f "$NOTIFY_UNITS/$NOTIFY_NAME.service" "$NOTIFY_UNITS/$NOTIFY_NAME.timer" "$NOTIFY_SCRIPT" rmdir "$STEAMIFY_BIN" 2>/dev/null || true user_systemctl daemon-reload state_clear notify ok "Update notifications off." } # ----- lib/first-login.sh ----- # The ISO applies Steamify in the installer, before the user ever logged # in (steamify.sh --defaults without a session). What needs Plasma's first # layout is finished at the first desktop login, by a one-time autostart # that then opens the app, next to CachyOS Hello. FIRST_LOGIN_ENTRY="${XDG_CONFIG_HOME:-$HOME/.config}/autostart/steamify-first-login.desktop" FIRST_LOGIN_SCRIPT="$STEAMIFY_BIN/first-login" FIRST_LOGIN_BUNDLE="$STEAMIFY_BIN/first-login-steamify.sh" first_login_schedule() { # The newest release's Steamify runs at the first login. STEAMIFY_NO_DOWNLOAD (the ISO was started with # steamify.nodownload, to test an unreleased Steamify): this very bundle runs instead. # (Only a single-file bundle can be copied; SCRIPT_DIR doesn't exist in one.) local run self="${BASH_SOURCE[0]:-}" if [[ -n "${STEAMIFY_NO_DOWNLOAD:-}" && -f "$self" ]] && grep -q '^# Single-file build' "$self"; then install_executable "$FIRST_LOGIN_BUNDLE" 755 < "$self" run="bash \"$FIRST_LOGIN_BUNDLE\" --first-login; rm -f \"$FIRST_LOGIN_BUNDLE\"" else run="curl -fsSL --max-time 30 \"$WIZARD_URL\" | bash -s -- --first-login" fi install_executable "$FIRST_LOGIN_SCRIPT" 755 << EOF2 #!/bin/bash # Installed by Steamify CachyOS: runs once, at the first desktop login. set -o pipefail mkdir -p ~/.cache { $run; } > ~/.cache/steamify-first-login.log 2>&1 rm -f "$FIRST_LOGIN_ENTRY" "\$0" [[ -x "$WIZARD_APP_LAUNCHER" ]] && exec "$WIZARD_APP_LAUNCHER" EOF2 printf '%s\n' '[Desktop Entry]' 'Type=Application' 'Name=Steamify first login' \ "Exec=$FIRST_LOGIN_SCRIPT" 'OnlyShowIn=KDE;' 'X-KDE-autostart-phase=2' 'NoDisplay=true' | install_executable "$FIRST_LOGIN_ENTRY" 644 } first_login_run() { # plasmashell has just laid out the desktop from the look-and-feel # package; wait for its config, then put single user's launcher # settings on the new launcher. local _ for _ in $(seq 30); do [[ -n "$(plasma_applets org.kde.plasma.kickoff)" ]] && break sleep 2 done stop_plasmashell_for_edit taskbar_drop_discover single_status && single_launcher restart_plasmashell_if_stopped return 0 } # ----- lib/menu.sh ----- # Interactive menu: detects which components are on, lets the user pick what # they want, then turns components on or off to match. # 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 silent glyphs nvidia bigpicture nvsilent theme single launcher steamgame notify extended_controller_support 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 [silent]=gaming [glyphs]=gaming [bigpicture]=nvidia [nvsilent]=nvidia [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, # which has CEC like on SteamOS. HDMI refresh boost needs someone at the # screen to confirm each step. Extended controller support builds kernel modules for # every installed kernel: opt-in. NO_PRESELECT=(boot silent nvsilent cec kpin hdmi extended_controller_support) # Feature versions: the Steamify version in which what a component's enable # sets up last changed; set it to the new VERSION whenever you change one. # Each successful run records it (state "features"); a component that's on # with an older one is ticked and re-applied by a normal run, and the app # shows it as an update. A setup from before these were recorded counts as # FEATURE_BASELINE. FEATURE_BASELINE=2.1.0 declare -A FEATURE_VERSION=( [gaming]=2.9.0 [boot]=2.1.0 [silent]=2.11.0 [nvidia]=2.12.0 [bigpicture]=2.10.0 [nvsilent]=2.11.0 [theme]=2.1.0 [glyphs]=2.1.0 [single]=2.1.0 [launcher]=2.1.0 [cec]=2.7.0 [machine]=2.9.0 [poweroff]=2.2.0 [vram]=2.3.0 [notify]=2.5.0 [steamgame]=2.5.1 [extended_controller_support]=2.10.0 [kpin]=2.1.0 [hdmi]=2.1.0 ) declare -A LABEL=( [gaming]="SteamOS conversion: boot into gaming mode, Steam on the desktop" [boot]="Boot into the desktop instead of gaming mode" [nvidia]="Gaming on NVIDIA: Steam on the desktop, started at login" [silent]="Start Steam silently in desktop mode: in the tray at login on the desktop, no window" [nvsilent]="Start Steam silently: in the tray at login, no window (untick: Steam's normal window)" [bigpicture]="Steam starts in Big Picture: the controller-friendly Steam (untick: Steam's normal window)" [theme]="Install SteamOS theme: Vapor look (cachyos-vapor)" [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" [extended_controller_support]="Extended controller support: Xbox wireless dongle (xone), Xbox controllers over Bluetooth (xpadneo)" [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)" [machine]="Steam Machine support: LED bar driver, hardware settings in Steam" [poweroff]="Power-off fix: the Steam Machine stays off after shutting down" [kpin]="Pin the kernel to $PINNED_KERNEL_VER (untick for CachyOS's current kernel)" [hdmi]="HDMI refresh boost: highest refresh your HDMI display runs" [bios]="Update BIOS" ) declare -A CURRENT WANTED component_available() { case "$1" in # The SteamOS conversion is offered on NVIDIA PCs too: it installs the patched gamescope there (lib/gamescope-nvidia.sh) # and turns off "Gaming on NVIDIA" (lib/nvidia.sh), which is not offered any more (nvidia_available). nvidia|bigpicture|nvsilent) nvidia_available ;; machine|poweroff) machine_available ;; vram) vram_available ;; # Steamify in the Steam library is for gaming mode's controller: not needed for Big Picture on the desktop (NVIDIA PCs; # shown anyway when it's already on, so it can be turned off). steamgame) steamgame_available && { ! nvidia_present || steamgame_status; } ;; kpin) kpin_available ;; hdmi) hdmi_available ;; bios) bios_available ;; esac } is_action() { [[ " ${ACTIONS[*]} " == *" $1 "* ]]; } boot_mode() { # boot_mode 0|1: the "Boot into" choice as a word, for messages; "turn # off boot into the desktop" reads as the opposite of what was chosen. if [[ "$1" == 1 ]]; then echo desktop; else echo gamescope; fi } menu_visible() { # Shown in the menu. A sub-option ("Boot into", the kernel pin) only # while its parent is ticked. component_available "$1" || return 1 # Silent start is hidden while Steam starts in Big Picture. [[ "$1" == nvsilent && "${WANTED[bigpicture]:-0}" == 1 ]] && return 1 [[ -z "${PARENT[$1]:-}" || "${WANTED[${PARENT[$1]}]:-0}" == 1 ]] } feature_record_unticked() { # After a run the user confirmed: options shown but left unticked that # have no record yet count as turned off, or the next run would offer a # default one again as "new" (feature_new) and tick it: e.g. single user # mode unticked on a first run came back ticked when anything else # changed. Hidden sub-options weren't a choice, so they're left alone. local c for c in "${COMPONENTS[@]}"; do is_action "$c" && continue menu_visible "$c" || continue [[ "${WANTED[$c]:-0}" == 0 && -z "$(state_get features "$c")" ]] && state_set features "$c" off done return 0 } feature_record() { # feature_record <component> <enable|disable>: after a successful run. if [[ "$2" == enable ]]; then state_set features "$1" "${FEATURE_VERSION[$1]:-$FEATURE_BASELINE}" else state_set features "$1" off; fi } feature_outdated() { # On, but set up by an older version of that feature (compared as # versions: 2.10.0 is newer than 2.9.0). local have want # "Gaming on NVIDIA" is on: the SteamOS conversion replaces it, as an update of what the PC has (lib/nvidia.sh). [[ "$1" == gaming ]] && nvidia_migration_pending && return 0 [[ "${CURRENT[$1]:-0}" == 1 ]] || return 1 # NVIDIA: a newer patched gamescope is out (lib/gamescope-nvidia.sh): a normal run installs it. [[ "$1" == gaming ]] && nvidia_present && { gsn_update_pending || gsn_display_pending; } && return 0 have="$(state_get features "$1" "$FEATURE_BASELINE")" [[ "$have" == off ]] && have="$FEATURE_BASELINE" want="${FEATURE_VERSION[$1]:-$FEATURE_BASELINE}" [[ "$have" != "$want" && "$(printf '%s\n' "$have" "$want" | sort -V | head -n 1)" == "$have" ]] } menu_label() { # The label, with "(update)" after the name when a newer version of it # will be applied, "(new)" for a default sub-option added since the # last run. local l="${LABEL[$1]}" b="" if feature_outdated "$1"; then b="${c_yellow}(update)${c_reset}" elif feature_new "$1" || feature_new_optin "$1"; then b="${c_green}(new)${c_reset}"; fi if [[ -n "$b" ]]; then if [[ "$l" == *:* ]]; then l="${l%%:*} $b:${l#*:}" else l+=" $b"; fi fi printf '%s' "$l" } feature_added() { # An option added after its parent was set up (e.g. the power-off fix under # Steam Machine support; a top-level one counts the SteamOS conversion, or # "Gaming on NVIDIA" where that replaces it, as its parent), not on yet, # with no record: shown as "new". local p="${PARENT[$1]:-}" parent_on=0 if [[ -n "$p" ]]; then parent_on="${CURRENT[$p]:-0}" elif [[ "${CURRENT[gaming]:-0}" == 1 || "${CURRENT[nvidia]:-0}" == 1 ]]; then parent_on=1; fi [[ "$1" != gaming && "$parent_on" == 1 && "${CURRENT[$1]:-0}" == 0 ]] && ! is_action "$1" && component_selectable "$1" && [[ -z "$(state_get features "$1")" ]] } feature_new() { # A new default option: ticked for you (never turned on or off by itself). feature_added "$1" && [[ " ${NO_PRESELECT[*]} " != *" $1 "* ]] } feature_new_optin() { # A new opt-in option (unticked by default): the "new" badge only, it stays # unticked. Boot into is a choice row of the conversion, not an item. feature_added "$1" && [[ "$1" != boot && " ${NO_PRESELECT[*]} " == *" $1 "* ]] } component_selectable() { # Greyed out and not tickable when it has nothing to do. case "$1" in bios) bios_selectable ;; vram) vram_selectable ;; esac } detect_components() { local c any=false for c in "${COMPONENTS[@]}"; do CURRENT[$c]=0; WANTED[$c]=0; done for c in "${COMPONENTS[@]}"; do component_available "$c" || continue if "${c}_status"; then CURRENT[$c]=1; any=true; else CURRENT[$c]=0; fi WANTED[$c]=${CURRENT[$c]} done # HDMI-CEC set up by an older version: tick it, so a normal run fixes it. component_available cec && cec_repair && WANTED[cec]=1 # Updated features and new default sub-options: ticked, so a normal run # applies them. for c in "${COMPONENTS[@]}"; do component_available "$c" || continue { feature_outdated "$c" || feature_new "$c"; } && WANTED[$c]=1 done # "Gaming on NVIDIA" was on: its replacement, the SteamOS conversion, comes with the defaults of a first run, and the # PC starts the way it did: Steam in Big Picture at login becomes booting into gaming mode, any other Steam start # (a normal window, or silent in the tray) becomes booting into the desktop, with the silent start kept. if nvidia_migration_pending; then WANTED[glyphs]=1 if bigpicture_status; then WANTED[boot]=0 else WANTED[boot]=1 nvsilent_status && WANTED[silent]=1 fi fi # The kernel pin is no longer needed (the power-off fix), and HDMI # refresh boost needed the pin: whatever is left of either is unticked, # so a normal run removes it. component_available kpin && WANTED[kpin]=0 component_available hdmi && WANTED[hdmi]=0 # The terminal-only Steamify shortcut from before 2.0.1: tick it, so a # normal run replaces it with the app. launcher_repair && WANTED[launcher]=1 # Shows the current and newest BIOS version. bios_available && { bios_lookup_newest; LABEL[bios]="$(bios_label)"; } # Greyed out with an NVIDIA card: say why, and what to do. if component_available vram && ! vram_selectable; then VRAM_NVIDIA_CASE="$(vram_nvidia_case)" LABEL[vram]="VRAM booster: $(vram_nvidia_hint "$VRAM_NVIDIA_CASE")" fi # First run: preselect the full SteamOS experience (never an action). if [[ "$any" == false ]]; then for c in "${COMPONENTS[@]}"; do component_available "$c" && component_selectable "$c" && ! is_action "$c" && [[ " ${NO_PRESELECT[*]} " != *" $c "* ]] && WANTED[$c]=1 done machine_available && WANTED[cec]=1 fi } defaults_options() { # defaults_options [--options <id>[,<id>...]] [--boot gamescope|desktop]: # what --defaults sets up, from the Steam Machine ISO's installer page. # --options: exactly these items on, every other off (without it, the # menu's preselection). An item that's on brings its parent (single user # the conversion, like ticking it in the menu); one this PC can't use is # left off with a warning. --boot desktop needs the conversion. Returns 1 # on an unknown option, item or value. local c ids list=false boot="" while [[ $# -gt 0 ]]; do # Both take a value: without one, shift 2 fails and never shifts, so # the loop would spin forever. [[ "$1" == --options || "$1" == --boot ]] && [[ $# -lt 2 ]] && { err "$1 needs a value"; return 1; } case "$1" in --options) list=true IFS=, read -ra ids <<< "$2" shift 2 ;; --boot) case "$2" in gamescope|desktop) boot="$2" ;; *) err "--boot takes gamescope or desktop"; return 1 ;; esac shift 2 ;; *) err "Unknown option: $1"; return 1 ;; esac done if [[ "$list" == true ]]; then for c in "${ids[@]}"; do [[ -n "$c" ]] || continue [[ -n "${LABEL[$c]:-}" ]] || { err "Unknown item: $c"; return 1; } [[ "$c" == boot ]] && { err "Where to start is --boot, not an item."; return 1; } is_action "$c" && { err "$c can't be part of the install."; return 1; } done for c in "${COMPONENTS[@]}"; do WANTED[$c]=0; done for c in "${ids[@]}"; do [[ -n "$c" ]] || continue if component_available "$c" && component_selectable "$c"; then WANTED[$c]=1 else warn "Leaving out ${LABEL[$c]%%:*}: not available on this PC." fi done [[ "${WANTED[single]}" == 1 ]] && component_available gaming && WANTED[gaming]=1 for c in "${COMPONENTS[@]}"; do [[ "${WANTED[$c]}" == 1 && -n "${PARENT[$c]:-}" ]] && WANTED[${PARENT[$c]}]=1 done fi case "$boot" in desktop) [[ "${WANTED[gaming]}" == 1 ]] || { err "--boot desktop needs the SteamOS conversion (gaming)."; return 1; } WANTED[boot]=1 ;; gamescope) WANTED[boot]=0 ;; esac return 0 } defaults_list() { # --defaults --list: what --defaults can set up on this PC, as one JSON # array, for an installer page (the Steam Machine ISO builds its Steamify # page from it, so new items show up without a new ISO). Per item: id, # label, hint, kind (toggle, or choice for boot: on = desktop), parent, # on (preselected like a first run) and selectable. Actions, retired # items and items this PC can't use (e.g. no Steam account yet) are # left out. Read-only: no sudo, no state. local c kind on sel items="" for c in "${COMPONENTS[@]}"; do component_available "$c" || continue is_action "$c" && continue [[ "$c" == kpin || "$c" == hdmi ]] && continue kind=toggle; [[ "$c" == boot ]] && kind=choice sel=false; component_selectable "$c" && sel=true on=false [[ "$sel" == true && " ${NO_PRESELECT[*]} " != *" $c "* ]] && on=true [[ "$c" == cec ]] && machine_available && on=true items+="${items:+,}{\"id\":$(json_str "$c"),\"label\":$(json_str "${LABEL[$c]%%:*}")" items+=",\"hint\":$(json_str "$( [[ "${LABEL[$c]}" == *:* ]] && echo "${LABEL[$c]#*: }")")" items+=",\"kind\":\"$kind\",\"parent\":$(json_str "${PARENT[$c]:-}"),\"needs\":\"\",\"hideWhen\":$(json_str "$([[ "$c" == nvsilent ]] && echo bigpicture)"),\"on\":$on,\"selectable\":$sel}" done printf '[%s]\n' "$items" } toggle_component() { local c="$1" component_selectable "$c" || return 1 WANTED[$c]=$(( 1 - WANTED[$c] )) # Single user mode only makes sense on top of the SteamOS conversion. if [[ "$c" == single && "${WANTED[single]}" == 1 ]] && component_available gaming; then WANTED[gaming]=1; fi if [[ "$c" == gaming && "${WANTED[gaming]}" == 0 ]]; then WANTED[single]=0; WANTED[boot]=0; fi # Where to boot to is part of the conversion, too. if [[ "$c" == boot && "${WANTED[boot]}" == 1 ]]; then WANTED[gaming]=1; fi # Big Picture is opt-out: ticked along with "Gaming on NVIDIA". if [[ "$c" == nvidia ]]; then WANTED[bigpicture]=${WANTED[nvidia]}; fi if [[ "$c" == bigpicture && "${WANTED[bigpicture]}" == 1 ]]; then WANTED[nvidia]=1; WANTED[nvsilent]=0; fi # Silent and Big Picture exclude each other. if [[ "$c" == nvsilent && "${WANTED[nvsilent]}" == 1 ]]; then WANTED[nvidia]=1; WANTED[bigpicture]=0; fi if [[ "$c" == nvidia && "${WANTED[nvidia]}" == 0 ]]; then WANTED[nvsilent]=0; fi if [[ "$c" == gaming && "${WANTED[gaming]}" == 0 ]]; then WANTED[silent]=0; WANTED[glyphs]=0; fi if [[ "$c" == silent && "${WANTED[silent]}" == 1 ]]; then WANTED[gaming]=1; fi if [[ "$c" == glyphs && "${WANTED[glyphs]}" == 1 ]]; then WANTED[gaming]=1; fi # The power-off fix is opt-out: ticked along with Steam Machine support. if [[ "$c" == machine ]]; then WANTED[poweroff]=${WANTED[machine]}; fi if [[ "$c" == poweroff && "${WANTED[poweroff]}" == 1 ]]; then WANTED[machine]=1; fi 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() { local i=0 c now want echo gsn_fail_note printf " ${c_bold}%-3s %-6s %-6s %s${c_reset}\n" "#" "Now" "Want" "Component" MENU_ITEMS=() for c in "${COMPONENTS[@]}"; do menu_visible "$c" || continue i=$((i + 1)); MENU_ITEMS[$i]=$c # Pad the plain word, then colour it: colour codes would count as width. now="off"; [[ "${CURRENT[$c]}" == 1 ]] && now="on" is_action "$c" && now="-" now="$(printf '%-6s' "$now")" [[ "${CURRENT[$c]}" == 1 ]] && now="${now/on/${c_green}on${c_reset}}" want="[ ]"; [[ "${WANTED[$c]}" == 1 ]] && want="[x]" if [[ "$c" == boot ]]; then # A choice rather than a checkbox: Now/Want show the mode. now="gaming"; [[ "${CURRENT[boot]}" == 1 ]] && now="desk" [[ "${CURRENT[gaming]}" == 1 ]] || now="-" want="gaming"; [[ "${WANTED[boot]}" == 1 ]] && want="desk" printf " %-3s %-6s %-6s └ %s\n" "$i" "$now" "$want" "$(boot_choice "${WANTED[boot]}")" elif ! component_selectable "$c"; then local tree=""; [[ -n "${PARENT[$c]:-}" ]] && tree=" └ " printf " %b%-3s %-6s %-6s %s%s (not available)%b\n" "$c_dim" "$i" "$now" "$want" "$tree" "${LABEL[$c]}" "$c_reset" elif [[ -n "${PARENT[$c]:-}" ]]; then printf " %-3s %b %-6s └ %b\n" "$i" "$now" "$want" "$(menu_label "$c")" else printf " %-3s %b %-6s %b\n" "$i" "$now" "$want" "$(menu_label "$c")" fi done echo echo -e "$KERNEL_OVERVIEW" echo } run_menu() { # Sets REAPPLY. Returns 1 if the user quit. A checkbox list on a # terminal; a plain numbered prompt when input is piped (scripted runs). REAPPLY=false # Built once: the TUI redraws on every key press. KERNEL_OVERVIEW="$(kernel_overview)" if [[ -t 0 && -t 1 ]]; then run_menu_tui else run_menu_lines fi } draw_menu_tui() { local cursor="$1" i=0 c box state line printf '\033[H\033[2J' echo -e "${c_bold}Steamify CachyOS${c_reset} v$VERSION" echo "Pick what you want. Anything you untick is put back the way it was." echo MENU_ITEMS=() for c in "${COMPONENTS[@]}"; do menu_visible "$c" || continue MENU_ITEMS[$i]=$c box="[ ]"; [[ "${WANTED[$c]}" == 1 ]] && box="[${c_green}x${c_reset}]" state=" (now: off)"; [[ "${CURRENT[$c]}" == 1 ]] && state=" (now: ${c_green}on${c_reset})" is_action "$c" && state=" (opt-in, runs once)" line="$box ${LABEL[$c]}" if [[ "$c" == boot ]]; then # A choice rather than a checkbox; Space switches it. # Indented under the conversion, whose sub-option it is. line="$(boot_choice "${WANTED[boot]}")" line=" └ ${line/\[/[${c_green}}"; line="${line/\]/${c_reset}]}" local mode=gamescope; [[ "${CURRENT[boot]}" == 1 ]] && mode=desktop state=" (${c_bold}←/→${c_reset} choose)" [[ "${CURRENT[gaming]}" == 1 ]] && state=" (now: $mode; ${c_bold}←/→${c_reset} choose)" elif [[ -n "${PARENT[$c]:-}" ]]; then line=" └ $line" fi if ! component_selectable "$c"; then # Greyed out: nothing to do (e.g. BIOS already up to date). local mark=" " tree=""; (( i == cursor )) && mark=" ${c_cyan}>${c_reset} " [[ -n "${PARENT[$c]:-}" ]] && tree=" └ " echo -e "${mark}${c_dim}${tree}[ ] ${LABEL[$c]} (not available)${c_reset}" elif (( i == cursor )); then # The green x's reset would end the bold too: re-enable it after. echo -e " ${c_cyan}>${c_reset} ${c_bold}${line//"$c_reset"/"$c_reset$c_bold"}${c_reset}${state}" else echo -e " ${line}${state}" fi i=$((i + 1)) done echo echo -e "$KERNEL_OVERVIEW" echo echo -e " ${c_bold}Up/Down${c_reset} move ${c_bold}Space${c_reset} select ${c_bold}Left/Right${c_reset} choose ${c_bold}Enter${c_reset} run" echo -e " ${c_bold}a${c_reset} run + re-apply what's on ${c_bold}q${c_reset} quit" } run_menu_tui() { local cursor=0 key rest count tput civis 2>/dev/null trap 'tput cnorm 2>/dev/null' EXIT while true; do draw_menu_tui "$cursor" count=${#MENU_ITEMS[@]} # The list shrinks when the conversion (and its sub-option) is unticked. if (( cursor >= count )); then cursor=$(( count - 1 )); draw_menu_tui "$cursor"; fi IFS= read -rsn1 key if [[ "$key" == $'\e' ]]; then IFS= read -rsn2 -t 0.05 rest key+="$rest" fi case "$key" in $'\e[A'|k) (( cursor = (cursor + count - 1) % count )) ;; $'\e[B'|j) (( cursor = (cursor + 1) % count )) ;; " ") toggle_component "${MENU_ITEMS[$cursor]}" ;; # On the "Boot into" row: left = gamescope, right = desktop. $'\e[D'|h) [[ "${MENU_ITEMS[$cursor]}" == boot && "${WANTED[boot]}" == 1 ]] && toggle_component boot ;; $'\e[C'|l) [[ "${MENU_ITEMS[$cursor]}" == boot && "${WANTED[boot]}" == 0 ]] && toggle_component boot ;; "") break ;; a|A) REAPPLY=true; break ;; q|Q) tput cnorm 2>/dev/null; echo; return 1 ;; esac done tput cnorm 2>/dev/null echo return 0 } run_menu_lines() { local reply while true; do show_menu read -rp "$(echo -e "${c_bold}Type a number to toggle, Enter to continue, a = also re-apply what's on, q = quit:${c_reset} ")" reply || return 1 case "$reply" in "") return 0 ;; a|A) REAPPLY=true; return 0 ;; q|Q) return 1 ;; *) if [[ "$reply" =~ ^[0-9]+$ && -n "${MENU_ITEMS[$reply]:-}" ]]; then toggle_component "${MENU_ITEMS[$reply]}" || warn "Not available: ${LABEL[${MENU_ITEMS[$reply]}]}" else warn "Unknown choice: $reply" fi ;; esac done } plan_changes() { # Fills TO_DISABLE (reverse order) and TO_ENABLE. Switching single user # on or off moves gaming mode to the other login manager, so gaming # mode is re-applied then too. TO_DISABLE=(); TO_ENABLE=() local c i for (( i = ${#COMPONENTS[@]} - 1; i >= 0; i-- )); do c=${COMPONENTS[$i]} component_available "$c" || continue [[ "${CURRENT[$c]}" == 1 && "${WANTED[$c]}" == 0 ]] && TO_DISABLE+=("$c") done for c in "${COMPONENTS[@]}"; do component_available "$c" || continue [[ "${WANTED[$c]}" == 1 ]] || continue if is_action "$c"; then TO_ENABLE+=("$c"); continue; fi if [[ "${CURRENT[$c]}" == 0 || "$REAPPLY" == true ]] || feature_outdated "$c" || [[ "$c" == gaming && "${CURRENT[single]}" != "${WANTED[single]}" ]]; then TO_ENABLE+=("$c") fi done } apply_changes() { local c failed=() LOGIN_MANAGER=plasmalogin [[ "${WANTED[single]}" == 1 ]] && LOGIN_MANAGER=sddm for c in "${TO_DISABLE[@]}"; do if [[ "$c" == boot ]]; then echo; echo -e "${c_bold}Boot into: gamescope${c_reset}" else echo; echo -e "${c_bold}Turning off: ${LABEL[$c]}${c_reset}"; fi if "${c}_disable"; then feature_record "$c" disable; else failed+=("$c"); fi done for c in "${TO_ENABLE[@]}"; do # A sub-option of something that just failed isn't applied: it would be left on without its parent. if [[ -n "${PARENT[$c]:-}" && " ${failed[*]:-} " == *" ${PARENT[$c]} "* ]]; then warn "Skipping ${LABEL[$c]%%:*}: ${LABEL[${PARENT[$c]}]%%:*} didn't work." continue fi if [[ "$c" == boot ]]; then echo; echo -e "${c_bold}Boot into: desktop${c_reset}" elif is_action "$c"; then echo; echo -e "${c_bold}Running: ${LABEL[$c]%%:*}${c_reset}" else echo; echo -e "${c_bold}Turning on: ${LABEL[$c]}${c_reset}"; fi if "${c}_enable"; then is_action "$c" || feature_record "$c" enable; else failed+=("$c"); fi done feature_record_unticked os_version_refresh FAILED=("${failed[@]}") } # ----- lib/backend.sh ----- # Machine-readable mode for the graphical app (steamify-ui): the same # components and plan as the menu, as JSON on stdout. # steamify.sh --backend status # one JSON object: version, machine, and every menu item with its # current state and what a run would pick by default. # steamify.sh --backend apply [--reapply] [--boot gamescope|desktop] <id>... # turns on the listed components and off the others (like ticking them # in the menu), then streams one JSON object per line: plan, start, # log, done, finished. sudo asks through $SUDO_ASKPASS (no terminal). # steamify.sh --backend bios-prepare | bios-flash # the BIOS update in two steps, so the app shows both warnings between # them (bios-ready event: current, newest, checksum, compatible). json_str() { # json_str <text>: <text> as a JSON string (quotes, backslashes and # control characters escaped; colour codes dropped). local s="$1" s="$(printf '%s' "$s" | sed -E $'s/\x1b\\[[0-9;]*[A-Za-z]//g')" s="${s//\\/\\\\}"; s="${s//\"/\\\"}" s="${s//$'\t'/\\t}"; s="${s//$'\r'/}"; s="${s//$'\n'/\\n}" s="$(printf '%s' "$s" | tr -d '\000-\010\013\014\016-\037')" printf '"%s"' "$s" } json_list() { # json_list <item>...: a JSON array of strings. local out="" x for x in "$@"; do out+="${out:+,}$(json_str "$x")"; done printf '[%s]' "$out" } backend_event() { # backend_event <event> [<json fields without braces>] printf '{"event":%s%s}\n' "$(json_str "$1")" "${2:+,$2}" } backend_status() { local c first_run=true items="" kind parent now detect_components for c in "${COMPONENTS[@]}"; do component_available "$c" && ! is_action "$c" && [[ "${CURRENT[$c]}" == 1 ]] && first_run=false done for c in "${COMPONENTS[@]}"; do component_available "$c" || continue kind=toggle; is_action "$c" && kind=action; [[ "$c" == boot ]] && kind=choice parent="${PARENT[$c]:-}" now="${CURRENT[$c]:-0}" items+="${items:+,}{\"id\":$(json_str "$c"),\"label\":$(json_str "${LABEL[$c]%%:*}")" items+=",\"hint\":$(json_str "$( [[ "${LABEL[$c]}" == *:* ]] && echo "${LABEL[$c]#*: }")")" items+=",\"kind\":\"$kind\",\"parent\":$(json_str "$parent")" items+=",\"needs\":\"\"" items+=",\"on\":$( [[ "$now" == 1 ]] && echo true || echo false)" items+=",\"wanted\":$( [[ "${WANTED[$c]:-0}" == 1 ]] && echo true || echo false)" items+=",\"update\":$(feature_outdated "$c" && echo true || echo false)" items+=",\"new\":$({ feature_new "$c" || feature_new_optin "$c"; } && echo true || echo false)" items+=",\"selectable\":$(component_selectable "$c" && echo true || echo false)" # Why it can't be turned on, for the app's explanation. [[ "$c" == vram && -n "${VRAM_NVIDIA_CASE:-}" ]] && items+=",\"note\":$(json_str "$(vram_nvidia_note "$VRAM_NVIDIA_CASE")")" items+="}" done local cec="" f for f in /dev/cec*; do [[ -e "$f" ]] && cec+="${cec:+ }$(basename "$f")"; done local bios='null' if bios_available; then bios="{\"current\":$(json_str "$(bios_current)"),\"newest\":$(json_str "${BIOS_NEWEST:-}"),\"selectable\":$(bios_selectable && echo true || echo false),\"dryRun\":$([[ -n "$BIOS_DRY_RUN" ]] && echo true || echo false)}" fi printf '{"version":%s,"firstRun":%s,"steamMachine":%s,"kernel":%s,"pinnedKernel":%s,"cecDevices":%s,"leds":%s,"bios":%s,"items":[%s]}\n' \ "$(json_str "$VERSION")" "$first_run" \ "$(detect_valve_fremont && echo true || echo false)" \ "$(json_str "$(uname -r)")" "$(json_str "${PINNED_KERNEL_VER:-}")" \ "$(json_str "$cec")" \ "$(compgen -G '/sys/class/leds/valve-leds*' | wc -l)" \ "$bios" "$items" } backend_run_component() { # backend_run_component <id> <enable|disable>: runs it in this shell # (its variables, e.g. RESTART_FOR_LOGIN, must survive), each output line # as a log event. local c="$1" what="$2" rc backend_event start "\"id\":$(json_str "$c"),\"action\":\"$what\",\"label\":$(json_str "${LABEL[$c]%%:*}")" "${c}_${what}" > >(while IFS= read -r line; do backend_event log "\"id\":$(json_str "$c"),\"line\":$(json_str "$line")"; done) 2>&1 rc=$? # Let the log reader finish before the next event. wait $! 2>/dev/null backend_event "done" "\"id\":$(json_str "$c"),\"ok\":$([[ $rc -eq 0 ]] && echo true || echo false)" return $rc } backend_sudo() { # sudo from the app has no terminal: ask through its helper, and keep the # credentials fresh as the menu does. if [[ -z "${SUDO_ASKPASS:-}" ]] && ! sudo -n true 2>/dev/null; then backend_event finished '"failed":[],"restart":false,"error":"no-sudo"' return 1 fi sudo() { command sudo ${SUDO_ASKPASS:+-A} "$@"; } if ! sudo -n true 2>/dev/null && ! sudo true 2>/dev/null; then backend_event finished '"failed":[],"restart":false,"error":"wrong-password"' return 1 fi while true; do sudo -n true; sleep 50; kill -0 "$$" 2>/dev/null || exit; done 2>/dev/null & } backend_bios_prepare() { # Download, checksum and fwupd's device check; the app then shows its # two warnings and calls bios-flash. local rc backend_sudo || return 1 backend_event start '"id":"bios","action":"prepare","label":"Update BIOS"' bios_prepare > >(while IFS= read -r line; do backend_event log "\"id\":\"bios\",\"line\":$(json_str "$line")"; done) 2>&1 rc=$?; wait $! 2>/dev/null case $rc in 0) backend_event bios-ready "\"current\":$(json_str "$(bios_current)"),\"newest\":$(json_str "$BIOS_NEWEST"),\"checksum\":true,\"compatible\":$(json_str "$BIOS_COMPATIBLE")" ;; 2) backend_event finished '"failed":[],"restart":false,"nothing":true' ;; *) backend_event finished '"failed":["bios"],"restart":false' ;; esac } backend_bios_flash() { backend_sudo || return 1 local rc backend_run_component bios flash_only; rc=$? backend_event finished "\"failed\":$([[ $rc -eq 0 ]] && echo '[]' || echo '["bios"]'),\"restart\":$(restart_needed && echo true || echo false),\"bios\":true" } backend_apply() { local reapply=false boot="" id c local -a want=() while [[ $# -gt 0 ]]; do case "$1" in --reapply) reapply=true ;; --boot) boot="$2"; shift ;; *) want+=("$1") ;; esac shift done detect_components # What the app ticked: exactly these on, every other component off. for c in "${COMPONENTS[@]}"; do component_available "$c" || continue is_action "$c" && { WANTED[$c]=0; continue; } WANTED[$c]=0 for id in "${want[@]}"; do [[ "$id" == "$c" ]] && WANTED[$c]=1; done done # The same rules as the menu's toggles. [[ "${WANTED[single]:-0}" == 1 ]] && component_available gaming && WANTED[gaming]=1 if component_available gaming; then [[ "${WANTED[gaming]:-0}" == 0 ]] && { WANTED[single]=0; WANTED[boot]=0; } else WANTED[boot]=0 fi [[ "${WANTED[bigpicture]:-0}" == 1 ]] && WANTED[nvidia]=1 [[ "${WANTED[nvsilent]:-0}" == 1 ]] && WANTED[nvidia]=1 [[ "${WANTED[bigpicture]:-0}" == 1 ]] && WANTED[nvsilent]=0 [[ "${WANTED[nvidia]:-0}" == 0 ]] && { WANTED[bigpicture]=0; WANTED[nvsilent]=0; } [[ "${WANTED[silent]:-0}" == 1 || "${WANTED[glyphs]:-0}" == 1 ]] && component_available gaming && WANTED[gaming]=1 [[ "${WANTED[gaming]:-0}" == 0 ]] && { WANTED[silent]=0; WANTED[glyphs]=0; } [[ "${WANTED[poweroff]:-0}" == 1 ]] && WANTED[machine]=1 [[ "${WANTED[machine]:-0}" == 0 ]] && WANTED[poweroff]=0 [[ "${WANTED[kpin]:-0}" == 1 ]] && WANTED[machine]=1 [[ "${WANTED[machine]:-0}" == 0 ]] && WANTED[kpin]=0 # Retired: only ever removed. WANTED[hdmi]=0 case "$boot" in desktop) WANTED[boot]=1; WANTED[gaming]=1 ;; gamescope) WANTED[boot]=0 ;; esac REAPPLY=$reapply plan_changes backend_event plan "\"disable\":$(json_list "${TO_DISABLE[@]}"),\"enable\":$(json_list "${TO_ENABLE[@]}")" if [[ ${#TO_DISABLE[@]} -eq 0 && ${#TO_ENABLE[@]} -eq 0 ]]; then feature_record_unticked backend_event finished '"failed":[],"restart":false,"nothing":true' return 0 fi backend_sudo || return 1 LOGIN_MANAGER=plasmalogin [[ "${WANTED[single]}" == 1 ]] && LOGIN_MANAGER=sddm local -a failed=() for c in "${TO_DISABLE[@]}"; do if backend_run_component "$c" disable; then feature_record "$c" disable; else failed+=("$c"); fi done for c in "${TO_ENABLE[@]}"; do # A sub-option of something that just failed isn't applied: it would be left on without its parent. if [[ -n "${PARENT[$c]:-}" && " ${failed[*]:-} " == *" ${PARENT[$c]} "* ]]; then backend_event log "\"id\":$(json_str "$c"),\"line\":$(json_str "Skipped: ${LABEL[${PARENT[$c]}]%%:*} didn't work.")" failed+=("$c") continue fi if backend_run_component "$c" enable; then is_action "$c" || feature_record "$c" enable; else failed+=("$c"); fi done feature_record_unticked os_version_refresh [[ " ${TO_DISABLE[*]} ${TO_ENABLE[*]} " =~ \ (gaming|single|boot|kpin)\ ]] && RESTART_FOR_LOGIN=true backend_event finished "\"failed\":$(json_list "${failed[@]}"),\"restart\":$(restart_needed && echo true || echo false)" } backend_main() { case "${1:-}" in status) backend_status ;; apply) shift; backend_apply "$@" ;; bios-prepare) backend_bios_prepare ;; bios-flash) backend_bios_flash ;; *) err "Usage: steamify.sh --backend status | apply [--reapply] [--boot gamescope|desktop] <id>..."; return 2 ;; esac } # ----- patches/ ----- patch_file() { case "$1" in cros-ec-cec-single-port.patch) cat << 'STEAMIFY_PATCH_EOF' Steam Machine CEC: find amdgpu's HDMI port with a single CEC port. amdgpu registers its HDMI notifier without a port name, which only matches the driver's named lookup ("Port C") if the CEC driver loaded first; amdgpu loads from the initramfs, so it never does and /dev/cec0 stays at f.f.f.f. With a single CEC port, look the notifier up by device alone. Applies to Valve's drivers/media/cec/platform/cros-ec/cros-ec-cec.c (evlaV/linux-integration 10c8c880, see CEC_DRIVER_URL in lib/cec.sh). --- a/cros-ec-cec.c +++ b/cros-ec-cec.c @@ -485,7 +485,7 @@ goto out_probe_adapter; } - port->notify = cec_notifier_cec_adap_register(hdmi_dev, conns[port_num], + port->notify = cec_notifier_cec_adap_register(hdmi_dev, conns[1] ? conns[port_num] : NULL, port->adap); if (!port->notify) { ret = -ENOMEM; STEAMIFY_PATCH_EOF ;; cros-ec-cec.c) cat << 'STEAMIFY_PATCH_EOF' // SPDX-License-Identifier: GPL-2.0+ /* * CEC driver for ChromeOS Embedded Controller * * Copyright (c) 2018 BayLibre, SAS * Author: Neil Armstrong <narmstrong@baylibre.com> */ #include <linux/kernel.h> #include <linux/module.h> #include <linux/mod_devicetable.h> #include <linux/platform_device.h> #include <linux/dmi.h> #include <linux/pci.h> #include <linux/cec.h> #include <linux/slab.h> #include <linux/interrupt.h> #include <linux/platform_data/cros_ec_commands.h> #include <linux/platform_data/cros_ec_proto.h> #include <media/cec.h> #include <media/cec-notifier.h> #define DRV_NAME "cros-ec-cec" /** * struct cros_ec_cec_port - Driver data for a single EC CEC port * * @port_num: port number * @adap: CEC adapter * @notify: CEC notifier pointer * @rx_msg: storage for a received message * @cros_ec_cec: pointer to the parent struct */ struct cros_ec_cec_port { int port_num; struct cec_adapter *adap; struct cec_notifier *notify; struct cec_msg rx_msg; struct cros_ec_cec *cros_ec_cec; }; /** * struct cros_ec_cec - Driver data for EC CEC * * @cros_ec: Pointer to EC device * @notifier: Notifier info for responding to EC events * @write_cmd_version: Highest supported version of EC_CMD_CEC_WRITE_MSG. * @num_ports: Number of CEC ports * @ports: Array of ports */ struct cros_ec_cec { struct cros_ec_device *cros_ec; struct notifier_block notifier; int write_cmd_version; int num_ports; struct cros_ec_cec_port *ports[EC_CEC_MAX_PORTS]; }; static void cros_ec_cec_received_message(struct cros_ec_cec_port *port, uint8_t *msg, uint8_t len) { if (len > CEC_MAX_MSG_SIZE) len = CEC_MAX_MSG_SIZE; port->rx_msg.len = len; memcpy(port->rx_msg.msg, msg, len); cec_received_msg(port->adap, &port->rx_msg); } static void handle_cec_message(struct cros_ec_cec *cros_ec_cec) { struct cros_ec_device *cros_ec = cros_ec_cec->cros_ec; uint8_t *cec_message = cros_ec->event_data.data.cec_message; unsigned int len = cros_ec->event_size; struct cros_ec_cec_port *port; /* * There are two ways of receiving CEC messages: * 1. Old EC firmware which only supports one port sends the data in a * cec_message MKBP event. * 2. New EC firmware which supports multiple ports uses * EC_MKBP_CEC_HAVE_DATA to notify that data is ready and * EC_CMD_CEC_READ_MSG to read it. * Check that the EC only has one CEC port, and then we can assume the * message is from port 0. */ if (cros_ec_cec->num_ports != 1) { dev_err(cros_ec->dev, "received cec_message on device with %d ports\n", cros_ec_cec->num_ports); return; } port = cros_ec_cec->ports[0]; cros_ec_cec_received_message(port, cec_message, len); } static void cros_ec_cec_read_message(struct cros_ec_cec_port *port) { struct cros_ec_device *cros_ec = port->cros_ec_cec->cros_ec; struct ec_params_cec_read params = { .port = port->port_num, }; struct ec_response_cec_read response; int ret = 0; int count = 0; /* NOTE read as many messages as we can, return value from cros_ec_cmd * here should either equal to the bytes transferred or a negative * error code, so as long as we get positive integers we are * reading messages! */ do { ret = cros_ec_cmd(cros_ec, 0, EC_CMD_CEC_READ_MSG, ¶ms, sizeof(params), &response, sizeof(response)); if (ret > 0) { ++count; cros_ec_cec_received_message(port, response.msg, response.msg_len); } } while (ret > 0); if (ret < 0 && count == 0) { dev_err(cros_ec->dev, "error reading CEC message on EC: %d\n", ret); } } static void handle_cec_event(struct cros_ec_cec *cros_ec_cec) { struct cros_ec_device *cros_ec = cros_ec_cec->cros_ec; uint32_t cec_events = cros_ec->event_data.data.cec_events; uint32_t port_num = EC_MKBP_EVENT_CEC_GET_PORT(cec_events); uint32_t events = EC_MKBP_EVENT_CEC_GET_EVENTS(cec_events); struct cros_ec_cec_port *port; if (port_num >= cros_ec_cec->num_ports) { dev_err(cros_ec->dev, "received CEC event for invalid port %d\n", port_num); return; } port = cros_ec_cec->ports[port_num]; if (events & EC_MKBP_CEC_SEND_OK) cec_transmit_attempt_done(port->adap, CEC_TX_STATUS_OK); /* FW takes care of all retries, tell core to avoid more retries */ if (events & EC_MKBP_CEC_SEND_FAILED) cec_transmit_attempt_done(port->adap, CEC_TX_STATUS_MAX_RETRIES | CEC_TX_STATUS_NACK); if (events & EC_MKBP_CEC_HAVE_DATA) cros_ec_cec_read_message(port); } static int cros_ec_cec_event(struct notifier_block *nb, unsigned long queued_during_suspend, void *_notify) { struct cros_ec_cec *cros_ec_cec; struct cros_ec_device *cros_ec; cros_ec_cec = container_of(nb, struct cros_ec_cec, notifier); cros_ec = cros_ec_cec->cros_ec; if (cros_ec->event_data.event_type == EC_MKBP_EVENT_CEC_EVENT) { handle_cec_event(cros_ec_cec); return NOTIFY_OK; } if (cros_ec->event_data.event_type == EC_MKBP_EVENT_CEC_MESSAGE) { handle_cec_message(cros_ec_cec); return NOTIFY_OK; } return NOTIFY_DONE; } static int cros_ec_cec_set_log_addr(struct cec_adapter *adap, u8 logical_addr) { struct cros_ec_cec_port *port = adap->priv; struct cros_ec_cec *cros_ec_cec = port->cros_ec_cec; struct cros_ec_device *cros_ec = cros_ec_cec->cros_ec; struct ec_params_cec_set params = { .cmd = CEC_CMD_LOGICAL_ADDRESS, .port = port->port_num, .val = logical_addr, }; int ret; ret = cros_ec_cmd(cros_ec, 0, EC_CMD_CEC_SET, ¶ms, sizeof(params), NULL, 0); if (ret < 0) { dev_err(cros_ec->dev, "error setting CEC logical address on EC: %d\n", ret); return ret; } return 0; } static int cros_ec_cec_transmit(struct cec_adapter *adap, u8 attempts, u32 signal_free_time, struct cec_msg *cec_msg) { struct cros_ec_cec_port *port = adap->priv; struct cros_ec_cec *cros_ec_cec = port->cros_ec_cec; struct cros_ec_device *cros_ec = cros_ec_cec->cros_ec; struct ec_params_cec_write params; struct ec_params_cec_write_v1 params_v1; int ret; if (cros_ec_cec->write_cmd_version == 0) { memcpy(params.msg, cec_msg->msg, cec_msg->len); ret = cros_ec_cmd(cros_ec, 0, EC_CMD_CEC_WRITE_MSG, ¶ms, cec_msg->len, NULL, 0); } else { params_v1.port = port->port_num; params_v1.msg_len = cec_msg->len; memcpy(params_v1.msg, cec_msg->msg, cec_msg->len); ret = cros_ec_cmd(cros_ec, cros_ec_cec->write_cmd_version, EC_CMD_CEC_WRITE_MSG, ¶ms_v1, sizeof(params_v1), NULL, 0); } if (ret < 0) { dev_err(cros_ec->dev, "error writing CEC msg on EC: %d\n", ret); return ret; } return 0; } static int cros_ec_cec_adap_enable(struct cec_adapter *adap, bool enable) { struct cros_ec_cec_port *port = adap->priv; struct cros_ec_cec *cros_ec_cec = port->cros_ec_cec; struct cros_ec_device *cros_ec = cros_ec_cec->cros_ec; struct ec_params_cec_set params = { .cmd = CEC_CMD_ENABLE, .port = port->port_num, .val = enable, }; int ret; ret = cros_ec_cmd(cros_ec, 0, EC_CMD_CEC_SET, ¶ms, sizeof(params), NULL, 0); if (ret < 0) { dev_err(cros_ec->dev, "error %sabling CEC on EC: %d\n", (enable ? "en" : "dis"), ret); return ret; } return 0; } static const struct cec_adap_ops cros_ec_cec_ops = { .adap_enable = cros_ec_cec_adap_enable, .adap_log_addr = cros_ec_cec_set_log_addr, .adap_transmit = cros_ec_cec_transmit, }; #ifdef CONFIG_PM_SLEEP static int cros_ec_cec_suspend(struct device *dev) { struct platform_device *pdev = to_platform_device(dev); struct cros_ec_cec *cros_ec_cec = dev_get_drvdata(&pdev->dev); if (device_may_wakeup(dev)) enable_irq_wake(cros_ec_cec->cros_ec->irq); return 0; } static int cros_ec_cec_resume(struct device *dev) { struct platform_device *pdev = to_platform_device(dev); struct cros_ec_cec *cros_ec_cec = dev_get_drvdata(&pdev->dev); if (device_may_wakeup(dev)) disable_irq_wake(cros_ec_cec->cros_ec->irq); return 0; } #endif static SIMPLE_DEV_PM_OPS(cros_ec_cec_pm_ops, cros_ec_cec_suspend, cros_ec_cec_resume); #if IS_ENABLED(CONFIG_PCI) && IS_ENABLED(CONFIG_DMI) /* * Specify the DRM device name handling the HDMI output and the HDMI connector * corresponding to each CEC port. The order of connectors must match the order * in the EC (first connector is EC port 0, ...), and the number of connectors * must match the number of ports in the EC (which can be queried using the * EC_CMD_CEC_PORT_COUNT host command). */ struct cec_dmi_match { const char *sys_vendor; const char *product_name; const char *devname; const char *const *conns; }; static const char *const port_b_conns[] = { "Port B", NULL }; static const char *const port_c_conns[] = { "Port C", NULL }; static const char *const port_db_conns[] = { "Port D", "Port B", NULL }; static const char *const port_ba_conns[] = { "Port B", "Port A", NULL }; static const char *const port_ab_conns[] = { "Port A", "Port B", NULL }; static const char *const port_d_conns[] = { "Port D", NULL }; static const struct cec_dmi_match cec_dmi_match_table[] = { /* AMD Lilac */ { "AMD", "Lilac", "0000:03:00.0", port_c_conns }, /* Valve Fremont */ { "OEM", "F7F", "0000:03:00.0", port_c_conns }, { "Valve", "Fremont", "0000:03:00.0", port_c_conns }, /* Google Fizz */ { "Google", "Fizz", "0000:00:02.0", port_b_conns }, /* Google Brask */ { "Google", "Brask", "0000:00:02.0", port_b_conns }, /* Google Moli */ { "Google", "Moli", "0000:00:02.0", port_b_conns }, /* Google Kinox */ { "Google", "Kinox", "0000:00:02.0", port_b_conns }, /* Google Kuldax */ { "Google", "Kuldax", "0000:00:02.0", port_b_conns }, /* Google Aurash */ { "Google", "Aurash", "0000:00:02.0", port_b_conns }, /* Google Gladios */ { "Google", "Gladios", "0000:00:02.0", port_b_conns }, /* Google Lisbon */ { "Google", "Lisbon", "0000:00:02.0", port_b_conns }, /* Google Dibbi */ { "Google", "Dibbi", "0000:00:02.0", port_db_conns }, /* Google Constitution */ { "Google", "Constitution", "0000:00:02.0", port_ba_conns }, /* Google Boxy */ { "Google", "Boxy", "0000:00:02.0", port_d_conns }, /* Google Taranza */ { "Google", "Taranza", "0000:00:02.0", port_db_conns }, /* Google Dexi */ { "Google", "Dexi", "0000:00:02.0", port_db_conns }, /* Google Dita */ { "Google", "Dita", "0000:00:02.0", port_db_conns }, /* Google Dirks */ { "Google", "Dirks", "0000:00:02.0", port_ab_conns }, /* Google Moxie */ { "Google", "Moxie", "0000:00:02.0", port_b_conns }, /* AMD Lilac */ { "AMD", "Lilac", "0000:06:00.0", port_c_conns }, }; static struct device *cros_ec_cec_find_hdmi_dev(struct device *dev, const char * const **conns) { int i; for (i = 0 ; i < ARRAY_SIZE(cec_dmi_match_table) ; ++i) { const struct cec_dmi_match *m = &cec_dmi_match_table[i]; if (dmi_match(DMI_SYS_VENDOR, m->sys_vendor) && dmi_match(DMI_PRODUCT_NAME, m->product_name)) { struct device *d; /* Find the device, bail out if not yet registered */ d = bus_find_device_by_name(&pci_bus_type, NULL, m->devname); if (!d) return ERR_PTR(-EPROBE_DEFER); put_device(d); *conns = m->conns; return d; } } /* Hardware support must be added in the cec_dmi_match_table */ dev_warn(dev, "CEC notifier not configured for this hardware\n"); return ERR_PTR(-ENODEV); } #else static struct device *cros_ec_cec_find_hdmi_dev(struct device *dev, const char * const **conns) { return ERR_PTR(-ENODEV); } #endif static int cros_ec_cec_get_num_ports(struct cros_ec_cec *cros_ec_cec) { struct ec_response_cec_port_count response; int ret; ret = cros_ec_cmd(cros_ec_cec->cros_ec, 0, EC_CMD_CEC_PORT_COUNT, NULL, 0, &response, sizeof(response)); if (ret < 0) { /* * Old EC firmware only supports one port and does not support * the port count command, so fall back to assuming one port. */ cros_ec_cec->num_ports = 1; return 0; } if (response.port_count == 0) { dev_err(cros_ec_cec->cros_ec->dev, "EC reports 0 CEC ports\n"); return -ENODEV; } if (response.port_count > EC_CEC_MAX_PORTS) { dev_err(cros_ec_cec->cros_ec->dev, "EC reports too many ports: %d\n", response.port_count); return -EINVAL; } cros_ec_cec->num_ports = response.port_count; return 0; } static int cros_ec_cec_get_write_cmd_version(struct cros_ec_cec *cros_ec_cec) { struct cros_ec_device *cros_ec = cros_ec_cec->cros_ec; struct ec_params_get_cmd_versions_v1 params = { .cmd = EC_CMD_CEC_WRITE_MSG, }; struct ec_response_get_cmd_versions response; int ret; ret = cros_ec_cmd(cros_ec, 1, EC_CMD_GET_CMD_VERSIONS, ¶ms, sizeof(params), &response, sizeof(response)); if (ret < 0) { dev_err(cros_ec->dev, "error getting CEC write command version: %d\n", ret); return ret; } if (response.version_mask & EC_VER_MASK(1)) { cros_ec_cec->write_cmd_version = 1; } else { if (cros_ec_cec->num_ports != 1) { dev_err(cros_ec->dev, "v0 write command only supports 1 port, %d reported\n", cros_ec_cec->num_ports); return -EINVAL; } cros_ec_cec->write_cmd_version = 0; } return 0; } static int cros_ec_cec_init_port(struct device *dev, struct cros_ec_cec *cros_ec_cec, int port_num, struct device *hdmi_dev, const char * const *conns) { struct cros_ec_cec_port *port; int ret; port = devm_kzalloc(dev, sizeof(*port), GFP_KERNEL); if (!port) return -ENOMEM; port->cros_ec_cec = cros_ec_cec; port->port_num = port_num; port->adap = cec_allocate_adapter(&cros_ec_cec_ops, port, DRV_NAME, CEC_CAP_DEFAULTS | CEC_CAP_CONNECTOR_INFO, 1); if (IS_ERR(port->adap)) return PTR_ERR(port->adap); if (!conns[port_num]) { dev_err(dev, "no conn for port %d\n", port_num); ret = -ENODEV; goto out_probe_adapter; } port->notify = cec_notifier_cec_adap_register(hdmi_dev, conns[port_num], port->adap); if (!port->notify) { ret = -ENOMEM; goto out_probe_adapter; } ret = cec_register_adapter(port->adap, dev); if (ret < 0) goto out_probe_notify; cros_ec_cec->ports[port_num] = port; return 0; out_probe_notify: cec_notifier_cec_adap_unregister(port->notify, port->adap); out_probe_adapter: cec_delete_adapter(port->adap); return ret; } static int cros_ec_cec_probe(struct platform_device *pdev) { struct cros_ec_dev *ec_dev = dev_get_drvdata(pdev->dev.parent); struct cros_ec_device *cros_ec = ec_dev->ec_dev; struct cros_ec_cec *cros_ec_cec; struct cros_ec_cec_port *port; struct device *hdmi_dev; const char * const *conns = NULL; int ret; hdmi_dev = cros_ec_cec_find_hdmi_dev(&pdev->dev, &conns); if (IS_ERR(hdmi_dev)) return PTR_ERR(hdmi_dev); cros_ec_cec = devm_kzalloc(&pdev->dev, sizeof(*cros_ec_cec), GFP_KERNEL); if (!cros_ec_cec) return -ENOMEM; platform_set_drvdata(pdev, cros_ec_cec); cros_ec_cec->cros_ec = cros_ec; device_init_wakeup(&pdev->dev, 1); ret = cros_ec_cec_get_num_ports(cros_ec_cec); if (ret) return ret; ret = cros_ec_cec_get_write_cmd_version(cros_ec_cec); if (ret) return ret; for (int i = 0; i < cros_ec_cec->num_ports; i++) { ret = cros_ec_cec_init_port(&pdev->dev, cros_ec_cec, i, hdmi_dev, conns); if (ret) goto unregister_ports; } /* Get CEC events from the EC. */ cros_ec_cec->notifier.notifier_call = cros_ec_cec_event; ret = blocking_notifier_chain_register(&cros_ec->event_notifier, &cros_ec_cec->notifier); if (ret) { dev_err(&pdev->dev, "failed to register notifier\n"); goto unregister_ports; } return 0; unregister_ports: /* * Unregister any adapters which have been registered. We don't add the * port to the array until the adapter has been registered successfully, * so any non-NULL ports must have been registered. */ for (int i = 0; i < cros_ec_cec->num_ports; i++) { port = cros_ec_cec->ports[i]; if (!port) break; cec_notifier_cec_adap_unregister(port->notify, port->adap); cec_unregister_adapter(port->adap); } return ret; } static void cros_ec_cec_remove(struct platform_device *pdev) { struct cros_ec_cec *cros_ec_cec = platform_get_drvdata(pdev); struct device *dev = &pdev->dev; struct cros_ec_cec_port *port; int ret; /* * blocking_notifier_chain_unregister() only fails if the notifier isn't * in the list. We know it was added to it by .probe(), so there should * be no need for error checking. Be cautious and still check. */ ret = blocking_notifier_chain_unregister( &cros_ec_cec->cros_ec->event_notifier, &cros_ec_cec->notifier); if (ret) dev_err(dev, "failed to unregister notifier\n"); for (int i = 0; i < cros_ec_cec->num_ports; i++) { port = cros_ec_cec->ports[i]; cec_notifier_cec_adap_unregister(port->notify, port->adap); cec_unregister_adapter(port->adap); } } static const struct platform_device_id cros_ec_cec_id[] = { { DRV_NAME, 0 }, {} }; MODULE_DEVICE_TABLE(platform, cros_ec_cec_id); static struct platform_driver cros_ec_cec_driver = { .probe = cros_ec_cec_probe, .remove = cros_ec_cec_remove, .driver = { .name = DRV_NAME, .pm = &cros_ec_cec_pm_ops, }, .id_table = cros_ec_cec_id, }; module_platform_driver(cros_ec_cec_driver); MODULE_DESCRIPTION("CEC driver for ChromeOS ECs"); MODULE_AUTHOR("Neil Armstrong <narmstrong@baylibre.com>"); MODULE_LICENSE("GPL"); STEAMIFY_PATCH_EOF ;; ensure-kernel-headers.sh) cat << 'STEAMIFY_PATCH_EOF' #!/bin/bash # Installed by Steamify: install missing -headers for every # installed kernel, so DKMS builds leds-valve for it. set -u missing=() for k in $(pacman -Qqo /usr/lib/modules/*/pkgbase 2>/dev/null | sort -u); do pacman -Q "${k}-headers" >/dev/null 2>&1 && continue pacman -Si "${k}-headers" >/dev/null 2>&1 && missing+=("${k}-headers") done [[ ${#missing[@]} -eq 0 ]] && exit 0 # Another pacman is running (e.g. an update): try again next boot. [[ -e /var/lib/pacman/db.lck ]] && { echo "pacman is busy, skipping"; exit 0; } echo "Installing missing kernel headers: ${missing[*]}" exec pacman -S --needed --noconfirm "${missing[@]}" STEAMIFY_PATCH_EOF ;; gamescope-display-wrapper.sh) cat << 'STEAMIFY_PATCH_EOF' #!/bin/bash # Steamify: gamescope-session starts gamescope without a size or a refresh rate, so it takes the display's *preferred* mode, # which many monitors declare as 60 Hz (the faster modes sit in an extension block of the EDID). This starts it the way the # desktop runs that display: the mode KDE has saved for the connected display (System Settings > Display), when the display # still has it, else its biggest mode at the highest refresh rate. # Read at every session start (modetest, part of libdrm which gamescope needs, and KDE's ~/.config/kwinoutputconfig.json), so # changing the resolution in KDE, another monitor or a TV just works. Anything unclear (no modetest output, no modes, more than # one display connected: gamescope chooses which one it uses) leaves gamescope's own choice alone. real=/usr/bin/gamescope modes() { # One line per mode of every connected connector: "<connector> <width> <height> <refresh>" local m for m in nvidia-drm amdgpu i915 xe nouveau radeon; do timeout 10 modetest -M "$m" -c 2>/dev/null | awk ' /^[0-9]+[ \t]+[0-9]+[ \t]+(connected|disconnected|unknown)/ { conn = ($3 == "connected") ? $4 : ""; next } conn != "" && /^[ \t]+#[0-9]+ [0-9]+x[0-9]+ [0-9.]+ / && !/interlace/ { split($2, wh, "x"); printf "%s %d %d %.0f\n", conn, wh[1], wh[2], $3 }' done } kde_mode() { # kde_mode <connector>: "<width> <height> <refresh>" as KDE has it saved for that connector, or nothing. python3 -I - "$1" "${XDG_CONFIG_HOME:-$HOME/.config}/kwinoutputconfig.json" 2>/dev/null << 'EOF' import json, sys try: for block in json.load(open(sys.argv[2])): if block.get("name") == "outputs": for o in block.get("data", []): m = o.get("mode") if o.get("connectorName") == sys.argv[1] and m: print(m["width"], m["height"], round(m["refreshRate"] / 1000)) except Exception: pass EOF } args=() all="$(modes)" conn="$(awk '{print $1}' <<< "$all" | sort -u)" if [[ -n "$conn" && "$(wc -l <<< "$conn")" == 1 ]]; then read -r w h r <<< "$(kde_mode "$conn")" # KDE's mode only counts when the display really has it (a refresh rate within 1 Hz). if [[ -z "${w:-}" ]] || ! awk -v w="$w" -v h="${h:-0}" -v r="${r:-0}" '$2 == w && $3 == h && ($4 - r < 2 && r - $4 < 2) {f = 1} END {exit !f}' <<< "$all"; then read -r _ w h r < <(awk '{print $2 * $3, $2, $3, $4}' <<< "$all" | sort -k1,1nr -k4,4nr | head -1 | awk '{print "x", $2, $3, $4}') fi [[ "${w:-0}" -gt 0 && "${h:-0}" -gt 0 && "${r:-0}" -gt 0 ]] && args=(-W "$w" -H "$h" -r "$r") fi if [[ "${1:-}" == --print-args ]]; then echo "${args[*]}"; exit 0; fi exec "$real" "${args[@]}" "$@" STEAMIFY_PATCH_EOF ;; gamescope-gbm-scanout.patch) cat << 'STEAMIFY_PATCH_EOF' GBM-allocated scan-out buffers for gamescope's DRM backend on NVIDIA (opt-in: gamescope_drm_gbm_scanout=1). Nine commits from https://github.com/farnoy/gamescope (branch nvidia-gbm-wayland: 5cf847274, ffb525cf7, 211f4ca26, 117b593ea, 8da9459ff, cb0378edc, 4e879fbf7, 0c7fddbf3, ae7b99df3), as one diff against gamescope 3.16.31. Upstream issue: https://github.com/ValveSoftware/gamescope/issues/2309. Tested on an RTX 5080 (driver 615.78.08): Steam's gaming UI is clean with it and shows artifacts without it. Applies with `git apply` (it ignores these lines); newer gamescope versions need a rebase. diff --git a/README.md b/README.md index 0f53c60..7de4241 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ If running RadeonSI clients with older cards (GFX8 and below), currently have to Dependent first (**Debian/Debian-based**): ``` -apt install meson ninja-build pkg-config cmake libpipewire-0.3-dev hwdata libx11-dev libwayland-dev libvulkan-dev wayland-protocols libx11-xcb-dev libxdamage-dev libxcomposite-dev libxcursor-dev libxxf86vm-dev libxtst-dev libxres-dev libxmu-dev libxkbcommon-dev libcap-dev libsdl2-dev libavif-dev libpixman-1-dev liblcms2-dev libseat-dev libinput-dev xwayland libxcb-composite0-dev libxcb-ewmh-dev libxcb-icccm4-dev libxcb-res0-dev glslang-tools libluajit-5.1-dev libcatch2-dev +apt install meson ninja-build pkg-config cmake libpipewire-0.3-dev hwdata libx11-dev libwayland-dev libvulkan-dev libgbm-dev wayland-protocols libx11-xcb-dev libxdamage-dev libxcomposite-dev libxcursor-dev libxxf86vm-dev libxtst-dev libxres-dev libxmu-dev libxkbcommon-dev libcap-dev libsdl2-dev libavif-dev libpixman-1-dev liblcms2-dev libseat-dev libinput-dev xwayland libxcb-composite0-dev libxcb-ewmh-dev libxcb-icccm4-dev libxcb-res0-dev glslang-tools libluajit-5.1-dev libcatch2-dev ``` Build with: diff --git a/src/Backends/DRMBackend.cpp b/src/Backends/DRMBackend.cpp index 9aaab7b..9b40f02 100644 --- a/src/Backends/DRMBackend.cpp +++ b/src/Backends/DRMBackend.cpp @@ -56,6 +56,10 @@ #include "libdisplay-info/cta.h" #include "wlr_end.hpp" +#if HAVE_GBM +#include "GbmScanoutBuffer.h" +#endif + #include "gamescope-control-protocol.h" extern int g_nPreferredOutputWidth; @@ -75,6 +79,19 @@ gamescope::ConVar<bool> cv_drm_debug_disable_color_range( "drm_debug_disable_col gamescope::ConVar<bool> cv_drm_debug_disable_explicit_sync( "drm_debug_disable_explicit_sync", false, "Force disable explicit sync on the DRM backend." ); gamescope::ConVar<bool> cv_drm_debug_disable_in_fence_fd( "drm_debug_disable_in_fence_fd", false, "Force disable IN_FENCE_FD being set to avoid over-synchronization on the DRM backend." ); +gamescope::ConVar<bool> cv_drm_gbm_scanout( "drm_gbm_scanout", false, "Allocate scanout buffers with GBM and import them into Vulkan. Fixes corrupted scanout on the NVIDIA proprietary driver.", + []( gamescope::ConVar<bool> &cvar ) + { + // The callback also runs for writes of the unchanged value; only an + // actual toggle warrants rebuilding the output images. + static bool s_bLastValue = false; + if ( cvar == s_bLastValue ) + return; + s_bLastValue = cvar; + + g_bForceOutputImageRemake = true; + }); + gamescope::ConVar<bool> cv_drm_allow_dynamic_modes_for_external_display( "drm_allow_dynamic_modes_for_external_display", false, "Allow dynamic mode/refresh rate switching for external displays." ); int HackyDRMPresent( const FrameInfo_t *pFrameInfo, bool bAsync ); @@ -113,6 +130,13 @@ struct drm_t { bool allow_modifiers; struct wlr_drm_format_set formats; + // GBM device on a dup of the KMS fd, for backend-allocated scanout + // buffers. Buffers keep the device alive via this shared_ptr; the + // deleter destroys the device and closes its dup'd fd, so buffer + // teardown stays valid even after drm->fd itself is closed. + std::shared_ptr<struct gbm_device> gbm; + bool bIsNvidia = false; + std::vector< std::unique_ptr< gamescope::CDRMPlane > > planes; std::vector< std::unique_ptr< gamescope::CDRMCRTC > > crtcs; std::unordered_map< uint32_t, gamescope::CDRMConnector > connectors; @@ -547,10 +571,11 @@ namespace gamescope ~CDRMFb(); uint32_t GetFbId() const { return m_uFbId; } - + private: uint32_t m_uFbId = 0; }; + } uint32_t g_nDRMFormat = DRM_FORMAT_INVALID; @@ -1546,6 +1571,28 @@ bool init_drm(struct drm_t *drm, int width, int height, int refresh) drm->allow_modifiers = true; } + if ( drmVersion *pVersion = drmGetVersion( drm->fd ) ) + { + drm->bIsNvidia = pVersion->name && !strcmp( pVersion->name, "nvidia-drm" ); + drm_log.infof( "KMS driver: %s", pVersion->name ? pVersion->name : "unknown" ); + drmFreeVersion( pVersion ); + } + +#if HAVE_GBM + // Create the GBM device on a dup of the KMS fd so buffer teardown + // (gbm_bo_destroy) stays valid even after wlsession_close_kms() has + // closed drm->fd; textures holding GBM buffers can outlive the backend + // on shutdown. The deleter closes the dup'd fd after the device. + drm->gbm = gamescope::CreateGbmDevice( drm->fd ); + if ( !drm->gbm ) + drm_log.errorf( "GBM scanout buffers will be unavailable." ); + + if ( drm->bIsNvidia && !cv_drm_gbm_scanout ) + { + drm_log.infof( "nvidia-drm detected: scanout of Vulkan-allocated buffers is known to corrupt on this driver. Consider enabling GBM scanout buffers with gamescope_drm_gbm_scanout=1." ); + } +#endif + g_bSupportsAsyncFlips = drmGetCap(drm->fd, DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP, &cap) == 0 && cap != 0; if (!g_bSupportsAsyncFlips) drm_log.errorf("Immediate flips are not supported by the KMS driver"); @@ -1842,6 +1889,12 @@ void finish_drm(struct drm_t *drm) drm->crtcs.clear(); drm->connectors.clear(); + // Drop our reference to the GBM device. Outstanding scanout buffers + // keep it alive via their own references; the device owns a dup of the + // KMS fd, so late gbm_bo_destroy calls stay valid after + // wlsession_close_kms() below. + drm->gbm = nullptr; + // Signal the page-flip handler thread to exit and join it so it won't be // using the DRM fd while we clean it up. Closing the pipe write end @@ -4227,6 +4280,27 @@ namespace gamescope return drm_fbid_from_dmabuf( &g_DRM, pDmaBuf ); } + virtual bool SupportsExternalScanoutBuffers() const override + { +#if HAVE_GBM + return g_DRM.gbm != nullptr && g_DRM.allow_modifiers && cv_drm_gbm_scanout; +#else + return false; +#endif + } + + virtual std::shared_ptr<IBackendScanoutBuffer> CreateScanoutBuffer( uint32_t uWidth, uint32_t uHeight, uint32_t uDrmFormat, std::span<const uint64_t> ulModifiers, bool bLinear ) override + { +#if HAVE_GBM + if ( !g_DRM.gbm ) + return nullptr; + + return CreateGbmScanoutBuffer( g_DRM.gbm, uWidth, uHeight, uDrmFormat, ulModifiers, bLinear ); +#else + return nullptr; +#endif + } + virtual bool UsesModifiers() const override { return g_DRM.allow_modifiers; diff --git a/src/Backends/DeferredBackend.h b/src/Backends/DeferredBackend.h index 7f76122..a7385b3 100644 --- a/src/Backends/DeferredBackend.h +++ b/src/Backends/DeferredBackend.h @@ -163,6 +163,25 @@ namespace gamescope return m_pChild->ImportDmabufToBackend( pDmaBuf ); } + virtual bool SupportsExternalScanoutBuffers() const override + { + { + std::shared_lock lock{ m_mutInit }; + if ( m_bInittedChild ) + return m_pChild->SupportsExternalScanoutBuffers(); + } + return false; + } + virtual std::shared_ptr<IBackendScanoutBuffer> CreateScanoutBuffer( uint32_t uWidth, uint32_t uHeight, uint32_t uDrmFormat, std::span<const uint64_t> ulModifiers, bool bLinear ) override + { + { + std::shared_lock lock{ m_mutInit }; + if ( m_bInittedChild ) + return m_pChild->CreateScanoutBuffer( uWidth, uHeight, uDrmFormat, ulModifiers, bLinear ); + } + return nullptr; + } + virtual bool UsesModifiers() const override { return true; diff --git a/src/Backends/GbmScanoutBuffer.cpp b/src/Backends/GbmScanoutBuffer.cpp new file mode 100644 index 0000000..401f679 --- /dev/null +++ b/src/Backends/GbmScanoutBuffer.cpp @@ -0,0 +1,158 @@ +#include "GbmScanoutBuffer.h" + +#include "drm_include.h" +#include "log.hpp" + +#include <cinttypes> +#include <sys/stat.h> +#include <unistd.h> + +#include <gbm.h> + +namespace gamescope +{ + static LogScope s_GbmLog( "gbm" ); + + class CGbmScanoutBuffer final : public IBackendScanoutBuffer + { + public: + CGbmScanoutBuffer( std::shared_ptr<gbm_device> pDevice, gbm_bo *pBo, const wlr_dmabuf_attributes &attrs ) + : m_pDevice{ std::move( pDevice ) } + , m_pBo{ pBo } + , m_Attrs{ attrs } + { + } + + ~CGbmScanoutBuffer() + { + wlr_dmabuf_attributes_finish( &m_Attrs ); + gbm_bo_destroy( m_pBo ); + } + + const wlr_dmabuf_attributes *GetDmabufAttributes() const override { return &m_Attrs; } + + private: + // Declared before m_pBo so the device outlives the bo. + std::shared_ptr<gbm_device> m_pDevice; + gbm_bo *m_pBo = nullptr; + wlr_dmabuf_attributes m_Attrs = {}; + }; + + std::shared_ptr<gbm_device> CreateGbmDevice( int nFd ) + { + int nGbmFd = dup( nFd ); + if ( nGbmFd < 0 ) + { + s_GbmLog.errorf_errno( "Failed to dup fd for GBM" ); + return nullptr; + } + + gbm_device *pDevice = gbm_create_device( nGbmFd ); + if ( !pDevice ) + { + close( nGbmFd ); + s_GbmLog.errorf( "Failed to create GBM device" ); + return nullptr; + } + + s_GbmLog.infof( "Created GBM device (backend: %s)", gbm_device_get_backend_name( pDevice ) ); + return std::shared_ptr<gbm_device>( pDevice, [ nGbmFd ]( gbm_device *pDevice ){ gbm_device_destroy( pDevice ); close( nGbmFd ); } ); + } + + std::shared_ptr<IBackendScanoutBuffer> CreateGbmScanoutBuffer( const std::shared_ptr<gbm_device> &pDevice, uint32_t uWidth, uint32_t uHeight, uint32_t uDrmFormat, std::span<const uint64_t> ulModifiers, bool bLinear ) + { + if ( !pDevice ) + return nullptr; + + uint32_t uFlags = GBM_BO_USE_RENDERING | GBM_BO_USE_SCANOUT; + + bool bForceLinearModifier = false; + struct gbm_bo *pBo = nullptr; + if ( bLinear ) + { + // GBM rejects GBM_BO_USE_LINEAR combined with an explicit + // modifier list (EINVAL in Mesa), so allocate directly. + pBo = gbm_bo_create( pDevice.get(), uWidth, uHeight, uDrmFormat, uFlags | GBM_BO_USE_LINEAR ); + bForceLinearModifier = true; + } + else if ( !ulModifiers.empty() ) + { + pBo = gbm_bo_create_with_modifiers2( pDevice.get(), uWidth, uHeight, uDrmFormat, ulModifiers.data(), ulModifiers.size(), uFlags ); + + // Some GBM implementations cannot allocate with an explicit + // modifier list at all. If only LINEAR was requested, a plain + // allocation with GBM_BO_USE_LINEAR is equivalent. + if ( !pBo && ulModifiers.size() == 1 && ulModifiers[0] == DRM_FORMAT_MOD_LINEAR ) + { + pBo = gbm_bo_create( pDevice.get(), uWidth, uHeight, uDrmFormat, uFlags | GBM_BO_USE_LINEAR ); + bForceLinearModifier = true; + } + } + + if ( !pBo ) + { + s_GbmLog.errorf( "GBM allocation failed for %ux%u format 0x%x", uWidth, uHeight, uDrmFormat ); + return nullptr; + } + + wlr_dmabuf_attributes attrs = + { + .width = int32_t( uWidth ), + .height = int32_t( uHeight ), + .format = uDrmFormat, + .modifier = bForceLinearModifier ? DRM_FORMAT_MOD_LINEAR : gbm_bo_get_modifier( pBo ), + }; + + const int nPlaneCount = gbm_bo_get_plane_count( pBo ); + bool bSuccess = nPlaneCount >= 1 && nPlaneCount <= WLR_DMABUF_MAX_PLANES; + + if ( bSuccess && !bForceLinearModifier && !Algorithm::Contains( ulModifiers, attrs.modifier ) ) + { + s_GbmLog.errorf( "GBM returned modifier 0x%" PRIx64 " which was not in the allowed list", attrs.modifier ); + bSuccess = false; + } + + // Only count a plane in n_planes once we own its fd, so that + // wlr_dmabuf_attributes_finish never closes an fd we didn't acquire. + for ( int i = 0; bSuccess && i < nPlaneCount; i++ ) + { + int nFd = gbm_bo_get_fd_for_plane( pBo, i ); + if ( nFd < 0 ) + { + s_GbmLog.errorf_errno( "gbm_bo_get_fd_for_plane failed" ); + bSuccess = false; + break; + } + attrs.fd[i] = nFd; + attrs.offset[i] = gbm_bo_get_offset( pBo, i ); + attrs.stride[i] = gbm_bo_get_stride_for_plane( pBo, i ); + attrs.n_planes = i + 1; + } + bSuccess = bSuccess && attrs.n_planes == nPlaneCount; + + // Vulkan's dmabuf memory import is single-fd: all planes must + // reference the same underlying buffer. + for ( int i = 1; bSuccess && i < attrs.n_planes; i++ ) + { + struct stat first, other; + if ( fstat( attrs.fd[0], &first ) != 0 || fstat( attrs.fd[i], &other ) != 0 || + first.st_dev != other.st_dev || first.st_ino != other.st_ino ) + { + s_GbmLog.errorf( "GBM returned a disjoint multi-plane bo, cannot import" ); + bSuccess = false; + } + } + + if ( !bSuccess ) + { + wlr_dmabuf_attributes_finish( &attrs ); + gbm_bo_destroy( pBo ); + return nullptr; + } + + s_GbmLog.debugf( "allocated %ux%u format 0x%x modifier 0x%" PRIx64 " (%d planes)", + uWidth, uHeight, uDrmFormat, attrs.modifier, attrs.n_planes ); + + return std::make_shared<CGbmScanoutBuffer>( pDevice, pBo, attrs ); + } +} diff --git a/src/Backends/GbmScanoutBuffer.h b/src/Backends/GbmScanoutBuffer.h new file mode 100644 index 0000000..9dbf3b2 --- /dev/null +++ b/src/Backends/GbmScanoutBuffer.h @@ -0,0 +1,20 @@ +#pragma once + +#include "backend.h" + +#include <cstdint> +#include <memory> +#include <span> + +struct gbm_device; + +namespace gamescope +{ + // Creates a GBM device on a dup of nFd. The device owns the dup, so buffers + // that hold the device keep working after the caller closes nFd. + std::shared_ptr<gbm_device> CreateGbmDevice( int nFd ); + + // Allocates a scanout-capable buffer with GBM_BO_USE_SCANOUT. ulModifiers + // must not contain DRM_FORMAT_MOD_INVALID; returns nullptr on failure. + std::shared_ptr<IBackendScanoutBuffer> CreateGbmScanoutBuffer( const std::shared_ptr<gbm_device> &pDevice, uint32_t uWidth, uint32_t uHeight, uint32_t uDrmFormat, std::span<const uint64_t> ulModifiers, bool bLinear ); +} diff --git a/src/backend.h b/src/backend.h index bd57c42..12e4361 100644 --- a/src/backend.h +++ b/src/backend.h @@ -290,6 +290,17 @@ namespace gamescope virtual ~IBackendPlane() = default; }; + // A scanout-capable buffer allocated by the backend rather than by Vulkan + // (GBM on the DRM backend). Owns the underlying allocation and the fds + // inside its wlr_dmabuf_attributes; callers must dup() any fds they keep. + class IBackendScanoutBuffer + { + public: + virtual ~IBackendScanoutBuffer() {} + + virtual const wlr_dmabuf_attributes *GetDmabufAttributes() const = 0; + }; + class CBaseBackendFb : public IBackendFb { public: @@ -341,6 +352,19 @@ namespace gamescope // Rc manages acquire/release of buffer to/from client while imported. virtual OwningRc<IBackendFb> ImportDmabufToBackend( wlr_dmabuf_attributes *pDmaBuf ) = 0; + // Whether the backend can allocate scanout-capable buffers itself + // (GBM on the DRM backend) for the renderer to import, instead of the + // renderer allocating through Vulkan and exporting. + virtual bool SupportsExternalScanoutBuffers() const { return false; } + // ulModifiers: final allowed modifier list -- already intersected by the + // caller across KMS support ( GetSupportedModifiers ) and Vulkan + // importability for every format that will alias this buffer. + // Never contains DRM_FORMAT_MOD_INVALID. + virtual std::shared_ptr<IBackendScanoutBuffer> CreateScanoutBuffer( uint32_t uWidth, uint32_t uHeight, uint32_t uDrmFormat, std::span<const uint64_t> ulModifiers, bool bLinear ) + { + return nullptr; + } + virtual bool UsesModifiers() const = 0; virtual std::span<const uint64_t> GetSupportedModifiers( uint32_t uDrmFormat ) const = 0; inline bool SupportsFormat( uint32_t uDrmFormat ) const diff --git a/src/meson.build b/src/meson.build index ae3ac16..12fe4ae 100644 --- a/src/meson.build +++ b/src/meson.build @@ -11,6 +11,8 @@ dep_xmu = dependency('xmu') dep_xi = dependency('xi') drm_dep = dependency('libdrm', version: '>= 2.4.113', required: get_option('drm_backend')) +# gbm_bo_create_with_modifiers2 needs Mesa >= 21.3 +gbm_dep = dependency('gbm', version: '>= 21.3', required: false) eis_dep = dependency('libeis-1.0', required : get_option('input_emulation')) wayland_server = dependency('wayland-server', version: '>=1.21') @@ -149,6 +151,11 @@ if sdl2_dep.found() endif gamescope_cpp_args += '-DHAVE_DRM=@0@'.format(drm_dep.found().to_int()) +have_gbm = drm_dep.found() and gbm_dep.found() +if have_gbm + src += 'Backends/GbmScanoutBuffer.cpp' +endif +gamescope_cpp_args += '-DHAVE_GBM=@0@'.format(have_gbm.to_int()) gamescope_cpp_args += '-DHAVE_SDL2=@0@'.format(sdl2_dep.found().to_int()) gamescope_cpp_args += '-DHAVE_AVIF=@0@'.format(avif_dep.found().to_int()) gamescope_cpp_args += '-DHAVE_LIBCAP=@0@'.format(cap_dep.found().to_int()) @@ -196,7 +203,7 @@ gamescope_version = configure_file( include_directories : [reshade_include, sol2_include], dependencies: [ dep_wayland, dep_x11, dep_xdamage, dep_xcomposite, dep_xrender, dep_xext, dep_xfixes, - dep_xxf86vm, dep_xres, glm_dep, drm_dep, wayland_server, + dep_xxf86vm, dep_xres, glm_dep, drm_dep, gbm_dep, wayland_server, xkbcommon, thread_dep, sdl2_dep, wlroots_dep, vulkan_dep, liftoff_dep, dep_xtst, dep_xmu, cap_dep, epoll_dep, pipewire_dep, librt_dep, stb_dep, displayinfo_dep, openvr_dep, dep_xcursor, avif_dep, dep_xi, diff --git a/src/rendervulkan.cpp b/src/rendervulkan.cpp index 54e4efc..cd5769c 100644 --- a/src/rendervulkan.cpp +++ b/src/rendervulkan.cpp @@ -2080,44 +2080,109 @@ static VkImageViewType VulkanImageTypeToViewType(VkImageType type) } } -bool CVulkanTexture::BInit( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA /* = nullptr */, uint32_t contentWidth /* = 0 */, uint32_t contentHeight /* = 0 */, CVulkanTexture *pExistingImageToReuseMemory, gamescope::OwningRc<gamescope::IBackendFb> pBackendFb ) +static VkImageUsageFlags VulkanUsageFromCreateFlags( const CVulkanTexture::createFlags &flags ) { - m_pBackendFb = std::move( pBackendFb ); - m_drmFormat = drmFormat; - VkResult res = VK_ERROR_INITIALIZATION_FAILED; - - VkImageTiling tiling = (flags.bMappable || flags.bLinear) ? VK_IMAGE_TILING_LINEAR : VK_IMAGE_TILING_OPTIMAL; VkImageUsageFlags usage = 0; - VkMemoryPropertyFlags properties; if ( flags.bSampled == true ) - { usage |= VK_IMAGE_USAGE_SAMPLED_BIT; - } if ( flags.bStorage == true ) - { usage |= VK_IMAGE_USAGE_STORAGE_BIT; - } if ( flags.bColorAttachment == true ) - { usage |= VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; - } - if ( flags.bFlippable == true ) + if ( flags.bTransferSrc == true ) + usage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + + if ( flags.bTransferDst == true ) + usage |= VK_IMAGE_USAGE_TRANSFER_DST_BIT; + + return usage; +} + +static bool BSupportsExternalScanoutModifier( uint32_t drmFormat, VkImageType imageType, VkImageUsageFlags usage, uint64_t ulModifier ); +static std::vector<uint64_t> GetExternalScanoutModifiers( std::span<const uint32_t> drmFormats, const CVulkanTexture::createFlags &flags ); + +bool CVulkanTexture::BInit( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA /* = nullptr */, uint32_t contentWidth /* = 0 */, uint32_t contentHeight /* = 0 */, CVulkanTexture *pExistingImageToReuseMemory, gamescope::OwningRc<gamescope::IBackendFb> pBackendFb, std::shared_ptr<gamescope::IBackendScanoutBuffer> pScanoutBuffer ) +{ + // A retry only makes sense when the external scanout buffer was chosen + // internally, not handed in by the caller (who then owns the fallback). + const bool bCanRetryWithoutExternal = pDMA == nullptr && pScanoutBuffer == nullptr; + + if ( BInitInternal( width, height, depth, drmFormat, flags, pDMA, contentWidth, contentHeight, pExistingImageToReuseMemory, std::move( pBackendFb ), std::move( pScanoutBuffer ), true ) ) + return true; + + if ( bCanRetryWithoutExternal && m_pScanoutBuffer != nullptr ) { - flags.bExportable = true; + vk_log.errorf( "Failed to init texture from external scanout buffer, retrying with Vulkan allocation" ); + ReleaseResources(); + return BInitInternal( width, height, depth, drmFormat, flags, nullptr, contentWidth, contentHeight, pExistingImageToReuseMemory, nullptr, nullptr, false ); } - if ( flags.bTransferSrc == true ) - { - usage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + return false; +} + +bool CVulkanTexture::BInitInternal( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA, uint32_t contentWidth, uint32_t contentHeight, CVulkanTexture *pExistingImageToReuseMemory, gamescope::OwningRc<gamescope::IBackendFb> pBackendFb, std::shared_ptr<gamescope::IBackendScanoutBuffer> pScanoutBuffer, bool bAllowExternalScanout ) +{ + m_pBackendFb = std::move( pBackendFb ); + m_pScanoutBuffer = std::move( pScanoutBuffer ); + m_drmFormat = drmFormat; + VkResult res = VK_ERROR_INITIALIZATION_FAILED; + + VkImageTiling tiling = (flags.bMappable || flags.bLinear) ? VK_IMAGE_TILING_LINEAR : VK_IMAGE_TILING_OPTIMAL; + VkImageUsageFlags usage = VulkanUsageFromCreateFlags( flags ); + VkMemoryPropertyFlags properties; + + // Flippable images are allocated by the backend where supported (GBM on + // the DRM backend): scanout memory needs placement only the backend's + // allocator can guarantee (physically contiguous vidmem on NVIDIA). + // Output images alias two formats in one buffer and are handled by + // vulkan_make_output_images_external instead. + wlr_dmabuf_attributes externalScanoutAttrs = {}; + if ( bAllowExternalScanout && + flags.bFlippable && pDMA == nullptr && m_pScanoutBuffer == nullptr && + m_pBackendFb == nullptr && + !flags.bOutputImage && !flags.bMappable && pExistingImageToReuseMemory == nullptr && + flags.imageType == VK_IMAGE_TYPE_2D && depth == 1 && + GetBackend()->SupportsExternalScanoutBuffers() && g_device.supportsModifiers() ) + { + std::vector<uint64_t> modifiers; + if ( flags.bLinear ) + { + // Only LINEAR is acceptable here, so probe just that instead of + // enumerating every KMS modifier. + if ( gamescope::Algorithm::Contains( GetBackend()->GetSupportedModifiers( drmFormat ), uint64_t( DRM_FORMAT_MOD_LINEAR ) ) && + BSupportsExternalScanoutModifier( drmFormat, flags.imageType, usage, DRM_FORMAT_MOD_LINEAR ) ) + modifiers = { DRM_FORMAT_MOD_LINEAR }; + } + else + { + const uint32_t uFormats[1] = { drmFormat }; + modifiers = GetExternalScanoutModifiers( uFormats, flags ); + } + + if ( !modifiers.empty() ) + m_pScanoutBuffer = GetBackend()->CreateScanoutBuffer( width, height, drmFormat, modifiers, flags.bLinear ); + + if ( m_pScanoutBuffer ) + { + externalScanoutAttrs = *m_pScanoutBuffer->GetDmabufAttributes(); + pDMA = &externalScanoutAttrs; + } + else + { + vk_log.debugf( "External scanout buffer unavailable for format 0x%x, using Vulkan allocation", drmFormat ); + } } - if ( flags.bTransferDst == true ) + if ( flags.bFlippable == true && pDMA == nullptr ) { - usage |= VK_IMAGE_USAGE_TRANSFER_DST_BIT; + // Flippable images we allocate ourselves get exported to the backend. + // A flippable image created from an existing dmabuf (eg. a + // backend-allocated scanout buffer) is imported instead. + flags.bExportable = true; } if ( flags.bMappable == true ) @@ -2320,7 +2385,8 @@ bool CVulkanTexture::BInit( uint32_t width, uint32_t height, uint32_t depth, uin vk_errorf( res, "vkCreateImage failed" ); return false; } - + m_bOwnsImage = true; + VkMemoryRequirements memRequirements; g_device.vk.GetImageMemoryRequirements(g_device.device(), m_vkImage, &memRequirements); @@ -2383,6 +2449,37 @@ bool CVulkanTexture::BInit( uint32_t width, uint32_t height, uint32_t depth, uin return false; } + // A dmabuf import must pick a memory type from the mask + // vkGetMemoryFdPropertiesKHR reports for the FD, intersected + // with the image's memory requirements - not just any type + // satisfying the requirements. + VkMemoryFdPropertiesKHR fdProps = { + .sType = VK_STRUCTURE_TYPE_MEMORY_FD_PROPERTIES_KHR, + }; + res = g_device.vk.GetMemoryFdPropertiesKHR( g_device.device(), VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT, fd, &fdProps ); + if ( res != VK_SUCCESS ) + { + vk_errorf( res, "vkGetMemoryFdPropertiesKHR failed" ); + close( fd ); + return false; + } + + const uint32_t uImportTypeBits = memRequirements.memoryTypeBits & fdProps.memoryTypeBits; + int32_t nImportMemoryTypeIndex = g_device.findMemoryType( properties, uImportTypeBits ); + if ( nImportMemoryTypeIndex < 0 ) + { + // Fall back to any permitted type; a valid import beats the + // preferred property flags. + nImportMemoryTypeIndex = g_device.findMemoryType( 0, uImportTypeBits ); + } + if ( nImportMemoryTypeIndex < 0 ) + { + vk_log.errorf( "No compatible memory type for dmabuf import" ); + close( fd ); + return false; + } + allocInfo.memoryTypeIndex = uint32_t( nImportMemoryTypeIndex ); + // Memory already provided by pDMA importMemoryInfo = { .sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_FD_INFO_KHR, @@ -2547,13 +2644,43 @@ bool CVulkanTexture::BInit( uint32_t width, uint32_t height, uint32_t depth, uin m_dmabuf = dmabuf; } + else if ( pDMA != nullptr && flags.bFlippable == true ) + { + // Flippable image backed by an external (backend-allocated) dmabuf: + // keep our own copy of the attributes for pipewire/screenshots and + // for fbid creation. We own these fds; they are closed by + // wlr_dmabuf_attributes_finish in ~CVulkanTexture. + m_dmabuf = *pDMA; + m_dmabuf.n_planes = 0; + for ( int i = 0; i < pDMA->n_planes; i++ ) + { + m_dmabuf.fd[i] = dup( pDMA->fd[i] ); + if ( m_dmabuf.fd[i] < 0 ) + { + vk_log.errorf_errno( "dup failed" ); + return false; + } + m_dmabuf.n_planes = i + 1; + } + } - if ( flags.bFlippable == true ) + if ( flags.bFlippable == true && m_pBackendFb == nullptr ) { m_pBackendFb = GetBackend()->ImportDmabufToBackend( &m_dmabuf ); + if ( pDMA != nullptr && m_pBackendFb == nullptr ) + { + // For external scanout buffers the whole point is scanning out; + // fail so the caller can fall back to Vulkan allocation. + vk_log.errorf( "Failed to import external scanout buffer to backend" ); + return false; + } } - bool bHasAlpha = pDMA ? DRMFormatHasAlpha( pDMA->format ) : true; + // Self-allocated scanout buffers (m_pScanoutBuffer) must behave exactly like + // the export path above: X-channel formats map to alpha-bearing VkFormats + // with identity swizzle, and storage usage stays allowed. Only genuinely + // client-imported buffers take the SWIZZLE_ONE/no-storage handling. + bool bHasAlpha = ( pDMA && !m_pScanoutBuffer ) ? DRMFormatHasAlpha( pDMA->format ) : true; if (!bHasAlpha ) { @@ -2742,9 +2869,10 @@ CVulkanTexture::CVulkanTexture( void ) { } -CVulkanTexture::~CVulkanTexture( void ) +void CVulkanTexture::ReleaseResources() { wlr_dmabuf_attributes_finish( &m_dmabuf ); + m_dmabuf = {}; if ( m_pMappedData != nullptr && m_vkImageMemory ) { @@ -2764,24 +2892,47 @@ CVulkanTexture::~CVulkanTexture( void ) m_linearView = VK_NULL_HANDLE; } + if ( m_lumaView != VK_NULL_HANDLE ) + { + g_device.vk.DestroyImageView( g_device.device(), m_lumaView, nullptr ); + m_lumaView = VK_NULL_HANDLE; + } + + if ( m_chromaView != VK_NULL_HANDLE ) + { + g_device.vk.DestroyImageView( g_device.device(), m_chromaView, nullptr ); + m_chromaView = VK_NULL_HANDLE; + } + if ( m_pBackendFb != nullptr ) m_pBackendFb = nullptr; - if ( m_vkImageMemory != VK_NULL_HANDLE ) + // Images we created are destroyed even when the memory isn't ours + // (aliased textures, or an init that failed before allocating memory). + // Swapchain-owned images are never destroyed here. + if ( m_vkImage != VK_NULL_HANDLE && m_bOwnsImage ) { - if ( m_vkImage != VK_NULL_HANDLE ) - { - g_device.vk.DestroyImage( g_device.device(), m_vkImage, nullptr ); - m_vkImage = VK_NULL_HANDLE; - } + g_device.vk.DestroyImage( g_device.device(), m_vkImage, nullptr ); + } + m_vkImage = VK_NULL_HANDLE; + m_bOwnsImage = false; + if ( m_vkImageMemory != VK_NULL_HANDLE ) + { g_device.vk.FreeMemory( g_device.device(), m_vkImageMemory, nullptr ); m_vkImageMemory = VK_NULL_HANDLE; } + m_pScanoutBuffer = nullptr; + m_bInitialized = false; } +CVulkanTexture::~CVulkanTexture( void ) +{ + ReleaseResources(); +} + int CVulkanTexture::memoryFence() { const VkMemoryGetFdInfoKHR memory_get_fd_info = { @@ -3341,6 +3492,139 @@ bool vulkan_remake_swapchain( void ) return bRet; } +// Whether Vulkan can create + import a dmabuf image with this +// format/modifier and usage. Uses the same srgb/linear mutable-format view +// pair the real image in BInitInternal is created with. +static bool BSupportsExternalScanoutModifier( uint32_t drmFormat, VkImageType imageType, VkImageUsageFlags usage, uint64_t ulModifier ) +{ + VkImageCreateInfo imageInfo = { + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, + .imageType = imageType, + .format = DRMFormatToVulkan( drmFormat, false ), + .mipLevels = 1, + .arrayLayers = 1, + .samples = VK_SAMPLE_COUNT_1_BIT, + .tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT, + .usage = usage, + .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + }; + + std::array<VkFormat, 2> formats = { + DRMFormatToVulkan( drmFormat, false ), + DRMFormatToVulkan( drmFormat, true ), + }; + + VkImageFormatListCreateInfo formatList = { + .sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_LIST_CREATE_INFO, + .viewFormatCount = (uint32_t)formats.size(), + .pViewFormats = formats.data(), + }; + + if ( formats[0] != formats[1] ) + { + formatList.pNext = std::exchange( imageInfo.pNext, &formatList ); + imageInfo.flags |= VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT; + } + + VkExternalImageFormatProperties externalImageProperties = { + .sType = VK_STRUCTURE_TYPE_EXTERNAL_IMAGE_FORMAT_PROPERTIES, + }; + + VkResult res = getModifierProps( &imageInfo, ulModifier, &externalImageProperties ); + return res == VK_SUCCESS && + !!( externalImageProperties.externalMemoryProperties.externalMemoryFeatures & VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT ); +} + +// Modifiers that (a) the backend can scan out for every format in drmFormats, +// and (b) Vulkan can create + import a dmabuf image with, using the usage +// implied by the given createFlags, for every format in drmFormats. +// This is the "plane ∩ renderer" intersection other compositors (eg. KWin) +// use when allocating scanout buffers through GBM, extended across all +// formats that will alias the same buffer. +static std::vector<uint64_t> GetExternalScanoutModifiers( std::span<const uint32_t> drmFormats, const CVulkanTexture::createFlags &flags ) +{ + std::vector<uint64_t> modifiers; + if ( drmFormats.empty() ) + return modifiers; + + const VkImageUsageFlags usage = VulkanUsageFromCreateFlags( flags ); + + for ( uint64_t modifier : GetBackend()->GetSupportedModifiers( drmFormats[0] ) ) + { + if ( modifier == DRM_FORMAT_MOD_INVALID ) + continue; + + bool bCompatible = true; + for ( uint32_t drmFormat : drmFormats ) + { + if ( !gamescope::Algorithm::Contains( GetBackend()->GetSupportedModifiers( drmFormat ), modifier ) || + !BSupportsExternalScanoutModifier( drmFormat, flags.imageType, usage, modifier ) ) + { + bCompatible = false; + break; + } + } + + if ( bCompatible ) + modifiers.push_back( modifier ); + } + + return modifiers; +} + +// Allocate the output images through the backend (GBM on DRM) and import +// them into Vulkan, instead of allocating with Vulkan and exporting. +// Fixes scanout on drivers that need special placement for scanout memory +// (NVIDIA needs physical contiguity, which only its GBM path guarantees). +static bool vulkan_make_output_images_external( VulkanOutput_t *pOutput, const CVulkanTexture::createFlags &outputImageflags, uint32_t uOutputWidth, uint32_t uOutputHeight ) +{ + uint32_t uFormats[2] = { pOutput->uOutputFormat, DRM_FORMAT_INVALID }; + size_t nFormats = 1; + + const bool bOverlay = pOutput->uOutputFormatOverlay != VK_FORMAT_UNDEFINED && !kDisablePartialComposition; + if ( bOverlay ) + uFormats[nFormats++] = pOutput->uOutputFormatOverlay; + + std::vector<uint64_t> modifiers = GetExternalScanoutModifiers( std::span<const uint32_t>( uFormats, nFormats ), outputImageflags ); + if ( modifiers.empty() ) + { + vk_log.errorf( "No modifier is scanout-capable and Vulkan-importable for all output formats" ); + return false; + } + + for ( size_t i = 0; i < 3; i++ ) + { + std::shared_ptr<gamescope::IBackendScanoutBuffer> pScanoutBuffer = + GetBackend()->CreateScanoutBuffer( uOutputWidth, uOutputHeight, pOutput->uOutputFormat, modifiers, false ); + if ( !pScanoutBuffer ) + return false; + + // BInit never takes ownership of these attrs' fds; it dups what it keeps. + wlr_dmabuf_attributes attrs = *pScanoutBuffer->GetDmabufAttributes(); + + pOutput->outputImages[i] = new CVulkanTexture(); + if ( !pOutput->outputImages[i]->BInit( uOutputWidth, uOutputHeight, 1u, pOutput->uOutputFormat, outputImageflags, &attrs, 0, 0, nullptr, nullptr, pScanoutBuffer ) ) + return false; + + if ( bOverlay ) + { + // The overlay image is a second import of the same buffer with + // the overlay format: identical modifier and plane layout + // (both formats are 32bpp, so strides match), its own fbid, and + // its own kernel reference on the underlying memory. Both + // textures share the IBackendScanoutBuffer. + attrs = *pScanoutBuffer->GetDmabufAttributes(); + attrs.format = pOutput->uOutputFormatOverlay; + + pOutput->outputImagesPartialOverlay[i] = new CVulkanTexture(); + if ( !pOutput->outputImagesPartialOverlay[i]->BInit( uOutputWidth, uOutputHeight, 1u, pOutput->uOutputFormatOverlay, outputImageflags, &attrs, 0, 0, nullptr, nullptr, pScanoutBuffer ) ) + return false; + } + } + + return true; +} + static bool vulkan_make_output_images( VulkanOutput_t *pOutput ) { CVulkanTexture::createFlags outputImageflags; @@ -3360,14 +3644,30 @@ static bool vulkan_make_output_images( VulkanOutput_t *pOutput ) pOutput->outputImagesPartialOverlay[1] = nullptr; pOutput->outputImagesPartialOverlay[2] = nullptr; - uint32_t uDRMFormat = pOutput->uOutputFormat; - // Output images are physical-oriented; the panel scans out unrotated. uint32_t uOutputWidth = g_nOutputWidth; uint32_t uOutputHeight = g_nOutputHeight; if ( g_uOutputRotation & 1u ) std::swap( uOutputWidth, uOutputHeight ); + if ( GetBackend()->SupportsExternalScanoutBuffers() && g_device.supportsModifiers() ) + { + if ( vulkan_make_output_images_external( pOutput, outputImageflags, uOutputWidth, uOutputHeight ) ) + { + // Oh no. + pOutput->temporaryHackyBlankImage = vulkan_create_debug_blank_texture(); + return true; + } + + vk_log.errorf( "Failed to create backend (GBM) scanout buffers, falling back to Vulkan allocation" ); + for ( auto &pImage : pOutput->outputImages ) + pImage = nullptr; + for ( auto &pImage : pOutput->outputImagesPartialOverlay ) + pImage = nullptr; + } + + uint32_t uDRMFormat = pOutput->uOutputFormat; + pOutput->outputImages[0] = new CVulkanTexture(); bool bSuccess = pOutput->outputImages[0]->BInit( uOutputWidth, uOutputHeight, 1u, uDRMFormat, outputImageflags ); if ( bSuccess != true ) diff --git a/src/rendervulkan.hpp b/src/rendervulkan.hpp index fa519db..4f0bff0 100644 --- a/src/rendervulkan.hpp +++ b/src/rendervulkan.hpp @@ -161,7 +161,7 @@ public: VkImageType imageType; }; - bool BInit( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA = nullptr, uint32_t contentWidth = 0, uint32_t contentHeight = 0, CVulkanTexture *pExistingImageToReuseMemory = nullptr, gamescope::OwningRc<gamescope::IBackendFb> pBackendFb = nullptr ); + bool BInit( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA = nullptr, uint32_t contentWidth = 0, uint32_t contentHeight = 0, CVulkanTexture *pExistingImageToReuseMemory = nullptr, gamescope::OwningRc<gamescope::IBackendFb> pBackendFb = nullptr, std::shared_ptr<gamescope::IBackendScanoutBuffer> pScanoutBuffer = nullptr ); bool BInitFromSwapchain( VkImage image, uint32_t width, uint32_t height, VkFormat format ); uint32_t IncRef(); @@ -211,9 +211,17 @@ public: uint32_t queueFamily = VK_QUEUE_FAMILY_IGNORED; private: + bool BInitInternal( uint32_t width, uint32_t height, uint32_t depth, uint32_t drmFormat, createFlags flags, wlr_dmabuf_attributes *pDMA, uint32_t contentWidth, uint32_t contentHeight, CVulkanTexture *pExistingImageToReuseMemory, gamescope::OwningRc<gamescope::IBackendFb> pBackendFb, std::shared_ptr<gamescope::IBackendScanoutBuffer> pScanoutBuffer, bool bAllowExternalScanout ); + // Frees everything BInitInternal may have created, so a failed init can + // be retried. Also used by the destructor. + void ReleaseResources(); + bool m_bInitialized = false; bool m_bExternal = false; bool m_bOutputImage = false; + // False for swapchain images: those are owned by the swapchain and must + // never be passed to vkDestroyImage. + bool m_bOwnsImage = false; uint32_t m_drmFormat = DRM_FORMAT_INVALID; @@ -251,6 +259,12 @@ private: EStreamColorspace m_streamColorspace = k_EStreamColorspace_Unknown; struct wlr_dmabuf_attributes m_dmabuf = {}; + + // Set when the underlying memory was allocated by the backend (GBM on + // the DRM backend) and imported into Vulkan, rather than allocated by + // Vulkan. Keeps the backend allocation alive for the texture's lifetime; + // aliasing textures (eg. the partial-overlay output images) share it. + std::shared_ptr<gamescope::IBackendScanoutBuffer> m_pScanoutBuffer; }; struct vec2_t @@ -788,6 +802,7 @@ static inline uint32_t div_roundup(uint32_t x, uint32_t y) VK_FUNC(GetImageMemoryRequirements) \ VK_FUNC(GetImageSubresourceLayout) \ VK_FUNC(GetMemoryFdKHR) \ + VK_FUNC(GetMemoryFdPropertiesKHR) \ VK_FUNC(GetQueryPoolResults) \ VK_FUNC(GetSemaphoreCounterValue) \ VK_FUNC(GetSwapchainImagesKHR) \ diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp index 2ff296b..2cb5eb5 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp @@ -161,6 +161,7 @@ static lut3d_t g_tmpLut3d; extern int g_nDynamicRefreshHz; bool g_bForceHDRSupportDebug = false; +std::atomic<bool> g_bForceOutputImageRemake = { false }; extern float g_flInternalDisplayBrightnessNits; extern float g_flHDRItmSdrNits; extern float g_flHDRItmTargetNits; @@ -10255,13 +10256,21 @@ steamcompmgr_main(int argc, char **argv) // Pick our width/height for this potential frame, regardless of how it might change later // At some point we might even add proper locking so we get real updates atomically instead // of whatever jumble of races the below might cause over a couple of frames + // Consume the remake request exactly once, before evaluating the other + // conditions: a concurrent request (eg. the drm_gbm_scanout convar + // callback on the Wayland thread) must never be cleared unseen, only + // exchanged out - a write racing past this point stays set for the + // next iteration. + const bool bForceOutputImageRemake = g_bForceOutputImageRemake.exchange( false ); + if ( currentOutputWidth != g_nOutputWidth || currentOutputHeight != g_nOutputHeight || currentOutputRefresh != g_nOutputRefresh || currentOutputRotation != g_uOutputRotation || currentHDROutput != g_bOutputHDREnabled || currentHDRCapable != bOutputHDRCapable || - currentHDRForce != g_bForceHDRSupportDebug ) + currentHDRForce != g_bForceHDRSupportDebug || + bForceOutputImageRemake ) { if ( g_nXWaylandCount > 1 ) { diff --git a/src/steamcompmgr.hpp b/src/steamcompmgr.hpp index c1d1ab0..fc7a521 100644 --- a/src/steamcompmgr.hpp +++ b/src/steamcompmgr.hpp @@ -22,6 +22,7 @@ void steamcompmgr_main(int argc, char **argv); #include "vblankmanager.hpp" #include "mangoapp_control.h" +#include <atomic> #include <mutex> #include <vector> #include <memory> @@ -45,6 +46,10 @@ static const uint32_t g_zposMuraCorrection = 5; extern bool g_bHDRItmEnable; extern bool g_bForceHDRSupportDebug; +// Request a rebuild of the output images on the next steamcompmgr iteration, +// eg. after toggling scanout buffer allocation strategy at runtime. +extern std::atomic<bool> g_bForceOutputImageRemake; + extern EStreamColorspace g_ForcedNV12ColorSpace; struct CursorBarrierInfo STEAMIFY_PATCH_EOF ;; gamescope-nvidia.PKGBUILD) cat << 'STEAMIFY_PATCH_EOF' # Maintainer: theupriser # Based on Arch Linux's gamescope PKGBUILD (Maxime Gauduin, Giancarlo Razzolini, Sven-Hendrik Haase and contributors). # # gamescope with GBM-allocated scan-out buffers for NVIDIA (opt-in: gamescope_drm_gbm_scanout=1). # The patches are gamescope-gbm-scanout.patch and gamescope-ui-native.patch (next to this file, applied in this order); see the # README for where they come from. # UPSTREAM_VERSION (next to this file) names the gamescope tag the patches are made for. pkgname=steamify-gamescope-nvidia _srcname=gamescope pkgver=3.16.31 pkgrel=3 pkgdesc='SteamOS session compositing window manager, with GBM scan-out for NVIDIA (opt-in: gamescope_drm_gbm_scanout=1)' arch=(x86_64) url=https://github.com/theupriser/steamify-gamescope-nvidia license=( BSD-2-Clause BSD-3-Clause LicenseRef-Reshade ) provides=("gamescope=$pkgver") conflicts=(gamescope gamescope-nvidia-gbm) replaces=(gamescope-nvidia-gbm) depends=( glibc mesa libstdc++ libgcc lcms2 libavif libcap.so libdecor libdrm libei libinput libpipewire-0.3.so libx11 libxcb libxcomposite libxcursor libxdamage libxext libxfixes libxi libxkbcommon.so libxmu libxrender libxres libxtst libxxf86vm luajit pixman sdl2 seatd systemd-libs vulkan-icd-loader wayland xcb-util-errors xcb-util-wm xorg-server-xwayland ) makedepends=( benchmark cmake git glslang meson ninja vulkan-headers wayland-protocols catch2 ) source=( gamescope::git+https://github.com/ValveSoftware/gamescope.git#commit=${pkgver} gamescope-gbm-scanout.patch gamescope-ui-native.patch git+https://gitlab.freedesktop.org/emersion/libdisplay-info.git git+https://gitlab.freedesktop.org/emersion/libliftoff.git git+https://github.com/ValveSoftware/openvr.git git+https://github.com/Joshua-Ashton/reshade.git git+https://github.com/KhronosGroup/SPIRV-Headers.git git+https://github.com/Joshua-Ashton/vkroots.git git+https://gitlab.freedesktop.org/wlroots/wlroots.git ) b2sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP') prepare() { cd gamescope # GBM scan-out for NVIDIA. If it no longer applies to this gamescope version, stop here: the build must not # silently produce an unpatched gamescope under this package's name. git apply --check ../gamescope-gbm-scanout.patch || { echo "gbm patch does not apply to gamescope ${pkgver}" >&2; return 1; } git apply ../gamescope-gbm-scanout.patch git apply --check ../gamescope-ui-native.patch || { echo "ui-native patch does not apply to gamescope ${pkgver}" >&2; return 1; } git apply ../gamescope-ui-native.patch meson subprojects download for submodule in src/reshade subprojects/{libdisplay-info,libliftoff,openvr,vkroots,wlroots} thirdparty/SPIRV-Headers; do git submodule init ${submodule} git config submodule.${submodule}.url ../${submodule##*/} done git -c protocol.file.allow=always submodule update } build() { arch-meson gamescope build \ -Dforce_fallback_for=glm,stb,libdisplay-info,libliftoff,vkroots \ -Dpipewire=enabled meson compile -C build } package() { DESTDIR="${pkgdir}" meson install -C build \ --skip-subprojects install -Dm 644 gamescope/LICENSE -t "${pkgdir}"/usr/share/licenses/${pkgname}/ } # vim: ts=2 sw=2 et: STEAMIFY_PATCH_EOF ;; gamescope-ui-native.patch) cat << 'STEAMIFY_PATCH_EOF' Keeps Steam's UI X server (server 0) at the output size when Steam asks for a smaller one. With -steamos3 (what gamescope-session-cachyos passes) Steam sets its UI to 1920x1080 right after it starts (GAMESCOPE_XWAYLAND_MODE_CONTROL), so on a bigger display, and on one that isn't 16:9, the whole interface is a scaled-up 1080p picture. Games already run at the display's size. With this the server stays at the output size and Steam draws its window to match; Steam's SteamOS features (settings pages, game launching) keep working. Written for Steamify on an NVIDIA PC (RTX 5080, 3440x1440@175, tested with Steam's UI and a Proton game); not part of the farnoy patch. On by default; the setting gamescope_xwayland_ui_native=0 (in gamescope's environment) turns it off. Applies with `git apply` after gamescope-gbm-scanout.patch (it ignores these lines). diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp index 2cb5eb5..ce14c8d 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp @@ -129,6 +129,8 @@ LogScope g_WaitableLog("waitable"); gamescope::ConVar<bool> cv_overlay_unmultiplied_alpha{ "overlay_unmultiplied_alpha", false }; +gamescope::ConVar<bool> cv_xwayland_ui_native( "xwayland_ui_native", true, "Keep the Steam UI X server (server 0) at the output size when Steam asks for a smaller one." ); + gamescope::ConVar<bool> cv_vr_show_forwarded_overlays{ "vr_show_forwarded_overlays", false }; std::string *g_pVROverlayKey = nullptr; @@ -7240,6 +7242,15 @@ handle_property_notify(xwayland_ctx_t *ctx, XPropertyEvent *ev) height = std::min<int>(height, currentOutputHeight); } + // Steam asks for a smaller size for its own UI (server 0, 1920x1080 with -steamos3), which makes the whole + // interface a scaled-up picture on a bigger display, and on one that isn't 16:9 as well. The UI server + // stays at the output size: Steam sizes its window to the server. + if ( server_idx == 0 && cv_xwayland_ui_native ) + { + width = currentOutputWidth; + height = currentOutputHeight; + } + gamescope_xwayland_server_t *server = wlserver_get_xwayland_server( server_idx ); if ( server ) { STEAMIFY_PATCH_EOF ;; leds-valve-dkms.conf) cat << 'STEAMIFY_PATCH_EOF' # Written by Steamify: build leds-valve for DKMS's target kernel, not the running one. MAKE[0]="make KVERSION=${kernelver}" STEAMIFY_PATCH_EOF ;; python-hid-shim.PKGBUILD) cat << 'STEAMIFY_PATCH_EOF' # Maintainer: theupriser # An empty package that only tells pacman "python-hid is there". For a PC that keeps python-hidapi (liquidctl needs it): # the real python-hid conflicts with it, and jupiter-hw-support (a dependency of gamescope-session-cachyos) wants python-hid. # Only Valve's Steam Deck controller firmware updater in jupiter-hw-support uses it, which a PC doesn't run. # Steamify (lib/packages.sh) builds it with makepkg and installs it before the conversion's packages. pkgname=steamify-python-hid-shim pkgver=1 pkgrel=1 pkgdesc='Placeholder: python-hid for PCs that keep python-hidapi (they conflict)' arch=(any) license=(MIT) depends=(python-hidapi) provides=('python-hid=99') package() { : } STEAMIFY_PATCH_EOF ;; steam-shortcuts.py) cat << 'STEAMIFY_PATCH_EOF' #!/usr/bin/env python3 # Steamify CachyOS: Steam's non-Steam games list (userdata/<id>/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 <vdf> <exe> -> appid of an entry for exe # steam-shortcuts.py add <vdf> <name> <exe> <icon> <desktop file> # -> "added <appid>" / "updated <appid>" # steam-shortcuts.py remove <vdf> <appid> # An entry counts as ours when its Exe is <exe>, 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("<I", data, pos)[0])) pos += 4 else: raise ValueError(f"unknown VDF type {t} at {pos}") if not data: return [("shortcuts", [])] root = block() return root def dump(items): out = bytearray() for key, value in items: k = key.encode("utf-8", "surrogateescape") + b"\0" if isinstance(value, list): out += bytes([MAP]) + k + dump(value) elif isinstance(value, int): out += bytes([INT]) + k + struct.pack("<I", value & 0xFFFFFFFF) else: out += bytes([STR]) + k + value.encode("utf-8", "surrogateescape") + b"\0" return bytes(out) + bytes([END]) def get(entry, key, default=None): # Steam's key names vary in case between versions (appid/AppName/Exe). for k, v in entry: if k.lower() == key.lower(): return v return default def put(entry, key, value): for i, (k, _) in enumerate(entry): if k.lower() == key.lower(): entry[i] = (k, value) return entry.append((key, value)) def shortcuts(root): for k, v in root: if k.lower() == "shortcuts": return v raise ValueError("no shortcuts block") def ours(entry, exe): have = get(entry, "Exe", "").strip('"') return have == exe or (have.endswith("/run-app") and ("/steamify/" in have or "/cachyos-gamescope-boot/" in have)) def load(path): try: with open(path, "rb") as f: return parse(f.read()) except FileNotFoundError: return [("shortcuts", [])] def save(path, root): tmp = path + ".steamify-tmp" with open(tmp, "wb") as f: f.write(dump(root)) os.replace(tmp, path) def main(argv): cmd, path = argv[1], argv[2] root = load(path) entries = shortcuts(root) if cmd == "find": for _, e in entries: if ours(e, argv[3]): print(get(e, "appid", 0)) return 0 return 1 if cmd == "add": name, exe, icon, desktop = argv[3:7] for _, e in entries: if ours(e, exe): put(e, "AppName", name) put(e, "Exe", f'"{exe}"') put(e, "StartDir", f'"{os.path.dirname(exe)}/"') put(e, "icon", icon) put(e, "ShortcutPath", desktop) save(path, root) print("updated", get(e, "appid", 0)) return 0 # Steam's own id for a shortcut: CRC32 of exe and name, top bit set. appid = (zlib.crc32(f'"{exe}"{name}'.encode()) | 0x80000000) & 0xFFFFFFFF entry = [("appid", appid), ("AppName", name), ("Exe", f'"{exe}"'), ("StartDir", f'"{os.path.dirname(exe)}/"'), ("icon", icon), ("ShortcutPath", desktop), ("LaunchOptions", ""), ("IsHidden", 0), ("AllowDesktopConfig", 1), ("AllowOverlay", 1), ("OpenVR", 0), ("Devkit", 0), ("DevkitGameID", ""), ("DevkitOverrideAppID", 0), ("LastPlayTime", 0), ("FlatpakAppID", ""), ("tags", [])] entries.append((str(len(entries)), entry)) if os.path.dirname(path): os.makedirs(os.path.dirname(path), exist_ok=True) save(path, root) print("added", appid) return 0 if cmd == "remove": keep = [e for _, e in entries if str(get(e, "appid", "")) != argv[3]] if len(keep) == len(entries): return 0 entries[:] = [(str(i), e) for i, e in enumerate(keep)] save(path, root) return 0 print(f"unknown command {cmd}", file=sys.stderr) return 2 if __name__ == "__main__": sys.exit(main(sys.argv)) STEAMIFY_PATCH_EOF ;; steamify-cros-ec-cec.dkms.conf) cat << 'STEAMIFY_PATCH_EOF' # Written by Steamify: Valve's cros_ec_cec, which knows the Steam Machine. PACKAGE_NAME="@NAME@" PACKAGE_VERSION="@VERSION@" BUILT_MODULE_NAME[0]="cros-ec-cec" DEST_MODULE_LOCATION[0]="/updates/dkms" AUTOINSTALL="yes" STEAMIFY_PATCH_EOF ;; steamify-fremont-poweroff.c) cat << 'STEAMIFY_PATCH_EOF' // SPDX-License-Identifier: GPL-2.0 /* * Steam Machine (Fremont): power off instead of booting up again. * * Since "pinctrl-amd: Don't clear S4 wake bits at probe" (Linux 7.2, also * backported to 6.x, 7.0 and 7.1 updates) the S4/S5 wake bit the firmware * leaves set on GPIO pin 18 (_SB.PCI0.GPP6) stays set, and the machine starts again right after powering off. * Valve's kernel clears that bit at probe on Fremont (not for upstream); * this does the same right before power-off, for kernels without it. * * What it does follows Valve's Steam Machine kernel * (https://github.com/evlaV/linux-integration, GPL-2.0 like the rest of the * Linux kernel); this module is Steamify's own implementation of it, also * GPL-2.0. Not affiliated with or endorsed by Valve Corporation. */ #include <linux/acpi.h> #include <linux/bits.h> #include <linux/dmi.h> #include <linux/io.h> #include <linux/module.h> #include <linux/platform_device.h> #include <linux/reboot.h> #define FREMONT_GPP6_PIN 18 #define WAKE_CNTRL_OFF_S4 15 static void __iomem *pin_reg; static struct sys_off_handler *handler; static int fremont_poweroff_prepare(struct sys_off_data *data) { u32 v = readl(pin_reg); if (v & BIT(WAKE_CNTRL_OFF_S4)) writel(v & ~BIT(WAKE_CNTRL_OFF_S4), pin_reg); return NOTIFY_DONE; } static int __init fremont_poweroff_init(void) { struct acpi_device *adev; struct device *dev; struct resource *res; u32 v; if (!dmi_match(DMI_BOARD_NAME, "Fremont")) return -ENODEV; adev = acpi_dev_get_first_match_dev("AMDI0030", NULL, -1); if (!adev) return -ENODEV; dev = bus_find_device_by_acpi_dev(&platform_bus_type, adev); acpi_dev_put(adev); if (!dev) return -ENODEV; res = platform_get_resource(to_platform_device(dev), IORESOURCE_MEM, 0); put_device(dev); if (!res || resource_size(res) < (FREMONT_GPP6_PIN + 1) * 4) return -ENODEV; /* pinctrl-amd owns the region; only this one register is touched. */ pin_reg = ioremap(res->start + FREMONT_GPP6_PIN * 4, 4); if (!pin_reg) return -ENOMEM; v = readl(pin_reg); pr_info("GPIO %d register 0x%08x, S4/S5 wake %s\n", FREMONT_GPP6_PIN, v, v & BIT(WAKE_CNTRL_OFF_S4) ? "set (cleared at power-off)" : "clear"); handler = register_sys_off_handler(SYS_OFF_MODE_POWER_OFF_PREPARE, SYS_OFF_PRIO_DEFAULT, fremont_poweroff_prepare, NULL); if (IS_ERR(handler)) { iounmap(pin_reg); return PTR_ERR(handler); } return 0; } static void __exit fremont_poweroff_exit(void) { unregister_sys_off_handler(handler); iounmap(pin_reg); } module_init(fremont_poweroff_init); module_exit(fremont_poweroff_exit); MODULE_DESCRIPTION("Steam Machine: clear GPIO 18's S4 wake bit before power-off"); MODULE_LICENSE("GPL"); MODULE_ALIAS("dmi:*:rnFremont:*"); STEAMIFY_PATCH_EOF ;; steamify-fremont-poweroff.dkms.conf) cat << 'STEAMIFY_PATCH_EOF' # Written by Steamify: the Steam Machine powers off instead of booting up again. PACKAGE_NAME="@NAME@" PACKAGE_VERSION="@VERSION@" BUILT_MODULE_NAME[0]="@NAME@" DEST_MODULE_LOCATION[0]="/updates/dkms" AUTOINSTALL="yes" STEAMIFY_PATCH_EOF ;; steamify-notifier.py) cat << 'STEAMIFY_PATCH_EOF' #!/usr/bin/env python3 # Steamify CachyOS: update notifications (installed by the "Update # notifications" menu item, run by a systemd user timer and at each Plasma # login). Never updates anything itself: when GitHub has a newer release # than the last Steamify that ran here, it shows a notification (Open # Steamify / Skip this version) and a tray icon until you open the app, # skip the version or pick "Remind me later". Nothing runs in between. import configparser import json import os import subprocess import sys import threading import time import urllib.request REPO = "theupriser/steamify-cachyos" STATE_DIR = os.path.join(os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state"), "steamify") STATE = os.path.join(STATE_DIR, "notify.state") DATA = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") RUN_APP = os.path.join(DATA, "steamify", "bin", "run-app") RUN_TERMINAL = os.path.join(DATA, "steamify", "bin", "run-wizard") RELEASE = f"https://github.com/{REPO}/releases/latest/download" def version(v): try: return tuple(int(p) for p in v.lstrip("v").split(".")) except ValueError: return None def state(key): cp = configparser.ConfigParser(interpolation=None) cp.read(STATE) return cp.get("State", key, fallback="") def set_state(key, value): # Same ini as lib/state.sh's state_set writes. subprocess.run(["kwriteconfig6", "--file", STATE, "--group", "State", "--key", key, value], check=False) def latest(): req = urllib.request.Request(f"https://api.github.com/repos/{REPO}/releases/latest", headers={"User-Agent": "steamify-notifier", "Accept": "application/vnd.github+json"}) with urllib.request.urlopen(req, timeout=30) as r: return json.load(r)["tag_name"].lstrip("v") def pending(new): # Newer than the last Steamify that ran here, and not skipped. seen = version(state("seen") or "0") return seen is not None and version(new) > seen and new != state("skipped") def desktop(): # Only on the Plasma desktop: gamescope doesn't show notifications or a # tray, so an update found in gaming mode waits for the next desktop login. return subprocess.run(["pgrep", "-u", str(os.getuid()), "-x", "plasmashell"], stdout=subprocess.DEVNULL).returncode == 0 def main(): if "--delay" in sys.argv: time.sleep(int(sys.argv[sys.argv.index("--delay") + 1])) if not desktop(): return 0 try: new = latest() except Exception as e: # offline, rate limited: try again next time print(f"Couldn't check for a new Steamify: {e}", file=sys.stderr) return 0 if version(new) is None or not pending(new): return 0 from PySide6.QtCore import QObject, QTimer, Signal from PySide6.QtGui import QAction, QIcon from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon app = QApplication(sys.argv[:1]) app.setApplicationName("Steamify") app.setDesktopFileName("steamify-ui") app.setQuitOnLastWindowClosed(False) icon = QIcon.fromTheme("steamify", QIcon.fromTheme("steam")) def open_app(): # The way Steamify was last used (the app or the terminal menu), the # newest release either way; it records its version as seen, which # ends the reminder. In its own unit: this one's processes are # stopped when the check exits. if state("frontend") == "terminal": cmd = ["konsole", "-e", RUN_TERMINAL] if os.access(RUN_TERMINAL, os.X_OK) else \ ["konsole", "-e", "bash", "-c", f"set -o pipefail; curl -fsSL --max-time 30 {RELEASE}/steamify.sh | bash; read -rp 'Press Enter to close. ' _"] else: cmd = [RUN_APP] if os.access(RUN_APP, os.X_OK) else \ ["bash", "-c", f"set -o pipefail; curl -fsSL --max-time 30 {RELEASE}/steamify-app.sh | bash"] # A scope lives as long as anything in it: the start script puts the # app in the background and exits. subprocess.Popen(["systemd-run", "--user", "--scope", "--collect", "--quiet", *cmd], start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) app.quit() def skip(): set_state("skipped", new) app.quit() tray = QSystemTrayIcon(icon) tray.setToolTip(f"Steamify {new} is available") menu = QMenu() for text, fn in ((f"Open Steamify {new}", open_app), ("Skip this version", skip), ("Remind me later", app.quit)): a = QAction(text, menu) a.triggered.connect(fn) menu.addAction(a) tray.setContextMenu(menu) tray.activated.connect(lambda reason: open_app() if reason == QSystemTrayIcon.ActivationReason.Trigger else None) tray.show() # The notification's buttons: notify-send waits for the click, so it # runs beside the event loop. Closing it without a click keeps the icon. class Relay(QObject): picked = Signal(str) relay = Relay() relay.picked.connect(lambda a: open_app() if a == "open" else skip() if a == "skip" else None) def notify(): r = subprocess.run(["notify-send", "--app-name=Steamify", "--icon=steamify", "-A", "open=Open Steamify", "-A", "skip=Skip this version", f"Steamify {new} is available", "Open Steamify to see what's new and choose what to update."], capture_output=True, text=True) relay.picked.emit(r.stdout.strip()) threading.Thread(target=notify, daemon=True).start() # Opened some other way (the shortcut, the terminal): the reminder is done. poll = QTimer() poll.timeout.connect(lambda: None if pending(new) else app.quit()) poll.start(60_000) return app.exec() if __name__ == "__main__": sys.exit(main()) STEAMIFY_PATCH_EOF ;; steamify-nvidia-initramfs.hook) cat << 'STEAMIFY_PATCH_EOF' # Installed by Steamify CachyOS (SteamOS conversion, NVIDIA fix): decides # again whether the NVIDIA modules go into the initramfs, after DKMS has built # its modules (71-dkms-install) and before the initramfs is built # (90-mkinitcpio-install); hooks run in file name order. [Trigger] Operation = Install Operation = Upgrade Operation = Remove Type = Path Target = usr/lib/modules/*/vmlinuz Target = usr/lib/modules/*/extramodules/nvidia* Target = usr/lib/modules/*/updates/* [Action] Description = Steamify: NVIDIA modules in the initramfs only if every kernel has them When = PostTransaction Exec = @SCRIPT@ sync STEAMIFY_PATCH_EOF ;; steamify-nvidia-initramfs.sh) cat << 'STEAMIFY_PATCH_EOF' #!/bin/bash # Installed by Steamify CachyOS (SteamOS conversion, NVIDIA fix), run by the # pacman hook steamify-nvidia-initramfs.hook before the initramfs is built. # The NVIDIA modules go into the initramfs (early KMS) only while EVERY # installed kernel has them: mkinitcpio fails on a module a kernel lacks, and # limine-mkinitcpio then skips that kernel's boot entry, kernel parameters # included. Decided again at every kernel or driver change, so a new kernel # without the modules (another kernel, a failed DKMS build, a downgrade) gets # a working initramfs and the file comes back once it has them. # steamify-nvidia-initramfs [sync|check] # sync (default) write or remove the drop-in to match # check exit status 0 when every installed kernel has nvidia_drm conf="${NVIDIA_INITRAMFS_CONF:-/etc/mkinitcpio.conf.d/90-steamify-nvidia.conf}" mods="${NVIDIA_MODULES_DIR:-/usr/lib/modules}" everywhere() { local k found=0 for k in "$mods"/*/; do [[ -f "$k/vmlinuz" || -f "$k/pkgbase" ]] || continue # an installed kernel, not a leftover folder find "$k" -name 'nvidia-drm.ko*' -print -quit 2>/dev/null | grep -q . || return 1 found=1 done [[ $found == 1 ]] } case "${1:-sync}" in check) everywhere ;; sync) if everywhere; then [[ -f "$conf" ]] || { mkdir -p "$(dirname "$conf")" && echo 'MODULES+=(nvidia nvidia_modeset nvidia_uvm nvidia_drm)' > "$conf"; } else rm -f "$conf" fi ;; *) echo "usage: $0 [sync|check]" >&2; exit 2 ;; esac STEAMIFY_PATCH_EOF ;; steamify-os-release.hook) cat << 'STEAMIFY_PATCH_EOF' # Installed by Steamify CachyOS (SteamOS conversion): runs after # cachyos-hooks' os-release/cachyos-branding hooks (hooks run in file name # order). [Trigger] Operation = Install Operation = Upgrade Type = Package Target = filesystem Target = cachyos-hooks [Action] Description = Steamify: the OS variant and version Steam shows When = PostTransaction Exec = @SCRIPT@ STEAMIFY_PATCH_EOF ;; steamify-os-release.sh) cat << 'STEAMIFY_PATCH_EOF' #!/bin/sh # Installed by Steamify CachyOS (SteamOS conversion): what Steam shows in its # System settings besides the OS name, which stays CachyOS's: the SteamOS # release Steamify follows as the OS version (VERSION_ID, e.g. steamos-3.9), # steam-machine as the codename, and Steamify with its version as the variant # (VARIANT_ID, which Steam shows). KDE's About this System would show # VERSION_ID after the name: the conversion sets its UseOSReleaseVersion, so it # shows os-release's VERSION instead, which CachyOS doesn't have. NAME and # PRETTY_NAME are never touched: limine-snapper-sync finds the boot entries by # the OS name. The zz-steamify-os-release hook runs this after cachyos-hooks. sed -i -e '/^VARIANT=/d' -e '/^VARIANT_ID=/d' -e '/^VERSION_ID=/d' -e '/^VERSION_CODENAME=/d' /etc/os-release printf '%s\n' 'VARIANT="Steamify @VERSION@"' 'VARIANT_ID=steamify-@VERSION@' 'VERSION_CODENAME=steam-machine' >> /etc/os-release [ -n "@STEAMOS@" ] && echo 'VERSION_ID=@STEAMOS@' >> /etc/os-release exit 0 STEAMIFY_PATCH_EOF ;; sync-steamos-session.sh) cat << 'STEAMIFY_PATCH_EOF' #!/bin/bash SRC="/etc/plasmalogin.conf.d/zz-steamos-autologin.conf" DEST="/etc/plasmalogin.conf" [[ -f "$SRC" ]] || exit 0 SESSION=$(grep -oP '^Session=\K.*' "$SRC") [[ -z "$SESSION" ]] && exit 0 # Only touch Session= inside [Autologin]; other sections may have their own. if sed -n '/^\[Autologin\]/,/^\[/p' "$DEST" | grep -q '^Session='; then sed -i "/^\[Autologin\]/,/^\[/ s|^Session=.*|Session=$SESSION|" "$DEST" elif grep -q '^\[Autologin\]' "$DEST"; then sed -i "/^\[Autologin\]/a Session=$SESSION" "$DEST" else printf '\n[Autologin]\nSession=%s\n' "$SESSION" >> "$DEST" fi STEAMIFY_PATCH_EOF ;; *) return 1 ;; esac } # ----- services/ ----- service_raw() { case "$1" in ensure-kernel-headers.service) cat << 'STEAMIFY_PATCH_EOF' [Unit] Description=Install missing kernel headers so DKMS builds leds-valve for every kernel Wants=network-online.target After=network-online.target [Service] Type=oneshot ExecStart=@HEADERS_SCRIPT@ [Install] WantedBy=multi-user.target STEAMIFY_PATCH_EOF ;; steamify-boot-desktop.service) cat << 'STEAMIFY_PATCH_EOF' [Unit] Description=Steamify CachyOS: boot into the desktop instead of gaming mode Before=display-manager.service sddm.service plasmalogin.service [Service] Type=oneshot ExecStart=/usr/lib/steamos/steam-set-session plasma.desktop ExecStart=/bin/sh -c '[ -x @SESSION_SYNC@ ] && exec @SESSION_SYNC@ || true' [Install] WantedBy=graphical.target STEAMIFY_PATCH_EOF ;; steamify-steam-autostart.service) cat << 'STEAMIFY_PATCH_EOF' [Unit] Description=Steam at login (Steamify, NVIDIA PCs) After=graphical-session.target PartOf=graphical-session.target [Service] Type=simple # Only in the Plasma desktop session. ExecCondition=/bin/sh -c '[ "$XDG_CURRENT_DESKTOP" = KDE ]' ExecStart=/usr/bin/steam @ARGS@ Restart=on-failure RestartSec=5 [Install] WantedBy=graphical-session.target STEAMIFY_PATCH_EOF ;; steamify-update-check.service) cat << 'STEAMIFY_PATCH_EOF' [Unit] Description=Steamify: check for a new release After=plasma-workspace.target # The tray icon goes with a logout. PartOf=graphical-session.target [Service] Type=simple # The short wait at login lets the tray come up first (in the script: an # ExecStartPre sleep runs into the start timeout). # Quoted: the path is in the home folder, which may contain a space. ExecStart="@NOTIFY_SCRIPT@" --delay 60 [Install] WantedBy=plasma-workspace.target STEAMIFY_PATCH_EOF ;; steamify-update-check.timer) cat << 'STEAMIFY_PATCH_EOF' [Unit] Description=Steamify: check for a new release daily [Timer] OnCalendar=daily RandomizedDelaySec=1h Persistent=true [Install] WantedBy=timers.target STEAMIFY_PATCH_EOF ;; sync-steamos-session.path) cat << 'STEAMIFY_PATCH_EOF' [Unit] Description=Watch for steamos session changes [Path] PathModified=/etc/plasmalogin.conf.d/zz-steamos-autologin.conf [Install] WantedBy=multi-user.target STEAMIFY_PATCH_EOF ;; sync-steamos-session.service) cat << 'STEAMIFY_PATCH_EOF' [Unit] Description=Sync steamos session selection into plasmalogin.conf # Session switches can come in bursts (switch + autologin reset); never # let systemd's start rate limit silently stop the bridge. StartLimitIntervalSec=0 [Service] Type=oneshot ExecStart=/usr/local/bin/sync-steamos-session.sh STEAMIFY_PATCH_EOF ;; *) return 1 ;; esac } # ----- steamify.sh ----- main() { require_root_helper BACKEND=false [[ "${1:-}" == --backend ]] && BACKEND=true # --defaults [--options <id>,...] [--boot gamescope|desktop]: apply what the # menu would preselect (or exactly the listed items), without the menu or any # prompt (the Steam Machine ISO's first login runs this; sudo must not ask). DEFAULTS=false [[ "${1:-}" == --defaults ]] && DEFAULTS=true # --defaults --list: what --defaults can set up here, as JSON (an installer # page is built from it); nothing else on stdout. LIST=false [[ "$DEFAULTS" == true && "${2:-}" == --list ]] && LIST=true # --boot gamescope|desktop: only change where an installed conversion # starts, everything else stays as it is (for scripts; no menu). BOOT_ONLY=false [[ "${1:-}" == --boot ]] && BOOT_ONLY=true if [[ "$BACKEND" == false && "$LIST" == false ]]; then echo -e "${c_bold}Steamify CachyOS${c_reset} v$VERSION" echo "Turn the SteamOS-style parts on or off. The menu shows what is on now;" echo "anything you turn off is put back the way it was." fi if ! command -v pacman >/dev/null 2>&1; then err "This doesn't look like an Arch/CachyOS system (no pacman found). Aborting." exit 1 fi # Everything per-user (autologin user, Steam, theme, shortcut) is for the # user running the script. TARGET_USER="$(id -un)" migrate_layout notify_seen restart_needed() { [[ -n "${BIOS_NEEDS_RESTART:-}" || "$RESTART_FOR_LOGIN" == true ]]; } restart_now() { if [[ -n "${BIOS_NEEDS_RESTART:-}" ]]; then warn "The BIOS update is written during this restart. Keep the power on and don't" warn "touch the machine until it has fully started again, even if the screen stays black." fi if [[ -n "${BIOS_DRY_RUN:-}" ]]; then ok "Dry run: would restart now (sudo reboot); not restarting." exit 0 fi info "Restarting..." sudo reboot exit 0 } after_run() { # After a run: back to the menu, or restart right away when something # that just ran needs it. Returns 1 when input has ended (scripted runs). local reply if ! restart_needed; then read -rp "Press Enter to go back to the menu... " _ || return 1 return 0 fi if [[ -n "${BIOS_NEEDS_RESTART:-}" ]]; then warn "The BIOS update is installed at the next restart." else info "The login changes take effect after a restart." fi while true; do read -rp "$(echo -e "${c_bold}[m]${c_reset} back to the menu ${c_bold}[r]${c_reset} restart now: ")" reply || return 1 case "$reply" in m|M|"") return 0 ;; r|R) restart_now ;; *) warn "Type m or r." ;; esac done } quit_prompt() { # On q: when something needs a restart, offer it (default yes); "n" goes # back to the menu, and the next q asks again. Returns 0 to show the menu # again, 1 to quit (nothing to restart, or input ended in scripted runs). local reply restart_needed || return 1 echo if [[ -n "${BIOS_NEEDS_RESTART:-}" ]]; then warn "The BIOS update is written during the next restart. Keep the power on and" warn "don't touch the machine until it has fully started again, even if the screen stays black." else info "The changes take effect after a restart." fi read -rp "$(echo -e "${c_bold}Restart now?${c_reset} [Y/n] (n = back to the menu, Ctrl+C = quit without restarting) ")" reply || return 1 case "$reply" in ""|y|Y) restart_now ;; esac return 0 } # The graphical app (steamify-ui) drives the same components through # lib/backend.sh instead of the menu. if [[ "$BACKEND" == true ]]; then RESTART_FOR_LOGIN=false shift backend_main "$@" exit $? fi if [[ "${1:-}" == --first-login ]]; then first_login_run exit 0 fi if [[ "$LIST" == true ]]; then defaults_list exit 0 fi if [[ "$DEFAULTS" == true ]]; then RESTART_FOR_LOGIN=false REAPPLY=false sudo -n true 2>/dev/null || { err "--defaults needs sudo without a password."; exit 1; } detect_components defaults_options "${@:2}" || exit 1 plan_changes apply_changes # Run by the installer: the rest waits for the first desktop login. user_session || first_login_schedule [[ ${#FAILED[@]} -eq 0 ]] || { warn "These had problems (see above): ${FAILED[*]}"; exit 1; } ok "Done; the changes take effect after a restart." exit 0 fi if [[ "$BOOT_ONLY" == true ]]; then RESTART_FOR_LOGIN=false REAPPLY=false case "${2:-}" in gamescope|desktop) ;; *) err "--boot takes gamescope or desktop"; exit 1 ;; esac detect_components [[ "${CURRENT[gaming]}" == 1 ]] || { err "--boot needs the SteamOS conversion; turn it on first."; exit 1; } # What is on stays on, and nothing else changes: not the updates or # removals a normal run would pick. for c in "${COMPONENTS[@]}"; do WANTED[$c]=${CURRENT[$c]:-0}; done WANTED[boot]=0; [[ "$2" == desktop ]] && WANTED[boot]=1 plan_changes if [[ ${#TO_DISABLE[@]} -eq 0 && ${#TO_ENABLE[@]} -eq 0 ]]; then ok "Already starting in $2." exit 0 fi apply_changes [[ ${#FAILED[@]} -eq 0 ]] || { warn "These had problems (see above): ${FAILED[*]}"; exit 1; } ok "Starts in $2 from the next boot on." exit 0 fi # Menu loop: after each run the menu comes back with the new state, until # the user quits; the restart question comes then, once, for everything. RESTART_FOR_LOGIN=false SUDO_KEEPALIVE=false while true; do detect_components if ! run_menu; then quit_prompt && continue break fi plan_changes if [[ ${#TO_DISABLE[@]} -eq 0 && ${#TO_ENABLE[@]} -eq 0 ]]; then feature_record_unticked ok "Everything is already the way you want it." read -rp "Press Enter to go back to the menu... " _ || break continue fi echo echo -e "${c_bold}This will:${c_reset}" for c in "${TO_DISABLE[@]}"; do if [[ "$c" == boot ]]; then echo " - boot into: gamescope (from the next boot)" else echo " - turn off: ${LABEL[$c]}"; fi done for c in "${TO_ENABLE[@]}"; do if [[ "$c" == boot ]]; then echo " - boot into: desktop (from the next boot)" elif is_action "$c"; then echo " - run: ${LABEL[$c]%%:*} at your own risk (checks, then asks twice more)" elif feature_outdated "$c"; then echo " - update: ${LABEL[$c]} (changed in this version)" elif [[ "${CURRENT[$c]}" == 1 ]]; then echo " - re-apply: ${LABEL[$c]}" elif feature_new "$c"; then echo " - turn on: ${LABEL[$c]} (new in this version)" else echo " - turn on: ${LABEL[$c]}"; fi done ask_yn "Go ahead?" y || { info "Nothing changed."; continue; } # Ask for the sudo password once and keep it fresh, instead of prompting # at random points during the run. sudo -n true 2>/dev/null || sudo -v || exit 1 if [[ "$SUDO_KEEPALIVE" == false ]]; then SUDO_KEEPALIVE=true while true; do sudo -n true; sleep 50; kill -0 "$$" 2>/dev/null || exit; done 2>/dev/null & fi apply_changes # Login manager changes only take effect after a restart. [[ " ${TO_DISABLE[*]} ${TO_ENABLE[*]} " =~ \ (gaming|single|boot|kpin)\ ]] && RESTART_FOR_LOGIN=true echo detect_components echo -e "${c_bold}Done. Current state:${c_reset}" for c in "${COMPONENTS[@]}"; do component_available "$c" && ! is_action "$c" || continue if [[ "$c" == boot ]]; then [[ "${CURRENT[gaming]}" == 1 ]] && echo " └ boots into: $(boot_mode "${CURRENT[boot]}")" elif [[ -n "${PARENT[$c]:-}" ]]; then [[ "${CURRENT[${PARENT[$c]}]}" == 1 ]] || continue if [[ "${CURRENT[$c]}" == 1 ]]; then echo -e " └ ${c_green}on ${c_reset} ${LABEL[$c]}"; else echo " └ off ${LABEL[$c]}"; fi elif [[ "${CURRENT[$c]}" == 1 ]]; then echo -e " ${c_green}on ${c_reset} ${LABEL[$c]}"; else echo " off ${LABEL[$c]}"; fi done if [[ ${#FAILED[@]} -gt 0 ]]; then warn "These had problems (see above): ${FAILED[*]}" fi echo after_run || break done echo if restart_needed; then # Only reached when input ended (scripted runs): never restart then. info "Not restarted; the changes take effect after a restart." else info "Bye." fi } # Piped in via curl, bash reads this very file from stdin. Give the menu # the terminal instead - on the same line as main and exit: bash has read # this line in full, so it never reads further script from the terminal. reattach_tty() { if [[ ! -t 0 && -z "${WIZARD_KEEP_STDIN:-}" ]]; then { exec </dev/tty; } 2>/dev/null || true; fi; } reattach_tty; main "$@"; exit $?