mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
Merge branch 'main' into fix/steam-client-download
This commit is contained in:
commit
11bd604dfd
10 files changed
+841
-43
No files matched your search
@@ -72,6 +72,7 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-steam-desktop-ui" to "usr/local/bin/droiddeck-steam-desktop-ui",
|
||||
"usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library",
|
||||
"usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
|
||||
"usr/local/bin/droiddeck-launcher-cores" to "usr/local/bin/droiddeck-launcher-cores",
|
||||
"usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra",
|
||||
"usr/local/bin/droiddeck-netmanager" to "usr/local/bin/droiddeck-netmanager",
|
||||
"usr/local/bin/droiddeck-login1" to "usr/local/bin/droiddeck-login1",
|
||||
|
||||
@@ -255,8 +255,9 @@ EVALUATOR_SCRIPT = re.compile(r"(?:^|[\\/])evaluatorscript_[0-9]+\.vdf$", re.IGN
|
||||
|
||||
def mark_install_scripts(command, env):
|
||||
"""The client is about to run a game's install scripts (iscriptevaluator.exe): mark every process
|
||||
in them as already run first, so no redistributable installer starts - on first launch the
|
||||
shared ones may still be downloading, and on ARM64 Proton the VC++ ones stop with an error."""
|
||||
in them but a Vulkan runtime installer as already run first, so no redistributable installer
|
||||
starts - on first launch the shared ones may still be downloading, and on ARM64 Proton the VC++
|
||||
ones stop with an error."""
|
||||
if not any(arg.lower().replace("\\", "/").endswith("iscriptevaluator.exe") for arg in command):
|
||||
return
|
||||
root = env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or str(Path.home() / ".local/share/Steam")
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/python3
|
||||
"""Holds a game launcher's hidden web interface on one slow core while something else has focus.
|
||||
|
||||
Rockstar's launcher keeps drawing its Chromium interface (SocialClubHelper.exe: a renderer, and a
|
||||
GPU process compositing through DXVK) for as long as it runs, hidden behind the game too, and at a
|
||||
higher priority than the game's (Wine passes Chromium's on: the GPU process ran at nice -10). With
|
||||
Red Dead Redemption 2 in front on the RedMagic that was 1.3 to 2.5 cores under FEX, on a phone
|
||||
already held back by its temperature, for a page nobody sees. Held, riding at the throttle limit
|
||||
went from 46.5 to 48.7 fps, 1% lows from 27.5 to 32.6. So while the focused window belongs to
|
||||
anything but the launcher, every helper thread runs on one core of the slowest cluster at nice 19;
|
||||
when the launcher's own window has focus again (signing in, the game closed) each thread gets its
|
||||
own mask and priority back. Stopping the helpers instead would not hold: proot, the tracer of every
|
||||
guest process, resumes a stopped tracee.
|
||||
|
||||
Focus comes from droiddeck-agent's focus.json (the path is the argument), which the agent rewrites
|
||||
whenever gamescope's focus changes. droiddeck-session starts this for Steam sessions;
|
||||
BL_LAUNCHER_CORES=0 leaves the launchers alone."""
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Image paths (lower case, backslashes), matched at the end: the helpers held back, and the
|
||||
# programs whose window having focus means the user is looking at the launcher.
|
||||
HELPERS = ("\\rockstar games\\social club\\socialclubhelper.exe",)
|
||||
FRONTS = HELPERS + ("\\rockstar games\\launcher\\launcher.exe",)
|
||||
NICE = 19
|
||||
POLL = 2.0
|
||||
# How often a held launcher is looked over again: for a helper the unheld Launcher.exe started
|
||||
# since, and for threads the program has moved back.
|
||||
RESCAN = 10.0
|
||||
STEAM_CLIENT = 769
|
||||
|
||||
|
||||
def log(message):
|
||||
print("droiddeck-launcher-cores: " + message, flush=True)
|
||||
|
||||
|
||||
def image(proc, pid):
|
||||
"""A process's program as Wine names it (argv[0]), lower case with backslashes, or None."""
|
||||
try:
|
||||
with open(proc / str(pid) / "cmdline", "rb") as stream:
|
||||
first = stream.read(4096).split(b"\0", 1)[0]
|
||||
except OSError:
|
||||
return None
|
||||
return first.decode("utf-8", "replace").lower().replace("/", "\\")
|
||||
|
||||
|
||||
def is_one_of(path, suffixes):
|
||||
return path is not None and path.endswith(suffixes)
|
||||
|
||||
|
||||
def tasks(proc, pid):
|
||||
try:
|
||||
return [int(tid) for tid in os.listdir(proc / str(pid) / "task") if tid.isdigit()]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
class Cores:
|
||||
"""The kernel calls, apart so a test can stand in for them."""
|
||||
|
||||
def own(self):
|
||||
return os.sched_getaffinity(0)
|
||||
|
||||
def get(self, tid):
|
||||
return os.sched_getaffinity(tid), os.getpriority(os.PRIO_PROCESS, tid)
|
||||
|
||||
def set(self, tid, cpus, nice):
|
||||
try:
|
||||
os.sched_setaffinity(tid, cpus)
|
||||
except OSError:
|
||||
# A mask outside the app's cpuset now (Android shrinks it in the background) is
|
||||
# EINVAL; the session's own is always allowed.
|
||||
os.sched_setaffinity(tid, self.own())
|
||||
os.setpriority(os.PRIO_PROCESS, tid, nice)
|
||||
|
||||
|
||||
class Holder:
|
||||
def __init__(self, proc=Path("/proc"), cpufreq=Path("/sys/devices/system/cpu"), cores=None):
|
||||
self.proc = proc
|
||||
self.cpufreq = cpufreq
|
||||
self.cores = cores or Cores()
|
||||
self.held = {} # pid -> {tid: (cpus, nice) before the hold}
|
||||
self.core = None
|
||||
|
||||
def slow_core(self):
|
||||
"""The last core of the slowest cluster the session has - not of the game's cores, which
|
||||
the launcher inherits when BL_GAME_CPUS is set: the core the game misses least."""
|
||||
def top(cpu):
|
||||
try:
|
||||
return int((self.cpufreq / f"cpu{cpu}/cpufreq/cpuinfo_max_freq").read_text())
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
cpus = self.cores.own()
|
||||
slowest = min(top(cpu) for cpu in cpus)
|
||||
return max(cpu for cpu in cpus if top(cpu) == slowest)
|
||||
|
||||
def helpers(self):
|
||||
for name in os.listdir(self.proc):
|
||||
if name.isdigit() and is_one_of(image(self.proc, name), HELPERS):
|
||||
yield int(name)
|
||||
|
||||
def hold(self):
|
||||
"""Every helper thread onto the slow core at NICE: new ones, and held ones the program has
|
||||
moved since (Chromium sets its threads' priorities, and Wine passes that on)."""
|
||||
if self.core is None:
|
||||
self.core = self.slow_core()
|
||||
hold = ({self.core}, NICE)
|
||||
for pid in self.helpers():
|
||||
before = self.held.setdefault(pid, {})
|
||||
for tid in tasks(self.proc, pid):
|
||||
try:
|
||||
now = self.cores.get(tid)
|
||||
if now != hold:
|
||||
self.cores.set(tid, *hold)
|
||||
except OSError:
|
||||
continue # the thread ended
|
||||
if tid not in before:
|
||||
# A thread that already has the hold inherited it from a held parent: it gets
|
||||
# what its process had, or the helpers' first.
|
||||
before[tid] = now if now != hold else before.get(pid) or self.first() or now
|
||||
for pid in [pid for pid in self.held if not (self.proc / str(pid)).exists()]:
|
||||
del self.held[pid]
|
||||
|
||||
def first(self):
|
||||
for before in self.held.values():
|
||||
for state in before.values():
|
||||
if state != ({self.core}, NICE):
|
||||
return state
|
||||
return None
|
||||
|
||||
def release(self):
|
||||
"""Every held thread back to its own mask and priority; one started during the hold gets
|
||||
what its process had."""
|
||||
for pid, before in self.held.items():
|
||||
default = before.get(pid) or self.first()
|
||||
for tid in tasks(self.proc, pid):
|
||||
state = before.get(tid, default)
|
||||
if state is None:
|
||||
continue
|
||||
try:
|
||||
self.cores.set(tid, *state)
|
||||
except OSError:
|
||||
pass
|
||||
self.held.clear()
|
||||
|
||||
|
||||
def focused(snapshot):
|
||||
"""(app id, pid) of the window gamescope has focused, from droiddeck-agent's snapshot."""
|
||||
window = snapshot.get("focusedWindow")
|
||||
for entry in snapshot.get("focusableWindows") or []:
|
||||
if entry.get("window") == window:
|
||||
return snapshot.get("focusedApp"), entry.get("pid")
|
||||
return snapshot.get("focusedApp"), None
|
||||
|
||||
|
||||
class Governor:
|
||||
def __init__(self, focus_path, holder=None, clock=time.monotonic):
|
||||
self.focus_path = Path(focus_path)
|
||||
self.holder = holder or Holder()
|
||||
self.clock = clock
|
||||
self.seen = None
|
||||
self.app = self.pid = None
|
||||
self.scanned = None
|
||||
self.reported = None
|
||||
|
||||
def read_focus(self):
|
||||
try:
|
||||
stamp = self.focus_path.stat().st_mtime_ns
|
||||
except OSError:
|
||||
return False
|
||||
if stamp == self.seen:
|
||||
return False
|
||||
try:
|
||||
self.app, self.pid = focused(json.loads(self.focus_path.read_text()))
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return False
|
||||
self.seen = stamp
|
||||
return True
|
||||
|
||||
def tick(self):
|
||||
changed = self.read_focus()
|
||||
if self.pid is None:
|
||||
return
|
||||
launcher_in_front = is_one_of(image(self.holder.proc, self.pid), FRONTS)
|
||||
if launcher_in_front:
|
||||
if self.holder.held:
|
||||
self.holder.release()
|
||||
self.report("released: the launcher has focus")
|
||||
return
|
||||
# Nothing to look for until a game has had focus: launchers only run for one, and a launcher
|
||||
# left running after its game stays held through the Steam client's own focus.
|
||||
if self.app in (None, 0, STEAM_CLIENT) and not self.holder.held:
|
||||
return
|
||||
now = self.clock()
|
||||
if changed or self.scanned is None or now - self.scanned >= RESCAN:
|
||||
self.scanned = now
|
||||
self.holder.hold()
|
||||
if self.holder.held:
|
||||
self.report(f"{len(self.holder.held)} SocialClubHelper process(es) on core {self.holder.core} "
|
||||
f"at nice {NICE} while the launcher is hidden")
|
||||
|
||||
def report(self, message):
|
||||
if message != self.reported:
|
||||
self.reported = message
|
||||
log(message)
|
||||
|
||||
def stop(self, *_):
|
||||
self.holder.release()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 1:
|
||||
print("usage: droiddeck-launcher-cores <agent focus.json>", file=sys.stderr)
|
||||
return 2
|
||||
governor = Governor(argv[0])
|
||||
signal.signal(signal.SIGTERM, governor.stop)
|
||||
signal.signal(signal.SIGHUP, governor.stop)
|
||||
signal.signal(signal.SIGINT, governor.stop)
|
||||
while True:
|
||||
governor.tick()
|
||||
time.sleep(POLL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -17,6 +17,11 @@
|
||||
# 2015-2022 runtime is recorded as installed the way Microsoft's installers look for it: on ARM64
|
||||
# Proton the x64 bundle otherwise stops with "Microsoft Visual C++ 2015-2022 Redistributable (arm64)
|
||||
# is required", while Proton's own msvcp140/vcruntime140 already serve the game.
|
||||
#
|
||||
# A Vulkan runtime installer is the one left to run. Proton has no stand-in for it: for a game whose
|
||||
# Steam compat config says nativevulkanloader (Red Dead Redemption 2) it loads vulkan-1.dll native
|
||||
# only, and Khronos' loader is in the prefix only once that installer has run - without it the game
|
||||
# exits with STATUS_DLL_NOT_FOUND before it opens a window. The installer finishes in a second.
|
||||
set -u
|
||||
steam_root=${1:?steam root}
|
||||
LIBRARIES=${DROIDDECK_LIBRARIES-/mnt/droiddeck /mnt/droiddeck-sd}
|
||||
@@ -25,14 +30,15 @@ redist="$steam_root/steamapps/common/Steamworks Shared/_CommonRedist"
|
||||
|
||||
# Every process an install script would run, as the registry key and value the client writes once
|
||||
# it has run it. Names sit one level inside "Run Process", the key they share a level further in.
|
||||
read_scripts() { xargs -0 -r awk '
|
||||
# "read_scripts vulkan" lists only the Vulkan runtime installers, which the plain call leaves out.
|
||||
read_scripts() { xargs -0 -r awk -v want="${1:-redists}" '
|
||||
# A block whose HasRunKey is set is one process; its name is the key the block opened under, at
|
||||
# whatever depth - an install script nests it under "Run Process", the client'"'"'s evaluator script
|
||||
# for a game wraps the same blocks once more.
|
||||
FNR == 1 { depth = 0; pending = "" }
|
||||
/^[[:space:]]*\{/ { depth++; name[depth] = pending; key[depth] = ""; pending = ""; next }
|
||||
/^[[:space:]]*\{/ { depth++; name[depth] = pending; key[depth] = ""; vulkan[depth] = 0; pending = ""; next }
|
||||
/^[[:space:]]*\}/ {
|
||||
if (depth > 0 && key[depth] != "" && name[depth] != "")
|
||||
if (depth > 0 && key[depth] != "" && name[depth] != "" && vulkan[depth] == (want == "vulkan"))
|
||||
printf "%s\t%s\n", key[depth], name[depth]
|
||||
if (depth > 0) depth--
|
||||
next
|
||||
@@ -40,6 +46,12 @@ read_scripts() { xargs -0 -r awk '
|
||||
{
|
||||
n = split($0, part, "\"")
|
||||
if (n >= 5 && tolower(part[2]) == "hasrunkey") { key[depth] = part[4]; next }
|
||||
if (n >= 5 && tolower(part[2]) ~ /^process [0-9]+$/) {
|
||||
program = tolower(part[4])
|
||||
sub(/.*[\\\/]/, "", program)
|
||||
if (program ~ /vulkan/) vulkan[depth] = 1
|
||||
next
|
||||
}
|
||||
if (n == 3) pending = part[2]
|
||||
}
|
||||
'; }
|
||||
@@ -84,13 +96,17 @@ if [ "$(head -n 1 "$cache" 2>/dev/null)" != "$sig" ]; then
|
||||
done <<<"$libraries" | sort -u
|
||||
} >"$cache.tmp" && mv -f "$cache.tmp" "$cache"
|
||||
fi
|
||||
game_pairs=$(tail -n +2 "$cache" 2>/dev/null | tr '\n' '\0' | read_scripts)
|
||||
# DROIDDECK_SEED_SCRIPTS: more scripts, one path a line - droiddeck-game-env passes the client's
|
||||
# evaluator script for the game about to run its installers, read before the client looks.
|
||||
if [ -n "${DROIDDECK_SEED_SCRIPTS:-}" ]; then
|
||||
game_pairs=$(printf '%s\n%s\n' "$game_pairs" "$(printf '%s\n' "$DROIDDECK_SEED_SCRIPTS" | tr '\n' '\0' | read_scripts)")
|
||||
fi
|
||||
game_scripts() { { tail -n +2 "$cache" 2>/dev/null; printf '%s\n' "${DROIDDECK_SEED_SCRIPTS:-}"; } | sed '/^$/d' | tr '\n' '\0'; }
|
||||
game_pairs=$(game_scripts | read_scripts)
|
||||
[ -n "$(printf '%s' "$game_pairs" | tr -d '[:space:]')" ] && pairs=$(printf '%s\n%s\n' "$pairs" "$game_pairs" | sort -u | sed '/^$/d')
|
||||
# A Vulkan runtime installer marked as run by an earlier seeding still has its marker: those come out
|
||||
# again below, so the client runs the installer on the game's next launch.
|
||||
declare -A vulkan=()
|
||||
while IFS= read -r line; do
|
||||
[[ $line == *$'\t'* ]] && vulkan[$line]=1
|
||||
done < <(game_scripts | read_scripts vulkan)
|
||||
|
||||
# The VC++ 2015-2022 runtime as its installers check for it, for each architecture and both registry
|
||||
# views. The version is the newest the scripts above know, so any 2015-2022 bundle finds itself
|
||||
@@ -138,6 +154,38 @@ block_for() { printf '%s\n' "$@" | awk -F'\t' -v now="$now" '
|
||||
for line; do [[ $line == vcrt$'\t'* ]] && vcrt "$now"; done
|
||||
}
|
||||
|
||||
# Takes the given markers out of a registry file again, in both views, keeping its date.
|
||||
unseed() {
|
||||
local reg=$1 out
|
||||
shift
|
||||
out=$(mktemp "${reg%/*}/.system.reg.XXXXXX") || return 1
|
||||
if printf '%s\n' "$@" | awk -F'\t' '
|
||||
NR == FNR {
|
||||
path = $1
|
||||
sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path)
|
||||
if (path == $1)
|
||||
next
|
||||
path = tolower(path)
|
||||
drop["[" path "]" SUBSEP tolower($2)] = 1
|
||||
if (substr(path, 1, 10) == "software\\\\")
|
||||
drop["[software\\\\wow6432node\\\\" substr(path, 11) "]" SUBSEP tolower($2)] = 1
|
||||
next
|
||||
}
|
||||
/^\[/ { section = tolower(substr($0, 1, index($0, "]"))) }
|
||||
/^"/ {
|
||||
value = substr($0, 2)
|
||||
value = tolower(substr(value, 1, index(value, "\"=") - 1))
|
||||
if ((section SUBSEP value) in drop)
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' - "$reg" >"$out" && touch -r "$reg" "$out" && mv -f "$out" "$reg"; then
|
||||
return 0
|
||||
fi
|
||||
rm -f "$out"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Proton builds a new prefix from its template, and the client runs the install scripts in that
|
||||
# prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a
|
||||
# changed one as a new template and upgrades every prefix made from the old.
|
||||
@@ -158,6 +206,16 @@ for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/syste
|
||||
if [ -f "$seeded" ]; then
|
||||
while IFS= read -r line; do [[ $line == *$'\t'* ]] && had[$line]=1; done <"$seeded"
|
||||
fi
|
||||
stale=() kept=()
|
||||
for line in "${!had[@]}"; do
|
||||
if [ -n "${vulkan[$line]:-}" ]; then stale+=("$line"); else kept+=("$line"); fi
|
||||
done
|
||||
if [ ${#stale[@]} -gt 0 ] && unseed "$reg" "${stale[@]}"; then
|
||||
had=()
|
||||
for line in "${kept[@]}"; do had[$line]=1; done
|
||||
printf '%s\n' "${kept[@]}" >"$seeded"
|
||||
echo "== took Vulkan runtime markers out of $reg"
|
||||
fi
|
||||
missing=()
|
||||
for line in "${want[@]}"; do [ -n "${had[$line]:-}" ] || missing+=("$line"); done
|
||||
[ ${#missing[@]} -eq 0 ] && continue
|
||||
|
||||
@@ -50,6 +50,37 @@ prepare_locale() {
|
||||
}
|
||||
[ -z "${BL_INSIDE:-}" ] && prepare_locale
|
||||
|
||||
# What earlier sessions left in /tmp. The guest's /tmp is a directory in the rootfs, never emptied
|
||||
# by a boot, and Android ends sessions by killing them: each left its mangoapp config, the client's
|
||||
# temporary directories and shared-memory sockets, Chromium's singleton directory (the web
|
||||
# helper's, an Electron app's), a session bus socket per run and a run's start stamp - 1,400
|
||||
# entries on a device after a few weeks. Each kind is swept while nothing that could own one runs,
|
||||
# with one find for the lot.
|
||||
clear_stale_tmp() {
|
||||
local running names=() n
|
||||
running=$(pgrep -af 'steamwebhelper|steamrtarm64/steam|--type=|droiddeck-appimage-run|dbus-run-session|dbus-daemon.*--session|dbus-launch|kwin|plasmashell' 2>/dev/null)
|
||||
case $running in
|
||||
*steamwebhelper* | *steamrtarm64/steam*) ;;
|
||||
*) names+=(-o -name 'mangohud.????????' -o -name '.com.valvesoftware.Steam.??????'
|
||||
-o -name 'steam_chrome_shmem_uid*' -o \( -type d -empty -name 'steam??????' \)) ;;
|
||||
esac
|
||||
case $running in *--type=*) ;; *) names+=(-o \( -type d -name 'scoped_dir??????' \)) ;; esac
|
||||
case $running in *droiddeck-appimage-run*) ;; *) names+=(-o -name 'droiddeck-appimage.??????') ;; esac
|
||||
case $running in
|
||||
*dbus-run-session* | *dbus-daemon*--session* | *dbus-launch*) ;;
|
||||
*) names+=(-o \( -type s -name 'dbus-??????????' \)) ;;
|
||||
esac
|
||||
case $running in
|
||||
*kwin* | *plasmashell*) ;;
|
||||
*) names+=(-o \( -type d -empty -name 'plasma-csd-generator.??????' \)) ;;
|
||||
esac
|
||||
[ ${#names[@]} -gt 0 ] || return 0
|
||||
n=$(find /tmp -mindepth 1 -maxdepth 1 \( "${names[@]:1}" \) -print -exec rm -rf {} + 2>/dev/null | wc -l)
|
||||
[ "$n" -gt 0 ] && echo "== /tmp: cleared $n entries earlier sessions left"
|
||||
return 0
|
||||
}
|
||||
[ -z "${BL_INSIDE:-}" ] && clear_stale_tmp
|
||||
|
||||
# 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() {
|
||||
@@ -271,7 +302,8 @@ if [ -z "${BL_INSIDE:-}" ]; then
|
||||
# BL_MANGOAPP=0 turns it off (one community build found Valve's mangoapp crashing on Turnip).
|
||||
if [ "$mode" = steam ] && [ "${BL_MANGOAPP:-1}" = 1 ] \
|
||||
&& [ "${BL_STEAM_UI:-}" != desktop ] && command -v mangoapp >/dev/null 2>&1; then
|
||||
MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX)
|
||||
# One fixed file, rewritten each session: a mktemp name per session was never removed.
|
||||
MANGOHUD_CONFIGFILE=/tmp/droiddeck-mangohud.conf
|
||||
echo no_display > "$MANGOHUD_CONFIGFILE"
|
||||
export MANGOHUD_CONFIGFILE STEAM_USE_MANGOAPP=1 STEAM_MANGOAPP_PRESETS_SUPPORTED=1 STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1
|
||||
args+=(--mangoapp)
|
||||
@@ -436,6 +468,13 @@ case $mode in
|
||||
done
|
||||
) &
|
||||
fi
|
||||
# Rockstar's launcher keeps drawing its hidden web interface for as long as a game of theirs
|
||||
# runs; droiddeck-launcher-cores holds it on one slow core while it is out of sight, from
|
||||
# gamescope's focus as the agent publishes it. BL_LAUNCHER_CORES=0 leaves launchers alone.
|
||||
if [ "${BL_LAUNCHER_CORES:-1}" != 0 ] && [ -n "${BL_LAUNCH_DIR:-}" ] && [ -z "${BL_DESKTOP:-}" ] \
|
||||
&& [ "${BL_STEAM_UI:-}" != desktop ] && [ -x /usr/local/bin/droiddeck-launcher-cores ]; then
|
||||
/usr/local/bin/droiddeck-launcher-cores "$BL_LAUNCH_DIR/agent/focus.json" 2>&1 &
|
||||
fi
|
||||
|
||||
step "registering the app's game library with Steam"
|
||||
droiddeck-steam-library "$steam_root" || true
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
#!/usr/bin/python3
|
||||
"""Initialize Steam's own Gamescope display settings once; subsequent choices belong to Steam.
|
||||
"""Initialize Steam's own display settings once; subsequent choices belong to Steam.
|
||||
|
||||
The UI scale, and native resolution: the client treats our gamescope as an external display and,
|
||||
unless "Enable Native Resolution on External Displays" is on, draws itself at 1920x1080 on any
|
||||
larger output - a 2340x1080 session came up as 16:9 with bars. The session's size is already the
|
||||
user's Resolution choice, so the client should use all of it."""
|
||||
user's Resolution choice, so the client should use all of it.
|
||||
|
||||
And the screensaver over a running game. The client's defaults (steamui.so's settings descriptor)
|
||||
show screensavers while a game runs, after 300 s on AC power with no button pressed - and a stick or
|
||||
trigger held is no press. Red Dead Redemption 2's cutscenes run longer than that: the slideshow
|
||||
covered the game, and the button that woke it reached the game as well (B asked to quit to the
|
||||
desktop). Customization > Screensaver > "Show screensavers when playing a game" still turns it on."""
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -15,6 +21,8 @@ import tempfile
|
||||
|
||||
DISPLAY = "External: gamescope|||Windowed"
|
||||
NATIVE_RESOLUTION = "EnableSteamNativeExternalResolution"
|
||||
# screensaver_while_ingame: kept in config.vdf like the Gamescope settings, under this path.
|
||||
SCREENSAVER_IN_GAME = ("Customization", "Screensaver", "WhileInGame")
|
||||
TOKEN = re.compile(r'\s+|//[^\n]*|"(?:\\.|[^"\\])*"|[{}]')
|
||||
|
||||
|
||||
@@ -108,25 +116,43 @@ def seed_text(text, scale):
|
||||
return text[:at] + insertion + text[at:], True
|
||||
|
||||
|
||||
def seed_native_resolution(text):
|
||||
def seed_value(text, path, value):
|
||||
"""InstallConfigStore/<path> = value, unless the client already has a value there (the
|
||||
user's), adding only the blocks that are missing."""
|
||||
root = parse(text).get('InstallConfigStore')
|
||||
if not isinstance(root, Block):
|
||||
raise ValueError("missing InstallConfigStore")
|
||||
gamescope = root.get('Gamescope')
|
||||
if gamescope is not None and not isinstance(gamescope, Block):
|
||||
raise ValueError("invalid Gamescope settings")
|
||||
if gamescope is not None and gamescope.get(NATIVE_RESOLUTION) is not None:
|
||||
*sections, name = path
|
||||
block, depth = root, 1
|
||||
while sections:
|
||||
child = block.get(sections[0])
|
||||
if child is None:
|
||||
break
|
||||
if not isinstance(child, Block):
|
||||
raise ValueError(f"invalid {sections[0]} settings")
|
||||
block, depth = child, depth + 1
|
||||
sections.pop(0)
|
||||
if not sections and block.get(name) is not None:
|
||||
return text, False
|
||||
entry = f'"{NATIVE_RESOLUTION}" "1"'
|
||||
if gamescope is not None:
|
||||
at, indent = gamescope.end, 2
|
||||
else:
|
||||
entry = '"Gamescope"\n{\n' + entry + '\n}'
|
||||
at, indent = root.end, 1
|
||||
insertion = '\n' + '\n'.join('\t' * indent + line for line in entry.splitlines()) + '\n'
|
||||
lines = [(len(sections), f'"{name}"\t\t"{value}"')]
|
||||
for level, section in reversed(list(enumerate(sections))):
|
||||
lines = [(level, f'"{section}"'), (level, '{')] + lines + [(level, '}')]
|
||||
insertion = ''.join('\t' * (depth + level) + line + '\n' for level, line in lines)
|
||||
# Before the line the block closes on, when it has one of its own; else beside the brace.
|
||||
at = text.rfind('\n', 0, block.end) + 1
|
||||
if text[at:block.end].strip():
|
||||
at, insertion = block.end, '\n' + insertion
|
||||
return text[:at] + insertion + text[at:], True
|
||||
|
||||
|
||||
def seed_native_resolution(text):
|
||||
return seed_value(text, ('Gamescope', NATIVE_RESOLUTION), '1')
|
||||
|
||||
|
||||
def seed_no_screensaver_in_game(text):
|
||||
return seed_value(text, SCREENSAVER_IN_GAME, '0')
|
||||
|
||||
|
||||
def atomic_write(path, text):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
|
||||
@@ -140,31 +166,36 @@ def atomic_write(path, text):
|
||||
|
||||
|
||||
def initialize(steam_root, width, height):
|
||||
scale = initial_scale(width, height)
|
||||
return seed_once(steam_root, 'droiddeck-ui-scale-initialized', lambda text: seed_text(text, scale),
|
||||
f'== steam: initial handheld UI scale {scale:.3f}')
|
||||
|
||||
|
||||
def seed_once(steam_root, marker_name, seed, message):
|
||||
"""Applies seed to config.vdf the first time only: the marker keeps a later choice - including
|
||||
a return to the client's own default, which the client may store by dropping the key - the
|
||||
user's."""
|
||||
config = Path(steam_root) / 'config/config.vdf'
|
||||
marker = config.with_name('droiddeck-ui-scale-initialized')
|
||||
marker = config.with_name(marker_name)
|
||||
if marker.exists():
|
||||
return False
|
||||
text = config.read_text() if config.exists() else '"InstallConfigStore"\n{\n}\n'
|
||||
updated, changed = seed_text(text, initial_scale(width, height))
|
||||
updated, changed = seed(text)
|
||||
if changed:
|
||||
atomic_write(config, updated)
|
||||
print(f'== steam: initial handheld UI scale {initial_scale(width, height):.3f}')
|
||||
print(message)
|
||||
atomic_write(marker, '1\n')
|
||||
return changed
|
||||
|
||||
|
||||
def initialize_native_resolution(steam_root):
|
||||
config = Path(steam_root) / 'config/config.vdf'
|
||||
marker = config.with_name('droiddeck-native-resolution-initialized')
|
||||
if marker.exists():
|
||||
return False
|
||||
text = config.read_text() if config.exists() else '"InstallConfigStore"\n{\n}\n'
|
||||
updated, changed = seed_native_resolution(text)
|
||||
if changed:
|
||||
atomic_write(config, updated)
|
||||
print('== steam: native resolution on the session display')
|
||||
atomic_write(marker, '1\n')
|
||||
return changed
|
||||
return seed_once(steam_root, 'droiddeck-native-resolution-initialized', seed_native_resolution,
|
||||
'== steam: native resolution on the session display')
|
||||
|
||||
|
||||
def initialize_screensaver(steam_root):
|
||||
return seed_once(steam_root, 'droiddeck-screensaver-initialized', seed_no_screensaver_in_game,
|
||||
'== steam: no screensaver over a running game')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
@@ -174,9 +205,10 @@ if __name__ == '__main__':
|
||||
except (OSError, ValueError, RecursionError) as error:
|
||||
print('== steam: left UI scale unchanged: ' + str(error), file=sys.stderr)
|
||||
status = 1
|
||||
try:
|
||||
initialize_native_resolution(sys.argv[1])
|
||||
except (OSError, ValueError, RecursionError) as error:
|
||||
print('== steam: left native resolution unchanged: ' + str(error), file=sys.stderr)
|
||||
status = 1
|
||||
for what, step in (('native resolution', initialize_native_resolution), ('screensaver', initialize_screensaver)):
|
||||
try:
|
||||
step(sys.argv[1])
|
||||
except (OSError, ValueError, RecursionError) as error:
|
||||
print(f'== steam: left {what} unchanged: {error}', file=sys.stderr)
|
||||
status = 1
|
||||
sys.exit(status)
|
||||
@@ -0,0 +1,220 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
|
||||
MODULE = runpy.run_path(str(BIN / 'droiddeck-launcher-cores'))
|
||||
Holder = MODULE['Holder']
|
||||
Governor = MODULE['Governor']
|
||||
NICE = MODULE['NICE']
|
||||
|
||||
HELPER = 'C:\\Program Files\\Rockstar Games\\Social Club\\SocialClubHelper.exe'
|
||||
LAUNCHER = 'C:\\Program Files\\Rockstar Games\\Launcher\\Launcher.exe'
|
||||
GAME = 'S:\\steamapps\\common\\Red Dead Redemption 2\\RDR2.exe'
|
||||
ALL = set(range(8))
|
||||
|
||||
|
||||
class FakeCores:
|
||||
"""Per-thread masks and priorities, with every change recorded."""
|
||||
|
||||
def __init__(self):
|
||||
self.state = {}
|
||||
self.calls = []
|
||||
|
||||
def own(self):
|
||||
return set(ALL)
|
||||
|
||||
def get(self, tid):
|
||||
if tid not in self.state:
|
||||
raise ProcessLookupError(tid)
|
||||
return self.state[tid]
|
||||
|
||||
def set(self, tid, cpus, nice):
|
||||
if tid not in self.state:
|
||||
raise ProcessLookupError(tid)
|
||||
self.calls.append((tid, set(cpus), nice))
|
||||
self.state[tid] = (set(cpus), nice)
|
||||
|
||||
|
||||
class LauncherCoresTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temp.cleanup)
|
||||
self.root = Path(temp.name)
|
||||
self.proc = self.root / 'proc'
|
||||
self.proc.mkdir()
|
||||
cpufreq = self.root / 'cpu'
|
||||
for cpu in ALL: # 0-5 one cluster, 6-7 the fast one
|
||||
(cpufreq / f'cpu{cpu}/cpufreq').mkdir(parents=True)
|
||||
(cpufreq / f'cpu{cpu}/cpufreq/cpuinfo_max_freq').write_text('3532800\n' if cpu < 6 else '4320000\n')
|
||||
self.cores = FakeCores()
|
||||
self.holder = Holder(self.proc, cpufreq, self.cores)
|
||||
self.focus = self.root / 'focus.json'
|
||||
self.now = 0.0
|
||||
self.governor = Governor(self.focus, self.holder, clock=lambda: self.now)
|
||||
|
||||
def process(self, pid, argv0, threads=(), cpus=ALL, nice=0):
|
||||
directory = self.proc / str(pid)
|
||||
(directory / 'task').mkdir(parents=True)
|
||||
(directory / 'cmdline').write_bytes(argv0.encode() + b'\0--type=renderer\0')
|
||||
for tid in (pid, *threads):
|
||||
self.thread(pid, tid, cpus, nice)
|
||||
|
||||
def thread(self, pid, tid, cpus=ALL, nice=0):
|
||||
(self.proc / str(pid) / 'task' / str(tid)).mkdir()
|
||||
self.cores.state[tid] = (set(cpus), nice)
|
||||
|
||||
def end(self, pid):
|
||||
for tid in os.listdir(self.proc / str(pid) / 'task'):
|
||||
self.cores.state.pop(int(tid), None)
|
||||
(self.proc / str(pid) / 'task' / tid).rmdir()
|
||||
(self.proc / str(pid) / 'task').rmdir()
|
||||
(self.proc / str(pid) / 'cmdline').unlink()
|
||||
(self.proc / str(pid)).rmdir()
|
||||
|
||||
def focus_on(self, pid, app=1174180):
|
||||
stamp = getattr(self, 'stamp', 1_000_000_000) + 1_000_000_000
|
||||
self.stamp = stamp
|
||||
self.focus.write_text(json.dumps({
|
||||
'focusedApp': app, 'focusedWindow': 0x200001,
|
||||
'focusableWindows': [{'window': 0x100001, 'appId': 769, 'pid': 9},
|
||||
{'window': 0x200001, 'appId': app, 'pid': pid}]}))
|
||||
os.utime(self.focus, ns=(stamp, stamp))
|
||||
|
||||
def held(self, tid):
|
||||
return self.cores.state[tid] == ({5}, NICE)
|
||||
|
||||
def test_helpers_held_on_the_slow_clusters_last_core_while_the_game_has_focus(self):
|
||||
self.process(100, LAUNCHER, (101,))
|
||||
self.process(200, HELPER, (201, 202), nice=-2)
|
||||
self.process(300, HELPER, (301,))
|
||||
self.process(400, GAME, (401,))
|
||||
self.focus_on(400)
|
||||
self.governor.tick()
|
||||
for tid in (200, 201, 202, 300, 301):
|
||||
self.assertTrue(self.held(tid), tid)
|
||||
for tid in (100, 101, 400, 401):
|
||||
self.assertEqual((ALL, 0), self.cores.state[tid])
|
||||
|
||||
def test_launcher_focus_gives_each_thread_its_own_mask_and_priority_back(self):
|
||||
self.process(100, LAUNCHER)
|
||||
self.process(200, HELPER, (201,), cpus={0, 1, 2, 3}, nice=-2)
|
||||
self.cores.state[201] = ({6, 7}, 4)
|
||||
self.process(400, GAME)
|
||||
self.focus_on(400)
|
||||
self.governor.tick()
|
||||
self.assertTrue(self.held(201))
|
||||
self.focus_on(100)
|
||||
self.governor.tick()
|
||||
self.assertEqual(({0, 1, 2, 3}, -2), self.cores.state[200])
|
||||
self.assertEqual(({6, 7}, 4), self.cores.state[201])
|
||||
self.assertEqual({}, self.holder.held)
|
||||
|
||||
def test_nothing_is_touched_before_a_game_has_focus(self):
|
||||
self.process(100, LAUNCHER)
|
||||
self.process(200, HELPER)
|
||||
self.process(9, '/root/.local/share/Steam/steamrtarm64/steam')
|
||||
self.governor.tick() # no focus file yet
|
||||
self.focus_on(9, app=769)
|
||||
self.governor.tick()
|
||||
self.focus_on(100)
|
||||
self.governor.tick()
|
||||
self.assertEqual([], self.cores.calls)
|
||||
|
||||
def test_a_launcher_left_after_its_game_stays_held_behind_the_client(self):
|
||||
self.process(200, HELPER)
|
||||
self.process(400, GAME)
|
||||
self.process(9, '/root/.local/share/Steam/steamrtarm64/steam')
|
||||
self.focus_on(400)
|
||||
self.governor.tick()
|
||||
self.end(400)
|
||||
self.focus_on(9, app=769)
|
||||
self.governor.tick()
|
||||
self.assertTrue(self.held(200))
|
||||
|
||||
def test_a_thread_inheriting_the_hold_is_released_to_what_its_process_had(self):
|
||||
self.process(200, HELPER, cpus={0, 1, 2, 3, 4, 5}, nice=1)
|
||||
self.process(400, GAME)
|
||||
self.focus_on(400)
|
||||
self.governor.tick()
|
||||
# A new thread, and a new helper process the held one started: both born held.
|
||||
self.thread(200, 203, {5}, NICE)
|
||||
self.process(500, HELPER, (501,), cpus={5}, nice=NICE)
|
||||
self.now += 10
|
||||
self.governor.tick()
|
||||
self.focus_on(200)
|
||||
self.governor.tick()
|
||||
for tid in (200, 203, 500, 501):
|
||||
self.assertEqual(({0, 1, 2, 3, 4, 5}, 1), self.cores.state[tid], tid)
|
||||
|
||||
def test_a_held_thread_the_program_moves_back_is_held_again_on_the_next_look(self):
|
||||
self.process(200, HELPER, (201,))
|
||||
self.process(400, GAME)
|
||||
self.focus_on(400)
|
||||
self.governor.tick()
|
||||
self.cores.state[201] = (ALL, -5) # the program raised a thread's priority itself
|
||||
self.now += 5
|
||||
self.governor.tick()
|
||||
self.assertFalse(self.held(201)) # not looked at again before the rescan
|
||||
self.now += 5
|
||||
self.governor.tick()
|
||||
self.assertTrue(self.held(201))
|
||||
self.focus_on(200)
|
||||
self.governor.tick()
|
||||
self.assertEqual((ALL, 0), self.cores.state[201]) # its first state, not the program's
|
||||
|
||||
def test_a_steady_hold_is_not_rewritten(self):
|
||||
self.process(200, HELPER, (201,))
|
||||
self.process(400, GAME)
|
||||
self.focus_on(400)
|
||||
self.governor.tick()
|
||||
calls = len(self.cores.calls)
|
||||
for _ in range(5):
|
||||
self.now += 10
|
||||
self.governor.tick()
|
||||
self.assertEqual(calls, len(self.cores.calls))
|
||||
|
||||
def test_threads_that_end_are_skipped_and_ended_helpers_forgotten(self):
|
||||
self.process(200, HELPER, (201,))
|
||||
self.process(300, HELPER)
|
||||
self.process(400, GAME)
|
||||
del self.cores.state[201] # ended between the listing and the call
|
||||
self.focus_on(400)
|
||||
self.governor.tick()
|
||||
self.assertNotIn(201, self.holder.held[200])
|
||||
self.end(300)
|
||||
self.now += 10
|
||||
self.governor.tick()
|
||||
self.assertEqual([200], list(self.holder.held))
|
||||
|
||||
def test_stop_releases(self):
|
||||
self.process(200, HELPER)
|
||||
self.process(400, GAME)
|
||||
self.focus_on(400)
|
||||
self.governor.tick()
|
||||
with self.assertRaises(SystemExit):
|
||||
self.governor.stop()
|
||||
self.assertEqual((ALL, 0), self.cores.state[200])
|
||||
|
||||
def test_unreadable_focus_is_ignored(self):
|
||||
self.process(200, HELPER)
|
||||
self.focus.write_text('{"focusedApp": 1174')
|
||||
self.governor.tick()
|
||||
self.focus.write_text('[]')
|
||||
os.utime(self.focus, ns=(5, 5))
|
||||
self.governor.tick()
|
||||
self.assertEqual([], self.cores.calls)
|
||||
|
||||
def test_window_without_a_pid_is_left_alone(self):
|
||||
self.process(200, HELPER)
|
||||
self.focus.write_text(json.dumps({'focusedApp': 1174180, 'focusedWindow': 7, 'focusableWindows': []}))
|
||||
self.governor.tick()
|
||||
self.assertEqual([], self.cores.calls)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -22,6 +22,30 @@ GAME_SCRIPT = r'''"InstallScript"
|
||||
}
|
||||
'''
|
||||
|
||||
# Red Dead Redemption 2's own script: Proton needs Khronos' vulkan-1.dll for it, which only the
|
||||
# Vulkan runtime installer puts in the prefix.
|
||||
VULKAN_SCRIPT = r'''"InstallScript"
|
||||
{
|
||||
"Run Process"
|
||||
{
|
||||
"Vulkan111080"
|
||||
{
|
||||
"HasRunKey" "HKEY_LOCAL_MACHINE\\Software\\Valve\\Steam\\Apps\\1174180"
|
||||
"process 1" "%INSTALLDIR%\\Redistributables\\VulkanRT-1.1.108.0-Installer.exe"
|
||||
"command 1" "/S"
|
||||
"NoCleanUp" "0"
|
||||
}
|
||||
"RockstarLauncher"
|
||||
{
|
||||
"HasRunKey" "HKEY_LOCAL_MACHINE\\Software\\Rockstar Games\\Steam\\Launcher"
|
||||
"process 1" "%INSTALLDIR%\\Redistributables\\Rockstar-Games-Launcher.exe"
|
||||
"command 1" "/s /t"
|
||||
"NoCleanUp" "0"
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class SeedRedistsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -91,6 +115,45 @@ class SeedRedistsTest(unittest.TestCase):
|
||||
self.run_seed()
|
||||
self.assertEqual(int(self.reg.stat().st_mtime), 1_000_000_000)
|
||||
|
||||
def rdr2(self):
|
||||
path = self.steam / "steamapps/common/Red Dead Redemption 2/installscript.vdf"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(VULKAN_SCRIPT)
|
||||
(self.steam / "steamapps/appmanifest_1174180.acf").write_text('"AppState" { "installdir" "Red Dead Redemption 2" }')
|
||||
|
||||
def test_a_vulkan_runtime_installer_is_left_to_run(self):
|
||||
self.rdr2()
|
||||
self.run_seed()
|
||||
reg = self.reg.read_text()
|
||||
self.assertNotIn("Vulkan111080", reg)
|
||||
self.assertIn('"RockstarLauncher"=dword:00000001', reg)
|
||||
self.assertNotIn("Vulkan111080", (self.reg.parent / ".droiddeck-redists").read_text())
|
||||
|
||||
def test_a_vulkan_marker_an_earlier_seeding_wrote_comes_out_once(self):
|
||||
self.rdr2()
|
||||
marker = "HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\1174180\tVulkan111080"
|
||||
self.reg.write_text(
|
||||
"WINE REGISTRY Version 2\n"
|
||||
"\n[Software\\\\Valve\\\\Steam\\\\Apps\\\\1174180] 1791729070\n#time=1dd598d29161f72\n"
|
||||
'"Other"=dword:00000001\n"Vulkan111080"=dword:00000001\n'
|
||||
"\n[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\1174180] 1791729070\n"
|
||||
'"Vulkan111080"=dword:00000001\n'
|
||||
"\n[Software\\\\Valve\\\\Steam\\\\Apps\\\\22380] 1791311644\n"
|
||||
'"Vulkan111080"=dword:00000001\n')
|
||||
(self.reg.parent / ".droiddeck-redists").write_text(marker + "\n")
|
||||
os.utime(self.reg, (1_000_000_000, 1_000_000_000))
|
||||
self.run_seed()
|
||||
reg = self.reg.read_text()
|
||||
self.assertEqual(reg.count('"Vulkan111080"'), 1, "only the other app's value stays")
|
||||
self.assertIn('"Other"=dword:00000001', reg)
|
||||
self.assertNotIn("Vulkan111080", (self.reg.parent / ".droiddeck-redists").read_text())
|
||||
self.assertEqual(int(self.reg.stat().st_mtime), 1_000_000_000)
|
||||
# The client writes the marker itself once the installer has run; that one is left alone.
|
||||
with self.reg.open("a") as out:
|
||||
out.write('\n[Software\\\\Valve\\\\Steam\\\\Apps\\\\1174180] 1791730000\n"Vulkan111080"=dword:00000001\n')
|
||||
self.run_seed()
|
||||
self.assertEqual(self.reg.read_text().count('"Vulkan111080"'), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SESSION = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-session"
|
||||
|
||||
STEAM = ["mangohud.Ab3dE6gH", ".com.valvesoftware.Steam.x1Y2z3/", "steam_chrome_shmem_uid10480_0", "steamADfAnd/"]
|
||||
CHROMIUM = ["scoped_dir3yu9Xt/SingletonSocket"]
|
||||
APPIMAGE = ["droiddeck-appimage.19PoOD"]
|
||||
BUS = ["dbus-1zWfOP1IaR="] # = a socket
|
||||
KDE = ["plasma-csd-generator.BVWIWA/"]
|
||||
STALE = STEAM + CHROMIUM + APPIMAGE + BUS + KDE
|
||||
KEPT = ["droiddeck-mangohud.conf", ".X11-unix/", "steamworks", "steamABCDEF/inside", "notes.txt",
|
||||
"mangohud.conf", ".wine-10480/", "dbus-1zWfOP1IaR.log", "dbus-notasocket",
|
||||
"plasma-csd-generator.CQGZSw/kept"]
|
||||
|
||||
|
||||
def sweep_function(tmp):
|
||||
match = re.search(r"^clear_stale_tmp\(\) \{\n.*?^\}\n", SESSION.read_text(), re.M | re.S)
|
||||
return match.group(0).replace("find /tmp ", "find %s " % tmp)
|
||||
|
||||
|
||||
class SessionTmpTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
self.tmp = self.root / "tmp"
|
||||
self.tmp.mkdir()
|
||||
for name in STALE + KEPT:
|
||||
path = self.tmp / name
|
||||
if name.endswith("/"):
|
||||
path.mkdir(parents=True)
|
||||
elif name.endswith("="):
|
||||
listener = socket.socket(socket.AF_UNIX)
|
||||
listener.bind(str(self.tmp / name[:-1]))
|
||||
listener.close()
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("x")
|
||||
self.bin = self.root / "bin"
|
||||
self.bin.mkdir()
|
||||
|
||||
def sweep(self, running=""):
|
||||
pgrep = self.bin / "pgrep"
|
||||
pgrep.write_text("#!/bin/sh\nprintf '%s' '" + running + "'\n")
|
||||
pgrep.chmod(0o755)
|
||||
env = dict(os.environ, PATH="%s:%s" % (self.bin, os.environ["PATH"]))
|
||||
return subprocess.run(["bash", "-c", sweep_function(self.tmp) + "clear_stale_tmp"], env=env,
|
||||
capture_output=True, text=True, timeout=30, check=True).stdout
|
||||
|
||||
def exists(self, name):
|
||||
return os.path.lexists(self.tmp / name.rstrip("=").split("/")[0])
|
||||
|
||||
def test_what_killed_sessions_left_goes_and_nothing_else(self):
|
||||
self.assertEqual("== /tmp: cleared 8 entries earlier sessions left\n", self.sweep())
|
||||
for name in STALE:
|
||||
self.assertFalse(self.exists(name), name)
|
||||
for name in KEPT:
|
||||
self.assertTrue((self.tmp / name).exists(), name)
|
||||
|
||||
def kept_while(self, running, owned):
|
||||
self.sweep(running)
|
||||
for name in STALE:
|
||||
self.assertEqual(name in owned, self.exists(name), name)
|
||||
|
||||
def test_a_running_client_keeps_its_own(self):
|
||||
self.kept_while("1234 /root/.local/share/Steam/steamrtarm64/steam -gamepadui\n", STEAM)
|
||||
|
||||
def test_a_running_chromium_keeps_its_singleton(self):
|
||||
self.kept_while("88 /opt/Heroic/heroic --type=renderer --enable-features=x\n", CHROMIUM)
|
||||
|
||||
def test_a_running_appimage_keeps_its_stamp(self):
|
||||
self.kept_while("77 /bin/bash /usr/local/bin/droiddeck-appimage-run /root/Apps/X.AppImage\n", APPIMAGE)
|
||||
|
||||
def test_a_running_session_bus_keeps_its_socket(self):
|
||||
self.kept_while("66 dbus-daemon --config-file=/usr/share/dbus-1/session.conf --nofork --session\n", BUS)
|
||||
|
||||
def test_the_system_bus_is_not_a_session_bus(self):
|
||||
self.kept_while("55 dbus-daemon --config-file=/usr/local/share/droiddeck/system-bus.conf --nofork\n", [])
|
||||
|
||||
def test_a_running_desktop_keeps_its_decorations(self):
|
||||
self.kept_while("44 kwin_wayland --xwayland\n", KDE)
|
||||
|
||||
def test_a_clean_tmp_says_nothing(self):
|
||||
self.sweep()
|
||||
self.assertEqual("", self.sweep())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,6 +14,8 @@ DISPLAY = MODULE['DISPLAY']
|
||||
seed_native_resolution = MODULE['seed_native_resolution']
|
||||
initialize_native_resolution = MODULE['initialize_native_resolution']
|
||||
NATIVE_RESOLUTION = MODULE['NATIVE_RESOLUTION']
|
||||
seed_no_screensaver_in_game = MODULE['seed_no_screensaver_in_game']
|
||||
initialize_screensaver = MODULE['initialize_screensaver']
|
||||
|
||||
|
||||
class SteamUiScaleTest(unittest.TestCase):
|
||||
@@ -151,3 +153,60 @@ class SteamNativeResolutionTest(unittest.TestCase):
|
||||
initialize_native_resolution(self.root)
|
||||
self.assertEqual(text, self.config.read_text())
|
||||
self.assertFalse(self.marker.exists())
|
||||
|
||||
|
||||
class SteamScreensaverTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.config = self.root / 'config/config.vdf'
|
||||
self.config.parent.mkdir()
|
||||
self.marker = self.config.with_name('droiddeck-screensaver-initialized')
|
||||
|
||||
def in_game(self, text):
|
||||
return parse(text).get('InstallConfigStore').get('Customization').get('Screensaver').get('WhileInGame')
|
||||
|
||||
def test_fresh_client_shows_no_screensaver_over_a_game(self):
|
||||
self.assertTrue(initialize_screensaver(self.root))
|
||||
self.assertEqual('0', self.in_game(self.config.read_text()))
|
||||
self.assertTrue(self.marker.exists())
|
||||
|
||||
def test_it_goes_beside_the_screensaver_the_user_picked(self):
|
||||
text = ('"InstallConfigStore"\n{\n\t"Customization"\n\t{\n\t\t"Screensaver"\n\t\t{\n'
|
||||
'\t\t\t"CurrentScreensaver"\t\t"steam-bouncinglogo"\n\t\t}\n\t}\n}\n')
|
||||
updated, changed = seed_no_screensaver_in_game(text)
|
||||
self.assertTrue(changed)
|
||||
screensaver = parse(updated).get('InstallConfigStore').get('Customization').get('Screensaver')
|
||||
self.assertEqual('steam-bouncinglogo', screensaver.get('CurrentScreensaver'))
|
||||
self.assertEqual('0', screensaver.get('WhileInGame'))
|
||||
self.assertIn('\t\t\t"WhileInGame"\t\t"0"\n\t\t}\n', updated)
|
||||
|
||||
def test_users_choice_is_kept(self):
|
||||
text = '"InstallConfigStore" { "Customization" { "Screensaver" { "WhileInGame" "1" } } }'
|
||||
self.assertEqual((text, False), seed_no_screensaver_in_game(text))
|
||||
|
||||
def test_turning_it_back_on_survives_restart(self):
|
||||
initialize_screensaver(self.root)
|
||||
text = '"InstallConfigStore" { "Customization" { } }'
|
||||
self.config.write_text(text)
|
||||
self.assertFalse(initialize_screensaver(self.root))
|
||||
self.assertEqual(text, self.config.read_text())
|
||||
|
||||
def test_every_seeded_setting_lands_in_one_valid_config(self):
|
||||
initialize(self.root, 2340, 1080)
|
||||
initialize_native_resolution(self.root)
|
||||
initialize_screensaver(self.root)
|
||||
store = parse(self.config.read_text()).get('InstallConfigStore')
|
||||
self.assertIsNotNone(store.get('UI').get('display').get(DISPLAY))
|
||||
self.assertEqual('1', store.get('Gamescope').get(NATIVE_RESOLUTION))
|
||||
self.assertEqual('0', store.get('Customization').get('Screensaver').get('WhileInGame'))
|
||||
|
||||
def test_invalid_config_is_never_rewritten_or_marked_initialized(self):
|
||||
for text in ('"InstallConfigStore" {', '"InstallConfigStore" { "Customization" "bad" }',
|
||||
'"InstallConfigStore" { "Customization" { "Screensaver" "bad" } }'):
|
||||
self.config.write_text(text)
|
||||
with self.assertRaises(ValueError):
|
||||
initialize_screensaver(self.root)
|
||||
self.assertEqual(text, self.config.read_text())
|
||||
self.assertFalse(self.marker.exists())
|
||||
Reference in new issue
Block a user