Files
lhns--steam-frame-nix/install.sh
T
Pierre Kisters 6f163141f5 install --clone: clone with the template's credential helpers
The template now enables git (SteamOS's own, package = null; Home
Manager writes its config) with the GitHub CLI's credential helper for
github.com and `store` (~/.git-credentials) for other hosts.

Without a Home Manager configuration, `install --clone` first creates
and activates the template in the target directory, then clones next to
it with git's helpers and GIT_TERMINAL_PROMPT=0 (never a password
prompt). If that fails with a terminal, it offers `gh auth login` and
retries once; otherwise the template stays active and the message says
how to continue. After the clone the template is removed, the clone
takes its place and Home Manager switches to it.

Only a missing (or empty) target or the untouched template is cloned
into: create_config marks its configuration with the hash of its files
in .git/steam-frame-nix-template. Anything else there is used as it is,
so a rerun continues where a run stopped (after a failed switch it just
switches); Nix and the template switch (helpers present) are skipped
when already done. The origin comparison and the pull are gone.
2026-10-01 23:25:10 +02:00

1481 lines
57 KiB
Bash
Executable File

#!/usr/bin/env bash
# Install or uninstall Nix + Home Manager on SteamOS (Steam Frame, Steam Deck),
# and clean up what steam-frame-nix wrote outside the Nix store.
#
# curl -fsSL https://steam-frame-nix.lhns.de | bash -s -- install
# curl -fsSL https://steam-frame-nix.lhns.de | bash -s -- uninstall
# curl -fsSL https://steam-frame-nix.lhns.de | bash -s -- cleanup --all
#
# Run `install.sh --help` for details. Works from a file or piped into bash
# (prompts read from /dev/tty; tests: STEAM_FRAME_NIX_TTY).
set -euo pipefail
TEMPLATE="${STEAM_FRAME_NIX_TEMPLATE:-github:lhns/steam-frame-nix}"
NIX_INSTALLER_URL="${NIX_INSTALLER_URL:-https://artifacts.nixos.org/nix-installer}"
HM_FLAKE="${HM_FLAKE:-home-manager/master}"
NIX_PROFILE_SCRIPT=/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
NIX_RECEIPT=/nix/receipt.json
NIX_INSTALLER_BIN=/nix/nix-installer
FLAKES_LINE="experimental-features = nix-command flakes"
CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
HM_CONFIG_LINK="$CONFIG_HOME/home-manager"
DEFAULT_CONFIG_DIR="$HOME/nix-config"
USER_NIX_CONF="$CONFIG_HOME/nix/nix.conf"
USER_UNIT_DIR="$CONFIG_HOME/systemd/user"
USER_NAME="$(id -un)"
USER_ID="$(id -u)"
OUTER_RUNTIME_DIR="${STEAM_FRAME_NIX_RUNTIME_DIR:-/run/user/$USER_ID}"
SFN_PROC="${STEAM_FRAME_NIX_PROC:-/proc}" # tests: a fake /proc
SFN_TTY="${STEAM_FRAME_NIX_TTY:-/dev/tty}"
# Runtime drop-in for the uninstall (tests: another directory).
NIX_MOUNT_DROPIN="${STEAM_FRAME_NIX_SYSTEM_RUNTIME:-/run/systemd/system}/nix.mount.d/50-steam-frame-nix-lazy-unmount.conf"
ASSUME_YES=0
RO_RELOCK=0
CLONE_TMP=''
# --- output -----------------------------------------------------------------
if [[ -t 1 ]]; then B=$'\e[1m' G=$'\e[1;32m' Y=$'\e[1;33m' R=$'\e[1;31m' N=$'\e[0m'
else B='' G='' Y='' R='' N=''; fi
step() { printf '%s==>%s %s%s%s\n' "$G" "$N" "$B" "$*" "$N"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '%swarning:%s %s\n' "$Y" "$N" "$*" >&2; }
die() { printf '%serror:%s %s\n' "$R" "$N" "$*" >&2; exit 1; }
usage() {
cat <<EOF
Nix + Home Manager for SteamOS (Steam Frame, Steam Deck).
Usage: install.sh <command> [options]
Commands:
install [--flake <dir-or-flakeref> | --clone <git-url> [--dir <path>]
[--ref <branch>]] [--yes]
Install Nix (NixOS nix-installer, steam-deck planner, flakes enabled) if
it is missing, then activate a Home Manager configuration:
--flake given that flake (a directory or a flake reference)
--clone given your config's git repository (git@host:owner/repo,
https://..., github:owner/repo), cloned into
--dir (default ~/nix-config) on branch --ref,
then used like --flake <dir>. Without a Home
Manager configuration it activates the template
first, so git clones with its credential helpers
(gh, ~/.git-credentials); if that fails it offers
'gh auth login'. Only a missing directory or the
unchanged template is replaced by the clone;
anything else there is used as it is (a rerun
after a failed switch just switches)
otherwise ~/.config/home-manager
neither exists a new config in ~/nix-config from the
steam-frame-nix template, linked to
~/.config/home-manager
Existing dotfiles that conflict are renamed to *.hm-backup-<time>.
Re-running just switches again.
uninstall [--yes] [--keep-nix]
Stop Home Manager's user services, run 'cleanup --all', uninstall Home
Manager, uninstall Nix (unless --keep-nix) and remove per-user Nix
leftovers. /nix is detached lazily: programs started from the Nix
store (listed) keep running until you log out or reboot. Your
configuration directory (also a --clone) is never deleted.
cleanup [--dry-run] [--quiet] (--all | --orphans [--keep <artifact>]...)
Remove what steam-frame-nix (any version) wrote outside the Nix store,
only where it is provably its own; everything else is reported as
"left alone". Safe to re-run. Needs bash, coreutils, findutils,
grep, sed, awk, jq.
--all everything, incl. the dashboard patches' saved choices
(~/.local/state/steam-frame-nix/ui-patches). Use after
rolling back to a generation without steam-frame-nix,
or before removing it.
--orphans what the configuration no longer uses (run by the Home
Manager module on every switch); keeps saved choices.
--keep still in use (with --orphans): debugger, screenshots, pet
--dry-run only print what would be done
--quiet print only actions, deferrals and warnings
SteamVR's VRWebHelper.DebuggerEnabled can't be changed while SteamVR
runs; it is then restored when SteamVR stops (a drop-in in
/run/user/<uid>, gone at reboot).
status
Show Nix, Home Manager and user service state, and what
'cleanup --all' would remove.
steamvr-debugger-arm
Internal (run before each SteamVR start by steamFrame.steamvrDebugger):
set VRWebHelper.DebuggerEnabled until SteamVR stops.
restart-check
Internal (run on every switch, and by install): name what of the
linked configuration only takes effect after a restart of the Steam
session or SteamVR (the keyboard layout, the SteamVR dashboard
patches).
Options:
--yes, -y Don't ask; answer yes to every question.
--help, -h Show this help.
Piped:
curl -fsSL https://steam-frame-nix.lhns.de | bash -s -- install
EOF
}
# --- helpers ----------------------------------------------------------------
have_tty() { { : <"$SFN_TTY"; } 2>/dev/null; }
confirm() { # question [default answer: n|y]
(( ASSUME_YES )) && return 0
have_tty || die "no terminal to ask \"$1\"; re-run with --yes"
local reply='' def=${2:-n}
printf '%s [%s] ' "$1" "$([[ $def == y ]] && echo Y/n || echo y/N)" >"$SFN_TTY"
read -r reply <"$SFN_TTY" || true
[[ ${reply:-$def} == [yY]* ]]
}
need_not_root() {
[[ $USER_ID -ne 0 ]] || die "run this as your normal user (e.g. steamos), not as root; it uses sudo where needed"
}
check_os() {
local id=''
# shellcheck disable=SC1091
[[ -r /etc/os-release ]] && id="$(. /etc/os-release && printf '%s' "${ID:-}")"
[[ $id == steamos ]] || warn "this does not look like SteamOS (ID=${id:-unknown}); continuing anyway"
}
need_sudo() {
sudo -n true 2>/dev/null && return 0
info "sudo needs your password. On SteamOS no password is set by default:"
info "if you never set one, press Ctrl+C and run 'passwd' first."
sudo -v || die "sudo failed. Set a password with 'passwd' (in a terminal), then re-run."
}
# Unlock SteamOS' read-only root for the (un)installer; relocked on exit.
ro_unlock() {
command -v steamos-readonly >/dev/null 2>&1 || return 0
(( RO_RELOCK )) && return 0
if sudo steamos-readonly status >/dev/null 2>&1; then
step "Temporarily disabling the read-only root filesystem"
sudo steamos-readonly disable
RO_RELOCK=1
fi
}
ro_relock() {
(( RO_RELOCK )) || return 0
RO_RELOCK=0
step "Re-enabling the read-only root filesystem"
sudo steamos-readonly enable || warn "could not re-enable it; run: sudo steamos-readonly enable"
}
on_exit() {
[[ -z $CLONE_TMP ]] || rm -rf -- "$CLONE_TMP" # an unfinished --clone
ro_relock
}
trap on_exit EXIT
trap 'exit 130' INT TERM
# Make nix available in this shell, with flakes enabled for our own calls.
load_nix() {
if ! command -v nix >/dev/null 2>&1 && [[ -r $NIX_PROFILE_SCRIPT ]]; then
unset __ETC_PROFILE_NIX_SOURCED
set +u
# shellcheck disable=SC1090
. "$NIX_PROFILE_SCRIPT"
set -u
fi
if [[ ${NIX_CONFIG:-} != *"nix-command flakes"* ]]; then
NIX_CONFIG="${NIX_CONFIG:+$NIX_CONFIG$'\n'}extra-experimental-features = nix-command flakes"
export NIX_CONFIG
fi
}
nix_works() {
command -v nix >/dev/null 2>&1 && nix --version >/dev/null 2>&1 || return 1
nix store info >/dev/null 2>&1
}
# experimental-features as configured (without our NIX_CONFIG)
nix_features() {
env -u NIX_CONFIG nix config show experimental-features 2>/dev/null
}
receipt_planner() { jq -r '.planner.planner // empty' "$NIX_RECEIPT" 2>/dev/null; }
# systemctl --user of the outer (Steam/VR) session; the nested desktop
# can't reach the user manager with its own environment.
outer_systemctl() {
XDG_RUNTIME_DIR="$OUTER_RUNTIME_DIR" \
DBUS_SESSION_BUS_ADDRESS="unix:path=$OUTER_RUNTIME_DIR/bus" \
/usr/bin/systemctl --user "$@"
}
outer_bus_ok() { [[ -S $OUTER_RUNTIME_DIR/bus && -x /usr/bin/systemctl ]]; }
HM_PROFILE="${XDG_STATE_HOME:-$HOME/.local/state}/nix/profiles/home-manager"
hm_installed() { [[ -e $HM_PROFILE || -L $HM_PROFILE ]]; }
# The home-manager command: the installed one (matches the config), else master.
hm() {
if command -v home-manager >/dev/null 2>&1; then
home-manager "$@"
else
nix run "$HM_FLAKE" -- "$@"
fi
}
# --- install ----------------------------------------------------------------
install_nix() {
need_sudo
ro_unlock
step "Installing Nix (planner steam-deck, flakes enabled)"
local tmp
tmp="$(mktemp)"
curl -fsSL -o "$tmp" "$NIX_INSTALLER_URL" || { rm -f "$tmp"; die "could not download $NIX_INSTALLER_URL"; }
if ! sh "$tmp" install steam-deck --no-confirm --enable-flakes; then
rm -f "$tmp"
die "the Nix installer failed. Remove a half-finished install with: sudo $NIX_INSTALLER_BIN uninstall --no-confirm"
fi
rm -f "$tmp"
ro_relock
}
nix_mounted() { mountpoint -q /nix 2>/dev/null; }
uninstall_nix() {
local rc=0 bin='' lazy=0 users=()
need_sudo
ro_unlock
step "Uninstalling Nix"
mapfile -t users < <(nix_users)
if (( ${#users[@]} )); then
info "Started from the Nix store, these keep running until you log out or reboot:"
printf ' - %s\n' "${users[@]}"
fi
# From a root-owned copy outside /nix.
if ! bin="$(sudo mktemp)" || ! sudo cp "$NIX_INSTALLER_BIN" "$bin" \
|| ! sudo chmod 700 "$bin" || ! sudo "$bin" --version >/dev/null 2>&1; then
[[ -n $bin ]] && sudo rm -f "$bin"
bin=$NIX_INSTALLER_BIN
fi
# Programs started from the Nix store keep /nix busy, so the uninstaller's
# `systemctl stop nix.mount` would fail. With LazyUnmount= that stop
# detaches /nix; the programs keep their open files until they exit.
if nix_mounted; then
if sudo mkdir -p "${NIX_MOUNT_DROPIN%/*}" \
&& printf '%s\n' '# steam-frame-nix: removing Nix detaches /nix while programs use it.' \
'[Mount]' 'LazyUnmount=yes' | sudo tee "$NIX_MOUNT_DROPIN" >/dev/null \
&& sudo systemctl daemon-reload; then
lazy=1
else
warn "could not add $NIX_MOUNT_DROPIN; removing Nix fails if a program still uses /nix"
fi
fi
sudo "$bin" uninstall --no-confirm || rc=$?
[[ $bin == "$NIX_INSTALLER_BIN" ]] || sudo rm -f "$bin"
if (( lazy )); then
sudo rm -f "$NIX_MOUNT_DROPIN"
sudo rmdir "${NIX_MOUNT_DROPIN%/*}" 2>/dev/null || true
sudo systemctl daemon-reload || true
fi
hash -r # commands found in /nix are gone
ro_relock
return "$rc"
}
ensure_nix() {
step "Checking Nix"
load_nix
if nix_works; then
info "$(nix --version): installed already"
else
if [[ -e $NIX_RECEIPT ]]; then
warn "Nix is installed ($NIX_RECEIPT) but not working"
confirm "Uninstall and reinstall Nix?" || die "aborted"
[[ -x $NIX_INSTALLER_BIN ]] || die "$NIX_INSTALLER_BIN is missing; can't uninstall the broken Nix"
uninstall_nix
elif [[ -d /nix ]] && [[ -n $(ls -A /nix 2>/dev/null) ]]; then
die "/nix exists but was not installed by nix-installer (no $NIX_RECEIPT); remove it or fix that Nix first"
fi
install_nix
load_nix
nix_works || die "Nix was installed but doesn't work in this shell; open a new terminal and re-run"
info "$(nix --version)"
fi
# Nix installed some other way may lack flakes; enable them for this user.
local features
features="$(nix_features || true)"
if [[ " $features " != *" flakes "* || " $features " != *" nix-command "* ]]; then
step "Enabling flakes in $USER_NIX_CONF"
mkdir -p "${USER_NIX_CONF%/*}"
printf '%s\n' "$FLAKES_LINE" >>"$USER_NIX_CONF"
fi
}
# Replace `name = "...";` in the template's let block.
set_let() { # file name value
local file=$1 name=$2 value=$3
grep -q "^ *$name = \"[^\"]*\";" "$file" || die "template: '$name' not found in $file"
sed -i "s|^\( *$name = \)\"[^\"]*\";|\1\"$value\";|" "$file"
}
# Marker of a configuration create_config made: the hash of its files, in
# .git (never a file of the configuration). `install --clone` replaces
# such a configuration while it is unchanged (template_untouched).
TEMPLATE_MARK=steam-frame-nix-template
# Hash of a configuration's files (without .git).
config_hash() {
(cd "$1" && find . -path ./.git -prune -o ! -type d -print0 | LC_ALL=C sort -z \
| xargs -0 -r sha256sum | sha256sum) | cut -d' ' -f1
}
# Whether <dir> is the template as create_config made it: the marker
# matches its files, at most one commit.
template_untouched() { # dir
local m=$1/.git/$TEMPLATE_MARK n
[[ -f $m ]] || return 1
n="$(git -C "$1" rev-list --count HEAD 2>/dev/null || echo 0)"
[[ $(<"$m") == "$(config_hash "$1")" ]] && (( n <= 1 ))
}
create_config() { # dir [question]
local dir=$1 system
if [[ -e $dir ]] && [[ -n $(ls -A "$dir" 2>/dev/null) ]]; then
if [[ -e $dir/flake.nix ]]; then
die "$dir already contains a flake. Use it with: install.sh install --flake $dir"
fi
die "$dir exists and is not empty; move it away or pass --flake <your config>"
fi
[[ $USER_NAME =~ ^[a-z_][a-z0-9_-]*$ ]] || die "unsupported user name '$USER_NAME' for the template"
[[ $HOME =~ ^/[A-Za-z0-9._/-]+$ ]] || die "unsupported home directory '$HOME' for the template"
confirm "${2:-No Home Manager configuration found. Create one in $dir from the steam-frame-nix template?}" \
|| die "aborted; pass --flake <your config> to use an existing one"
step "Creating a Home Manager configuration in $dir"
mkdir -p "$dir"
(cd "$dir" && nix flake init -t "$TEMPLATE")
system="$(nix eval --impure --raw --expr builtins.currentSystem)"
set_let "$dir/flake.nix" username "$USER_NAME"
set_let "$dir/flake.nix" homeDirectory "$HOME"
set_let "$dir/flake.nix" system "$system"
info "user $USER_NAME, home $HOME, system $system"
# Flakes in a git repository only see tracked files.
if command -v git >/dev/null 2>&1; then
git -C "$dir" init -q
git -C "$dir" add -A
nix flake lock "$dir"
git -C "$dir" add flake.lock
config_hash "$dir" >"$dir/.git/$TEMPLATE_MARK"
else
warn "git not found; $dir is used as a plain directory"
nix flake lock "$dir"
fi
mkdir -p "${HM_CONFIG_LINK%/*}"
ln -s "$dir" "$HM_CONFIG_LINK"
info "linked $HM_CONFIG_LINK -> $dir"
}
# git, or Nix's when SteamOS has none (only after ensure_nix).
git_any() {
if command -v git >/dev/null 2>&1; then git "$@"
else nix run nixpkgs#git -- "$@"; fi
}
# git URL for --clone: github:owner/repo -> https://github.com/owner/repo.git
clone_url() {
case $1 in
github:*)
[[ ${1#github:} =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] \
|| die "--clone: expected github:owner/repo, got '$1' (use --ref for a branch)"
printf 'https://github.com/%s.git\n' "${1#github:}" ;;
*) printf '%s\n' "$1" ;;
esac
}
# git with its credential helpers but without asking for a password.
git_noprompt() {
local -x GIT_TERMINAL_PROMPT=0
git_any "$@"
}
# Whether git has a credential helper (the template's: gh, store).
git_has_helpers() {
git_any config --global --get-regexp '^credential\..*helper$' 2>/dev/null \
| awk 'NF > 1 { f = 1 } END { exit !f }'
}
# `gh auth login` with gh from the Home Manager profile (the template's),
# for an HTTPS URL on github.com and only with a terminal.
gh_login() { # url
local gh=$HM_PROFILE/home-path/bin/gh
[[ $1 == https://github.com/* ]] && have_tty || return 1
[[ -x $gh ]] || gh="$(command -v gh)" || return 1
confirm "Log in to GitHub now with 'gh auth login'?" y || return 1
"$gh" auth login --hostname github.com --git-protocol https <"$SFN_TTY"
}
# --clone: <dir> missing (or empty) or the untouched template gets the clone
# of <url>; anything else in <dir> is used as it is (also the clone of an
# earlier run whose switch failed). Each step checks whether an earlier run
# did it already:
# - without a Home Manager configuration (or with the template in <dir>
# linked), the template is created in <dir> and activated, so git has
# the credential helpers (skipped if they are there);
# - <url> is cloned next to <dir> (on failure the template stays active
# in <dir>); the template is removed and the clone takes its place.
clone_config() { # url dir ref
local url dir=$2 ref=$3 tmpl=0 parent link=''
url="$(clone_url "$1")"
if [[ -e $dir ]] && [[ -n $(ls -A "$dir" 2>/dev/null) ]]; then
if ! template_untouched "$dir"; then
step "$dir already exists and isn't the untouched template: using it as it is (not cloning)"
return 0
fi
tmpl=1
fi
# The template, for git's credential helpers.
if [[ -L $HM_CONFIG_LINK || -e $HM_CONFIG_LINK ]]; then
link="$(readlink -f "$HM_CONFIG_LINK" || true)"
fi
if (( ! tmpl )) && [[ -z $link ]]; then
create_config "$dir" "No Home Manager configuration found. First activate the steam-frame-nix template from $dir (git's credential helpers, e.g. 'gh auth login'), then replace it with the clone of $url?"
tmpl=1
hm_activate "$dir#$USER_NAME" || die "the switch to the template failed (see above); run install --clone again to continue"
elif (( tmpl )) && [[ -z $link || $link -ef $dir ]]; then
if [[ -z $link ]]; then
mkdir -p "${HM_CONFIG_LINK%/*}"
ln -s "$dir" "$HM_CONFIG_LINK"
info "linked $HM_CONFIG_LINK -> $dir"
fi
if [[ -n $link ]] && hm_installed && git_has_helpers; then
step "The template in $dir is active with git's credential helpers (skipped)"
else
hm_activate "$dir#$USER_NAME" || die "the switch to the template failed (see above); run install --clone again to continue"
fi
fi
parent="$(mkdir -p "${dir%/*}" && cd "${dir%/*}" && pwd)"
CLONE_TMP="$(mktemp -d "$parent/.${dir##*/}.clone.XXXXXX")"
chmod "$(umask -S)" "$CLONE_TMP"
step "Cloning $url"
if ! git_noprompt clone ${ref:+--branch "$ref"} -- "$url" "$CLONE_TMP" \
&& ! { gh_login "$url" && git_noprompt clone ${ref:+--branch "$ref"} -- "$url" "$CLONE_TMP"; }; then
(( tmpl )) && warn "the steam-frame-nix template stays active in $dir; install --clone replaces it while it is unchanged"
die "git clone failed. For a private repository log in to GitHub with \
'gh auth login' or use an SSH URL (git@github.com:owner/repo) with a key \
your account knows, then run install --clone again: it continues where it stopped."
fi
if (( tmpl )); then
template_untouched "$dir" || die "$dir was changed while cloning; the clone is not used"
rm -rf -- "$dir"
info "removed the template in $dir (only needed for the clone)"
elif [[ -d $dir ]]; then
rmdir -- "$dir" # empty
fi
mv -T -- "$CLONE_TMP" "$dir"
CLONE_TMP=''
info "cloned into $dir"
}
# home-manager switch; files in its way are renamed to *.hm-backup-<time>
# (unique per run: Home Manager aborts if a backup from an earlier run exists).
hm_activate() { # flake ref
local backup
backup="hm-backup-$(date +%Y%m%d-%H%M%S)"
step "Activating Home Manager (conflicting files are renamed to *.$backup)"
hm switch --flake "$1" -b "$backup"
}
cmd_install() {
local flake='' ref dir='' clone='' clone_dir='' clone_ref=''
while (( $# )); do
case $1 in
--flake) [[ $# -ge 2 ]] || die "--flake needs an argument"; flake=$2; shift 2 ;;
--clone) [[ $# -ge 2 ]] || die "--clone needs an argument"; clone=$2; shift 2 ;;
--dir) [[ $# -ge 2 ]] || die "--dir needs an argument"; clone_dir=$2; shift 2 ;;
--ref) [[ $# -ge 2 ]] || die "--ref needs an argument"; clone_ref=$2; shift 2 ;;
-y|--yes) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "install: unknown option '$1' (see --help)" ;;
esac
done
if [[ -n $clone ]]; then
[[ -z $flake ]] || die "install: --clone and --flake exclude each other"
clone_url "$clone" >/dev/null
clone_dir=${clone_dir:-$DEFAULT_CONFIG_DIR}
[[ $clone_dir == /* ]] || clone_dir=$PWD/$clone_dir
elif [[ -n $clone_dir || -n $clone_ref ]]; then
die "install: --dir and --ref need --clone"
fi
need_not_root
check_os
command -v curl >/dev/null 2>&1 || die "curl not found"
ensure_nix
if [[ -n $clone ]]; then
clone_config "$clone" "$clone_dir" "$clone_ref"
flake=$clone_dir
fi
step "Finding the Home Manager configuration"
if [[ -n $flake ]]; then
if [[ -d ${flake%%#*} ]]; then
dir="$(cd "${flake%%#*}" && pwd -P)"
[[ -e $dir/flake.nix ]] || die "$dir has no flake.nix"
ref="$dir${flake#"${flake%%#*}"}"
if [[ ! -e $HM_CONFIG_LINK && ! -L $HM_CONFIG_LINK ]]; then
mkdir -p "${HM_CONFIG_LINK%/*}"
ln -s "$dir" "$HM_CONFIG_LINK"
info "linked $HM_CONFIG_LINK -> $dir"
fi
else
ref=$flake
fi
elif [[ -e $HM_CONFIG_LINK/flake.nix ]]; then
dir="$(readlink -f "$HM_CONFIG_LINK")"
ref=$dir
elif [[ -e $HM_CONFIG_LINK || -L $HM_CONFIG_LINK ]]; then
die "$HM_CONFIG_LINK exists but has no flake.nix; pass --flake <your config>"
else
dir=$DEFAULT_CONFIG_DIR
create_config "$dir"
ref="$dir#$USER_NAME"
fi
info "using $ref"
if [[ -n $dir ]] && git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
local untracked
# || true: with pipefail, head closing the pipe early fails git.
untracked="$(git -C "$dir" ls-files --others --exclude-standard -- '*.nix' | head -n5 || true)"
[[ -z $untracked ]] || warn "untracked files are invisible to the flake (git add them): $(echo "$untracked" | tr '\n' ' ')"
fi
hm_activate "$ref" || die "home-manager switch failed (see above)${clone:+; fix $dir and run install --clone again: it continues with this switch}"
# Settings read only at a process start (the activation warned already).
local pending=()
mapfile -t pending < <(restart_pending)
local switch_cmd="home-manager switch --flake $ref" edit="your configuration"
if [[ -n $dir ]]; then
edit="$dir/home.nix"
if [[ $(readlink -f "$HM_CONFIG_LINK" 2>/dev/null) == "$dir" ]] \
&& [[ $ref == "$dir" || $ref == "$dir#$USER_NAME" ]]; then
switch_cmd="home-manager switch"
fi
fi
step "Done"
if (( ${#pending[@]} )); then
printf '\n%sRestart once%s (reboot, or restart the Steam session) for:\n' "$Y" "$N"
printf ' - %s\n' "${pending[@]}"
printf '%s\n' " Everything else already works in the running session."
fi
cat <<EOF
Next steps:
- Open a new terminal so that nix and home-manager are on your PATH.
- Edit $edit (e.g. uncomment the steamFrame block),
then apply it with:
$switch_cmd
Run it from a terminal in the nested desktop. Flakes only see files
tracked by git: 'git add' new files first.
- Konsole in the Steam session's "+" menu needs Steam Developer Mode
(Steam Settings > System > Enable Developer Mode) or
steamFrame.launcherMenu.showAllApps = true.
- Status: install.sh status. Remove everything: install.sh uninstall
EOF
}
# --- cleanup ----------------------------------------------------------------
#
# Every artifact any version of steam-frame-nix wrote outside the Nix store
# and Home Manager's links, and how each is proven to be ours (the
# clean_<artifact> functions below): docs/cleanup.md. Anything not proven
# ours is reported as "left alone" and never touched. --orphans keeps what
# the current configuration still uses (--keep ...); the Home Manager module
# runs it on every switch.
SFN_STATE="${XDG_STATE_HOME:-$HOME/.local/state}/steam-frame-nix"
SFN_DATA="${XDG_DATA_HOME:-$HOME/.local/share}"
SFN_RUNTIME="$OUTER_RUNTIME_DIR/steam-frame-nix"
VRSETTINGS="$HOME/.config/openvr/config/steamvr.vrsettings"
DEBUGGER_ARMED="$SFN_STATE/steamvr-debugger.armed"
DEBUGGER_MARKER_V1="$SFN_STATE/steamvr-debugger"
DEBUGGER_RESTORE="$SFN_RUNTIME/steamvr-debugger-restore"
DEBUGGER_DROPIN="$OUTER_RUNTIME_DIR/systemd/user/steamvr.service.d/50-steam-frame-nix-debugger.conf"
ICON_DIR="$SFN_DATA/icons/hicolor/scalable/apps"
ICON_MANIFEST="$SFN_STATE/icon-fallbacks"
FF_DIR="$HOME/.var/app/org.mozilla.firefox/config/mozilla/firefox"
FF_DESKTOP_JS=/app/etc/firefox/steam-frame-nix-desktop-user.js
FF_MARKER="// Managed by steam-frame-nix (steamFrame.firefox); rewritten on switch."
JF_APP=org.jellyfin.JellyfinDesktop
JF_OVERRIDE="$SFN_DATA/flatpak/overrides/$JF_APP"
JF_SHIM_COPY="$HOME/.var/app/$JF_APP/mpv-hwdec-shim.so"
JF_SHIM_MARKER="$SFN_STATE/jellyfin-hwdec-shim"
UI_STATE="$SFN_STATE/ui-patches"
CLEAN_DRY=0
CLEAN_QUIET=0
CLEAN_HEADER=''
CLEAN_ACTIONS=0
CLEAN_DEFERRED=()
declare -A CLEAN_GONE=()
# Output; the header only before the first line (--quiet).
c_line() { if [[ -n $CLEAN_HEADER ]]; then step "$CLEAN_HEADER"; CLEAN_HEADER=''; fi; info "$1"; }
c_do() { CLEAN_ACTIONS=$((CLEAN_ACTIONS + 1)); if (( CLEAN_DRY )); then c_line "would $1"; return 1; fi; }
c_done() { c_line "$1"; }
c_left() { (( CLEAN_QUIET )) || c_line "left alone: $1"; }
c_defer() { CLEAN_DEFERRED+=("$1"); c_line "deferred: $1"; }
c_rm() { # path why
c_do "remove $1 ($2)" || { CLEAN_GONE[$1]=1; return 0; }
rm -f -- "$1" && CLEAN_GONE[$1]=1 && c_done "removed $1 ($2)"
}
# Empty, or will be once the planned removals are done (dry run).
c_empty_after() {
local e
while IFS= read -r -d '' e; do
[[ -n ${CLEAN_GONE[$e]:-} ]] || return 1
done < <(find "$1" -mindepth 1 -maxdepth 1 -print0 2>/dev/null)
}
c_rmdir() { # dir: removed if empty (after the planned removals)
[[ -d $1 && ! -L $1 ]] && c_empty_after "$1" || return 0
c_do "remove empty directory $1" || { CLEAN_GONE[$1]=1; return 0; }
rmdir -- "$1" && CLEAN_GONE[$1]=1 && c_done "removed empty directory $1"
}
# Not fed through a pipe: a pipe's subshell would lose CLEAN_ACTIONS and the
# --quiet header state.
c_write() { # path why content: replace the contents (same inode and mode)
c_do "rewrite $1 ($2)" || return 0
printf '%s\n' "$3" >"$1"
c_done "rewrote $1 ($2)"
}
# systemctl --user of the outer session (overridable for tests).
c_systemctl() {
if [[ -n ${STEAM_FRAME_NIX_SYSTEMCTL:-} ]]; then "$STEAM_FRAME_NIX_SYSTEMCTL" --user "$@"
elif outer_bus_ok; then outer_systemctl "$@"
else return 1; fi
}
# running | stopped | unknown
steamvr_state() {
local s
if s="$(c_systemctl show -p ActiveState --value steamvr.service 2>/dev/null)" && [[ -n $s ]]; then
case $s in inactive|failed) echo stopped ;; *) echo running ;; esac
elif pgrep -u "$USER_ID" -x vrserver >/dev/null 2>&1; then echo running
else echo unknown; fi
}
# --- the SteamVR debugger key ---
# Puts VRWebHelper.DebuggerEnabled back to the value in <armed> ("absent",
# "false"; "nofile": the file didn't exist) if it is still true, then removes
# <armed>. Standalone (also written into the runtime restore script): bash,
# coreutils and jq only.
debugger_restore() { # vrsettings armed
local f=$1 armed=$2 prior cur tmp filter
[[ -f $armed ]] || return 0
prior="$(tr -d '[:space:]' <"$armed")"
case $prior in
false) filter='.VRWebHelper.DebuggerEnabled = false' ;;
absent|nofile|'') filter='.VRWebHelper |= del(.DebuggerEnabled) | if .VRWebHelper == {} then del(.VRWebHelper) else . end' ;;
*) echo "steam-frame-nix: $armed holds '$prior', not a value it writes; left as is" >&2; return 1 ;;
esac
if [[ -f $f ]]; then
cur="$(jq -r '.VRWebHelper.DebuggerEnabled | if . == null then "absent" else tostring end' "$f")" || { echo "steam-frame-nix: can't read $f" >&2; return 1; }
if [[ $cur == true ]]; then
tmp="$(mktemp "$f.XXXXXX")" || return 1
if ! jq --indent 3 "$filter" "$f" >"$tmp"; then rm -f "$tmp"; return 1; fi
chmod --reference="$f" "$tmp"
mv -f "$tmp" "$f"
if [[ $prior == nofile && $(jq -c . "$f") == '{}' ]]; then
rm -f "$f"
echo "steam-frame-nix: removed $f (created only for VRWebHelper.DebuggerEnabled)"
else
echo "steam-frame-nix: VRWebHelper.DebuggerEnabled in $f restored (${prior/nofile/absent})"
fi
else
echo "steam-frame-nix: VRWebHelper.DebuggerEnabled in $f is $cur (changed since); left as is"
fi
fi
rm -f "$armed"
}
debugger_restore_script() {
# shellcheck disable=SC2016 # literal lines of the script
printf '%s\n' '#!/usr/bin/bash' \
'# steam-frame-nix: puts SteamVR'"'"'s VRWebHelper.DebuggerEnabled back to its' \
'# value from before steam-frame-nix set it. Run by the runtime drop-in' \
'# steamvr.service.d/50-steam-frame-nix-debugger.conf when SteamVR stops;' \
'# both live in $XDG_RUNTIME_DIR until reboot. /usr/bin tools only.' \
'PATH=/usr/bin:/bin${PATH:+:$PATH}'
declare -f debugger_restore
printf 'debugger_restore %q %q\n' "$VRSETTINGS" "$DEBUGGER_ARMED"
}
debugger_dropin() {
printf '%s\n' '# steam-frame-nix: restores VRWebHelper.DebuggerEnabled when SteamVR stops.' \
'# Runtime only (gone at reboot).' \
'[Service]' \
'ExecStopPost=-/usr/bin/bash %t/steam-frame-nix/steamvr-debugger-restore'
}
# Runtime drop-in + script so that stopping SteamVR restores the key, even
# after Nix and Home Manager are gone. Best effort: called with `|| true`
# (no errexit inside).
debugger_ensure_hook() {
if [[ $(cat "$DEBUGGER_RESTORE" 2>/dev/null) != "$(debugger_restore_script)" ]]; then
if c_do "write the restore script $DEBUGGER_RESTORE"; then
mkdir -p "$SFN_RUNTIME"
debugger_restore_script >"$DEBUGGER_RESTORE"
c_done "wrote the restore script $DEBUGGER_RESTORE"
fi
fi
if [[ $(cat "$DEBUGGER_DROPIN" 2>/dev/null) != "$(debugger_dropin)" ]]; then
if c_do "write the runtime drop-in $DEBUGGER_DROPIN"; then
mkdir -p "${DEBUGGER_DROPIN%/*}"
debugger_dropin >"$DEBUGGER_DROPIN"
c_done "wrote the runtime drop-in $DEBUGGER_DROPIN"
c_systemctl daemon-reload || warn "could not reload the user manager; the restore on SteamVR stop needs: systemctl --user daemon-reload"
fi
fi
}
debugger_remove_hook() {
[[ -f $DEBUGGER_RESTORE ]] && c_rm "$DEBUGGER_RESTORE" "debugger restore script"
c_rmdir "$SFN_RUNTIME"
[[ -f $DEBUGGER_DROPIN ]] || return 0
c_rm "$DEBUGGER_DROPIN" "runtime drop-in of the debugger restore"
# The directories only if they held nothing but the drop-in.
c_rmdir "${DEBUGGER_DROPIN%/*}"
c_rmdir "$OUTER_RUNTIME_DIR/systemd/user"
c_rmdir "$OUTER_RUNTIME_DIR/systemd"
(( CLEAN_DRY )) || c_systemctl daemon-reload || true
return 0
}
# Before each SteamVR start (the steamvr-webhelper-debugger oneshot of
# steamFrame.steamvrDebugger): remember the key's value in
# steamvr-debugger.armed, set it to true and make sure the runtime drop-in
# puts it back when SteamVR stops. A key that is already true (without
# .armed) is the user's own and never touched.
cmd_debugger_arm() {
local cur tmp
need_not_root
command -v jq >/dev/null 2>&1 || die "jq not found"
if [[ ! -f $DEBUGGER_ARMED ]]; then
if [[ -f $VRSETTINGS ]]; then
cur="$(jq -r 'if (.VRWebHelper | type) == "object" and (.VRWebHelper | has("DebuggerEnabled"))
then .VRWebHelper.DebuggerEnabled | tostring else "absent" end' "$VRSETTINGS")" \
|| die "can't read $VRSETTINGS"
else
cur=nofile
fi
case $cur in
true) info "VRWebHelper.DebuggerEnabled is already true (not set by steam-frame-nix): left as is"; return 0 ;;
false|absent|nofile) ;;
*) warn "VRWebHelper.DebuggerEnabled is $cur; left as is"; return 0 ;;
esac
mkdir -p "$SFN_STATE"
printf '%s\n' "$cur" >"$DEBUGGER_ARMED"
fi
if [[ ! -f $VRSETTINGS ]]; then
mkdir -p "${VRSETTINGS%/*}"
printf '{}\n' >"$VRSETTINGS"
fi
if [[ $(jq '.VRWebHelper.DebuggerEnabled' "$VRSETTINGS") != true ]]; then
tmp="$(mktemp "$VRSETTINGS.XXXXXX")"
jq --indent 3 '.VRWebHelper.DebuggerEnabled = true' "$VRSETTINGS" >"$tmp" || { rm -f "$tmp"; die "can't write $VRSETTINGS"; }
chmod --reference="$VRSETTINGS" "$tmp"
mv -f "$tmp" "$VRSETTINGS"
info "VRWebHelper.DebuggerEnabled set to true until SteamVR stops (before: $(<"$DEBUGGER_ARMED"))"
else
info "VRWebHelper.DebuggerEnabled already true (set by steam-frame-nix)"
fi
debugger_ensure_hook || true
}
clean_debugger() { # keep
local keep=$1 prior='' state cur out
if [[ -f $DEBUGGER_ARMED ]]; then
prior="$(tr -d '[:space:]' <"$DEBUGGER_ARMED")"
[[ -e $DEBUGGER_MARKER_V1 ]] && c_rm "$DEBUGGER_MARKER_V1" "old debugger marker, superseded by ${DEBUGGER_ARMED##*/}"
elif [[ -f $DEBUGGER_MARKER_V1 ]]; then
prior=absent
if c_do "migrate $DEBUGGER_MARKER_V1 to $DEBUGGER_ARMED (value before: absent)"; then
printf 'absent\n' >"$DEBUGGER_ARMED"
rm -f -- "$DEBUGGER_MARKER_V1"
c_done "migrated $DEBUGGER_MARKER_V1 to $DEBUGGER_ARMED (value before: absent)"
else
CLEAN_GONE[$DEBUGGER_MARKER_V1]=1
fi
fi
if [[ -z $prior ]]; then
if [[ -f $VRSETTINGS ]] && command -v jq >/dev/null 2>&1 \
&& [[ $(jq -r '.VRWebHelper.DebuggerEnabled // empty' "$VRSETTINGS" 2>/dev/null) == true ]] \
&& (( ! keep )); then
c_left "VRWebHelper.DebuggerEnabled = true in $VRSETTINGS (not set by steam-frame-nix)"
fi
(( keep )) || debugger_remove_hook
return 0
fi
command -v jq >/dev/null 2>&1 || { warn "jq not found; VRWebHelper.DebuggerEnabled left as is"; return 0; }
state="$(steamvr_state)"
if [[ $state == stopped ]]; then
cur="$(jq -r '.VRWebHelper.DebuggerEnabled // "absent"' "$VRSETTINGS" 2>/dev/null || echo '?')"
if [[ $cur == true ]]; then
if c_do "restore VRWebHelper.DebuggerEnabled in $VRSETTINGS (${prior/nofile/absent})"; then
out="$(debugger_restore "$VRSETTINGS" "$DEBUGGER_ARMED" 2>&1)" \
|| warn "restoring VRWebHelper.DebuggerEnabled failed; $DEBUGGER_ARMED kept"
[[ -z $out ]] || c_line "${out//steam-frame-nix: /}"
fi
fi
if [[ -f $DEBUGGER_ARMED && ( $cur != true || CLEAN_DRY -eq 1 ) ]]; then
c_rm "$DEBUGGER_ARMED" "proof of the debugger key, no longer needed"
fi
(( keep )) || debugger_remove_hook
else
# Running (or unknown): SteamVR rewrites the file from memory; restore
# when it stops.
if [[ $state == unknown ]] && ! c_systemctl --version >/dev/null 2>&1; then
c_defer "VRWebHelper.DebuggerEnabled ($VRSETTINGS): user manager not reachable; run this again from the Steam session or with SteamVR stopped"
return 0
fi
debugger_ensure_hook || true
(( keep )) || c_defer "VRWebHelper.DebuggerEnabled ($VRSETTINGS) is restored to ${prior/nofile/absent} when SteamVR stops"
fi
return 0
}
# --- icon fallback links ---
clean_icons() {
local l removed=0
for l in "$ICON_DIR"/*.svg; do
[[ -L $l ]] || continue
# Home Manager's own links (to *-home-manager-files) are left as they are.
if [[ $(readlink "$l") == /nix/store/*-breeze-icons-*/share/icons/breeze/apps/* ]]; then
c_rm "$l" "icon fallback link to Breeze"; removed=1
fi
done
[[ -e $ICON_MANIFEST ]] && c_rm "$ICON_MANIFEST" "icon fallback manifest" && removed=1
(( removed )) || return 0
# The directories only if they held nothing but our links.
c_rmdir "$ICON_DIR"
c_rmdir "${ICON_DIR%/*}"
c_rmdir "${ICON_DIR%/*/*}"
# GTK (Steam) rescans a theme only when a theme dir's mtime changes.
if [[ -d ${ICON_DIR%/*/*} && -z ${CLEAN_GONE[${ICON_DIR%/*/*}]:-} ]]; then
if c_do "touch ${ICON_DIR%/*/*} (mtime only, so Steam rescans icons)"; then
touch -- "${ICON_DIR%/*/*}"; c_done "touched ${ICON_DIR%/*/*} (mtime only, so Steam rescans icons)"
fi
fi
return 0
}
# --- Firefox user.js ---
ff_keys() { sed -n 's/^[[:space:]]*user_pref(\("[^"]*"\),.*/\1/p' "$1" 2>/dev/null || true; }
# Not `find | grep -q`: find fails on other users' /proc/<pid>/fd, and with
# pipefail that would make every profile look unused.
ff_in_use() { [[ -n $(find /proc/[0-9]*/fd -lname "$1/.parentlock" -print -quit 2>/dev/null) ]]; }
clean_firefox() { # all
local all=$1 prof name u t kind keys pats left rc
[[ -d $FF_DIR ]] || return 0
for prof in "$FF_DIR"/*/; do
prof=${prof%/}; name=${prof##*/}; u=$prof/user.js
[[ -e $u || -L $u ]] || continue
keys=''
if [[ -L $u ]]; then
t="$(readlink "$u")"
case $t in
"$FF_DESKTOP_JS")
# The wrapper's, while Firefox runs in the desktop profile.
if ff_in_use "$prof"; then
(( all )) && c_defer "$u: Firefox is using profile $name; close it and run this again"
continue
fi
kind="desktop profile user.js link"; keys='"full-screen-api.ignore-widgets"' ;;
/nix/store/*-firefox-user.js|/nix/store/*-firefox-desktop-user.js)
kind="user.js link of an older version"; keys="$(ff_keys "$u")"
[[ -e $u ]] || c_line "note: $t is gone; values it set stay in $prof/prefs.js" ;;
*) c_left "$u (link to $t)"; continue ;;
esac
elif [[ -f $u && $(head -n1 "$u") == "$FF_MARKER" ]]; then
kind="user.js copy of an older version"; keys="$(ff_keys "$u")"
else
c_left "$u (not written by steam-frame-nix)"; continue
fi
# Firefox stored the values the file set in prefs.js; take them out too.
# shellcheck disable=SC2001 # per line
pats="$(sed 's/.*/user_pref(&,/' <<<"$keys")"
if [[ -n $keys && -f $prof/prefs.js ]] && grep -qF "$pats" "$prof/prefs.js"; then
if ff_in_use "$prof"; then
c_defer "$u: Firefox is using profile $name; close it and run this again"
continue
fi
rc=0; left="$(grep -vF "$pats" "$prof/prefs.js")" || rc=$?
if (( rc > 1 )); then warn "could not read $prof/prefs.js; left as is"; continue; fi
c_write "$prof/prefs.js" "values of the $kind: ${keys//$'\n'/ }" "$left"
fi
c_rm "$u" "$kind"
c_rmdir "$prof"
done
return 0
}
# --- Jellyfin ---
# The override with our entries taken out: LD_PRELOAD of the shim,
# SFN_MPV_HWDEC, the shim's filesystems entry and, only if one of these was
# there, devices=all. Prints "devices=<0|1>" (devices=all removed), then the
# result; exit 3 if nothing of ours was found.
jf_strip() {
awk '
function trim(s) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", s); return s }
function drop(list, pat, n, a, i, out) {
n = split(list, a, ";"); out = ""
for (i = 1; i <= n; i++) { a[i] = trim(a[i]); if (a[i] == "") continue
if (a[i] ~ pat) { hit = 1; continue } out = out a[i] ";" }
return out }
FNR == 1 { pass++; sec = "" }
/^[[:space:]]*\[/ { sec = trim($0); if (pass == 2) { hdr[++nh] = sec; body[nh] = "" } next }
{
line = $0; key = trim(substr(line, 1, index(line, "=") - 1)); val = substr(line, index(line, "=") + 1)
hit = 0
if (sec == "[Environment]" && key == "LD_PRELOAD" && val ~ /^\/nix\/store\/[^\/]*mpv-hwdec-shim[^\/]*\/lib\/mpv-hwdec-shim\.so$/) { hit = 1; line = "" }
else if (sec == "[Environment]" && key == "SFN_MPV_HWDEC") { hit = 1; line = "" }
else if (sec == "[Context]" && key == "filesystems") {
v = drop(val, "^/nix/store/[^/]*mpv-hwdec-shim[^/]*(:ro)?$"); line = (v == "" ? "" : key "=" v) }
else if (sec == "[Context]" && key == "devices" && pass == 2 && ours) {
v = drop(val, "^all$"); if (hit) devs = 1; line = (v == "" ? "" : key "=" v); hit = 0 }
if (pass == 1) { if (hit) ours = 1; next }
if (trim(line) == "") next
body[nh] = body[nh] line "\n"
}
END {
if (!ours) exit 3
print "devices=" (devs ? 1 : 0)
for (i = 0; i <= nh; i++) if (body[i] != "") { if (i) printf "%s\n", hdr[i]; printf "%s\n", body[i] }
}
' "$1" "$1"
}
clean_jellyfin() {
local out rc t
if [[ -L $JF_OVERRIDE ]]; then
t="$(readlink "$JF_OVERRIDE")"
[[ $t == *-home-manager-files/* ]] || c_left "$JF_OVERRIDE (link to $t)"
elif [[ -f $JF_OVERRIDE ]]; then
if [[ -z $(tr -d '[:space:]' <"$JF_OVERRIDE") ]]; then
c_rm "$JF_OVERRIDE" "empty Flatpak override"
else
rc=0; out="$(jf_strip "$JF_OVERRIDE")" || rc=$?
if (( rc == 0 )); then
[[ ${out%%$'\n'*} == devices=1 ]] && c_line "devices=all goes too (it came with the hwdec shim entries)"
out=${out#*$'\n'}; [[ $out == devices=? ]] && out=''
if [[ -z $(tr -d '[:space:]' <<<"$out") ]]; then
c_rm "$JF_OVERRIDE" "Flatpak override with only the hwdec shim entries"
else
c_write "$JF_OVERRIDE" "hwdec shim entries" "$out"
warn "$JF_OVERRIDE keeps entries of its own: $(grep -v '^\[' <<<"$out" | grep . | tr '\n' ' ')"
fi
elif (( rc == 3 )); then
c_left "$JF_OVERRIDE (no hwdec shim entries)"
else
warn "could not read $JF_OVERRIDE; left as is"
fi
fi
fi
if [[ -e $JF_SHIM_MARKER ]]; then
[[ -e $JF_SHIM_COPY || -L $JF_SHIM_COPY ]] && c_rm "$JF_SHIM_COPY" "hwdec shim copy of an older version"
c_rm "$JF_SHIM_MARKER" "its marker"
elif [[ -e $JF_SHIM_COPY ]]; then
c_left "$JF_SHIM_COPY (no marker $JF_SHIM_MARKER)"
fi
return 0
}
# --- launcher entries (tmpfs) ---
clean_launchers() {
local d=$SFN_RUNTIME/applications f
[[ -d $d && ! -L $d ]] || return 0
while IFS= read -r -d '' f; do
c_rm "$f" "generated launcher entry"
done < <(find "$d" -mindepth 1 -maxdepth 1 -type f -print0)
c_rmdir "$d"
}
# --- screenshots link (tmpfs) ---
clean_screenshots() { # keep
local l=$SFN_RUNTIME/screenshots
(( $1 )) && return 0
[[ -L $l ]] || return 0
if [[ $(readlink "$l") == */userdata/*/760/remote/250820/screenshots ]]; then
c_rm "$l" "link to the SteamVR screenshots"
else
c_left "$l (not a link to SteamVR screenshots)"
fi
}
# --- VR pet "+" menu icon link (tmpfs) ---
clean_pet() { # keep
local d=$SFN_RUNTIME/vr-pet l f
(( $1 )) && return 0
[[ -d $d && ! -L $d ]] || return 0
l=$d/icon.png
if [[ -L $l ]]; then
if [[ $(readlink "$l") == /nix/store/*-vr-pet-icons/*.png ]]; then
c_rm "$l" "link to the VR pet's \"+\" menu icon"
else
c_left "$l (not a link to a VR pet icon)"
fi
fi
while IFS= read -r -d '' f; do
c_rm "$f" "the VR pet icon's lock or unfinished link"
done < <(find "$d" -mindepth 1 -maxdepth 1 \( -name .lock -type f -empty -o -name '.icon.tmp.*' -type l \) -print0)
c_rmdir "$d"
}
# --- dashboard patch state ---
clean_ui_state() { # all
local f n age=(-mmin +1) # --orphans: not one being written right now
[[ -d $UI_STATE ]] || return 0
(( $1 )) && age=()
while IFS= read -r -d '' f; do
c_rm "$f" "unfinished write of the dashboard patch state"
done < <(find "$UI_STATE" -maxdepth 1 -type f -name '*.json.tmp' "${age[@]}" -print0)
if (( $1 )); then
for f in "$UI_STATE"/*.json; do
[[ -f $f ]] || continue
n=${f##*/}; c_rm "$f" "saved choices of the dashboard patch ${n%.json}"
done
fi
c_rmdir "$UI_STATE"
return 0
}
cmd_cleanup() {
local mode='' all=0 keep_debugger=0 keep_screenshots=0 keep_pet=0 keeps=0 k
while (( $# )); do
case $1 in
--all) mode=all; shift ;;
--orphans) mode=orphans; shift ;;
--dry-run|-n) CLEAN_DRY=1; shift ;;
--quiet|-q) CLEAN_QUIET=1; shift ;;
--keep)
[[ $# -ge 2 ]] || die "--keep needs an artifact"
k=$2; shift 2; keeps=$((keeps + 1))
case $k in
debugger) keep_debugger=1 ;;
screenshots) keep_screenshots=1 ;;
pet) keep_pet=1 ;;
*) die "cleanup: unknown artifact '$k' for --keep (debugger, screenshots, pet)" ;;
esac ;;
-h|--help) usage; exit 0 ;;
*) die "cleanup: unknown option '$1' (see --help)" ;;
esac
done
[[ -n $mode ]] || die "cleanup: --all or --orphans is required (see --help)"
[[ $mode == all && $keeps -gt 0 ]] && die "cleanup: --keep only goes with --orphans"
need_not_root
[[ $mode == all ]] && all=1
CLEAN_ACTIONS=0; CLEAN_DEFERRED=(); CLEAN_GONE=()
CLEAN_HEADER="steam-frame-nix: cleaning up files outside Nix ($mode"
if (( CLEAN_DRY )); then CLEAN_HEADER+=", dry run)"; else CLEAN_HEADER+=")"; fi
if (( ! CLEAN_QUIET )); then step "$CLEAN_HEADER"; CLEAN_HEADER=''; fi
clean_debugger "$keep_debugger"
clean_icons
clean_firefox "$all"
clean_jellyfin
clean_ui_state "$all"
c_rmdir "$SFN_STATE"
clean_screenshots "$keep_screenshots"
clean_pet "$keep_pet"
(( all )) && clean_launchers
c_rmdir "$SFN_RUNTIME"
(( CLEAN_ACTIONS || ${#CLEAN_DEFERRED[@]} || CLEAN_QUIET )) || info "nothing to clean up"
return 0
}
# --- what waits for a restart -------------------------------------------------
#
# Two settings are read only when their process starts, so a switch (or the
# first install) can't apply them to the running session:
# - the keyboard layout (keyboard-layout.nix: XKB_DEFAULT_* in a drop-in on
# gamescope-session.service), read by gamescope at its start;
# - SteamVR's DevTools port (steamvr-debugger.nix: a Wants= drop-in on
# steamvr.service sets VRWebHelper.DebuggerEnabled before SteamVR starts),
# which the dashboard patches (VR pet, the keyboard's suggestion strip, ...)
# need.
# Both are compared with the running session; neither can be applied at
# runtime (gamescope sends its own keymap to Xwayland again, e.g. whenever
# the VR keyboard types, and only a SteamVR start opens the port).
SFN_CGROUP="${STEAM_FRAME_NIX_CGROUP:-/sys/fs/cgroup}"
LAYOUT_DROPIN="$USER_UNIT_DIR/gamescope-session.service.d/keyboard.conf"
DEBUGGER_HM_DROPIN="$USER_UNIT_DIR/steamvr.service.d/webhelper-debugger.conf"
DEBUGGER_PORT=8087
# Value of Environment=<name>=... in a unit file (empty if unset).
unit_env() { # file name
sed -n "s/^Environment=$2=//p" "$1" 2>/dev/null | tail -n1
}
# XKB_DEFAULT_<what> of the running gamescope session: from the first
# process of gamescope-session.service whose environment is readable
# (gamescope itself isn't: it has capabilities). Fails if unknown.
session_xkb() { # LAYOUT|VARIANT
local cg pid env found=1
[[ $(c_systemctl show -p ActiveState --value gamescope-session.service 2>/dev/null) == active ]] || return 1
cg="$(c_systemctl show -p ControlGroup --value gamescope-session.service 2>/dev/null)" && [[ -n $cg ]] || return 1
[[ -r $SFN_CGROUP$cg/cgroup.procs ]] || return 1
while read -r pid; do
env="$( { tr '\0' '\n' <"$SFN_PROC/$pid/environ"; } 2>/dev/null)" || continue
[[ -n $env ]] || continue
sed -n "s/^XKB_DEFAULT_$1=//p" <<<"$env" | tail -n1
found=0
break
done <"$SFN_CGROUP$cg/cgroup.procs"
return $found
}
# Whether something listens on 127.0.0.1/::1/any :<port> (/proc/net/tcp*).
port_listening() { # port
local hex f files=()
printf -v hex '%04X' "$1"
for f in "$SFN_PROC/net/tcp" "$SFN_PROC/net/tcp6"; do [[ -r $f ]] && files+=("$f"); done
(( ${#files[@]} )) || return 1
awk -v p=":$hex" 'FNR > 1 && $4 == "0A" && substr($2, length($2) - 4) == p { f = 1 } END { exit !f }' "${files[@]}"
}
# One line per setting that waits for a restart.
restart_pending() {
local want_l want_v run_l run_v
if [[ -e $LAYOUT_DROPIN ]]; then
want_l="$(unit_env "$LAYOUT_DROPIN" XKB_DEFAULT_LAYOUT)"
want_v="$(unit_env "$LAYOUT_DROPIN" XKB_DEFAULT_VARIANT)"
if run_l="$(session_xkb LAYOUT)" && run_v="$(session_xkb VARIANT)" \
&& [[ $run_l != "$want_l" || $run_v != "$want_v" ]]; then
echo "keyboard layout ${want_l:-us}${want_v:+ ($want_v)} (the running Steam session has ${run_l:-us}${run_v:+ ($run_v)}): from the next start of the Steam session"
fi
fi
if [[ -e $DEBUGGER_HM_DROPIN && $(steamvr_state) == running ]] && ! port_listening "$DEBUGGER_PORT"; then
echo "SteamVR dashboard patches (e.g. the VR pet, the VR keyboard's suggestion strip; DevTools port $DEBUGGER_PORT not open yet): from the next SteamVR start"
fi
return 0
}
cmd_restart_check() {
local pending=()
need_not_root
mapfile -t pending < <(restart_pending)
(( ${#pending[@]} )) || return 0
warn "steam-frame-nix: some settings take effect only after a restart (reboot once, or restart the Steam session):"
printf ' - %s\n' "${pending[@]}" >&2
}
# --- uninstall --------------------------------------------------------------
# User units installed by Home Manager (unit files resolving into /nix/store).
hm_user_units() {
local f
[[ -d $USER_UNIT_DIR ]] || return 0
for f in "$USER_UNIT_DIR"/*; do
[[ -L $f && -f $f ]] || continue
case ${f##*/} in
*@.*) continue ;;
*.service|*.socket|*.timer|*.path) ;;
*) continue ;;
esac
[[ $(readlink -f "$f") == /nix/store/* ]] && printf '%s\n' "${f##*/}"
done
return 0
}
stop_hm_services() {
step "Stopping Home Manager user services"
local units=()
mapfile -t units < <(hm_user_units)
if (( ${#units[@]} == 0 )); then info "none found"; return 0; fi
if ! outer_bus_ok; then
warn "user manager not reachable at $OUTER_RUNTIME_DIR; not stopping: ${units[*]}"
return 0
fi
info "${units[*]}"
outer_systemctl stop -- "${units[@]}" || warn "some units failed to stop"
}
remove_hm() {
step "Uninstalling Home Manager"
if ! hm_installed; then info "no Home Manager profile found"; return 0; fi
if ! nix_works; then warn "Nix doesn't work; skipping 'home-manager uninstall'"; return 0; fi
# `home-manager uninstall` builds an empty config with <nixpkgs>; take the
# nixpkgs the current config uses (already in the store), else the registry.
local nixpkgs='' dir
if [[ -e $HM_CONFIG_LINK/flake.nix ]]; then
dir="$(readlink -f "$HM_CONFIG_LINK")"
nixpkgs="$(nix eval --raw "$dir#homeConfigurations.\"$USER_NAME\".pkgs.path" 2>/dev/null || true)"
fi
[[ -n $nixpkgs ]] || nixpkgs=flake:nixpkgs
# It asks "Really uninstall Home Manager? [y/n]" (one character from stdin);
# we already asked.
printf 'y' | hm -I "nixpkgs=$nixpkgs" uninstall \
|| die "'home-manager uninstall' failed; fix the error and re-run (Nix was not touched)"
if outer_bus_ok; then
outer_systemctl daemon-reload || true
outer_systemctl reset-failed >/dev/null 2>&1 || true
fi
}
# --- programs from the Nix store ---
#
# Listed before Nix goes, for information (uninstall_nix detaches /nix
# lazily): their program, a library, an open file or their working
# directory is in /nix. Only this user's processes are readable. This
# script, its subshells and its curl | bash pipeline are skipped.
proc_ppid() { # pid
local k v
{ while read -r k v; do [[ $k == PPid: ]] && { echo "$v"; return 0; }; done <"$SFN_PROC/$1/status"; } 2>/dev/null
return 0
}
proc_pgid() { # pid
local s
{ read -r s <"$SFN_PROC/$1/stat"; } 2>/dev/null || return 0
s=${s##*) } # after "pid (comm) "; comm may contain spaces
read -r _ _ s _ <<<"$s" # state ppid pgrp
echo "$s"
}
# "<name> (PID <pid>)" per process using /nix.
nix_users() {
local p pid name argv0 self=$$ pgid
pgid="$(proc_pgid "$self")"
{ find "$SFN_PROC"/[0-9]*/{exe,cwd,root} "$SFN_PROC"/[0-9]*/fd -maxdepth 1 -lname '/nix/*' 2>/dev/null || true
grep -ls '[[:space:]]/nix/' "$SFN_PROC"/[0-9]*/maps || true
} | while IFS= read -r p; do p=${p#"$SFN_PROC"/}; echo "${p%%/*}"; done | sort -un \
| while read -r pid; do
[[ $pid != "$self" ]] || continue
{ read -r name <"$SFN_PROC/$pid/comm"; } 2>/dev/null || continue # exited
# argv[0]'s name says more than comm (often a thread name)
argv0=''; { IFS= read -r -d '' argv0 <"$SFN_PROC/$pid/cmdline"; } 2>/dev/null || true
[[ ${argv0##*/} == '' || ${argv0##*/} == exe ]] || name=${argv0##*/}
[[ -n $pgid && $(proc_pgid "$pid") == "$pgid" ]] && continue # our pipeline
p=$pid
while [[ $p =~ ^[0-9]+$ && $p != "$self" ]] && (( p > 1 )); do p="$(proc_ppid "$p")"; done
[[ $p == "$self" ]] && continue # our subshells
echo "$name (PID $pid)"
done
}
remove_path() {
if [[ -e $1 || -L $1 ]]; then rm -rf -- "$1"; info "removed $1"; fi
}
remove_leftovers() { # keep_nix
step "Removing per-user leftovers"
local state="${XDG_STATE_HOME:-$HOME/.local/state}"
if (( ! $1 )); then
remove_path "$HOME/.nix-profile"
remove_path "$HOME/.nix-defexpr"
remove_path "$HOME/.nix-channels"
remove_path "$state/nix"
remove_path "${XDG_CACHE_HOME:-$HOME/.cache}/nix"
if [[ -f $USER_NIX_CONF ]]; then
if [[ -z $(grep -Ev '^[[:space:]]*(#|$)' "$USER_NIX_CONF" \
| grep -Ev '^[[:space:]]*(extra-)?experimental-features[[:space:]]*=[[:space:]]*(nix-command[[:space:]]+flakes|flakes[[:space:]]+nix-command|flakes)[[:space:]]*$' || true) ]]; then
remove_path "$USER_NIX_CONF"
rmdir "${USER_NIX_CONF%/*}" 2>/dev/null || true
else
info "kept $USER_NIX_CONF (has more than the flakes setting)"
fi
fi
fi
remove_path "$state/home-manager"
if [[ -L $HM_CONFIG_LINK ]]; then
info "$HM_CONFIG_LINK pointed to $(readlink "$HM_CONFIG_LINK")"
remove_path "$HM_CONFIG_LINK"
elif [[ -e $HM_CONFIG_LINK ]]; then
info "kept $HM_CONFIG_LINK (a real directory, your configuration)"
fi
}
cmd_uninstall() {
local keep_nix=0
while (( $# )); do
case $1 in
-y|--yes) ASSUME_YES=1; shift ;;
--keep-nix) keep_nix=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "uninstall: unknown option '$1' (see --help)" ;;
esac
done
need_not_root
if (( keep_nix )); then
step "This uninstalls Home Manager (its files and services); Nix stays"
else
step "This uninstalls Home Manager (its files and services) and Nix (/nix, /home/nix)"
fi
confirm "Continue?" || die "aborted"
load_nix
stop_hm_services
cmd_cleanup --all
remove_hm
hash -r # commands found in Home Manager's profile are gone
# A failed Nix uninstall must not skip the rest: Home Manager is gone by
# now, so its config link goes regardless; the per-user Nix files stay
# for the Nix that is still there.
local nix_failed=0
if (( ! keep_nix )); then
if [[ -x $NIX_INSTALLER_BIN ]]; then
if ! uninstall_nix; then
warn "the Nix uninstall failed (see above)"
nix_failed=1 keep_nix=1
fi
elif command -v nix >/dev/null 2>&1; then
warn "Nix wasn't installed by nix-installer ($NIX_INSTALLER_BIN missing); not removing it"
keep_nix=1
else
info "Nix is not installed"
fi
fi
remove_leftovers "$keep_nix"
step "Done"
cat <<EOF
Intentionally left in place:
- your configuration (e.g. $DEFAULT_CONFIG_DIR, also when cloned)
- files Home Manager renamed to *.hm-backup-<time>
- app data, e.g. ~/.local/share/docker, Firefox profiles, and Flatpak apps
Log out or reboot: running sessions drop the removed tweaks, and programs
that were started from Nix restart without it.
EOF
if (( ${#CLEAN_DEFERRED[@]} || nix_failed )); then
printf '\nNot done yet:\n'
(( ${#CLEAN_DEFERRED[@]} )) && printf ' - %s\n' "${CLEAN_DEFERRED[@]}"
(( nix_failed )) && printf '%s\n' \
" - Nix: its uninstaller failed. Reboot, then run uninstall again;" \
" it also removes ~/.nix-profile and ~/.local/state/nix."
fi
(( ! nix_failed ))
}
# --- status -----------------------------------------------------------------
cmd_status() {
local gen target features ro unit units=()
load_nix
step "Nix"
if nix_works; then
info "version: $(nix --version)"
elif command -v nix >/dev/null 2>&1; then
info "version: $(nix --version 2>/dev/null || echo '?') (daemon not reachable)"
else
info "not installed"
fi
if [[ -r $NIX_RECEIPT ]]; then
info "installer: nix-installer, planner $(receipt_planner || echo '?')"
fi
if command -v nix >/dev/null 2>&1; then
features="$(nix_features || true)"
info "features: ${features:-?}"
fi
info "daemon: nix-daemon.socket $(systemctl is-active nix-daemon.socket 2>/dev/null || true)"
if command -v btrfs >/dev/null 2>&1; then
ro="$(btrfs property get / ro 2>/dev/null || true)"
[[ -n $ro ]] && info "root fs: $([[ $ro == ro=true ]] && echo read-only || echo read-write)"
fi
step "Home Manager"
if hm_installed; then
target="$(readlink -f "$HM_PROFILE")"
gen="$(readlink "$HM_PROFILE")"; gen="${gen##*/}"; gen="${gen#home-manager-}"; gen="${gen%-link}"
info "generation: $gen ($target)"
info "activated: $(stat -c %y "$HM_PROFILE" | cut -d. -f1)"
else
info "not installed"
fi
if [[ -L $HM_CONFIG_LINK ]]; then
info "config: $HM_CONFIG_LINK -> $(readlink -f "$HM_CONFIG_LINK")"
elif [[ -e $HM_CONFIG_LINK ]]; then
info "config: $HM_CONFIG_LINK"
else
info "config: none at $HM_CONFIG_LINK"
fi
step "User services from Home Manager"
mapfile -t units < <(hm_user_units)
if (( ${#units[@]} == 0 )); then
info "none"
elif outer_bus_ok; then
for unit in "${units[@]}"; do
info "$(printf '%-30s %s' "$unit" "$(outer_systemctl is-active "$unit" 2>/dev/null || true)")"
done
else
info "user manager not reachable at $OUTER_RUNTIME_DIR"
fi
info "$(printf '%-30s %s' clipboard-sync "$(pgrep -u "$USER_ID" -x clipboard-sync >/dev/null && echo running || echo 'not running')")"
cmd_cleanup --dry-run --all
}
# --- main -------------------------------------------------------------------
main() {
local cmd=${1:-}
(( $# )) && shift
case $cmd in
install) cmd_install "$@" ;;
uninstall) cmd_uninstall "$@" ;;
status) cmd_status "$@" ;;
cleanup) cmd_cleanup "$@" ;;
steamvr-debugger-arm) cmd_debugger_arm "$@" ;;
restart-check) cmd_restart_check "$@" ;;
-h|--help|help) usage ;;
'') usage >&2; exit 2 ;;
*) printf 'unknown command: %s\n\n' "$cmd" >&2; usage >&2; exit 2 ;;
esac
}
# Run unless sourced (piped into bash counts as run).
(return 0 2>/dev/null) || main "$@"