mirror of
https://github.com/theupriser/steamify-cachyos.git
synced 2026-10-03 17:41:58 +02:00
commit
4c3186109f
11 files changed
+1954
-67
No files matched your search
@@ -5,12 +5,18 @@
|
||||
# release are moved to it, so both of these always get the newest version:
|
||||
# curl -fsSL https://github.com/theupriser/steamify-cachyos/releases/latest/download/steamify.sh | bash
|
||||
# curl -fsSL https://github.com/theupriser/steamify-cachyos/releases/download/latest/steamify.sh | bash
|
||||
# Each release also has the app (v2): steamify-app.tar.gz (ui/, steamify.sh,
|
||||
# lib/, the icon) and its launcher, run with:
|
||||
# curl -fsSL https://github.com/theupriser/steamify-cachyos/releases/latest/download/steamify-app.sh | bash
|
||||
# A push to feat/v2-ui (the app, not released yet) updates the pre-release
|
||||
# v2-ui-preview instead, whose launcher gets that build:
|
||||
# curl -fsSL https://github.com/theupriser/steamify-cachyos/releases/download/v2-ui-preview/steamify-app.sh | bash
|
||||
# Pull requests only build and check it, and warn when VERSION is already released.
|
||||
name: Single-file build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, feat/v2-ui]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -28,17 +34,26 @@ jobs:
|
||||
- name: Build
|
||||
run: |
|
||||
.github/tools/bundle.sh dist/steamify.sh
|
||||
# The app: sources, not the single-file build (its backend mode runs
|
||||
# steamify.sh from its own folder).
|
||||
tar -czf dist/steamify-app.tar.gz steamify.sh lib ui assets/steam-gaming-settings.svg LICENSE.md
|
||||
cp ui/steamify-app.sh dist/steamify-app.sh
|
||||
|
||||
- name: Check
|
||||
run: |
|
||||
bash -n dist/steamify.sh
|
||||
# SC2034/SC2154: variables used across the inlined modules.
|
||||
shellcheck -S warning -e SC2034,SC2154 dist/steamify.sh
|
||||
shellcheck -S warning dist/steamify-app.sh
|
||||
python3 -m py_compile ui/steamify-ui
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: steamify
|
||||
path: dist/steamify.sh
|
||||
path: |
|
||||
dist/steamify.sh
|
||||
dist/steamify-app.tar.gz
|
||||
dist/steamify-app.sh
|
||||
|
||||
- name: Version
|
||||
id: version
|
||||
@@ -71,9 +86,13 @@ jobs:
|
||||
|
||||
curl -fsSL https://github.com/${{ github.repository }}/releases/latest/download/steamify.sh | bash
|
||||
|
||||
Or the app (v2):
|
||||
|
||||
curl -fsSL https://github.com/${{ github.repository }}/releases/latest/download/steamify-app.sh | bash
|
||||
|
||||
## Changes in v$VERSION
|
||||
$changes"
|
||||
gh release create "v$VERSION" dist/steamify.sh assets/steam-gaming-settings.svg \
|
||||
gh release create "v$VERSION" dist/steamify.sh dist/steamify-app.sh dist/steamify-app.tar.gz assets/steam-gaming-settings.svg \
|
||||
--target "$GITHUB_SHA" --title "Steamify CachyOS v$VERSION" --notes "$notes" --latest
|
||||
|
||||
# "latest" follows the newest version tag, for the older install URL
|
||||
@@ -86,4 +105,24 @@ jobs:
|
||||
else
|
||||
gh release create latest --title "Steamify CachyOS - latest (v$VERSION)" --notes "$latest_notes" --latest=false
|
||||
fi
|
||||
gh release upload latest dist/steamify.sh assets/steam-gaming-settings.svg --clobber
|
||||
gh release upload latest dist/steamify.sh dist/steamify-app.sh dist/steamify-app.tar.gz assets/steam-gaming-settings.svg --clobber
|
||||
|
||||
- name: Publish the v2-ui-preview pre-release
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/feat/v2-ui'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Its launcher downloads this pre-release, not the newest release.
|
||||
sed -i 's/STEAMIFY_RELEASE:-latest/STEAMIFY_RELEASE:-v2-ui-preview/' dist/steamify-app.sh
|
||||
grep -q 'STEAMIFY_RELEASE:-v2-ui-preview' dist/steamify-app.sh
|
||||
git tag -f v2-ui-preview "$GITHUB_SHA" && git push -f origin v2-ui-preview
|
||||
notes="Preview of the Steamify app (v2) from feat/v2-ui ($(git describe --always), $(git log -1 --format='%cs %s')). Not a release.
|
||||
Run it with:
|
||||
|
||||
curl -fsSL https://github.com/${{ github.repository }}/releases/download/v2-ui-preview/steamify-app.sh | bash"
|
||||
if gh release view v2-ui-preview >/dev/null 2>&1; then
|
||||
gh release edit v2-ui-preview --title "Steamify app (v2) preview" --notes "$notes" --prerelease --latest=false
|
||||
else
|
||||
gh release create v2-ui-preview --target "$GITHUB_SHA" --title "Steamify app (v2) preview" --notes "$notes" --prerelease --latest=false
|
||||
fi
|
||||
gh release upload v2-ui-preview dist/steamify-app.sh dist/steamify-app.tar.gz --clobber
|
||||
@@ -1 +1,2 @@
|
||||
dist/
|
||||
__pycache__/
|
||||
@@ -173,9 +173,12 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine
|
||||
download would run an empty script and count as success.
|
||||
- HDMI refresh boost (`hdmi`, `lib/hdmi-refresh.sh`): a sub-option of
|
||||
`machine` (like `kpin` and `bios`), only on Fremont with the pinned kernel
|
||||
(stays visible while on); unticking `kpin` unticks it. It needs someone at the
|
||||
screen: the graphical app's backend (`BACKEND=true`) must refuse it, and every step needs a "y" within 15 s
|
||||
(`WIZARD_HDMI_CONFIRM_SECONDS` for scripted tests). debugfs is root-only
|
||||
(stays visible while on); unticking `kpin` unticks it. It needs someone at
|
||||
the screen. Terminal: every step needs a "y" within 15 s
|
||||
(`WIZARD_HDMI_CONFIRM_SECONDS` for scripted tests). App: its own screen
|
||||
(`hdmi-options`, `hdmi-try`, `hdmi-reset` backend commands, the EDID cached
|
||||
in `$XDG_RUNTIME_DIR/steamify-hdmi`), then `apply --hdmi
|
||||
<output>=<w>x<h>:<rates>`; without `--hdmi` the backend refuses it. debugfs is root-only
|
||||
(glob it under sudo), and `edid_override` takes exactly `reset` with no
|
||||
newline. Build the EDID from the DDC read, not from sysfs: a live override
|
||||
replaces the kernel's copy. The kernel command-line value holds a path, so
|
||||
|
||||
@@ -5,6 +5,26 @@ All notable changes, per version and per commit. Versions follow
|
||||
`steamify.sh`. Versions before 0.7.0 were numbered afterwards,
|
||||
one per merged pull request.
|
||||
|
||||
## 2.0.0 - 2026-09-25
|
||||
|
||||
- **feat: Steamify app (v2 UI)**
|
||||
- A graphical app for controller, keyboard and TV remote, driving the same
|
||||
components through `steamify.sh --backend`; the BIOS update with both
|
||||
warnings; built by GitHub and run with curl.
|
||||
- **feat(ui): HDMI refresh boost in the app**
|
||||
- "Set up…" opens its own screen: the display, its resolution and the
|
||||
rates on offer (its own mode, calculated tens, the HDMI 2.0 limit). The
|
||||
display's own faster mode is switched to right away; every other rate
|
||||
has a Try button (switch live, wait for the picture, then keep it within
|
||||
15 s or it switches back) and a tick box. Install installs the ticked
|
||||
rates. All rates are loaded when the screen opens, so a try is only a
|
||||
mode switch.
|
||||
- Unticking Steam Machine support or the kernel pin unticks it, as in the
|
||||
menu.
|
||||
- **fix(ui): The details panel scrolls instead of overflowing**
|
||||
- **fix: Switching back to a rate like 59.97 Hz**
|
||||
- `hdmi_set_mode` compared it as a whole number and printed an error.
|
||||
|
||||
## 1.2.0 - 2026-09-25
|
||||
|
||||
- **feat: HDMI refresh boost (Steam Machine, pinned kernel)**
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
#!/bin/bash
|
||||
# Machine-readable mode for the graphical app (steamify-ui): the same
|
||||
# components and plan as the menu, as JSON on stdout.
|
||||
# steamify.sh --backend status
|
||||
# one JSON object: version, machine, and every menu item with its
|
||||
# current state and what a run would pick by default.
|
||||
# steamify.sh --backend apply [--reapply] [--boot gamescope|desktop] <id>...
|
||||
# turns on the listed components and off the others (like ticking them
|
||||
# in the menu), then streams one JSON object per line: plan, start,
|
||||
# log, done, finished. sudo asks through $SUDO_ASKPASS (no terminal).
|
||||
# steamify.sh --backend apply ... --hdmi <output>=<w>x<h>:<rate>,... hdmi
|
||||
# HDMI refresh boost with the rates the app confirmed.
|
||||
# steamify.sh --backend hdmi-options
|
||||
# hdmi-options event: every HDMI output, its mode and rates to offer.
|
||||
# steamify.sh --backend hdmi-try <connector> <w> <h> <hz> [<rate>...]
|
||||
# steamify.sh --backend hdmi-reset <connector> <w> <h> <hz>
|
||||
# switch to a rate live (hdmi-tried event), or back to the display's
|
||||
# own EDID (hdmi-reset event); the app asks in between.
|
||||
# steamify.sh --backend bios-prepare | bios-flash
|
||||
# the BIOS update in two steps, so the app shows both warnings between
|
||||
# them (bios-ready event: current, newest, checksum, compatible).
|
||||
# Sourced by steamify.sh; not meant to be run on its own.
|
||||
|
||||
json_str() {
|
||||
# json_str <text>: <text> as a JSON string (quotes, backslashes and
|
||||
# control characters escaped; colour codes dropped).
|
||||
local s="$1"
|
||||
s="$(printf '%s' "$s" | sed -E $'s/\x1b\\[[0-9;]*[A-Za-z]//g')"
|
||||
s="${s//\\/\\\\}"; s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"; s="${s//$'\r'/}"; s="${s//$'\n'/\\n}"
|
||||
s="$(printf '%s' "$s" | tr -d '\000-\010\013\014\016-\037')"
|
||||
printf '"%s"' "$s"
|
||||
}
|
||||
|
||||
json_list() {
|
||||
# json_list <item>...: a JSON array of strings.
|
||||
local out="" x
|
||||
for x in "$@"; do out+="${out:+,}$(json_str "$x")"; done
|
||||
printf '[%s]' "$out"
|
||||
}
|
||||
|
||||
backend_event() {
|
||||
# backend_event <event> [<json fields without braces>]
|
||||
printf '{"event":%s%s}\n' "$(json_str "$1")" "${2:+,$2}"
|
||||
}
|
||||
|
||||
backend_status() {
|
||||
local c first_run=true items="" kind parent now
|
||||
detect_components
|
||||
for c in "${COMPONENTS[@]}"; do
|
||||
component_available "$c" && ! is_action "$c" && [[ "${CURRENT[$c]}" == 1 ]] && first_run=false
|
||||
done
|
||||
for c in "${COMPONENTS[@]}"; do
|
||||
component_available "$c" || continue
|
||||
kind=toggle; is_action "$c" && kind=action; [[ "$c" == boot ]] && kind=choice
|
||||
parent="${PARENT[$c]:-}"
|
||||
now="${CURRENT[$c]:-0}"
|
||||
items+="${items:+,}{\"id\":$(json_str "$c"),\"label\":$(json_str "${LABEL[$c]%%:*}")"
|
||||
items+=",\"hint\":$(json_str "$( [[ "${LABEL[$c]}" == *:* ]] && echo "${LABEL[$c]#*: }")")"
|
||||
items+=",\"kind\":\"$kind\",\"parent\":$(json_str "$parent")"
|
||||
items+=",\"on\":$( [[ "$now" == 1 ]] && echo true || echo false)"
|
||||
items+=",\"wanted\":$( [[ "${WANTED[$c]:-0}" == 1 ]] && echo true || echo false)"
|
||||
items+=",\"selectable\":$(component_selectable "$c" && echo true || echo false)}"
|
||||
done
|
||||
local cec="" f
|
||||
for f in /dev/cec*; do [[ -e "$f" ]] && cec+="${cec:+ }$(basename "$f")"; done
|
||||
local bios='null'
|
||||
if bios_available; then
|
||||
bios="{\"current\":$(json_str "$(bios_current)"),\"newest\":$(json_str "${BIOS_NEWEST:-}"),\"selectable\":$(bios_selectable && echo true || echo false),\"dryRun\":$([[ -n "$BIOS_DRY_RUN" ]] && echo true || echo false)}"
|
||||
fi
|
||||
printf '{"version":%s,"firstRun":%s,"steamMachine":%s,"kernel":%s,"pinnedKernel":%s,"cecDevices":%s,"leds":%s,"bios":%s,"items":[%s]}\n' \
|
||||
"$(json_str "$VERSION")" "$first_run" \
|
||||
"$(detect_valve_fremont && echo true || echo false)" \
|
||||
"$(json_str "$(uname -r)")" "$(json_str "${PINNED_KERNEL_VER:-}")" \
|
||||
"$(json_str "$cec")" \
|
||||
"$(compgen -G '/sys/class/leds/valve-leds*' | wc -l)" \
|
||||
"$bios" "$items"
|
||||
}
|
||||
|
||||
backend_run_component() {
|
||||
# backend_run_component <id> <enable|disable>: runs it in this shell
|
||||
# (its variables, e.g. RESTART_FOR_LOGIN, must survive), each output line
|
||||
# as a log event.
|
||||
local c="$1" what="$2" rc
|
||||
backend_event start "\"id\":$(json_str "$c"),\"action\":\"$what\",\"label\":$(json_str "${LABEL[$c]%%:*}")"
|
||||
"${c}_${what}" > >(while IFS= read -r line; do backend_event log "\"id\":$(json_str "$c"),\"line\":$(json_str "$line")"; done) 2>&1
|
||||
rc=$?
|
||||
# Let the log reader finish before the next event.
|
||||
wait $! 2>/dev/null
|
||||
backend_event "done" "\"id\":$(json_str "$c"),\"ok\":$([[ $rc -eq 0 ]] && echo true || echo false)"
|
||||
return $rc
|
||||
}
|
||||
|
||||
backend_sudo() {
|
||||
# sudo from the app has no terminal: ask through its helper, and keep the
|
||||
# credentials fresh as the menu does.
|
||||
if [[ -z "${SUDO_ASKPASS:-}" ]] && ! sudo -n true 2>/dev/null; then
|
||||
backend_event finished '"failed":[],"restart":false,"error":"no-sudo"'
|
||||
return 1
|
||||
fi
|
||||
sudo() { command sudo ${SUDO_ASKPASS:+-A} "$@"; }
|
||||
if ! sudo -n true 2>/dev/null && ! sudo true 2>/dev/null; then
|
||||
backend_event finished '"failed":[],"restart":false,"error":"wrong-password"'
|
||||
return 1
|
||||
fi
|
||||
while true; do sudo -n true; sleep 50; kill -0 "$$" 2>/dev/null || exit; done 2>/dev/null &
|
||||
}
|
||||
|
||||
backend_bios_prepare() {
|
||||
# Download, checksum and fwupd's device check; the app then shows its
|
||||
# two warnings and calls bios-flash.
|
||||
local rc
|
||||
backend_sudo || return 1
|
||||
backend_event start '"id":"bios","action":"prepare","label":"Update BIOS"'
|
||||
bios_prepare > >(while IFS= read -r line; do backend_event log "\"id\":\"bios\",\"line\":$(json_str "$line")"; done) 2>&1
|
||||
rc=$?; wait $! 2>/dev/null
|
||||
case $rc in
|
||||
0) backend_event bios-ready "\"current\":$(json_str "$(bios_current)"),\"newest\":$(json_str "$BIOS_NEWEST"),\"checksum\":true,\"compatible\":$(json_str "$BIOS_COMPATIBLE")" ;;
|
||||
2) backend_event finished '"failed":[],"restart":false,"nothing":true' ;;
|
||||
*) backend_event finished '"failed":["bios"],"restart":false' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
backend_bios_flash() {
|
||||
backend_sudo || return 1
|
||||
local rc
|
||||
backend_run_component bios flash_only; rc=$?
|
||||
backend_event finished "\"failed\":$([[ $rc -eq 0 ]] && echo '[]' || echo '["bios"]'),\"restart\":$(restart_needed && echo true || echo false),\"bios\":true"
|
||||
}
|
||||
|
||||
backend_apply() {
|
||||
local reapply=false boot="" id c
|
||||
local -a want=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--reapply) reapply=true ;;
|
||||
--boot) boot="$2"; shift ;;
|
||||
--hdmi) HDMI_CHOICE+=("$2"); shift ;;
|
||||
*) want+=("$1") ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
detect_components
|
||||
# What the app ticked: exactly these on, every other component off.
|
||||
for c in "${COMPONENTS[@]}"; do
|
||||
component_available "$c" || continue
|
||||
is_action "$c" && { WANTED[$c]=0; continue; }
|
||||
WANTED[$c]=0
|
||||
for id in "${want[@]}"; do [[ "$id" == "$c" ]] && WANTED[$c]=1; done
|
||||
done
|
||||
# The same rules as the menu's toggles.
|
||||
[[ "${WANTED[single]:-0}" == 1 ]] && WANTED[gaming]=1
|
||||
[[ "${WANTED[gaming]:-0}" == 0 ]] && { WANTED[single]=0; WANTED[boot]=0; }
|
||||
[[ "${WANTED[kpin]:-0}" == 1 ]] && WANTED[machine]=1
|
||||
[[ "${WANTED[machine]:-0}" == 0 ]] && WANTED[kpin]=0
|
||||
[[ "${WANTED[hdmi]:-0}" == 1 ]] && { WANTED[machine]=1; WANTED[kpin]=1; }
|
||||
[[ "${WANTED[kpin]:-0}" == 0 ]] && WANTED[hdmi]=0
|
||||
case "$boot" in desktop) WANTED[boot]=1; WANTED[gaming]=1 ;; gamescope) WANTED[boot]=0 ;; esac
|
||||
|
||||
REAPPLY=$reapply
|
||||
plan_changes
|
||||
backend_event plan "\"disable\":$(json_list "${TO_DISABLE[@]}"),\"enable\":$(json_list "${TO_ENABLE[@]}")"
|
||||
if [[ ${#TO_DISABLE[@]} -eq 0 && ${#TO_ENABLE[@]} -eq 0 ]]; then
|
||||
backend_event finished '"failed":[],"restart":false,"nothing":true'
|
||||
return 0
|
||||
fi
|
||||
|
||||
backend_sudo || return 1
|
||||
|
||||
LOGIN_MANAGER=plasmalogin
|
||||
[[ "${WANTED[single]}" == 1 ]] && LOGIN_MANAGER=sddm
|
||||
local -a failed=()
|
||||
for c in "${TO_DISABLE[@]}"; do backend_run_component "$c" disable || failed+=("$c"); done
|
||||
for c in "${TO_ENABLE[@]}"; do backend_run_component "$c" enable || failed+=("$c"); done
|
||||
[[ " ${TO_DISABLE[*]} ${TO_ENABLE[*]} " =~ \ (gaming|single|boot|kpin)\ ]] && RESTART_FOR_LOGIN=true
|
||||
backend_event finished "\"failed\":$(json_list "${failed[@]}"),\"restart\":$(restart_needed && echo true || echo false)"
|
||||
}
|
||||
|
||||
backend_hdmi() {
|
||||
# backend_hdmi options|try|reset [args]: the app's HDMI screen. Events
|
||||
# only (no "finished"): the app goes on from its own screen.
|
||||
local what="$1" rc
|
||||
shift
|
||||
backend_sudo || return 1
|
||||
case "$what" in
|
||||
options)
|
||||
sudo pacman -S --needed --noconfirm i2c-tools python >/dev/null 2>&1
|
||||
backend_event hdmi-options "\"outputs\":$(hdmi_options 2>/dev/null)" ;;
|
||||
try)
|
||||
hdmi_try "$@" >/dev/null 2>&1; rc=$?
|
||||
backend_event hdmi-tried "\"hz\":$4,\"ok\":$([[ $rc -eq 0 ]] && echo true || echo false)" ;;
|
||||
reset)
|
||||
hdmi_reset "$@" >/dev/null 2>&1
|
||||
backend_event hdmi-reset ;;
|
||||
esac
|
||||
}
|
||||
|
||||
backend_main() {
|
||||
case "${1:-}" in
|
||||
status) backend_status ;;
|
||||
apply) shift; backend_apply "$@" ;;
|
||||
bios-prepare) backend_bios_prepare ;;
|
||||
bios-flash) backend_bios_flash ;;
|
||||
hdmi-options) backend_hdmi options ;;
|
||||
hdmi-try) shift; backend_hdmi try "$@" ;;
|
||||
hdmi-reset) shift; backend_hdmi reset "$@" ;;
|
||||
*) err "Usage: steamify.sh --backend status | apply [--reapply] [--boot gamescope|desktop] <id>..."; return 2 ;;
|
||||
esac
|
||||
}
|
||||
+74
-44
@@ -121,54 +121,102 @@ bios_fits_device() {
|
||||
grep -q '"Guid"' <<< "$details" && ! grep -q '"UpdateError"' <<< "$details"
|
||||
}
|
||||
|
||||
bios_enable() {
|
||||
# The checked firmware waits here between bios_prepare and bios_flash, so the
|
||||
# app can show its warnings in between (and the terminal flow uses it too).
|
||||
BIOS_STAGE_DIR="${XDG_RUNTIME_DIR:-/tmp}/steamify-bios"
|
||||
|
||||
bios_prepare() {
|
||||
# Downloads Valve's package, checks its SHA-256 and extracts the .cab to
|
||||
# $BIOS_STAGE_DIR, then asks fwupd whether it fits this machine. Sets
|
||||
# BIOS_COMPATIBLE (yes|dry-run). 0 = ready to flash, 2 = nothing to do
|
||||
# (already the newest), 1 = error (nothing was touched).
|
||||
if ! bios_lookup_newest; then
|
||||
err "Couldn't find the newest Steam Machine BIOS on Valve's mirror ($VALVE_MIRROR)."
|
||||
return 1
|
||||
fi
|
||||
local current tmp compatible
|
||||
current="$(bios_current)"
|
||||
[[ -n "$BIOS_DRY_RUN" ]] && warn "DRY RUN (WIZARD_BIOS_DRY_RUN): nothing will be flashed."
|
||||
if [[ "$current" == "$BIOS_NEWEST" ]]; then
|
||||
ok "The BIOS is already the newest version ($current); nothing to do."
|
||||
return 0
|
||||
if [[ "$(bios_current)" == "$BIOS_NEWEST" ]]; then
|
||||
ok "The BIOS is already the newest version ($BIOS_NEWEST); nothing to do."
|
||||
return 2
|
||||
fi
|
||||
|
||||
# Download and check everything before asking: the warnings only come
|
||||
# when the file is Valve's (SHA-256 from Valve's repo database) and fwupd
|
||||
# confirms it's firmware for this very machine.
|
||||
pacman -Q fwupd >/dev/null 2>&1 || sudo pacman -S --needed --noconfirm fwupd ||
|
||||
{ err "Installing fwupd failed."; return 1; }
|
||||
tmp="$(mktemp -d)"
|
||||
rm -rf "$BIOS_STAGE_DIR"; mkdir -p "$BIOS_STAGE_DIR"; chmod 700 "$BIOS_STAGE_DIR"
|
||||
info "Downloading $BIOS_PKG ($BIOS_REPO)..."
|
||||
if ! curl -fsSL "$VALVE_MIRROR/$BIOS_REPO/os/x86_64/$BIOS_PKG" -o "$tmp/pkg.tar.zst" ||
|
||||
! echo "$BIOS_SHA256 $tmp/pkg.tar.zst" | sha256sum -c --quiet - ||
|
||||
! tar -I unzstd -xf "$tmp/pkg.tar.zst" -C "$tmp" "$BIOS_CAB"; then
|
||||
if ! curl -fsSL "$VALVE_MIRROR/$BIOS_REPO/os/x86_64/$BIOS_PKG" -o "$BIOS_STAGE_DIR/pkg.tar.zst" ||
|
||||
! echo "$BIOS_SHA256 $BIOS_STAGE_DIR/pkg.tar.zst" | sha256sum -c --quiet - ||
|
||||
! tar -I unzstd -xf "$BIOS_STAGE_DIR/pkg.tar.zst" -C "$BIOS_STAGE_DIR" "$BIOS_CAB"; then
|
||||
err "Downloading or verifying $BIOS_PKG failed; the BIOS was not touched."
|
||||
rm -rf "$tmp"
|
||||
rm -rf "$BIOS_STAGE_DIR"
|
||||
return 1
|
||||
fi
|
||||
ok "Checksum OK: this is Valve's $BIOS_PKG."
|
||||
local yes="$c_green$c_bold" b="$c_bold" n="$c_reset"
|
||||
if [[ -n "$BIOS_DRY_RUN" ]]; then
|
||||
warn "Dry run: skipping fwupd's check that the firmware fits this machine."
|
||||
compatible="${c_yellow}${b}not checked${n} (dry run)"
|
||||
elif bios_fits_device "$tmp/$BIOS_CAB"; then
|
||||
BIOS_COMPATIBLE=dry-run
|
||||
elif bios_fits_device "$BIOS_STAGE_DIR/$BIOS_CAB"; then
|
||||
ok "fwupd confirms BIOS $BIOS_NEWEST is firmware for this machine."
|
||||
compatible="${yes}yes${n} (checked by fwupd)"
|
||||
BIOS_COMPATIBLE=yes
|
||||
else
|
||||
err "fwupd says BIOS $BIOS_NEWEST is not for this machine's hardware; not installing it."
|
||||
rm -rf "$tmp"
|
||||
rm -rf "$BIOS_STAGE_DIR"
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$BIOS_NEWEST" "$BIOS_CAB" > "$BIOS_STAGE_DIR/ready"
|
||||
}
|
||||
|
||||
bios_flash() {
|
||||
# Hands the firmware bios_prepare checked to fwupd; it is written during
|
||||
# the next restart. Sets BIOS_NEEDS_RESTART.
|
||||
local newest cab
|
||||
{ read -r newest; read -r cab; } < "$BIOS_STAGE_DIR/ready" 2>/dev/null
|
||||
if [[ -z "${cab:-}" || ! -f "$BIOS_STAGE_DIR/$cab" ]]; then
|
||||
err "No checked BIOS file waiting; nothing was flashed."
|
||||
return 1
|
||||
fi
|
||||
if [[ -n "$BIOS_DRY_RUN" ]]; then
|
||||
ok "Dry run: would run: fwupdmgr install -y --no-reboot-check $(basename "$cab")"
|
||||
ok "Dry run finished; nothing was flashed."
|
||||
# Treated as staged, so the restart choices that follow a real
|
||||
# update show up too; restarting only prints (see restart_now).
|
||||
BIOS_NEEDS_RESTART=1
|
||||
rm -rf "$BIOS_STAGE_DIR"
|
||||
return 0
|
||||
fi
|
||||
info "Handing BIOS $newest to fwupd. Do NOT turn off the power from now on."
|
||||
# -y: the user confirmed twice; --no-reboot-check: our own restart
|
||||
# question comes at the end.
|
||||
if ! sudo fwupdmgr install -y --no-reboot-check "$BIOS_STAGE_DIR/$cab"; then
|
||||
err "fwupd could not install the BIOS update (see above); the BIOS was not changed."
|
||||
rm -rf "$BIOS_STAGE_DIR"
|
||||
return 1
|
||||
fi
|
||||
rm -rf "$BIOS_STAGE_DIR"
|
||||
BIOS_NEEDS_RESTART=1
|
||||
ok "BIOS $newest is staged. It is written during the next restart:"
|
||||
warn "keep the power on and don't touch the machine until it has fully started again."
|
||||
}
|
||||
|
||||
bios_enable() {
|
||||
local rc current compatible
|
||||
current="$(bios_current)"
|
||||
bios_prepare; rc=$?
|
||||
[[ $rc -eq 2 ]] && return 0
|
||||
[[ $rc -ne 0 ]] && return 1
|
||||
|
||||
local yes="$c_green$c_bold" b="$c_bold" n="$c_reset"
|
||||
if [[ "$BIOS_COMPATIBLE" == dry-run ]]; then
|
||||
compatible="${c_yellow}${b}not checked${n} (dry run)"
|
||||
else
|
||||
compatible="${yes}yes${n} (checked by fwupd)"
|
||||
fi
|
||||
bios_disclaimer "WARNING: BIOS UPDATE - ENTIRELY AT YOUR OWN RISK" \
|
||||
"Current BIOS: ${b}$current${n}" \
|
||||
"New BIOS: ${b}$BIOS_NEWEST${n}" \
|
||||
"Checksum: ${yes}OK${n} (Valve's package)" \
|
||||
"Compatible: $compatible"
|
||||
if ! ask_yn "Do you understand the risks and want to continue?" n; then
|
||||
info "BIOS update cancelled; nothing was changed."; rm -rf "$tmp"; return 0
|
||||
info "BIOS update cancelled; nothing was changed."; rm -rf "$BIOS_STAGE_DIR"; return 0
|
||||
fi
|
||||
bios_disclaimer "LAST CHANCE: THIS FLASHES BIOS $BIOS_NEWEST" \
|
||||
"After this, keep the power on until the machine has fully" \
|
||||
@@ -176,28 +224,10 @@ bios_enable() {
|
||||
local reply
|
||||
read -rp "$(echo -e "${c_red}${c_bold}Type UPDATE (in capitals) to flash the BIOS, anything else cancels:${c_reset} ")" reply
|
||||
if [[ "$reply" != UPDATE ]]; then
|
||||
info "BIOS update cancelled; nothing was changed."; rm -rf "$tmp"; return 0
|
||||
info "BIOS update cancelled; nothing was changed."; rm -rf "$BIOS_STAGE_DIR"; return 0
|
||||
fi
|
||||
|
||||
if [[ -n "$BIOS_DRY_RUN" ]]; then
|
||||
ok "Dry run: would run: fwupdmgr install -y --no-reboot-check $(basename "$BIOS_CAB")"
|
||||
ok "Dry run finished; nothing was flashed."
|
||||
# Treated as staged, so the restart choices that follow a real
|
||||
# update show up too; restarting only prints (see restart_now).
|
||||
BIOS_NEEDS_RESTART=1
|
||||
rm -rf "$tmp"
|
||||
return 0
|
||||
fi
|
||||
info "Handing BIOS $BIOS_NEWEST to fwupd. Do NOT turn off the power from now on."
|
||||
# -y: we already asked twice; --no-reboot-check: the wizard's own
|
||||
# restart question comes at the end.
|
||||
if ! sudo fwupdmgr install -y --no-reboot-check "$tmp/$BIOS_CAB"; then
|
||||
err "fwupd could not install the BIOS update (see above); the BIOS was not changed."
|
||||
rm -rf "$tmp"
|
||||
return 1
|
||||
fi
|
||||
rm -rf "$tmp"
|
||||
BIOS_NEEDS_RESTART=1
|
||||
ok "BIOS $BIOS_NEWEST is staged. It is written during the next restart:"
|
||||
warn "keep the power on and don't touch the machine until it has fully started again."
|
||||
bios_flash
|
||||
}
|
||||
|
||||
# For the app (lib/backend.sh): the flash step alone, after its warnings.
|
||||
bios_flash_only() { bios_flash; }
|
||||
+132
-11
@@ -19,6 +19,9 @@ HDMI_INITRAMFS_CONF=/etc/mkinitcpio.conf.d/90-steamify-edid.conf
|
||||
HDMI_CONFIRM_SECONDS="${WIZARD_HDMI_CONFIRM_SECONDS:-15}"
|
||||
# amdgpu's HDMI 2.0 limit; the display's own limit (from its EDID) may be lower.
|
||||
HDMI_TMDS_MAX_KHZ=600000
|
||||
# Rates picked and tested in the app ("<output>=<w>x<h>:<rate>,..."), set by
|
||||
# the backend's apply --hdmi; empty in the terminal menu, which tests itself.
|
||||
HDMI_CHOICE=()
|
||||
|
||||
hdmi_connectors() {
|
||||
# Connected HDMI outputs, as "<card>-<connector>" (e.g. card1-HDMI-A-1).
|
||||
@@ -173,6 +176,36 @@ elif cmd == 'plan':
|
||||
steps = sorted({top // 10 * 10, top // 100 * 100})
|
||||
steps = [s for s in steps if s > best + 1 and not any(abs(s - x) < 1 for x in listed)]
|
||||
print(lim // 1000, round(best, 2), *steps)
|
||||
elif cmd == 'options':
|
||||
# JSON for the app: the display's name, the HDMI limit and the rates to
|
||||
# offer at w x h: its own modes the kernel skips ("monitor"), calculated
|
||||
# tens up to the limit ("calculated", ticked) and the exact limit when
|
||||
# that's no ten ("limit", not ticked).
|
||||
import json
|
||||
e, k = load(args[0]), load(args[1]); w, h, cur = int(args[2]), int(args[3]), float(args[4])
|
||||
lim, t = tmds_khz(e), shortest(e, w, h)
|
||||
name = ''
|
||||
for o in range(54, 126, 18):
|
||||
if e[o:o + 3] == b'\0\0\0' and e[o + 3] == 0xfc:
|
||||
name = e[o + 5:o + 18].split(b'\n')[0].decode('ascii', 'replace').strip()
|
||||
rates = []
|
||||
if t:
|
||||
fits = [x for x in timings(e) if x[2] == w and x[7] == h and x[1] <= lim]
|
||||
known = [x[0] for x in timings(k) if x[2] == w and x[7] == h]
|
||||
listed = [x[0] for x in fits]
|
||||
best = max(listed + [cur])
|
||||
for x in sorted(fits):
|
||||
if x[0] > cur + 1 and not any(abs(x[0] - y) < 0.5 for y in known):
|
||||
rates.append({'hz': round(x[0], 2), 'mhz': round(x[1] / 1000, 1), 'kind': 'monitor', 'pick': True})
|
||||
size = (t[2] + t[3]) * (t[7] + t[8])
|
||||
vmax = max_vrefresh(e) or int(max(listed + [cur]))
|
||||
top = min(int(lim * 1000 // size), vmax)
|
||||
tens = [r for r in range(10, top + 1, 10) if r > best + 1 and not any(abs(r - y) < 1 for y in listed)][-3:]
|
||||
for r in tens:
|
||||
rates.append({'hz': r, 'mhz': round(size * r / 1e6, 1), 'kind': 'calculated', 'pick': True})
|
||||
if top % 10 and top > best + 1:
|
||||
rates.append({'hz': top, 'mhz': round(size * top / 1e6, 1), 'kind': 'limit', 'pick': False})
|
||||
print(json.dumps({'name': name, 'limit': lim // 1000, 'rates': rates}))
|
||||
elif cmd == 'build':
|
||||
e = load(args[0]); w, h = int(args[2]), int(args[3]); rates = [int(r) for r in args[4:]]
|
||||
t = shortest(e, w, h)
|
||||
@@ -278,7 +311,8 @@ hdmi_set_mode() {
|
||||
kscreen-doctor "output.$1.mode.$id" >/dev/null 2>&1 || return 1
|
||||
sleep 2
|
||||
rate="$(hdmi_kscreen current "$1" | cut -d' ' -f3)"
|
||||
[[ "${rate%.*}" -ge $(( $4 - 1 )) ]]
|
||||
# Whole hertz: the rate asked for can be e.g. 59.97.
|
||||
[[ "${rate%.*}" -ge $(( ${4%.*} - 1 )) ]]
|
||||
}
|
||||
|
||||
hdmi_confirm() {
|
||||
@@ -370,9 +404,89 @@ hdmi_tune() {
|
||||
HDMI_RESULT="$w $h ${ok_rates[*]}"
|
||||
}
|
||||
|
||||
# --- For the app: options, live tries, and installing what was confirmed ---
|
||||
# The EDIDs read by hdmi_options, reused by the tries and the install.
|
||||
HDMI_CACHE="${XDG_RUNTIME_DIR:-/tmp}/steamify-hdmi"
|
||||
|
||||
hdmi_options() {
|
||||
# JSON array: per connected HDMI output its name, mode and rate options.
|
||||
local conn out cur w h hz opts sep=""
|
||||
mkdir -p "$HDMI_CACHE"
|
||||
printf '['
|
||||
for conn in $(hdmi_connectors); do
|
||||
out="${conn#card*-}"
|
||||
# A live EDID left by an earlier try would look like the display's own.
|
||||
hdmi_status || hdmi_override_live "$conn" reset 2>/dev/null
|
||||
cur="$(hdmi_kscreen current "$out")"
|
||||
[[ -n "$cur" ]] || continue
|
||||
read -r w h hz <<< "$cur"
|
||||
hdmi_read_edid "$conn" "$HDMI_CACHE/$out.orig" || continue
|
||||
opts="$(hdmi_edid_tool options "$HDMI_CACHE/$out.orig" "/sys/class/drm/$conn/edid" "$w" "$h" "$hz")" || continue
|
||||
# Every rate on offer loaded now, while the list is on screen: a try
|
||||
# is then only a mode switch (the current mode stays in the EDID).
|
||||
hdmi_status || hdmi_preload "$conn" "$w" "$h" "$opts"
|
||||
printf '%s{"connector":"%s","output":"%s","width":%s,"height":%s,"hz":%s,"info":%s}' "$sep" "$conn" "$out" "$w" "$h" "$hz" "$opts"
|
||||
sep=","
|
||||
done
|
||||
printf ']'
|
||||
}
|
||||
|
||||
hdmi_preload() {
|
||||
# hdmi_preload <card-connector> <w> <h> <options json>: load the EDID
|
||||
# with the calculated rates, as hdmi_try would on the first try.
|
||||
local conn="$1" out="${1#card*-}"
|
||||
local -a rates
|
||||
read -ra rates <<< "$(python3 -c 'import json, sys
|
||||
print(" ".join(str(r["hz"]) for r in json.loads(sys.argv[1])["rates"] if r["kind"] != "monitor"))' "$4")"
|
||||
hdmi_edid_tool build "$HDMI_CACHE/$out.orig" "$HDMI_CACHE/$out.test" "$2" "$3" "${rates[@]}" &&
|
||||
hdmi_override_live "$conn" "$HDMI_CACHE/$out.test" &&
|
||||
cp "$HDMI_CACHE/$out.test" "$HDMI_CACHE/$out.loaded"
|
||||
}
|
||||
|
||||
hdmi_try() {
|
||||
# hdmi_try <card-connector> <w> <h> <hz> [<rate>...]: switch to hz, with
|
||||
# an EDID holding the given calculated rates (loaded once per set).
|
||||
local conn="$1" w="$2" h="$3" hz="$4" out="${1#card*-}"
|
||||
shift 4
|
||||
[[ -f "$HDMI_CACHE/$out.orig" ]] || hdmi_read_edid "$conn" "$HDMI_CACHE/$out.orig"
|
||||
hdmi_edid_tool build "$HDMI_CACHE/$out.orig" "$HDMI_CACHE/$out.test" "$w" "$h" "$@" || return 1
|
||||
if ! cmp -s "$HDMI_CACHE/$out.test" "$HDMI_CACHE/$out.loaded" 2>/dev/null; then
|
||||
hdmi_override_live "$conn" "$HDMI_CACHE/$out.test" || return 1
|
||||
cp "$HDMI_CACHE/$out.test" "$HDMI_CACHE/$out.loaded"
|
||||
fi
|
||||
hdmi_set_mode "$out" "$w" "$h" "$hz"
|
||||
}
|
||||
|
||||
hdmi_reset() {
|
||||
# hdmi_reset <card-connector> <w> <h> <hz>: the display's own EDID again.
|
||||
hdmi_override_live "$1" reset
|
||||
rm -f "$HDMI_CACHE/${1#card*-}.loaded"
|
||||
hdmi_set_mode "${1#card*-}" "$2" "$3" "$4" >/dev/null
|
||||
}
|
||||
|
||||
hdmi_choose() {
|
||||
# For HDMI_CHOICE ("<output>=<w>x<h>:<rate>,..." from the app, rates it
|
||||
# confirmed): builds the final EDIDs in <tmpdir> like hdmi_tune does.
|
||||
local tmp="$1" item out mode rates w h
|
||||
local -a r
|
||||
for item in "${HDMI_CHOICE[@]}"; do
|
||||
out="${item%%=*}"; mode="${item#*=}"; rates="${mode#*:}"; mode="${mode%%:*}"
|
||||
w="${mode%x*}"; h="${mode#*x}"
|
||||
IFS=',' read -ra r <<< "$rates"
|
||||
HDMI_CHOSEN_CONN="$(basename /sys/class/drm/card*-"$out")"
|
||||
if [[ -f "$HDMI_CACHE/$out.orig" ]]; then cp "$HDMI_CACHE/$out.orig" "$tmp/$out.orig"
|
||||
else hdmi_read_edid "$HDMI_CHOSEN_CONN" "$tmp/$out.orig" || return 1; fi
|
||||
hdmi_edid_tool build "$tmp/$out.orig" "$tmp/$out.final" "$w" "$h" "${r[@]}" &&
|
||||
hdmi_override_live "$HDMI_CHOSEN_CONN" "$tmp/$out.final" || return 1
|
||||
[[ ${#r[@]} -gt 0 ]] && hdmi_set_mode "$out" "$w" "$h" "${r[-1]}" >/dev/null
|
||||
HDMI_RESULTS+=("$out|$w $h ${r[*]}")
|
||||
done
|
||||
}
|
||||
|
||||
hdmi_enable() {
|
||||
if [[ "${BACKEND:-false}" == true ]]; then
|
||||
err "HDMI refresh boost needs you at the screen: run it from the Steamify menu in Konsole."
|
||||
local -a HDMI_RESULTS=()
|
||||
if [[ "${BACKEND:-false}" == true && ${#HDMI_CHOICE[@]} -eq 0 ]]; then
|
||||
err "HDMI refresh boost needs you at the screen: pick and test the rates in the app first."
|
||||
return 1
|
||||
fi
|
||||
if [[ "${XDG_CURRENT_DESKTOP:-}" != *KDE* ]] || ! command -v kscreen-doctor >/dev/null; then
|
||||
@@ -389,24 +503,31 @@ hdmi_enable() {
|
||||
ok "Already set up for the connected display(s). Untick and tick it again to re-test."
|
||||
return 0
|
||||
fi
|
||||
warn "Set up for another display; testing the one connected now."
|
||||
warn "Set up for another display; setting up the one connected now."
|
||||
hdmi_disable || return 1
|
||||
fi
|
||||
sudo pacman -S --needed --noconfirm i2c-tools python >/dev/null 2>&1 || { err "Installing i2c-tools failed."; return 1; }
|
||||
|
||||
local tmp conn out param="" files=() rc=0
|
||||
local tmp conn out param="" files=() rc=0 res
|
||||
tmp="$(mktemp -d)"
|
||||
for conn in $(hdmi_connectors); do
|
||||
out="${conn#card*-}"
|
||||
hdmi_tune "$conn" "$tmp" || { rc=1; continue; }
|
||||
[[ -n "$HDMI_RESULT" ]] || continue
|
||||
if [[ ${#HDMI_CHOICE[@]} -gt 0 ]]; then
|
||||
# Picked and tested in the app.
|
||||
hdmi_choose "$tmp" || rc=1
|
||||
else
|
||||
for conn in $(hdmi_connectors); do
|
||||
hdmi_tune "$conn" "$tmp" || { rc=1; continue; }
|
||||
[[ -n "$HDMI_RESULT" ]] && HDMI_RESULTS+=("${conn#card*-}|$HDMI_RESULT")
|
||||
done
|
||||
fi
|
||||
for res in "${HDMI_RESULTS[@]}"; do
|
||||
out="${res%%|*}"
|
||||
sudo install -Dm644 "$tmp/$out.final" "$HDMI_FW_DIR/steamify-$out.bin" || { rc=1; continue; }
|
||||
state_set hdmi "$out" "$(hdmi_edid_tool id "$tmp/$out.orig")"
|
||||
state_set hdmi "$out-mode" "$HDMI_RESULT"
|
||||
state_set hdmi "$out-mode" "${res#*|}"
|
||||
param+="${param:+,}$out:edid/steamify-$out.bin"
|
||||
files+=("$HDMI_FW_DIR/steamify-$out.bin")
|
||||
done
|
||||
rm -rf "$tmp"
|
||||
rm -rf "$tmp" "$HDMI_CACHE"
|
||||
[[ -n "$param" ]] || return $rc
|
||||
|
||||
# amdgpu loads from the initramfs (the kms hook), before the root
|
||||
|
||||
+19
-5
@@ -15,20 +15,25 @@
|
||||
set -uo pipefail
|
||||
|
||||
# Release version, see CHANGELOG.md.
|
||||
VERSION=1.2.0
|
||||
VERSION=2.0.0
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
for lib in common state packages login-manager single-user steam-desktop steam-machine hdmi-refresh cec boot-session vapor-theme steamos-extras bios desktop-shortcut wizard-shortcut menu; do
|
||||
for lib in common state packages login-manager single-user steam-desktop steam-machine hdmi-refresh cec boot-session vapor-theme steamos-extras bios desktop-shortcut wizard-shortcut menu backend; do
|
||||
# shellcheck source=/dev/null
|
||||
source "$SCRIPT_DIR/lib/$lib.sh"
|
||||
done
|
||||
|
||||
require_root_helper
|
||||
|
||||
echo -e "${c_bold}Steamify CachyOS${c_reset} v$VERSION"
|
||||
echo "Turn the SteamOS-style parts on or off. The menu shows what is on now;"
|
||||
echo "anything you turn off is put back the way it was."
|
||||
BACKEND=false
|
||||
[[ "${1:-}" == --backend ]] && BACKEND=true
|
||||
|
||||
if [[ "$BACKEND" == false ]]; then
|
||||
echo -e "${c_bold}Steamify CachyOS${c_reset} v$VERSION"
|
||||
echo "Turn the SteamOS-style parts on or off. The menu shows what is on now;"
|
||||
echo "anything you turn off is put back the way it was."
|
||||
fi
|
||||
|
||||
if ! command -v pacman >/dev/null 2>&1; then
|
||||
err "This doesn't look like an Arch/CachyOS system (no pacman found). Aborting."
|
||||
@@ -98,6 +103,15 @@ quit_prompt() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# The graphical app (steamify-ui) drives the same components through
|
||||
# lib/backend.sh instead of the menu.
|
||||
if [[ "$BACKEND" == true ]]; then
|
||||
RESTART_FOR_LOGIN=false
|
||||
shift
|
||||
backend_main "$@"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# Menu loop: after each run the menu comes back with the new state, until
|
||||
# the user quits; the restart question comes then, once, for everything.
|
||||
RESTART_FOR_LOGIN=false
|
||||
|
||||
+1010
File diff suppressed because it is too large.
Load diff
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
# Steamify CachyOS - get and start the app (v2), for `curl | bash`:
|
||||
# curl -fsSL https://github.com/theupriser/steamify-cachyos/releases/latest/download/steamify-app.sh | bash
|
||||
# Downloads the newest release's app package (steamify-app.tar.gz: the app,
|
||||
# steamify.sh and lib/) to ~/.local/share/steamify/app, installs PySide6 if
|
||||
# it's missing, adds a launcher entry, and starts the app.
|
||||
# STEAMIFY_RELEASE=<tag> takes that release instead of the newest (the
|
||||
# v2-ui-preview build's copy of this script sets it). STEAMIFY_BRANCH=<branch>
|
||||
# takes that branch's source instead (for testing before a build).
|
||||
set -euo pipefail
|
||||
|
||||
REPO="theupriser/steamify-cachyos"
|
||||
RELEASE="${STEAMIFY_RELEASE:-latest}"
|
||||
DEST="${XDG_DATA_HOME:-$HOME/.local/share}/steamify/app"
|
||||
APPS="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||||
|
||||
say() { printf '\033[36m[INFO]\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[31m[ERROR] %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
[[ $EUID -ne 0 ]] || die "Run this as your normal user, not as root."
|
||||
command -v pacman >/dev/null || die "This is for CachyOS/Arch (no pacman found)."
|
||||
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
||||
if [[ -n "${STEAMIFY_BRANCH:-}" ]]; then
|
||||
say "Downloading Steamify from the $STEAMIFY_BRANCH branch..."
|
||||
curl -fsSL "https://github.com/$REPO/archive/refs/heads/$STEAMIFY_BRANCH.tar.gz" -o "$tmp/src.tar.gz" ||
|
||||
die "Couldn't download the $STEAMIFY_BRANCH branch."
|
||||
mkdir "$tmp/app"
|
||||
tar -xzf "$tmp/src.tar.gz" -C "$tmp/app" --strip-components=1
|
||||
else
|
||||
if [[ "$RELEASE" == latest ]]; then
|
||||
url="https://github.com/$REPO/releases/latest/download/steamify-app.tar.gz"
|
||||
say "Downloading the newest Steamify app..."
|
||||
else
|
||||
url="https://github.com/$REPO/releases/download/$RELEASE/steamify-app.tar.gz"
|
||||
say "Downloading the Steamify app ($RELEASE)..."
|
||||
fi
|
||||
curl -fsSL "$url" -o "$tmp/app.tar.gz" ||
|
||||
die "Couldn't download the app (is there a release with steamify-app.tar.gz yet?)."
|
||||
mkdir "$tmp/app"
|
||||
tar -xzf "$tmp/app.tar.gz" -C "$tmp/app"
|
||||
fi
|
||||
[[ -x "$tmp/app/ui/steamify-ui" && -f "$tmp/app/steamify.sh" ]] || die "The download doesn't contain the app."
|
||||
|
||||
mkdir -p "$(dirname "$DEST")"
|
||||
rm -rf "$DEST.new"; mv "$tmp/app" "$DEST.new"
|
||||
rm -rf "$DEST"; mv "$DEST.new" "$DEST"
|
||||
say "Installed to $DEST ($(grep -m1 -oP '^VERSION=\K.*' "$DEST/steamify.sh"))."
|
||||
|
||||
if ! python3 -c 'import PySide6.QtQml' 2>/dev/null; then
|
||||
say "The app needs PySide6 (Qt for Python); installing it..."
|
||||
# Piped in via curl, stdin isn't the terminal: sudo asks on it anyway.
|
||||
sudo pacman -S --needed --noconfirm pyside6 </dev/tty || die "Installing pyside6 failed."
|
||||
fi
|
||||
|
||||
# Launcher entry (its id also names the app to the desktop portal).
|
||||
mkdir -p "$APPS"
|
||||
cat > "$APPS/steamify-ui.desktop" << EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Steamify CachyOS
|
||||
Comment=Turn this PC into a SteamOS-style console
|
||||
Exec=$DEST/ui/steamify-ui
|
||||
Icon=$DEST/assets/steam-gaming-settings.svg
|
||||
Terminal=false
|
||||
Categories=Game;Settings;
|
||||
EOF
|
||||
command -v update-desktop-database >/dev/null && update-desktop-database "$APPS" 2>/dev/null || true
|
||||
|
||||
say "Starting Steamify..."
|
||||
setsid "$DEST/ui/steamify-ui" >/dev/null 2>&1 < /dev/null &
|
||||
Executable
+369
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Steamify CachyOS - graphical app (v2).
|
||||
|
||||
A controller-, TV remote- and keyboard-friendly front end for steamify.sh:
|
||||
the components and their logic stay in lib/*.sh, reached through
|
||||
`steamify.sh --backend status|apply` (lib/backend.sh). Needs PySide6
|
||||
(pacman -S pyside6).
|
||||
|
||||
steamify-ui [--windowed|--fullscreen]
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import stat
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
try:
|
||||
from PySide6.QtCore import (QObject, QProcess, QProcessEnvironment,
|
||||
Property, Signal, Slot, QUrl, SLOT)
|
||||
from PySide6.QtGui import QGuiApplication, QIcon
|
||||
from PySide6.QtQml import QQmlApplicationEngine
|
||||
from PySide6.QtDBus import QDBusConnection
|
||||
except ImportError:
|
||||
msg = ("Steamify's app needs PySide6 (Qt for Python).\n"
|
||||
"Install it with: sudo pacman -S pyside6\n"
|
||||
"or run the terminal version: steamify.sh")
|
||||
print(msg, file=sys.stderr)
|
||||
if shutil.which("kdialog"):
|
||||
os.system("kdialog --title Steamify --sorry " + json.dumps(msg))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def find_script():
|
||||
"""steamify.sh next to the app: the repository (../steamify.sh) or an
|
||||
install that keeps both in one folder."""
|
||||
for p in (os.path.join(HERE, "..", "steamify.sh"),
|
||||
os.path.join(HERE, "steamify.sh")):
|
||||
if os.path.isfile(p):
|
||||
return os.path.realpath(p)
|
||||
return None
|
||||
|
||||
|
||||
# --- Controller input -------------------------------------------------------
|
||||
# Qt 6 has no gamepad module: read the evdev device directly. Controllers
|
||||
# are tagged uaccess, so the logged-in user may read them.
|
||||
|
||||
EV_KEY, EV_ABS = 0x01, 0x03
|
||||
BUTTONS = {0x130: "a", 0x131: "b", 0x133: "y", 0x134: "x", # SOUTH EAST NORTH WEST
|
||||
0x13a: "select", 0x13b: "start", 0x13c: "guide",
|
||||
0x220: "up", 0x221: "down", 0x222: "left", 0x223: "right"} # BTN_DPAD_*
|
||||
ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y = 0x00, 0x01, 0x10, 0x11
|
||||
EVENT = struct.Struct("llHHi")
|
||||
|
||||
|
||||
def controller_devices():
|
||||
"""/dev/input/eventN of every device that has a gamepad's south button."""
|
||||
found = []
|
||||
base = "/sys/class/input"
|
||||
for name in sorted(os.listdir(base)):
|
||||
if not name.startswith("event"):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(base, name, "device", "capabilities", "key")) as f:
|
||||
words = f.read().split()
|
||||
except OSError:
|
||||
continue
|
||||
bits = 0
|
||||
for w in words:
|
||||
bits = (bits << 64) | int(w, 16)
|
||||
if bits >> 0x130 & 1:
|
||||
found.append("/dev/input/" + name)
|
||||
return found
|
||||
|
||||
|
||||
class Gamepad(QObject):
|
||||
"""Emits button("a"|"b"|"x"|"y"|"up"|...) from any controller, and
|
||||
"<name>_up" when a button is released."""
|
||||
button = Signal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._stop = False
|
||||
threading.Thread(target=self._run, daemon=True).start()
|
||||
|
||||
def _run(self):
|
||||
files = {}
|
||||
stick = {ABS_X: 0, ABS_Y: 0}
|
||||
last_scan = 0
|
||||
while not self._stop:
|
||||
if time.time() - last_scan > 2: # plugged in or out
|
||||
last_scan = time.time()
|
||||
for path in controller_devices():
|
||||
if path not in files:
|
||||
try:
|
||||
files[path] = open(path, "rb", buffering=0)
|
||||
except OSError:
|
||||
pass
|
||||
if not files:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
try:
|
||||
ready, _, _ = select.select(list(files.values()), [], [], 0.5)
|
||||
except (OSError, ValueError):
|
||||
ready = []
|
||||
for f in ready:
|
||||
try:
|
||||
data = f.read(EVENT.size * 32)
|
||||
except OSError:
|
||||
data = b""
|
||||
if not data:
|
||||
files = {p: x for p, x in files.items() if x is not f}
|
||||
continue
|
||||
for off in range(0, len(data) - EVENT.size + 1, EVENT.size):
|
||||
_, _, typ, code, value = EVENT.unpack_from(data, off)
|
||||
if typ == EV_KEY and code in BUTTONS and value in (0, 1):
|
||||
# Presses as "a"; releases as "a_up" (the BIOS
|
||||
# confirmation is a 5-second hold).
|
||||
self.button.emit(BUTTONS[code] + ("" if value else "_up"))
|
||||
elif typ == EV_ABS and code in (ABS_HAT0X, ABS_HAT0Y) and value:
|
||||
self.button.emit({(ABS_HAT0X, -1): "left", (ABS_HAT0X, 1): "right",
|
||||
(ABS_HAT0Y, -1): "up", (ABS_HAT0Y, 1): "down"}[(code, value)])
|
||||
elif typ == EV_ABS and code in stick:
|
||||
# Left stick as a D-pad: one step per push past half-way.
|
||||
was = stick[code]
|
||||
now = -1 if value < -16000 else (1 if value > 16000 else 0)
|
||||
stick[code] = now
|
||||
if now and not was:
|
||||
self.button.emit({(ABS_X, -1): "left", (ABS_X, 1): "right",
|
||||
(ABS_Y, -1): "up", (ABS_Y, 1): "down"}[(code, now)])
|
||||
|
||||
|
||||
# --- Backend ----------------------------------------------------------------
|
||||
|
||||
class Backend(QObject):
|
||||
statusChanged = Signal()
|
||||
event = Signal("QVariant") # one parsed JSON event from apply
|
||||
busyChanged = Signal()
|
||||
remotePressed = Signal() # a key from the TV remote (cecd)
|
||||
|
||||
def __init__(self, script):
|
||||
super().__init__()
|
||||
self._script = script
|
||||
self._status = {}
|
||||
self._busy = False
|
||||
self._proc = None
|
||||
self._buf = b""
|
||||
self._secret_dir = None
|
||||
# The TV remote's keys reach Qt as keys; cecd also announces them on
|
||||
# D-Bus, which tells the app they came from the remote.
|
||||
try:
|
||||
QDBusConnection.sessionBus().connect(
|
||||
"com.steampowered.CecDaemon1", "", "com.steampowered.CecDaemon1.CecDevice1",
|
||||
"UserControlPressed", self, SLOT("_on_remote()"))
|
||||
except Exception as e: # no remote detection; keys still work
|
||||
print("steamify-ui: no CEC remote detection:", e, file=sys.stderr)
|
||||
|
||||
@Slot()
|
||||
def _on_remote(self):
|
||||
self.remotePressed.emit()
|
||||
|
||||
def _get_status(self):
|
||||
return self._status
|
||||
|
||||
status = Property("QVariant", _get_status, notify=statusChanged)
|
||||
|
||||
def _get_busy(self):
|
||||
return self._busy
|
||||
|
||||
busy = Property(bool, _get_busy, notify=busyChanged)
|
||||
|
||||
@Slot(result=str)
|
||||
def user(self):
|
||||
return os.environ.get("USER") or os.getlogin()
|
||||
|
||||
@Slot()
|
||||
def refresh(self):
|
||||
p = QProcess(self)
|
||||
p.setProgram("bash")
|
||||
p.setArguments([self._script, "--backend", "status"])
|
||||
|
||||
def done(*_):
|
||||
try:
|
||||
self._status = json.loads(bytes(p.readAllStandardOutput()).decode())
|
||||
except ValueError:
|
||||
self._status = {"error": bytes(p.readAllStandardError()).decode()[-500:]}
|
||||
self.statusChanged.emit()
|
||||
p.deleteLater()
|
||||
p.finished.connect(done)
|
||||
p.start()
|
||||
|
||||
def _make_askpass(self, password):
|
||||
"""A private folder with the password, a helper that prints it for
|
||||
`sudo -A`, and a `sudo` that always uses the helper (makepkg and
|
||||
yay call plain sudo)."""
|
||||
base = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir()
|
||||
d = tempfile.mkdtemp(prefix="steamify-", dir=base)
|
||||
os.chmod(d, stat.S_IRWXU)
|
||||
secret = os.path.join(d, "secret")
|
||||
fd = os.open(secret, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(password + "\n")
|
||||
askpass = os.path.join(d, "askpass")
|
||||
with open(askpass, "w") as f:
|
||||
f.write("#!/bin/sh\nexec cat %s\n" % json.dumps(secret))
|
||||
os.chmod(askpass, 0o700)
|
||||
bindir = os.path.join(d, "bin")
|
||||
os.mkdir(bindir)
|
||||
wrapper = os.path.join(bindir, "sudo")
|
||||
with open(wrapper, "w") as f:
|
||||
f.write("#!/bin/sh\nexec /usr/bin/sudo -A \"$@\"\n")
|
||||
os.chmod(wrapper, 0o700)
|
||||
return d, askpass, bindir
|
||||
|
||||
def _drop_askpass(self):
|
||||
if self._secret_dir:
|
||||
shutil.rmtree(self._secret_dir, ignore_errors=True)
|
||||
self._secret_dir = None
|
||||
|
||||
def _start(self, args, password):
|
||||
if self._busy:
|
||||
return
|
||||
env = QProcessEnvironment.systemEnvironment()
|
||||
if password:
|
||||
self._secret_dir, askpass, bindir = self._make_askpass(password)
|
||||
env.insert("SUDO_ASKPASS", askpass)
|
||||
env.insert("PATH", bindir + ":" + env.value("PATH"))
|
||||
p = QProcess(self)
|
||||
p.setProcessEnvironment(env)
|
||||
p.setProcessChannelMode(QProcess.MergedChannels)
|
||||
p.setProgram("bash")
|
||||
p.setArguments([self._script, "--backend"] + args)
|
||||
self._buf = b""
|
||||
p.readyReadStandardOutput.connect(lambda: self._read(p))
|
||||
p.finished.connect(lambda *_: self._finished(p))
|
||||
self._proc = p
|
||||
self._busy = True
|
||||
self.busyChanged.emit()
|
||||
p.start()
|
||||
|
||||
@Slot("QVariant", str, bool, str, str)
|
||||
def apply(self, ids, boot, reapply, password, hdmi):
|
||||
try:
|
||||
if hasattr(ids, "toVariant"): # a JS array from QML
|
||||
ids = ids.toVariant()
|
||||
args = ["apply"]
|
||||
if reapply:
|
||||
args.append("--reapply")
|
||||
if boot:
|
||||
args += ["--boot", boot]
|
||||
if hdmi: # rates confirmed on the HDMI screen
|
||||
args += ["--hdmi", hdmi]
|
||||
self._start(args + [str(i) for i in (ids or [])], password)
|
||||
except Exception as e:
|
||||
# Never leave the app on the progress screen.
|
||||
print("steamify-ui: couldn't start:", e, file=sys.stderr)
|
||||
self._drop_askpass()
|
||||
self.event.emit({"event": "finished", "failed": [], "restart": False, "error": "start"})
|
||||
|
||||
@Slot(str)
|
||||
def biosPrepare(self, password):
|
||||
"""Download, checksum, fwupd check: ends with a bios-ready event."""
|
||||
self._start(["bios-prepare"], password)
|
||||
|
||||
@Slot(str)
|
||||
def biosFlash(self, password):
|
||||
"""Only after both warnings in the app."""
|
||||
self._start(["bios-flash"], password)
|
||||
|
||||
@Slot(str)
|
||||
def hdmiOptions(self, password):
|
||||
"""Reads the HDMI displays: ends with an hdmi-options event."""
|
||||
self._start(["hdmi-options"], password)
|
||||
|
||||
@Slot(str, str, int, int, float, str)
|
||||
def hdmiTry(self, password, conn, w, h, hz, rates):
|
||||
"""Switches to hz live: an hdmi-tried event says whether it worked."""
|
||||
self._start(["hdmi-try", conn, str(w), str(h), "%g" % hz] + [r for r in rates.split(",") if r], password)
|
||||
|
||||
@Slot(str, str, int, int, float)
|
||||
def hdmiReset(self, password, conn, w, h, hz):
|
||||
"""The display's own EDID and the old rate again (hdmi-reset event)."""
|
||||
self._start(["hdmi-reset", conn, str(w), str(h), "%g" % hz], password)
|
||||
|
||||
def _read(self, p):
|
||||
self._buf += bytes(p.readAllStandardOutput())
|
||||
while b"\n" in self._buf:
|
||||
line, self._buf = self._buf.split(b"\n", 1)
|
||||
text = line.decode(errors="replace")
|
||||
try:
|
||||
ev = json.loads(text)
|
||||
except ValueError:
|
||||
ev = {"event": "log", "id": "", "line": text}
|
||||
if isinstance(ev, dict):
|
||||
self.event.emit(ev)
|
||||
|
||||
def _finished(self, p):
|
||||
self._read(p)
|
||||
self._drop_askpass()
|
||||
self._busy = False
|
||||
self.busyChanged.emit()
|
||||
p.deleteLater()
|
||||
self.refresh()
|
||||
|
||||
@Slot(str, result=bool)
|
||||
def checkPassword(self, password):
|
||||
"""True if sudo accepts the password (or needs none)."""
|
||||
import subprocess
|
||||
if subprocess.run(["sudo", "-n", "true"], capture_output=True).returncode == 0:
|
||||
return True
|
||||
r = subprocess.run(["sudo", "-S", "-k", "-p", "", "true"],
|
||||
input=(password + "\n").encode(), capture_output=True)
|
||||
return r.returncode == 0
|
||||
|
||||
@Slot(result=bool)
|
||||
def needsPassword(self):
|
||||
import subprocess
|
||||
return subprocess.run(["sudo", "-n", "true"], capture_output=True).returncode != 0
|
||||
|
||||
@Slot()
|
||||
def restart(self):
|
||||
QProcess.startDetached("systemctl", ["reboot"])
|
||||
|
||||
def shutdown(self):
|
||||
if self._proc and self._proc.state() != QProcess.NotRunning:
|
||||
self._proc.kill()
|
||||
self._proc.waitForFinished(3000)
|
||||
self._drop_askpass()
|
||||
|
||||
|
||||
def main():
|
||||
script = find_script()
|
||||
if not script:
|
||||
print("steamify-ui: can't find steamify.sh next to the app.", file=sys.stderr)
|
||||
return 1
|
||||
in_gamescope = bool(os.environ.get("GAMESCOPE_WAYLAND_DISPLAY")) or \
|
||||
os.environ.get("XDG_CURRENT_DESKTOP", "").lower() == "gamescope"
|
||||
fullscreen = "--fullscreen" in sys.argv or (in_gamescope and "--windowed" not in sys.argv)
|
||||
|
||||
app = QGuiApplication(sys.argv)
|
||||
app.setApplicationName("Steamify")
|
||||
app.setDesktopFileName("steamify-ui")
|
||||
icon = os.path.join(HERE, "..", "assets", "steam-gaming-settings.svg")
|
||||
if os.path.isfile(icon):
|
||||
app.setWindowIcon(QIcon(icon))
|
||||
|
||||
backend = Backend(script)
|
||||
pad = Gamepad()
|
||||
engine = QQmlApplicationEngine()
|
||||
engine.setInitialProperties({"backend": backend, "gamepad": pad,
|
||||
"fullscreen": fullscreen,
|
||||
"iconUrl": QUrl.fromLocalFile(os.path.realpath(icon)).toString()})
|
||||
engine.load(os.path.join(HERE, "qml", "Main.qml"))
|
||||
if not engine.rootObjects():
|
||||
return 1
|
||||
app.aboutToQuit.connect(backend.shutdown)
|
||||
backend.refresh()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user