Files
DroidDeck/tools/linuxfs/overlay/usr/local/bin/droiddeck-session
T

1186 lines
64 KiB
Bash
Executable File

#!/bin/bash
# The Linux runtime's session. DroidDeck execs this under proot with the output size and mode;
# the first pass starts gamescope, which execs this script again inside it for the program:
# droiddeck-session desktop the file manager
# droiddeck-session run <path> a Linux program (AppImage, script or ELF)
# droiddeck-session steam [steam-url] the native Steam client
# droiddeck-session lxqt the KDE Plasma desktop (DroidDeck's desktop package)
# BL_WIDTH / BL_HEIGHT size the output, BL_FPS limits it, BL_LOG collects everything said here.
set -u
export HOME=${HOME:-/root}
mode=${1:-desktop}
shift || true
# One line per milestone, greppable as "== STEP", so a first run reads as a narrative rather than
# something to be reconstructed from Steam's own chatter. The app shows these while it waits.
step() { echo "== STEP $(date -u +%H:%M:%S) $*"; }
if [ -n "${BL_LOG:-}" ] && [ -z "${BL_INSIDE:-}" ]; then
mkdir -p "$(dirname "$BL_LOG")" 2>/dev/null
# The client alone writes megabytes an hour; one earlier log is kept, not every session's.
if [ "$(stat -c %s "$BL_LOG" 2>/dev/null || echo 0)" -gt 8388608 ]; then
mv -f "$BL_LOG" "$BL_LOG.1"
fi
# A guest looping on an error writes gigabytes in minutes (an Xwayland proot had lost track of
# managed 9 GB of one line in 19 min): the log keeps its first BL_LOG_CAP bytes and the rest is
# drained, not refused - a writer that filled it must not die of SIGPIPE.
exec > >({ dd of="$BL_LOG" oflag=append conv=notrunc iflag=count_bytes count="${BL_LOG_CAP:-268435456}" bs=65536 status=none; cat >/dev/null; }) 2>&1
echo "== droiddeck-session $(date) mode=$mode $*"
echo "== uid=$(id -u) size=${BL_WIDTH:-?}x${BL_HEIGHT:-?} fps=${BL_FPS:-0} icd=${VK_ICD_FILENAMES:-none}"
echo "== gamescope: $(gamescope --version 2>&1 | head -1) at $(command -v gamescope)"
echo "== rootfs: $(cat /.version 2>/dev/null || echo unversioned)"
fi
# Every program loads the locale LANG names (C.UTF-8) before main(). glibc ships it as a directory,
# which it looks for once per category under two spellings before reading gconv's module lists:
# about 30 opens, each a stop in proot, for every process in the session, a script's dirname or cat
# included. An archive holding it (and gconv's cache) makes that one open. Built again when the
# glibc package changes, or when the archive does (locale-gen rewrites it).
prepare_locale() {
local dir=/usr/lib/locale stamp=/usr/lib/locale/.droiddeck-archive want have= pkg
pkg=(/var/lib/pacman/local/glibc-[0-9]*)
want=${pkg[0]##*/}
[ -f "$stamp" ] && read -r have < "$stamp"
[ "$have" = "$want" ] && [ -f "$dir/locale-archive" ] && [ ! "$dir/locale-archive" -nt "$stamp" ] && return 0
[ -d "$dir/C.utf8" ] && command -v localedef >/dev/null 2>&1 || return 0
# localedef names a new archive by hard link, which Android refuses; libblsession renames it.
DROIDDECK_LINK_RENAME=1 localedef --add-to-archive --replace "$dir/C.utf8" >/dev/null 2>&1 || return 0
rm -f /usr/lib/gconv/gconv-modules.cache
command -v iconvconfig >/dev/null 2>&1 && iconvconfig >/dev/null 2>&1
printf '%s\n' "$want" > "$stamp"
echo "== locale archive built for $want"
}
[ -z "${BL_INSIDE:-}" ] && prepare_locale
# Until the runtime is stable, Steam's own logs are copied beside the session log at exit so a
# failed launch can be handed over as one folder.
collect_steam_logs() {
[ -n "${BL_DEBUG_DIR:-}" ] || return 0
local steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam
mkdir -p "$BL_DEBUG_DIR/steam" 2>/dev/null || return 0
# Into steam/ beside this session's own logs. The app scrubs these line by line after the
# session ends - they carry the account's session token and id - so nothing here is the last
# word on what the user ends up sharing; this is just the copy that survives the runtime.
# Up to 8 MB each, as the app's own copy takes them: the client's CEF and *.previous.txt logs
# reach 17 MB and say little about this session, and every share scrubs whatever is here.
[ -d "$steam_root/logs" ] && find "$steam_root/logs" -maxdepth 1 -type f -size -8192k \
-exec cp {} "$BL_DEBUG_DIR/steam/" \; 2>/dev/null
[ -f "$HOME/.steam/steam/logs/steamwebhelper.log" ] && cp "$HOME/.steam/steam/logs/steamwebhelper.log" "$BL_DEBUG_DIR/steam/" 2>/dev/null
# NOT copied, ever: loginusers.vdf, config.vdf and the ssfn* files, which hold the credentials
# themselves rather than a mention of them.
local sync_store=$HOME/.local/share/droiddeck-esync sync_file
if [ -d "$sync_store" ] && mkdir -p "$BL_DEBUG_DIR/droiddeck-esync" 2>/dev/null; then
for sync_file in launches.log tools.tsv wanted.tsv prefixes.json; do
[ -f "$sync_store/$sync_file" ] && cp "$sync_store/$sync_file" "$BL_DEBUG_DIR/droiddeck-esync/" 2>/dev/null
done
fi
local fex_state=$HOME/.local/share/droiddeck-fex
if [ -d "$fex_state" ] && mkdir -p "$BL_DEBUG_DIR/droiddeck-fex" 2>/dev/null; then
cp "$fex_state/status.json" "$fex_state/config/Config.json" "$BL_DEBUG_DIR/droiddeck-fex/" 2>/dev/null
fi
echo "== steam logs copied to $BL_DEBUG_DIR/steam"
}
# A session that is killed never reaches the copy below, and those are the sessions worth reading.
trap 'collect_steam_logs 2>/dev/null || true' TERM INT HUP
# The driver everything in this session draws with: gamescope's compositing, the client's UI
# (OpenGL through Zink), every game Proton launches, and in desktop mode every program on it.
# BL_VK_DRIVER names an imported driver's ICD manifest on the Android side, which is bound into the
# session at its own path; unset means the runtime keeps the Turnip it was built with.
# VK_DRIVER_FILES REPLACES the loader's search, so a manifest whose library is unreadable would
# leave the session with no Vulkan at all - check both before setting it, and say so in the log
# rather than fail silently half a minute later.
if [ -n "${BL_VK_DRIVER:-}" ]; then
bl_icd_lib=$(sed -n 's/.*"library_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$BL_VK_DRIVER" 2>/dev/null | head -1)
# JSON may escape the separator: org.json writes "\/data\/user\/0\/...". The Vulkan loader
# parses that correctly; this check has to as well, or an absolute path looks relative and gets
# glued onto the manifest's directory - which is exactly what happened the first time.
bl_icd_lib=$(printf '%s' "$bl_icd_lib" | sed 's|\\/|/|g')
case $bl_icd_lib in
/*) ;;
?*) bl_icd_lib="$(dirname "$BL_VK_DRIVER")/$bl_icd_lib" ;;
esac
if [ -r "$BL_VK_DRIVER" ] && [ -n "$bl_icd_lib" ] && [ -r "$bl_icd_lib" ]; then
export VK_DRIVER_FILES="$BL_VK_DRIVER"
# Loaders older than 1.3.207 only know the old name; both are harmless together.
export VK_ICD_FILENAMES="$BL_VK_DRIVER"
echo "== vulkan driver: imported $bl_icd_lib (via $BL_VK_DRIVER)"
else
echo "== vulkan driver: IGNORING $BL_VK_DRIVER (manifest or ${bl_icd_lib:-library} unreadable); using the runtime's own"
fi
unset bl_icd_lib
fi
# The app's gamescope build (tools/gamescope) asks for realtime-priority Vulkan queues on this
# switch instead of on CAP_SYS_NICE, which proot never has; it falls back to normal priority
# where the driver refuses, and the runtime's own build ignores it. On KGSL Turnip (Mesa 26.2) it
# changes nothing yet: the driver reports one submit-queue priority and creates every KGSL context
# with the same flags, so no priority reaches the GPU. Kept for a driver that maps it, and off
# unless the app's Performance page turns it on (BL_GAMESCOPE_REALTIME=1).
export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-0}
# A sleep that starts no process. Under proot every exec is a traced execve plus the loader's
# opens and maps, and the watchers below poll for the whole session: `sleep 1` in a loop was 60
# execs a minute each. read -t on a pipe nothing ever writes to waits in bash itself.
# The agent bridge's guest side (docs/agent-control.md): runs what the app's agent provider asks
# for inside this session and publishes gamescope's focus. Its files live under the session root
# ($BL_LAUNCH_DIR/agent), which the app empties before each session.
start_agent() {
[ -n "${BL_LAUNCH_DIR:-}" ] && [ -x /usr/local/bin/droiddeck-agent ] || return 0
mkdir -p "$BL_LAUNCH_DIR/agent"
/usr/local/bin/droiddeck-agent "$@" 2>&1 &
}
nap() {
[ -n "${bl_nap_fd:-}" ] || exec {bl_nap_fd}<> <(:)
read -r -t "$1" -u "$bl_nap_fd" _ 2>/dev/null || :
}
export XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share}
export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
export XDG_CACHE_HOME=${XDG_CACHE_HOME:-$HOME/.cache}
mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME"
if [ "$mode" = lxqt ] || [ "$mode" = desktop ]; then
/usr/local/bin/droiddeck-desktop-bookmarks || true
fi
# Flatpak (the store's apps): its sandboxes through the app instead of bubblewrap, which needs the
# user namespaces Android keeps from apps, and its apps' menu entries and icons on the desktop.
export FLATPAK_BWRAP=/usr/local/bin/droiddeck-bwrap
export XDG_DATA_DIRS=$XDG_DATA_HOME/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}
# The cores a program runs on outside Steam (the desktop and everything started from it, a
# program from the rail): the user's game cores when set (BL_GAME_CPUS), else every core but the
# slowest cluster - Android is free to park an emulator's threads on the little cores, and those
# are what compile shaders and recompile PPU/SPU code. BL_PIN_CORES=0 leaves the scheduler alone.
program_cores() {
[ "${BL_PIN_CORES:-1}" = 0 ] && return 0
command -v taskset >/dev/null 2>&1 || return 0
if [ -n "${BL_GAME_CPUS:-}" ]; then echo "$BL_GAME_CPUS"; return 0; fi
local f n max min= list=
for f in /sys/devices/system/cpu/cpu[0-9]*/cpufreq/cpuinfo_max_freq; do
max=$(cat "$f" 2>/dev/null) || continue
[ -z "$min" ] || [ "$max" -lt "$min" ] && min=$max
done
[ -n "$min" ] || return 0
for f in /sys/devices/system/cpu/cpu[0-9]*/cpufreq/cpuinfo_max_freq; do
max=$(cat "$f" 2>/dev/null) || continue
n=${f#/sys/devices/system/cpu/cpu}; n=${n%%/*}
[ "$max" -gt "$min" ] && list="$list${list:+,}$n"
done
echo "$list"
}
if [ -z "${BL_INSIDE:-}" ]; then
# Install the shared default-family aliases before Steam, gamescope or the desktop queries fonts.
/usr/local/bin/droiddeck-fonts 2>&1 | sed 's/^/[fonts] /'
# Android's directory binding and language-specific family matches handle CJK and Thai fonts.
device_fonts=/usr/local/share/fonts/android
if [ -d "$device_fonts" ] && [ -d /etc/fonts/conf.d ] && command -v fc-cache >/dev/null 2>&1; then
fonts_rules=$(
echo '<?xml version="1.0"?><!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd"><fontconfig>'
for pair in "zh-cn=Noto Sans CJK SC" "zh-sg=Noto Sans CJK SC" "zh-tw=Noto Sans CJK TC" "zh-hk=Noto Sans CJK HK" \
"zh-mo=Noto Sans CJK HK" "ja=Noto Sans CJK JP" "ko=Noto Sans CJK KR" "th=Noto Sans Thai"; do
printf '<match target="pattern"><test name="lang" compare="contains"><string>%s</string></test><edit name="family" mode="prepend_first"><string>%s</string></edit></match>\n' \
"${pair%%=*}" "${pair#*=}"
done
echo '</fontconfig>'
)
fonts_conf=/etc/fonts/conf.d/66-droiddeck-device-fonts.conf
[ "$fonts_rules" = "$(cat "$fonts_conf" 2>/dev/null)" ] || printf '%s\n' "$fonts_rules" > "$fonts_conf"
fonts_seen=$(ls -lL "$device_fonts" 2>/dev/null | md5sum)
fonts_stamp=/var/cache/fontconfig/.droiddeck-android-fonts
if [ "$fonts_seen" != "$(cat "$fonts_stamp" 2>/dev/null)" ]; then
fc-cache -f "${device_fonts%/*}" >/dev/null 2>&1 && printf '%s\n' "$fonts_seen" > "$fonts_stamp"
echo "== fontconfig: indexed $(ls "$device_fonts" | wc -l) device fonts, zh falls back to $(fc-match -f '%{family[0]}' ':lang=zh-cn' 2>/dev/null)"
fi
fi
# The compositor may still be starting, and a socket left by an earlier session is not
# a compositor: probe until a connection succeeds.
for _ in $(seq 1 100); do
wayland-info >/dev/null 2>&1 && break
sleep 0.1
done
# The desktop (DroidDeck's hosted package) is its own nested compositor, KWin, drawing straight
# into the app's: no gamescope in between.
if [ "$mode" = lxqt ]; then
step "starting the desktop"
start_agent --no-focus
[ -x /usr/local/bin/droiddeck-desktop ] || { echo "== the desktop package is not installed"; exit 66; }
cores=$(program_cores)
if [ -n "$cores" ]; then
echo "== desktop cores: $cores (the desktop and every program started from it)"
# Pin this shell and let exec carry the mask. Android keeps the app on a smaller cpuset
# until its activity is on top (foreground 0-5, background fewer), and a mask with no core
# of that set is EINVAL: taskset -c then exits 1 and takes the whole session with it.
taskset -cp "$cores" $$ >/dev/null 2>&1 || echo "== desktop cores: not allowed right now, every core"
fi
exec /usr/local/bin/droiddeck-desktop
fi
width=${BL_WIDTH:-1280}
height=${BL_HEIGHT:-720}
nested=$width
# Follow screen (BL_FOLLOW_OUTPUT=1): the app resizes gamescope's output when a foldable opens or
# closes, and with Steam on its own Xwayland gamescope resizes Steam's X screen with it, as on a
# Steam Deck docking. A stretched 16:9 nested size would pin it, so the two are exclusive.
if [ "${BL_FOLLOW_OUTPUT:-0}" = 1 ]; then
echo "== gamescope: the output follows the screen (${width}x${height} to start)"
elif [ "$mode" = steam ] && [ "${BL_GAMESCOPE_STRETCH_16X9:-0}" = 1 ]; then
wide=$(( (height * 16 + 4) / 9 / 2 * 2 ))
if [ "$wide" -gt "$width" ]; then
nested=$wide
echo "== gamescope: games at ${nested}x${height} stretched to the ${width}x${height} output"
fi
fi
args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$nested" -h "$height")
[ "$nested" != "$width" ] && args+=(-S stretch) && export BL_STRETCHED=1
# -r is what gamescope advertises as the refresh rate, and what a game reads as the display's.
# Left unset it defaults to 60, so a 120 Hz panel offers only 60 Hz in game settings. The FPS
# limiter wins when it is on (it is a deliberate cap); otherwise use the panel's own rate.
if [ "${BL_FPS:-0}" -gt 0 ]; then
args+=(-r "$BL_FPS")
elif [ "${BL_REFRESH:-0}" -gt 0 ]; then
args+=(-r "$BL_REFRESH")
fi
# Steam integration advertises a Deck session and makes the client select Big Picture even
# without -gamepadui. The desktop UI uses ordinary gamescope window management instead.
[ "$mode" = steam ] && [ "${BL_STEAM_UI:-}" != desktop ] && args+=(-e)
# Two Xwayland servers, as SteamOS runs Steam: the client on the first, games on the second
# (STEAM_GAME_DISPLAY_0). The client then sizes the games' server to each game's Game Resolution
# (GAMESCOPE_XWAYLAND_MODE_CONTROL; native unless one is set), and gamescope scales it to the
# output. With one server the client and its games share the output's size, so a game's own Game
# Resolution never reaches its display (docs/development/steam-display-scaling.md). The client's
# overlay is also on another server than the game, so opening it no longer takes the game's X
# keyboard focus, which made games like FlatOut shrink their window.
# BL_XWAYLAND_COUNT=1 in Download/droiddeck-env goes back to one.
if [ "$mode" = steam ] && [ "${BL_XWAYLAND_COUNT:-2}" -gt 1 ]; then
args+=(--xwayland-count "${BL_XWAYLAND_COUNT:-2}")
export STEAM_MULTIPLE_XWAYLANDS=1
fi
# Touch passes through to the client as touch until the client says otherwise, as SteamOS's
# gamescope-session starts it (TOUCH_MODE=4). The client sets the mode itself (STEAM_TOUCH_CLICK_MODE)
# only once it has a controller; until then gamescope's own default made a finger a left-click
# mouse, so the Steam interface took taps but would not scroll on a phone with no pad attached.
[ "$mode" = steam ] && args+=(--default-touch-mode 4)
# No --force-windows-fullscreen: it was for games that shrink their window when the Steam menu
# opens over them (FlatOut), which no longer happens with the games on their own Xwayland server
# (above), and it made any game drawing at another size than its display fight it. An experiment
# can still pass it with BL_GAMESCOPE_ARGS.
# HDR10: the app opened its compositor's colour-management gate for this session (BL_HDR=1);
# gamescope then offers HDR to its clients through the same wp_color_manager_v1 it binds on ours.
[ "${BL_HDR:-0}" = 1 ] && args+=(--hdr-enabled)
# Extra gamescope arguments for an experiment (--debug-focus, say), from Download/droiddeck-env.
[ -n "${BL_GAMESCOPE_ARGS:-}" ] && read -r -a _bl_extra <<<"$BL_GAMESCOPE_ARGS" && args+=("${_bl_extra[@]}")
# Deck mode's performance overlay (Quick Access Menu → Performance → Overlay Level) is drawn by
# mangoapp, which gamescope starts beside the client; the client sets the level by rewriting
# MANGOHUD_CONFIGFILE. The apk stages Valve's aarch64 build (tools/mangoapp) - without it the menu
# still offers the levels and nothing appears. Starts hidden until the client writes a level.
# BL_MANGOAPP=0 turns it off (one community build found Valve's mangoapp crashing on Turnip).
if [ "$mode" = steam ] && [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_MANGOAPP:-1}" = 1 ] \
&& [ "${BL_STEAM_UI:-}" != desktop ] && command -v mangoapp >/dev/null 2>&1; then
MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX)
echo no_display > "$MANGOHUD_CONFIGFILE"
export MANGOHUD_CONFIGFILE STEAM_USE_MANGOAPP=1 STEAM_MANGOAPP_PRESETS_SUPPORTED=1 STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1
args+=(--mangoapp)
echo "== gamescope: mangoapp on ($(command -v mangoapp), config $MANGOHUD_CONFIGFILE)"
fi
export BL_INSIDE=1
exec gamescope "${args[@]}" -- "$0" "$mode" "$@"
fi
# gamescope manages X windows: toolkits go through Xwayland, not gamescope's own Wayland socket.
# Root-window requests for the game (restore) go to every display gamescope gave us:
# the client's ($DISPLAY) and, with two servers, the games' (STEAM_GAME_DISPLAY_0); gamescope reads
# them per server.
bl_displays() {
printf '%s\n' "$DISPLAY"
[ -n "${STEAM_GAME_DISPLAY_0:-}" ] && [ "$STEAM_GAME_DISPLAY_0" != "$DISPLAY" ] && printf '%s\n' "$STEAM_GAME_DISPLAY_0"
return 0
}
bl_xprop_set_all() {
local d ok=1
for d in $(bl_displays); do
DISPLAY=$d xprop -root -f "$1" 32c -set "$1" "$2" 2>/dev/null || ok=0
done
[ "$ok" = 1 ]
}
export GDK_BACKEND=x11 QT_QPA_PLATFORM=xcb SDL_VIDEODRIVER=x11
# gamescope's reaper waits for every process left under it before the compositor comes down. A
# process a game left behind - a Wine service blocked on a server that is already gone - is
# reparented there when the client exits, and the screen stays black on it for good. Nothing of
# the session outlives the program it was started for.
reap_orphans() {
local round p stat pid left
for round in 1 2 3 4 5; do
left=
for p in /proc/[0-9]*; do
pid=${p#/proc/}
[ "$pid" = "$$" ] && continue
stat=$(cat "$p/stat" 2>/dev/null) || continue
stat=${stat##*) }
set -- $stat
[ "${2:-}" = "$PPID" ] && left="$left $pid"
done
[ -n "$left" ] || return 0
echo "== leaving behind:$left"
kill -9 $left 2>/dev/null
sleep 0.2
done
}
# Gamescope's Wayland backend does not forward the outer clipboard into Xwayland.
# This helper owns only Android text selections and exits with this guest session.
if [ -n "${BL_ANDROID_CLIPBOARD:-}" ] && [ -x /usr/local/bin/droiddeck-clipboard ]; then
/usr/local/bin/droiddeck-clipboard "$BL_ANDROID_CLIPBOARD" &
fi
start_agent
case $mode in
desktop)
exec pcmanfm
;;
run)
target=${1:?program path}
shift
cores=$(program_cores)
if [ -n "$cores" ]; then
echo "== program cores: $cores"
taskset -cp "$cores" $$ >/dev/null 2>&1 || true
fi
# Player 1 on the controller in an emulator that has no profile of its own yet.
python3 /usr/local/bin/droiddeck-pad-defaults 2>&1 || true
fex=()
if [ -x /usr/local/bin/droiddeck-fex ]; then
case $(od -An -tu2 -j18 -N2 "$target" 2>/dev/null | tr -d ' ') in
3|62) fex=(/usr/local/bin/droiddeck-fex run --mode on --) ;;
esac
fi
case ${target,,} in
*.appimage)
export APPIMAGE_EXTRACT_AND_RUN=1
chmod +x "$target" 2>/dev/null
exec "${fex[@]}" "$target" "$@"
;;
*.sh)
if [ -x /usr/local/bin/droiddeck-fex ]; then
exec /usr/local/bin/droiddeck-fex run --for "$target" -- bash "$target" "$@"
fi
exec bash "$target" "$@"
;;
*)
chmod +x "$target" 2>/dev/null
cd "$(dirname "$target")" && exec "${fex[@]}" "$target" "$@"
;;
esac
;;
steam)
# BL_STEAM_CLIENT_ARCH=x86_64 (the Steam page's Client setting): Valve's x86-64 client under FEX
# instead of the arm64 one, in a home of its own (droiddeck-steam-x64), for what the arm64
# client cannot do - VAC. Everything this session does around the client applies to it; what is
# only for the arm64 client (its ARM64 Proton, the library mapping, the probes that load its
# steamclient.so) is skipped.
steam_x64=0
[ "${BL_STEAM_CLIENT_ARCH:-}" = x86_64 ] && steam_x64=1
if [ "$steam_x64" = 1 ]; then
# The arm64 client's own files are part of this one: its web helper draws the interface, and
# its game overlay and steamclient.so serve the arm64 Protons the x86-64 client starts.
step "checking the Steam client's arm64 parts (first run downloads them, ~1-2 min)"
droiddeck-steam-install || exit $?
# Its web helper only talks to a client of its own build; the x86-64 client updates itself on
# every start, the arm64 one only when it runs.
droiddeck-steam-install --sync "${BL_STEAM_CHANNEL:-steamdeck_publicbeta}" || step "could not update the arm64 client's parts; the x86-64 client keeps its own web helper"
step "checking the x86-64 Steam client (FEX, experimental; the first run downloads ~2 GB)"
droiddeck-steam-x64 --prepare || exit $?
step "x86-64 Steam client ready"
steam_root=${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam
steam_client=(droiddeck-steam-x64 --exec)
# The client's browser is the arm64 client's web helper (droiddeck-x64-arm-webhelper), on the
# GPU with the same flags. The x86 one (BL_X64_X86_WEBHELPER=1) stays in software: its GPU
# process needs libgbm, which FEX does not thunk (device: GPU process restart loop, no first
# frame); BL_X64_CEF_GPU=1 tries it anyway.
if [ "${BL_X64_X86_WEBHELPER:-0}" != 1 ] ||
{ [ "${BL_X64_CEF_GPU:-0}" = 1 ] && droiddeck-steam-x64 --gpu; }; then
steam_cef=(-cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan)
else
steam_cef=(-cef-disable-gpu)
fi
else
step "checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)"
droiddeck-steam-install || exit $?
step "Steam client ready"
steam_root=$XDG_DATA_HOME/Steam
# One sign-in for both clients: a newer one from the x86-64 client, when it has been used.
if [ -f "${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam/local.vdf" ]; then
droiddeck-steam-x64 --sync-login || true
fi
steam_client=("$steam_root/steamrtarm64/steam")
steam_cef=(-cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan)
fi
# Register the card library (bound at /mnt/droiddeck-sd) with the client - internal storage is
# its main library already - and the ARM64 Proton that lets it launch the Windows ones.
# A request left in ~/.bl-proton-extra installs a third-party Proton before the client starts,
# so it is in the Compatibility dropdown the first time the user looks. One per line, each the
# arguments to droiddeck-proton-extra: "ge", "cachyos", "ge --tag GE-Proton11-7", or a URL.
# Hundreds of megabytes each, so it is only ever done on request, and the line is dropped once
# it has succeeded - a failed download stays and is retried next session.
# The x86-64 client runs the arm64 client's Protons, so they go there; its default is GE-Proton
# (Proton Experimental leaked Xwayland's window buffers until the device ran out of memory), so
# one is fetched when the arm64 client has none, as the arm64 side fetches its own Proton.
_bl_proton_root=$steam_root
if [ "$steam_x64" = 1 ]; then
_bl_proton_root=$XDG_DATA_HOME/Steam
if ! ls -d "$_bl_proton_root"/compatibilitytools.d/GE-Proton*-aarch64 >/dev/null 2>&1 &&
! grep -q '^ge' "$HOME/.bl-proton-extra" 2>/dev/null; then
echo ge >> "$HOME/.bl-proton-extra"
fi
fi
if [ -s "$HOME/.bl-proton-extra" ]; then
_bl_left=$HOME/.bl-proton-extra.left
: > "$_bl_left"
_bl_got_proton=0
while read -r _bl_extra || [ -n "$_bl_extra" ]; do
case ${_bl_extra# } in ""|"#"*) continue ;; esac
step "installing Proton '$_bl_extra' (large download, several minutes)"
if droiddeck-proton-extra "$_bl_proton_root" $_bl_extra; then
_bl_got_proton=1
step "installed Proton '$_bl_extra'"
else
step "Proton '$_bl_extra' failed; will retry next session"
echo "$_bl_extra" >> "$_bl_left"
fi
done < "$HOME/.bl-proton-extra"
mv -f "$_bl_left" "$HOME/.bl-proton-extra"
# The x86-64 client's wrappers are made at --prepare: once more for the new one.
if [ "$steam_x64" = 1 ] && [ "$_bl_got_proton" = 1 ]; then
droiddeck-steam-x64 --prepare || true
fi
fi
# steam:// URLs left in ~/.bl-steam-urls are handed to the client on its command line. The app
# writes one when the user asks for a Proton that is one of Valve's own depots rather than a
# tarball: those are not files anywhere, the client fetches them like any other app. One per
# line, and the file is cleared once they have been passed on - the client either acts on a URL
# or it does not, and repeating it every session would re-ask forever.
if [ -s "$HOME/.bl-steam-urls" ]; then
while read -r _bl_url || [ -n "$_bl_url" ]; do
case ${_bl_url# } in steam://*) ;; *) continue ;; esac
step "asking the client for $_bl_url"
set -- "$@" "$_bl_url"
done < "$HOME/.bl-steam-urls"
rm -f "$HOME/.bl-steam-urls"
fi
# Cores for the client itself. Steam pins its own interface renderer to a subset - on the
# device it took 5 of 8, leaving out both little cores and the fastest one - which is a
# reasonable thing to do while a game is running and the wrong thing when the client is all
# there is. BL_CLIENT_CPUS overrides it. Re-applied on a beat because steamwebhelper spawns
# children continuously and each one inherits Steam's choice, not ours. The beat reads /proc
# itself instead of running pgrep, and only runs taskset for a process that is new or has
# grown threads since it was last pinned: every fork is a round of traced syscalls, and a
# beat that forked a taskset per process every 5 s did that for an hour. taskset -a, because
# without it only a process's main thread moves and the renderer's workers stay where they were.
if [ -n "${BL_CLIENT_CPUS:-}" ]; then
step "client cores: $BL_CLIENT_CPUS"
(
declare -A _bl_pinned=()
for ((_i = 0; _i < 720; _i++)); do
for _d in /proc/[0-9]*; do
IFS= read -r -d '' _c < "$_d/cmdline" 2>/dev/null || continue
case $_c in *steamwebhelper*|*steamrtarm64/steam|*steamrt64/steam|*gamescope*) ;; *) continue ;; esac
_t=("$_d"/task/*)
_p=${_d#/proc/}
[ "${_bl_pinned[$_p]:-}" = "${#_t[@]}" ] && continue
taskset -a -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true
_bl_pinned[$_p]=${#_t[@]}
done
nap 5
done
) &
fi
if [ "$steam_x64" = 0 ]; then
step "registering the app's game library with Steam"
droiddeck-steam-library "$steam_root" || true
step "setting up the ARM64 Proton compatibility tool"
steam-compatibility "$steam_root" || true
if [ -z "${BL_DESKTOP:-}" ] && [ "${BL_STEAM_UI:-}" != desktop ]; then
droiddeck-steam-ui-scale "$steam_root" "${BL_WIDTH:-1280}" "${BL_HEIGHT:-720}" || true
fi
# The user's own games, as non-Steam shortcuts under that tool; the list is written by the app
# each session (empty = the app's earlier entries are removed, the client's own are kept).
if [ -n "${BL_ADDED_GAMES:-}" ]; then
step "adding the app's own games to the client's library"
droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
# Steam runs a game's shared redistributables inside its prefix on first launch; marking
# them as already run keeps those x86 installers from starting under emulation at all.
droiddeck-seed-redists "$steam_root" || true
else
# The x86-64 client: its own library (the clients share the sign-in, not games; see
# droiddeck-steam-x64), and its Windows titles run the arm64 Protons through wrappers. A per-app
# entry the client aims at one of Valve's x86 Protons is pointed at the wrapper it defaults to,
# the app's own games are its shortcuts too, and redistributables are marked in its prefixes.
_bl_x64_tool=$(droiddeck-steam-x64 --default-tool)
if [ -n "$_bl_x64_tool" ]; then
step "pointing the x86-64 client's Windows titles at $_bl_x64_tool"
BL_COMPAT_TOOL=$_bl_x64_tool steam-compatibility "$steam_root" --x64 || true
if [ -n "${BL_ADDED_GAMES:-}" ]; then
step "adding the app's own games to the x86-64 client's library"
BL_COMPAT_TOOL=$_bl_x64_tool droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
fi
droiddeck-seed-redists "$steam_root" || true
fi
# The compatibility tool runs Valve's arm64 Proton, which the client fetches like any depot.
# Asked for on the command line the first time, so a Windows title has it before it is needed.
if [ "$steam_x64" = 0 ] && [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then
step "asking the client to fetch the ARM64 Proton"
set -- "$@" steam://install/4427310
# Ported from Bannerlator's session script (device-proven 2026-09-22). The client decides
# a title's compatibility tool when the title is installed and keeps that decision for the
# rest of its run, and the tool is set up before the client comes up. So once the depot has
# fully landed, restart the client once - its own -shutdown, then the loop below, which knows
# the marker - and every title installed from then on runs through the tool, with the
# refreshed tool list and defaults, and no second launch by the user. A first run only: with
# the depot present at session start nothing here is armed.
(
_bl_acf="$steam_root/steamapps/appmanifest_4427310.acf"
for _ in $(seq 1 360); do
sleep 10
# StateFlags 4 = fully installed; a moment more for the client to finish its bookkeeping.
if grep -q '"StateFlags"[[:space:]]*"4"' "$_bl_acf" 2>/dev/null; then
sleep 10
steam-compatibility "$steam_root" >/dev/null 2>&1 || true
step "compatibility layer installed: restarting the Steam client once"
: > "$HOME/.bl-restart-once"
"${steam_client[@]}" -shutdown >/dev/null 2>&1 || true
exit 0
fi
done
) &
# A client that starts signed out drops that request - on a fresh install nobody has signed
# in yet - so the install dialog only appeared after the user restarted the client by hand.
# With no remembered sign-in, ask again once the sign-in has happened: the client binary
# started a second time with a URL hands it to the running instance and exits.
#
# The request is also dropped while the client is still in its post-logon compat phase (it
# probes every registered tool then): with publicbeta 1790380355 that phase took 27 s and a
# request sent 15 s after sign-in vanished (2026-09-26, no dialog; 1790121765 was done in
# time). So wait for the client to say the phase is over ("Waiting for compat in post-logon
# took" in its console log; at most 90 s if it never does), then ask - and ask again, twice
# at most, if no install has started a minute later.
if ! grep -q '"RememberPassword"[[:space:]]*"1"' "$steam_root/config/loginusers.vdf" 2>/dev/null; then
(
_bl_acf="$steam_root/steamapps/appmanifest_4427310.acf"
_bl_conn="$steam_root/logs/connection_log.txt"
_bl_cons="$steam_root/logs/console_log.txt"
_bl_from=$(stat -c %s "$_bl_conn" 2>/dev/null || echo 0)
_bl_cfrom=$(stat -c %s "$_bl_cons" 2>/dev/null || echo 0)
for _ in $(seq 1 180); do
sleep 10
[ -f "$_bl_acf" ] && exit 0
if tail -c +$((_bl_from + 1)) "$_bl_conn" 2>/dev/null | grep -q "RecvMsgClientLogOnResponse() : processing complete"; then
for _ in $(seq 1 45); do
tail -c +$((_bl_cfrom + 1)) "$_bl_cons" 2>/dev/null | grep -q "Waiting for compat in post-logon took" && break
sleep 2
done
for _bl_try in 1 2 3; do
sleep 10
[ -f "$_bl_acf" ] && exit 0
step "signed in: asking the client again for the ARM64 Proton (try $_bl_try of 3)"
"${steam_client[@]}" steam://install/4427310 >/dev/null 2>&1 || true
sleep 50
done
exit 0
fi
done
) &
fi
fi
# The tool is registered before the client starts and finds the depot at launch time, so
# nothing here waits for a download. What can still change under a running client is which
# titles are installed: a title installed this session gets the client's own per-app Proton
# choice, which it cannot start. Refresh the per-app entries every so often; the registrar is
# idempotent, so a repeat costs nothing.
# The seeder has the same problem and a worse consequence. It marks the shared redistributables
# as already run in each prefix that exists, and a game's prefix is not created until the
# client launches it - so a title played for the first time this session was never covered, and
# its install scripts run for real. That is exactly the hang the seeder exists to prevent: Need
# for Speed Payback sat on "Running install script" with vcredist_x86.exe blocked in pipe_read,
# holding both ends of a pipe nobody would write to, while the client waited for it to exit.
# Seeding again on the same beat covers a prefix the moment it appears.
# The seeder runs on a shorter beat than the registrar because it is racing something. The
# client creates a prefix when it launches a title and runs that title's install scripts
# moments later, so the seeder has only that gap to mark the redistributables as already run.
# It stamps each prefix it has covered and skips it thereafter, so the extra passes cost
# almost nothing.
# With the 16:9 stretch on, the client's own scaler must not undo it: in Deck mode it sets
# GAMESCOPE_NEW_SCALING_SCALER to Auto (0), which fits the 16:9 picture with bars and outranks -S.
# Auto is put back to Stretch (4); a mode picked in the QAM (Integer, Fit, Fill) is kept.
[ -z "${BL_DESKTOP:-}" ] && [ "${BL_STRETCHED:-0}" = 1 ] && (
while :; do
nap 1
cur=$(xprop -root GAMESCOPE_NEW_SCALING_SCALER 2>/dev/null) || continue
case $cur in *" = "*) cur=${cur##* = } ;; *) cur=0 ;; esac
[ "$cur" = 0 ] || continue
xprop -root -f GAMESCOPE_NEW_SCALING_SCALER 32c -set GAMESCOPE_NEW_SCALING_SCALER 4 2>/dev/null \
&& echo "== stretch 16:9 to panel: the client's scaler was Auto, set to Stretch"
done
) &
# Resume from the Steam menu. The menu is an overlay that takes input, and a fullscreen game that
# loses input minimizes itself; gamescope only takes a window out of that state when the focused
# window changes, which the menu never does, so the game stayed minimized after Resume (a black
# screen with its small caption in the top-left corner). gamescope-3.16.29-p4 restores it itself;
# this asks it again a moment after input comes back from the client (app 769) to anything else,
# which covers a game that minimizes late. Harmless when nothing is minimized.
# BL_RESTORE_ON_RESUME=0 turns it off.
[ -z "${BL_DESKTOP:-}" ] && [ "${BL_RESTORE_ON_RESUME:-1}" = 1 ] && (
prev=
xprop -spy -root GAMESCOPE_FOCUSED_APP 2>/dev/null | while read -r line; do
app=${line##*= }
case $app in ''|*[!0-9]*) prev=; continue ;; esac
if [ "$prev" = 769 ] && [ "$app" != 769 ]; then
nap 1
bl_xprop_set_all GAMESCOPE_RESTORE_FOCUS_WINDOW 1 \
&& echo "== resume: asked gamescope to restore the game window (app $app)"
fi
prev=$app
done
) &
# Evidence for touch reports: the touch mode the client asks gamescope for (STEAM_TOUCH_CLICK_MODE,
# set once it has a controller, per focused app) and which app has focus, logged as they change.
# A finger that clicks but won't scroll, or touch that stops once a pad is used, shows here as
# the mode in force at that moment. 4 is touch passed through, 1 a left-click mouse.
[ -z "${BL_DESKTOP:-}" ] && (
# What follows "=" in an xprop line, or $2 for a property that is missing or has no value
# yet ("NAME(CARDINAL) =", which read leaves with nothing after the "=").
xprop_value() {
case $1 in *'not found'*|*=) echo "$2"; return ;; esac
local v=${1#*=}
v=${v# }
echo "${v:-$2}"
}
tmode= app=
xprop -spy -root STEAM_TOUCH_CLICK_MODE GAMESCOPE_FOCUSED_APP 2>/dev/null | while read -r line; do
case $line in
STEAM_TOUCH_CLICK_MODE*)
v=$(xprop_value "$line" unset)
[ "$v" = "$tmode" ] && continue
tmode=$v
echo "== touch mode: $tmode (focused app ${app:-?}) at $(date +%T)"
;;
GAMESCOPE_FOCUSED_APP*)
v=$(xprop_value "$line" none)
[ "$v" = "$app" ] && continue
app=$v
echo "== focused app: $app (touch mode ${tmode:-unset}) at $(date +%T)"
;;
esac
done
) &
# The library beat avoids repeated helper starts and proot work during game loads. It checks
# changed libraries and runs every five minutes as a backstop. The active x86-64 client has its
# own library and uses the x64 compatibility mapping; the ARM64 client uses Valve's mapping.
_bl_beat_roots=("$steam_root")
(
beat=0
compat_stamp=$HOME/.bl-compat-beat
seed_stamp=$HOME/.bl-seed-beat
rm -f "$compat_stamp" "$seed_stamp"
changed_since() {
local d
for d in "${@:2}"; do
[ "$d" -nt "$1" ] && return 0
done
return 1
}
compat_due() {
[ -e "$compat_stamp" ] && [ "$((beat % 60))" -ne 0 ] || return 0
changed_since "$compat_stamp" "${libraries[@]}" "$steam_root/steamapps/common" "$steam_root/config" \
"$steam_root/compatibilitytools.d"
}
seed_due() {
local reg
[ -e "$seed_stamp" ] && [ "$((beat % 60))" -ne 0 ] || return 0
changed_since "$seed_stamp" "${libraries[@]}" && return 0
for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
/mnt/droiddeck-sd/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
/root/Games/*/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
"$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \
"$steam_root"/steamapps/compatdata/*/pfx/system.reg \
/mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg \
/root/Games/*/steamapps/compatdata/*/pfx/system.reg; do
[ -f "$reg" ] && [ ! -f "${reg%/*}/.droiddeck-redists" ] && return 0
done
return 1
}
# A title the compat pass maps while the client runs only takes that mapping at the client's
# next start (see droiddeck-steam-compat): until then the client launches it its own way -
# a Linux build stuck on "Downloading content (0%)" for a Steam Linux Runtime and FEX that
# cannot run here (Left 4 Dead 2, 2026-10-02). So restart the client once for it, through the
# same marker and -shutdown as the ARM64 Proton's restart: only when every new title has
# finished installing (StateFlags 4) and no game is running; another download just resumes.
# Once per title and three times per session at most, so a client that rewrites config.vdf
# can never loop it.
compat_pending=$HOME/.bl-compat-pending
compat_restarted=$HOME/.bl-compat-restarted
rm -f "$compat_pending"
: > "$compat_restarted"
compat_restarts=0
compat_settled() {
local app m flags
while read -r app; do
[ -n "$app" ] || continue
m=
for _r in "${_bl_beat_roots[@]}" /mnt/droiddeck-sd; do
[ -f "$_r/steamapps/appmanifest_$app.acf" ] && { m=$_r/steamapps/appmanifest_$app.acf; break; }
done
[ -n "$m" ] || return 1
flags=$(sed -n 's/^[[:space:]]*"StateFlags"[[:space:]]*"\([0-9]*\)".*/\1/p' "$m" | head -n 1)
[ "$flags" = 4 ] || return 1
done < "$compat_pending"
! pgrep -f 'reaper SteamLaunch' >/dev/null 2>&1
}
for ((_i = 0; _i < 720; _i++)); do
nap 5
beat=$((beat + 1))
[ "$((beat % 3))" -eq 0 ] || continue
libraries=("$steam_root/steamapps" /mnt/droiddeck/steamapps /mnt/droiddeck-sd/steamapps /root/Games/*/steamapps)
if seed_due; then
: > "$seed_stamp"
droiddeck-seed-redists "$steam_root" >/dev/null 2>&1 || true
fi
if compat_due; then
: > "$compat_stamp"
if [ "$steam_x64" = 1 ]; then
[ -n "${_bl_x64_tool:-}" ] &&
BL_COMPAT_TOOL=$_bl_x64_tool steam-compatibility "$steam_root" --x64 --live >/dev/null 2>&1 || true
else
steam-compatibility "$steam_root" >/dev/null 2>&1 || true
fi
fi
if [ -s "$compat_pending" ] && compat_settled; then
_bl_new=$(sort -u "$compat_pending" | grep -vxF -f "$compat_restarted" || true)
rm -f "$compat_pending"
[ -n "$_bl_new" ] && [ "$compat_restarts" -lt 3 ] || continue
compat_restarts=$((compat_restarts + 1))
printf '%s\n' $_bl_new >> "$compat_restarted"
step "newly installed title(s) $(echo $_bl_new) set to the ARM64 Proton: restarting the Steam client once so it takes effect"
: > "$HOME/.bl-restart-once"
"${steam_client[@]}" -shutdown >/dev/null 2>&1 || true
fi
done
) &
# A game the app asked to launch on a cold start is not put on the client's command line: the
# client takes it before its interface is up, its launch stops at "waiting for user response to
# ShowInterstitials" with nobody to answer, and the game page reads Launching for ever (device,
# 2026-10-09, and 2026-10-08 before it). It is handed over once the interface has focus
# (gamescope's focused app 769) and has had a moment, through the same steam-game request an
# in-session launch uses; after two minutes it goes anyway.
if [ -n "${BL_LAUNCH_DIR:-}" ] && [ -z "${BL_DESKTOP:-}" ]; then
_bl_held=
_bl_rest=()
for _bl_arg in "$@"; do
case $_bl_arg in
steam://rungameid/*) _bl_held=${_bl_arg#steam://rungameid/} ;;
*) _bl_rest+=("$_bl_arg") ;;
esac
done
if [ -n "$_bl_held" ]; then
set -- "${_bl_rest[@]}"
(
_bl_end=$((SECONDS + 120))
until [ "$(xprop -root GAMESCOPE_FOCUSED_APP 2>/dev/null | sed -n 's/.*= *//p')" = 769 ] || [ "$SECONDS" -ge "$_bl_end" ]; do
sleep 1
done
sleep 4
echo "== steam: interface up; launching the requested game $_bl_held"
printf '%s\n' "$_bl_held" > "$BL_LAUNCH_DIR/steam-game.tmp" && mv -f "$BL_LAUNCH_DIR/steam-game.tmp" "$BL_LAUNCH_DIR/steam-game"
) &
fi
fi
# Debug aid while the runtime is being brought up: an app id dropped in ~/.bl-autolaunch is
# handed to the client as a rungameid URL, so a launch can be reproduced without touching the
# screen. Removed once the runtime is stable.
if [ -s "$HOME/.bl-autolaunch" ]; then
_bl_app=$(cat "$HOME/.bl-autolaunch")
# A bare app id runs the game; a full steam:// URL is passed through, which is how a
# validate or an install is driven without touching the screen.
case $_bl_app in
steam://*) _bl_url=$_bl_app ;;
*) _bl_url=steam://rungameid/$_bl_app ;;
esac
echo "== autolaunch $_bl_url"
set -- "$@" "$_bl_url"
fi
# The client builds Settings > Internet and its connection indicator on NetworkManager, which
# it looks for on the SYSTEM bus as it starts. This runtime has neither, so the page failed
# ("StartScanningForNetworks is not a function") under a header that showed no connection even
# while downloads ran. A system bus and a stand-in that reports Android's connection - and
# refuses what it cannot do, since the app has no say over Wi-Fi - answer what the client asks.
# The bus config is written here rather than shipped, so a runtime installed earlier gets this
# from the app's copy of the session scripts without a re-host. (WinNative, maxjivi05, e7a224ae.)
if command -v dbus-daemon >/dev/null 2>&1 && command -v droiddeck-netmanager >/dev/null 2>&1; then
mkdir -p /run/dbus /usr/local/share/droiddeck
cat > /usr/local/share/droiddeck/system-bus.conf <<'BUSCONF'
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<type>system</type>
<listen>unix:path=/run/dbus/system_bus_socket</listen>
<auth>EXTERNAL</auth>
<policy context="default">
<allow user="*"/>
<allow own="*"/>
<allow send_destination="*"/>
<allow receive_sender="*"/>
</policy>
</busconfig>
BUSCONF
rm -f /run/dbus/system_bus_socket
dbus-daemon --config-file=/usr/local/share/droiddeck/system-bus.conf --nofork --nopidfile &
system_bus=$!
for _ in $(seq 1 50); do
[ -S /run/dbus/system_bus_socket ] && break
sleep 0.1
done
if [ -S /run/dbus/system_bus_socket ]; then
droiddeck-netmanager &
netmanager=$!
echo "== network: system bus up, NetworkManager stand-in running"
# Steam blanks its main UI before calling login1.Suspend. Without the
# matching resume notification it stays black although the menus work.
# Own the name before Steam constructs its login1 proxy at startup.
rm -f "$BL_LAUNCH_DIR/steam-sleep-ready"
droiddeck-login1 &
login_manager=$!
for ((_i = 0; _i < 50; _i++)); do
[ -f "$BL_LAUNCH_DIR/steam-sleep-ready" ] && break
kill -0 "$login_manager" 2>/dev/null || break
nap 0.1
done
if [ ! -f "$BL_LAUNCH_DIR/steam-sleep-ready" ]; then
echo "== sleep: login1 did not start; refusing a client that cannot resume"
exit 69
fi
echo "== sleep: login1 ready, Android session pause/resume available"
else
echo "== network: the system bus did not come up; the client will show no connection"
exit 69
fi
else
echo "== network: no dbus-daemon or netmanager in this runtime; the client will show no connection"
exit 69
fi
export STEAM_RUNTIME=1
export LD_LIBRARY_PATH=$steam_root/steamrtarm64:$steam_root/steamrtarm64/panorama${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
export SDL_VIDEO_X11_DGAMOUSE=0
# Decky is optional and session scoped: the app's marker opts in, and this supervisor is
# reaped with the client session. Native ARM64 runs directly. A legacy x86_64 asset is used
# only when its recorded architecture says so and droiddeck-fex has FEX and its runtime ready.
decky_loader=$HOME/homebrew/services/PluginLoader
# The x86-64 client's browser is the arm64 client's (droiddeck-x64-arm-webhelper), so the loader
# finds it on the same debugging port.
if [ -f "$HOME/.droiddeck-decky-enabled" ] && [ -x "$decky_loader" ]; then
decky_arch=$(cat "$HOME/homebrew/services/.droiddeck-decky-arch" 2>/dev/null || echo unknown)
if [ "$decky_arch" = arm64 ] || { [ "$decky_arch" = x86_64 ] && /usr/local/bin/droiddeck-fex prepare >/dev/null; }; then
(
decky_child=
decky_stop() { [ -z "$decky_child" ] || kill "$decky_child" 2>/dev/null; [ -z "$decky_child" ] || wait "$decky_child" 2>/dev/null; exit 0; }
trap decky_stop TERM INT HUP
decky_exits=0
while :; do
if [ "$decky_arch" = x86_64 ]; then /usr/local/bin/droiddeck-fex run --mode on -- "$decky_loader" >>"$HOME/homebrew/services/PluginLoader.log" 2>&1 &
else "$decky_loader" >>"$HOME/homebrew/services/PluginLoader.log" 2>&1 &
fi
decky_child=$!
decky_status=0
wait "$decky_child" || decky_status=$?
decky_child=
decky_exits=$((decky_exits + 1))
if [ "$decky_exits" -ge 5 ]; then
echo "== Decky: stopped after $decky_exits loader exits (last status $decky_status)"
break
fi
decky_delay=$((decky_exits * 3))
echo "== Decky: loader exited with status $decky_status; restart $((decky_exits + 1))/5 in ${decky_delay}s"
sleep "$decky_delay"
done
) &
decky_supervisor=$!
echo "== Decky: session supervisor started ($decky_arch)"
else
echo "== Decky: installed loader architecture is unsupported by this guest; not starting"
fi
fi
_bl_cdp=()
if [ -f "$steam_root/.cef-enable-remote-debugging" ]; then
export BL_CDP_PORT=8080
else
export BL_CDP_PORT=$((20000 + RANDOM % 30000))
_bl_cdp=(-cef-enable-debugging -devtools-port "$BL_CDP_PORT")
fi
if [ -f "$HOME/.droiddeck-decky-enabled" ] && [ "${decky_arch:-}" = x86_64 ]; then
echo "== steam: devtools port $BL_CDP_PORT left open for the x86_64 Decky loader"
else
export BL_CDP_GUARD=$BL_CDP_PORT
echo "== steam: devtools port $BL_CDP_PORT, guest processes only"
fi
# The agent bridge drives the client through its CDP port.
[ -n "${BL_LAUNCH_DIR:-}" ] && mkdir -p "$BL_LAUNCH_DIR/agent" && echo "$BL_CDP_PORT" > "$BL_LAUNCH_DIR/agent/cdp-port"
# The ARM64 helper maps Linux titles to ARM64 Proton; the x86-64 client has its own mapping.
[ "$steam_x64" = 1 ] || droiddeck-steam-compat "$steam_root" 2>&1 &
# The client exits 42 when it has updated itself and wants a fresh start (steam.sh does this).
# BL_STEAM_UI=desktop = the client's desktop UI (a window, the library as a list), which the
# desktop's launchers ask for; gamescope shows one window at a time and the game takes over.
_bl_ui=(-gamepadui)
[ "${BL_STEAM_UI:-}" = desktop ] && _bl_ui=()
# Deck mode (BL_STEAMDECK=1, the Steam page's Client settings): -steamdeck is the flag that puts
# the Quick Access Menu and the Deck settings pages on screen; the QAM's performance overlay
# controls stay greyed out without -steamos3, as SteamOS and Armada launch it. -steamos3 makes
# the client manage itself as SteamOS does - it calls steamos-select-branch and writes
# steamdeck_stable to package/beta, and a branch on disk that loses to the -clientbeta below
# is an update that never installs: exit 42, a restart, for ever (Bannerlator 38996ea1). The
# loop below puts package/beta back before every start. The SteamOS helpers the client calls
# in this mode are staged as no-op stubs under /usr/bin.
# The x86-64 client too: with -steamos3 its SteamOS audio manager hands libpulse an operation a
# failed request never returned, and libpulse aborted the client right after start; the session
# preload answers it (preload/pulseguard.c). Device: Deck mode, logged on, 140 fps menus.
# BL_X64_DECK=0 starts the x86-64 client without the Deck flags.
if [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_STEAM_UI:-}" != desktop ] &&
{ [ "$steam_x64" = 0 ] || [ "${BL_X64_DECK:-1}" != 0 ]; }; then
_bl_ui+=(-steamdeck -steamos3)
# The QAM's Scaling Filter lists NVIDIA Image Scaling only when told gamescope has it (it does).
export STEAM_GAMESCOPE_NIS_SUPPORTED=1
# And shows its Scaling Mode and Scaling Filter only when told gamescope takes them.
export STEAM_GAMESCOPE_FANCY_SCALING_SUPPORT=1
echo "== steam: Deck mode on (-steamdeck -steamos3)"
fi
# What a killed session leaves behind. Android ends sessions without warning (backgrounding, the
# low-memory killer), and the client then finds .crash - opening its update UI on the spinner -
# and the web helper's Singleton* locks in htmlcache, owned by a process that is gone, which can
# leave the Gamepad UI blank. No client or helper is running at this point in a session.
if ! pgrep -f 'steamwebhelper|steamrtarm64/steam|steamrt64/steam' >/dev/null 2>&1; then
_stale=""
for _f in "$steam_root/.crash" "$steam_root/steam.pid" \
"$steam_root/config/htmlcache/SingletonLock" "$steam_root/config/htmlcache/SingletonCookie" \
"$steam_root/config/htmlcache/SingletonSocket"; do
if [ -e "$_f" ] || [ -L "$_f" ]; then rm -f "$_f" && _stale="$_stale ${_f##*/}"; fi
done
[ -n "$_stale" ] && echo "== steam: cleared what an earlier session left:$_stale"
unset _stale _f
fi
# Use the same configured branch as the first-run download. Whatever wrote package/beta, the branch on
# disk has to agree with the one forced here - a disagreement is the restart loop above, and
# it is silent apart from rc=42 in this log.
# Checked before every start, not once: the client rewrites package/beta during its own run,
# and a restart below (rc=42, or the one after the ARM64 Proton lands) would otherwise start it
# again on the branch it just wrote.
steam_channel=${BL_STEAM_CHANNEL:-steamdeck_publicbeta}
# The app's language (Setup, or the system's): the client's interface takes -language over its
# registry, and the registry is rewritten before each start below for its update window.
_bl_lang=()
[ -n "${BL_STEAM_LANGUAGE:-}" ] && _bl_lang=(-language "$BL_STEAM_LANGUAGE")
steam_beta=$steam_root/package/beta
if [ "$steam_x64" = 0 ] && [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
droiddeck-steam-games watch "$steam_root" "$BL_ADDED_GAMES" &
games_watcher=$!
fi
# Lossless Scaling (Steam app 993090): whether this account owns it, asked of the signed-in
# client once a session until the answer is yes. From then on the app offers importing
# Lossless.dll and passes BL_LOSSLESS_OWNED=1, so the question is never asked again.
if [ "$steam_x64" = 0 ] && [ "${BL_LOSSLESS_OWNED:-0}" != 1 ]; then
(
owned=$HOME/.config/droiddeck/lossless-owned
for ((_i = 0; _i < 60; _i++)); do
nap 30
[ -e "$owned" ] && exit 0
answer=$(droiddeck-steam-games probe "$steam_root" 993090 2>/dev/null) || continue
case $answer in
*'"993090": true'*) mkdir -p "${owned%/*}" && : > "$owned" && echo "== Lossless Scaling: owned" ;;
*) echo "== Lossless Scaling: not owned by this account" ;;
esac
exit 0
done
) &
fi
echo "== steam args: ${_bl_ui[*]} ${_bl_cdp[*]} ${_bl_lang[*]} -clientbeta $steam_channel -no-cef-sandbox ${steam_cef[*]} $*$([ "$steam_x64" = 1 ] && echo ' (x86-64 client under FEX)')"
# An update that cannot install asks for a restart (rc=42) every time; the count is capped so a
# session can never again loop in silence, which reads as a menu opening and closing by itself.
steam_updates=0
rm -f "$HOME/.bl-stop-requested"
# The client brings a game it launched to the front only while it counts someone as present,
# which it reads from gamescope's input counter. A game the app launches after a few minutes
# with no touch starts behind the library page and stays there until the next input (device,
# 2026-10-09). The game's window turning up behind the interface is counted as one input, as
# gamescope counts a key.
steam_bring_forward() {
local end=$((SECONDS + 600)) behind=0 line name value focused other n
while [ "$SECONDS" -lt "$end" ]; do
nap 2
focused= other=
while IFS= read -r line; do
name=${line%%(*} value=${line#*= }
case $name in
GAMESCOPE_FOCUSED_APP) focused=$value ;;
GAMESCOPE_FOCUSABLE_APPS) for n in ${value//,/ }; do [ "$n" = 769 ] || other=1; done ;;
esac
done < <(xprop -root GAMESCOPE_FOCUSED_APP GAMESCOPE_FOCUSABLE_APPS 2>/dev/null)
[ -n "$focused" ] || continue
[ "$focused" = 769 ] || return 0
if [ -n "$other" ]; then behind=$((behind + 1)); else behind=0; fi
[ "$behind" -ge 2 ] || continue
n=$(xprop -root GAMESCOPE_INPUT_COUNTER 2>/dev/null | sed -n 's/.*= *//p')
case $n in ''|*[!0-9]*) n=0 ;; esac
xprop -root -f GAMESCOPE_INPUT_COUNTER 32c -set GAMESCOPE_INPUT_COUNTER $(((n + 1) % 4294967296)) 2>/dev/null
echo "== steam: the game's window came up behind the interface; counted as an input"
return 0
done
}
if [ -n "${BL_LAUNCH_DIR:-}" ] && [ -d "$BL_LAUNCH_DIR" ]; then
rm -f "$BL_LAUNCH_DIR/steam-stop" "$BL_LAUNCH_DIR/steam-game" "$BL_LAUNCH_DIR/steam-game.tmp"
(
while :; do
# A steam:// URL left in $BL_LAUNCH_DIR/steam-url is handed to the running client (the
# client binary started with a URL passes it on and exits): how a game is started, or
# anything else the client takes a URL for, without touching the screen.
if [ -s "$BL_LAUNCH_DIR/steam-url" ]; then
_bl_req=$(head -n 1 "$BL_LAUNCH_DIR/steam-url")
rm -f "$BL_LAUNCH_DIR/steam-url"
case $_bl_req in
steam://*) echo "== steam: handing the client $_bl_req"; "${steam_client[@]}" "$_bl_req" >/dev/null 2>&1 & ;;
esac
fi
if [ -e "$BL_LAUNCH_DIR/steam-stop" ]; then
rm -f "$BL_LAUNCH_DIR/steam-stop"
: > "$HOME/.bl-stop-requested"
echo "== steam: the app asked for a clean exit"
"${steam_client[@]}" -shutdown >/dev/null 2>&1 || true
exit 0
fi
if [ -f "$BL_LAUNCH_DIR/steam-game" ]; then
game_id=$(cat "$BL_LAUNCH_DIR/steam-game" 2>/dev/null)
rm -f "$BL_LAUNCH_DIR/steam-game"
case $game_id in
''|0*|*[!0-9]*) echo "== steam: ignored invalid game launch request" ;;
*)
game_id_length=${#game_id}
if [ "$game_id_length" -lt 20 ] || {
[ "$game_id_length" -eq 20 ] && [[ "$game_id" < "18446744073709551616" ]];
}; then
echo "== steam: launching requested game $game_id in the running client"
# Send the request to the active client, then bring its window forward.
"${steam_client[@]}" "steam://rungameid/$game_id" >/dev/null 2>&1 &
[ -n "${forward_pid:-}" ] && kill "$forward_pid" 2>/dev/null
steam_bring_forward &
forward_pid=$!
else
echo "== steam: ignored out-of-range game launch request"
fi
;;
esac
fi
nap 0.5
done
) &
stop_watcher=$!
fi
while :; do
rm -f "$HOME/.bl-restart-once"
if [ -f "$steam_beta" ] && [ "$(cat "$steam_beta" 2>/dev/null)" != "$steam_channel" ]; then
echo "== steam: client branch on disk was '$(cat "$steam_beta" 2>/dev/null)', set to '$steam_channel' to match -clientbeta"
echo "$steam_channel" > "$steam_beta"
fi
if [ -z "${_bl_counted:-}" ] && [ -z "${BL_DESKTOP:-}" ]; then
echo start >> "$steam_root/package/droiddeck-unconfirmed"
_bl_counted=1
fi
# Between starts only: the client holds registry.vdf in memory and writes it back on exit.
if [ -n "${BL_STEAM_LANGUAGE:-}" ]; then
# The registry of the client that starts: the x86-64 one has its own home.
if [ "$steam_x64" = 1 ]; then
droiddeck-steam-language "${BL_X64_HOME:-$HOME/.droiddeck-x64}" "$BL_STEAM_LANGUAGE" || true
else
droiddeck-steam-language "$HOME" "$BL_STEAM_LANGUAGE" || true
fi
fi
step "starting the Steam client${BL_STEAM_UI:+ ($BL_STEAM_UI UI)}"
desktop_ui_pid=
if [ "${BL_STEAM_UI:-}" = desktop ]; then
/usr/local/bin/droiddeck-steam-desktop-ui &
desktop_ui_pid=$!
fi
"${steam_client[@]}" "${_bl_ui[@]}" "${_bl_cdp[@]}" "${_bl_lang[@]}" -clientbeta "$steam_channel" \
-no-cef-sandbox "${steam_cef[@]}" "$@"
rc=$?
if [ -n "$desktop_ui_pid" ]; then
kill "$desktop_ui_pid" 2>/dev/null || true
wait "$desktop_ui_pid" 2>/dev/null || true
fi
echo "== steam exited rc=$rc"
collect_steam_logs
if [ -f "$HOME/.config/droiddeck/fex-wanted" ] && [ -x /usr/local/bin/droiddeck-fex ]; then
/usr/local/bin/droiddeck-fex status >/dev/null 2>&1 || true
fi
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
if [ "$steam_x64" = 0 ]; then
droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
elif [ -n "${_bl_x64_tool:-}" ]; then
BL_COMPAT_TOOL=$_bl_x64_tool droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
fi
if [ -f "$HOME/.bl-stop-requested" ]; then
rm -f "$HOME/.bl-stop-requested" "$HOME/.bl-restart-once"
break
fi
# The session asked for this exit (the ARM64 Proton landed): start again, without the URL
# arguments, which were acted on. Not counted against the update cap.
if [ -f "$HOME/.bl-restart-once" ]; then
echo "== steam: restarting once so the new compatibility layer is in the client's lists"
# The client wrote config.vdf back from its own memory as it exited, which can drop the
# per-title entries written while it ran - the very ones this restart is for.
[ "$steam_x64" = 1 ] || droiddeck-steam-compat "$steam_root" >/dev/null 2>&1 || true
set --
continue
fi
[ "$rc" -eq 42 ] || break
steam_updates=$((steam_updates + 1))
if [ "$steam_updates" -ge 3 ]; then
echo "== steam: asked to restart for an update $steam_updates times and it is not installing; the session stops here rather than loop"
break
fi
# The URL arguments were acted on the first time; a restart must not repeat them.
set --
done
if [ -n "${stop_watcher:-}" ]; then
kill "$stop_watcher" 2>/dev/null || true
wait "$stop_watcher" 2>/dev/null || true
fi
if [ -n "${games_watcher:-}" ]; then
kill "$games_watcher" 2>/dev/null || true
wait "$games_watcher" 2>/dev/null || true
fi
if [ -n "${BL_DESKTOP:-}" ]; then
# Started from the desktop (droiddeck-steam-launch): the desktop's own programs share this
# script's parent, so reap_orphans - right under gamescope, where everything is the client's -
# is not for here. The helpers this run started (the cores and redistributables beats, the
# system bus and the NetworkManager stand-in) go with the client instead.
if [ -n "${decky_supervisor:-}" ]; then
kill "$decky_supervisor" 2>/dev/null || true
wait "$decky_supervisor" 2>/dev/null || true
echo "== Decky: session supervisor stopped"
fi
_bl_jobs=$(jobs -p)
[ -n "$_bl_jobs" ] && kill $_bl_jobs 2>/dev/null
echo "== steam: the client on the desktop has exited"
else
if [ -n "${decky_supervisor:-}" ]; then
kill "$decky_supervisor" 2>/dev/null || true
wait "$decky_supervisor" 2>/dev/null || true
echo "== Decky: session supervisor stopped"
fi
_bl_jobs=$(jobs -p)
[ -n "$_bl_jobs" ] && kill $_bl_jobs 2>/dev/null
reap_orphans
fi
exit "$rc"
;;
*)
echo "droiddeck-session: unknown mode '$mode'" >&2
exit 64
;;
esac