#!/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.10.0 # Single-file build (v2.10.0, 1074514), 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 } 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 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 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 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 krevert gaming os_name_disable 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_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 systemd_user_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" # 3. Setup robust background systemd service for Steam autostart on Desktop Mode systemd_user_dir="$HOME/.config/systemd/user" mkdir -p "$systemd_user_dir" service_file steam-desktop-autostart.service > "$systemd_user_dir/steam-desktop-autostart.service" # Clean out old .desktop shortcut so they don't fight rm -f "$HOME/.config/autostart/steam.desktop" # Reload user systemd context and activate the background loop user_systemctl daemon-reload user_systemctl enable --now steam-desktop-autostart.service ok "Steam UI parameters and keyboard autostart deployed successfully." } steam_disable() { info "Removing Steam desktop autostart..." user_systemctl disable --now steam-desktop-autostart.service 2>/dev/null rm -f "$HOME/.config/systemd/user/steam-desktop-autostart.service" \ "$HOME/.config/environment.d/99-kde-virtual-keyboard.conf" user_systemctl daemon-reload 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 && sudo systemd-tmpfiles --create "$SERIAL_TMPFILES" || { err "Making the serial number readable for Steam failed."; return 1; } } 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} # 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 } # Shown on NVIDIA PCs, unless the SteamOS conversion is already on there (it # has to be turned off first: both would start Steam at login). nvidia_available() { nvidia_present && ! gaming_status 2>/dev/null; } 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=""; bigpicture_status && unit_args=-gamepadui 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 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 pkgs="$(state_get nvidia installed_pkgs)" # 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 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." } # --- 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 the RTX 20 series, 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_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/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 "$CEC_DRIVER_URL" -o "$tmp/cros-ec-cec.c" || ! echo "$CEC_DRIVER_SHA256 $tmp/cros-ec-cec.c" | sha256sum -c --quiet -; then rm -rf "$tmp" err "Downloading Valve's CEC driver failed (or its checksum didn't match)." return 1 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. if single_status; then sleep 3 stop_plasmashell_for_edit single_launcher restart_plasmashell_if_stopped fi extras_enable || warn "SteamOS desktop extras not installed (see above); the theme itself is on." ok "Vapor theme active." } 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 (through our autostart unit when that's there). local was_running=false i if pgrep -x steam >/dev/null; then steamgame_steam_busy && return 1 was_running=true info "Closing Steam for a moment (it only reads its games list at startup)..." # A Steam that's still starting ignores the request: ask again. for ((i = 0; i < 90; i++)); do ((i % 15 == 0)) && steam -shutdown >/dev/null 2>&1 pgrep -x steam >/dev/null || break sleep 1 done pgrep -x steam >/dev/null && { err "Steam didn't close; nothing changed."; return 1; } fi "$1"; local rc=$? if [[ "$was_running" == true ]]; then if user_systemctl is-enabled -q steam-desktop-autostart.service 2>/dev/null; then user_systemctl start steam-desktop-autostart.service else systemd-run --user --collect -q steam -silent >/dev/null 2>&1 fi info "Steam is starting again." fi return "$rc" } steamgame_icon() { # The PNG, or the SVG when it can't be rendered (librsvg comes with KDE). if [[ -f "$WIZARD_ICON" ]] && command -v rsvg-convert >/dev/null && mkdir -p "$STEAMIFY_DATA" && rsvg-convert -w 256 -h 256 -o "$STEAMGAME_ICON" "$WIZARD_ICON" 2>/dev/null; then echo "$STEAMGAME_ICON" else echo "$WIZARD_ICON" fi } steamgame_add_all() { local f out icon icon="$(steamgame_icon)" while IFS= read -r f; do # A copy of each account's list before the first change. [[ -f "$f" && ! -f "$STATE_DIR/shortcuts-$(basename "$(dirname "$(dirname "$f")")").vdf" ]] && mkdir -p "$STATE_DIR" && cp -a "$f" "$STATE_DIR/shortcuts-$(basename "$(dirname "$(dirname "$f")")").vdf" out="$(steamgame_tool add "$f" "$STEAMGAME_NAME" "$WIZARD_APP_LAUNCHER" "$icon" "$WIZARD_APP_ENTRY")" || { err "Couldn't add Steamify to $f."; return 1; } state_set steamgame "$(basename "$(dirname "$(dirname "$f")")")" "${out#* } ${out%% *}" info "Steam library ($(basename "$(dirname "$(dirname "$f")")")): ${out%% *} $STEAMGAME_NAME." done < <(steamgame_files) } steamgame_remove_all() { local f id while IFS= read -r f; do [[ -f "$f" ]] || continue # Any entry for our start script: it can't start anything without it. while id="$(steamgame_tool find "$f" "$WIZARD_APP_LAUNCHER")"; do steamgame_tool remove "$f" "$id" || return 1 done done < <(steamgame_files) } steamgame_enable() { [[ -x "$WIZARD_APP_LAUNCHER" ]] || { err "The Steamify shortcut isn't set up; turn that on first."; return 1; } info "Adding $STEAMGAME_NAME to your Steam library..." steamgame_edit steamgame_add_all || return 1 ok "Steamify is in your Steam library (non-Steam games): start it from there for the controller, in gaming mode too." } steamgame_disable() { info "Removing $STEAMGAME_NAME from your Steam library..." steamgame_edit steamgame_remove_all || return 1 state_clear steamgame rm -f "$STEAMGAME_ICON" ok "Steamify removed from your Steam library." } # ----- 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_schedule() { install_executable "$FIRST_LOGIN_SCRIPT" 755 << EOF2 #!/bin/bash # Installed by Steamify CachyOS: runs once, at the first desktop login. set -o pipefail curl -fsSL --max-time 30 "$WIZARD_URL" | bash -s -- --first-login 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 if single_status; then stop_plasmashell_for_edit single_launcher restart_plasmashell_if_stopped fi 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 nvidia bigpicture theme glyphs 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 [bigpicture]=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 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 [nvidia]=2.10.0 [bigpicture]=2.10.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" [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 # gamescope's own session is broken on NVIDIA: the conversion is replaced by "Gaming on NVIDIA" there (shown # anyway when it's already on, so it can be turned off). gaming|boot|glyphs) ! nvidia_present || gaming_status ;; nvidia) nvidia_available ;; bigpicture) bigpicture_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 [[ -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 [[ "${CURRENT[$1]:-0}" == 1 ]] || return 1 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 # 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]:-}"),\"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. # (On NVIDIA the conversion isn't offered: single user mode logs in by itself, see lib/single-user.sh.) 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; 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 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 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+=",\"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. # (Single user mode only needs the conversion where it is offered: not on NVIDIA PCs.) [[ "${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[nvidia]:-0}" == 0 ]] && WANTED[bigpicture]=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 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 ;; 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 ;; 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 ;; 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. */ #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 ;; steam-desktop-autostart.service) cat << 'STEAMIFY_PATCH_EOF' [Unit] Description=Steam Background Autostart for Virtual Keyboard After=graphical-session.target PartOf=graphical-session.target [Service] Type=simple # Only in the Plasma desktop session; gamescope-session starts its own Steam. ExecCondition=/bin/sh -c '[ "$XDG_CURRENT_DESKTOP" = KDE ]' ExecStart=/usr/bin/steam -silent Restart=on-failure RestartSec=5 [Install] WantedBy=graphical-session.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 $?