Compare commits

...
32 Commits
Author SHA1 Message Date
theupriser 05a7d50c3f Merge pull request #22 from theupriser/docs/disclaimer
docs: Non-affiliation disclaimer in the README
2026-09-26 10:54:06 +02:00
theupriser badfd846d8 docs: Non-affiliation disclaimer in the README 2026-09-26 10:53:42 +02:00
theupriser ff364ffd69 Merge pull request #21 from theupriser/feat/app-shortcut
feat: The Steamify shortcut opens the app
2026-09-25 21:33:14 +02:00
theupriser 2b3f1de75a feat: The Steamify shortcut opens the app
Steamify CachyOS on the desktop and in the launcher starts the newest app
(in Konsole the first time, while PySide6 still has to be installed); the
terminal menu is Steamify Terminal in the launcher. A shortcut from before
2.0.1 is ticked in the menu, so a normal run replaces it. steamify-app.sh
leaves the shortcut's launcher entry (same id) alone.
2026-09-25 21:32:24 +02:00
theupriser 6d7d5861d6 Merge pull request #20 from theupriser/feat/v2-ui
Feat/v2 UI
2026-09-25 21:24:11 +02:00
theupriser 444ce29e57 Merge remote-tracking branch 'origin/main' into feat/v2-ui
# Conflicts:
#	AGENTS.md
#	CHANGELOG.md
#	lib/hdmi-refresh.sh
#	steamify.sh
2026-09-25 21:21:31 +02:00
theupriser 23ef5ef7ab feat(ui): HDMI refresh boost in the app
Set up… opens a screen with the display, its resolution and the rates on
offer. The display's own faster mode is switched to right away; the other
rates can each be tried live (wait for the picture, keep within 15 s or it
switches back) and ticked; Install installs the ticked ones through
apply --hdmi. All rates are loaded when the screen opens, so a try is only a
mode switch. Also fixes switching back to a rate like 59.97 Hz.
2026-09-25 21:21:13 +02:00
theupriser 178ee3405e fix(ui): The details panel scrolls instead of overflowing
Long texts (e.g. HDMI refresh boost) ran out of the card. The details are
in a Flickable now, which starts at the top for each item.
2026-09-25 20:42:08 +02:00
theupriser 601c05329d chore: Version 2.0.0 for the v2 app 2026-09-25 20:39:57 +02:00
theupriser 8ae9673eb2 Merge pull request #19 from theupriser/feat/hdmi-refresh-boost
Feat/hdmi refresh boost
2026-09-25 20:39:13 +02:00
theupriser dbafc43f2e feat(ui): HDMI refresh boost in the app, off only
The app gets its texts and the same rules as the menu: unticking Steam
Machine support or the kernel pin unticks it. It can't be turned on from
the app, as each step needs someone at the keyboard to confirm the picture.
2026-09-25 20:39:00 +02:00
theupriser ae8ed85fee feat: HDMI refresh boost and Update BIOS under Steam Machine support
Both are sub-options of Steam Machine support now, shown only while it is
ticked. Unticking it unticks them; unticking the kernel pin unticks the
HDMI refresh boost, which only works with the pinned kernel.
2026-09-25 20:37:56 +02:00
theupriser 00bcaea712 feat: HDMI refresh boost and Update BIOS under Steam Machine support
Both are sub-options of Steam Machine support now, shown only while it is
ticked. Unticking it unticks them; unticking the kernel pin unticks the
HDMI refresh boost, which only works with the pinned kernel.
2026-09-25 20:37:47 +02:00
theupriser a898d23b13 feat: HDMI refresh boost (Steam Machine, pinned kernel)
The pinned 7.1.6 kernel doesn't read the extra EDID block (HDMI Forum
EEODB) where monitors list their fast modes, and does no HDMI 2.1, so HDMI
displays often stay at 60 Hz. The new menu item reads the display's EDID
over DDC, calculates the highest rate that fits HDMI 2.0 at the desktop
resolution (rounded down to ten, plus the hundred below as a safe option),
tests each step live with a confirmation, and makes the confirmed ones
permanent through drm.edid_firmware (Limine, sdboot-manage or GRUB).
2026-09-25 20:33:46 +02:00
theupriser 3e53257fe2 feat: HDMI refresh boost (Steam Machine, pinned kernel)
The pinned 7.1.6 kernel doesn't read the extra EDID block (HDMI Forum
EEODB) where monitors list their fast modes, and does no HDMI 2.1, so HDMI
displays often stay at 60 Hz. The new menu item reads the display's EDID
over DDC, calculates the highest rate that fits HDMI 2.0 at the desktop
resolution (rounded down to ten, plus the hundred below as a safe option),
tests each step live with a confirmation, and makes the confirmed ones
permanent through drm.edid_firmware (Limine, sdboot-manage or GRUB).
2026-09-25 20:32:46 +02:00
theupriser a1eab9ac79 feat: Preview builds of feat/v2-ui; the app stretches to the window
Pushes to feat/v2-ui publish the pre-release v2-ui-preview (never latest);
its steamify-app.sh downloads that build (STEAMIFY_RELEASE). The app's
1280x720 layout now stretches to the window's shape, so the bars reach the
edges; re-apply resets when back on the menu.
2026-09-25 16:04:59 +02:00
theupriser c9c16b9eec fix(ui): Review and progress lists scroll; progress follows the running step 2026-09-25 16:02:19 +02:00
theupriser b17addd60f fix: Quote the backend's done event for shellcheck 2026-09-25 16:00:12 +02:00
theupriser ac613f05b3 feat: GitHub builds the app; run it with curl
steamify-app.tar.gz (ui/, steamify.sh, lib/, icon) and its launcher
steamify-app.sh in every release: it installs to ~/.local/share/steamify/app,
installs PySide6 if needed, adds a launcher entry and starts the app.
STEAMIFY_BRANCH=<branch> runs a branch before it's released.
2026-09-25 15:59:47 +02:00
theupriser 3a0eefab11 fix(ui): Applying got stuck: the ticked items came in as a JS value
Convert QML's array; if starting fails anyway, show the done screen with
the reason instead of staying on the progress screen.
2026-09-25 15:56:41 +02:00
theupriser 96558a9ca8 feat(ui): BIOS check runs invisibly, then the warnings, then the progress bar 2026-09-25 15:55:15 +02:00
theupriser 7c48b3d806 feat: BIOS update in the app, with both warnings
bios_enable split into bios_prepare (download, checksum, fwupd check) and
bios_flash; the terminal flow is unchanged. The app: check, warning 1,
warning 2 (type UPDATE, or hold A/OK for 5 seconds), flash, restart.
2026-09-25 15:53:30 +02:00
theupriser 396459b511 fix(ui): Controls aligned on the right, sub-items fold instead of the list jumping 2026-09-25 15:48:38 +02:00
theupriser 43a9541104 feat: Steamify app (v2 UI), first working version
ui/steamify-ui (PySide6 + QML): menu, review, password, applying and done
screens from the design, scaled to the window. Controller via evdev,
keyboard, TV remote (cecd). steamify.sh --backend status|apply (lib/backend.sh)
drives the same components; sudo asks through an askpass helper.
2026-09-25 15:46:02 +02:00
theupriser e09fe1609b Merge pull request #18 from theupriser/fix/no-lock-before-restart
fix: No lock screen in single user mode before a restart
2026-09-25 14:10:52 +02:00
theupriser 9dcac5cf08 fix: No lock screen in single user mode before a restart
The screen locker only read kscreenlockerrc at the next login, so the PC
still locked after idling until then. Reload it over D-Bus.
2026-09-25 14:01:00 +02:00
theupriser f91dd1f0b1 Merge pull request #16 from theupriser/fix/cec-steam-ui
Fix/cec steam UI
2026-09-25 13:18:52 +02:00
theupriser 0411429654 chore: Version 1.1.3 2026-09-25 13:17:02 +02:00
theupriser 1aa5e955fe fix: Repair HDMI-CEC set up by 1.1.0/1.1.1
It only counts as on with the steam-launcher drop-in; installed without it,
the menu ticks it, so a normal run adds it.
2026-09-25 13:16:48 +02:00
theupriser ce75e895c4 fix: Show the HDMI-CEC settings in Steam
CachyOS's gaming mode script sets STEAM_ENABLE_CEC=0. A drop-in for
steam-launcher.service overrides it with a later EnvironmentFile=.
2026-09-25 13:16:48 +02:00
theupriser 9aef0a986a Merge pull request #15 from theupriser/fix/cec-steam-settings
fix: Steam's HDMI-CEC settings reach cecd on a first run
2026-09-25 12:19:54 +02:00
theupriser f7aa2412df fix: Steam's HDMI-CEC settings reach cecd on a first run
HDMI-CEC runs before Steam Machine support, which installs steamos-manager,
so its configure-cecd unit couldn't be enabled. Link them from Steam Machine
support too.
2026-09-25 11:13:53 +02:00
18 changed files with 2657 additions and 97 deletions

No files matched your search

+43 -4
View File
@@ -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
View File
@@ -1 +1,2 @@
dist/
__pycache__/
+21 -4
View File
@@ -142,7 +142,7 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine
`steam-set-session plasma.desktop` plus the plasmalogin sync bridge when it
exists, at every boot. CachyOS's `cachyos-gamescope-autologin` still sets
gamescope during each desktop session; the unit corrects it at boot.
- `bios` is an *action* (`ACTIONS` in `lib/menu.sh`), not an on/off
- `bios` is an *action* (`ACTIONS` in `lib/menu.sh`) and a sub-option of `machine`, not an on/off
component: never preselected (not even on a first run), never re-applied
by `a`, not listed in the state overview, and `bios_status` is always off.
Only selectable when Valve's version differs from the installed one
@@ -161,9 +161,14 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine
now? [Y/n]" (`quit_prompt`): `n` goes back to the menu, the next `q` asks
again. Scripted input that runs out quits without restarting (never
restart on EOF: `ask_yn` would take its default).
- Steamify shortcut (`launcher`): the icon runs `curl | bash` of
`releases/latest/download/steamify.sh` in Konsole, so it's
always the newest release. Its icon, `assets/steam-gaming-settings.svg`
- Steamify shortcut (`launcher`): "Steamify CachyOS" (desktop and launcher,
id `steamify-ui.desktop`, the app's own id) runs `curl | bash` of
`releases/latest/download/steamify-app.sh` without a terminal (in Konsole
only while PySide6 is missing, for sudo); "Steamify Terminal" (launcher)
runs `steamify.sh` in Konsole. Both always the newest release. The entry
has `X-Steamify-Shortcut=true`: steamify-app.sh doesn't overwrite it, and
disable only removes it then. A pre-2.0.1 shortcut (terminal only) is
ticked by `launcher_repair`. Its icon, `assets/steam-gaming-settings.svg`
(Valve's GPL-2.0 return icon with a gear), is a release asset too, and is
downloaded from there (Steam's icon if that fails). Desktop files are
written with their mode already set (`install_executable`): Plasma opens a
@@ -171,6 +176,18 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine
The start script closes its window after a 10-second countdown on success
and waits for Enter after an error; it uses `pipefail`, or a failed
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. 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
`sed` uses `|` as its delimiter.
- `Relogin=true` means a gamescope that fails to start is relaunched in a
tight loop; keep that in mind when changing session handling.
+76
View File
@@ -5,6 +5,82 @@ 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.2 - 2026-09-26
- **docs: Non-affiliation disclaimer in the README**
- States that the project isn't affiliated with Valve or CachyOS, and that
Steam and SteamOS are Valve's trademarks.
## 2.0.1 - 2026-09-25
- **2b3f1de feat: The Steamify shortcut opens the app**
- "Steamify CachyOS" on the desktop and in the launcher starts the newest
app (in Konsole the first time, if PySide6 still has to be installed).
The terminal menu is "Steamify Terminal" in the launcher. A shortcut from
before 2.0.1 is ticked in the menu, so a normal run replaces it.
- steamify-app.sh leaves the shortcut's launcher entry alone (it has the
same id, `steamify-ui.desktop`, and always starts the newest release).
## 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)**
- The pinned 7.1.6 kernel doesn't read the extra EDID block where monitors
list their fast modes, and does no HDMI 2.1, so HDMI displays often stay
at 60 Hz. The new menu item reads the display's EDID over DDC,
calculates the highest rate that fits HDMI 2.0 at the desktop resolution
(rounded down to ten, plus the hundred below as a safe option), tests
each step live with a confirmation, and makes the confirmed ones
permanent through `drm.edid_firmware` (Limine, sdboot-manage or GRUB).
- **feat: HDMI refresh boost and Update BIOS under Steam Machine support**
- Both are sub-options now, shown only while Steam Machine support is
ticked. Unticking it unticks them; unticking the kernel pin unticks the
HDMI refresh boost.
## 1.1.4 - 2026-09-25
- **fix: No lock screen in single user mode before a restart**
- The screen locker only read the new settings at the next login, so the
PC still locked after 5 minutes idle until then. Turning single user
mode on or off now tells it to reload them right away.
## 1.1.3 - 2026-09-25
- **fix: HDMI-CEC settings shown in Steam**
- CachyOS's gaming mode script sets `STEAM_ENABLE_CEC=0`, which hides
Steam's HDMI-CEC settings (and its CEC volume buttons). HDMI-CEC now adds
a drop-in for `steam-launcher.service` that overrides it with
`STEAM_ENABLE_CEC=1` (`/etc/steamify/steam-cec.env`).
- HDMI-CEC set up by 1.1.0-1.1.2 shows as off and ticked, so a normal run
(Enter) adds this.
## 1.1.2 - 2026-09-25
- **fix: Steam's HDMI-CEC settings reach cecd on a first run**
- HDMI-CEC runs before Steam Machine support, which installs
steamos-manager; so steamos-manager's `configure-cecd` unit couldn't be
enabled and cecd ran without Steam's settings. Steam Machine support now
links them once steamos-manager is there.
## 1.1.1 - 2026-09-25
- **perf: Pinned kernel before the LED driver**
+18 -3
View File
@@ -25,8 +25,9 @@ Steam Machine.
KDE wallet password prompts (e.g. from Brave). Typing a password with a
controller is no fun
([details](TECHNICAL.md#single-user-mode-sddm-no-locking)).
5. **Steamify shortcut** - an icon on the desktop and in the launcher that
opens the newest Steamify, so you don't need the install command again.
5. **Steamify shortcut** - "Steamify CachyOS" on the desktop and in the
launcher opens the newest Steamify app, so you don't need the install
command again. "Steamify Terminal" in the launcher opens the terminal menu.
6. **HDMI-CEC** (experimental; off by default, on by default on a Steam
Machine) - use Steam with your TV's
remote, and the TV turns on and off with the PC. Needs a PC with CEC (like
@@ -50,7 +51,13 @@ What Steamify can do on a Valve Steam Machine:
[release](https://github.com/theupriser/steamify-cachyos/releases/tag/kernel-7.1.6-1),
checked against CachyOS's signature
([details](TECHNICAL.md#kernel-pin-steam-machine)).
- **Update BIOS** - installs Valve's newest Steam Machine BIOS. Never ticked
- **HDMI refresh boost** - only with the pinned kernel, never ticked by
default: gets the highest refresh rate your HDMI display runs at the
desktop resolution (e.g. 110 Hz instead of 60 on a 3440x1440 monitor).
Each step is shown and you confirm it; no answer within 15 s switches
back. For more (e.g. 175 Hz), use DisplayPort
([details](TECHNICAL.md#hdmi-refresh-boost-steam-machine)).
- **Update BIOS** - installs Valve's newest Steam Machine BIOS. Never ticked
by default, at your own risk, and only after two warnings
([details](TECHNICAL.md#bios-updates-steam-machine)).
@@ -124,3 +131,11 @@ Contributing (or an AI agent)? Read [AGENTS.md](AGENTS.md).
## License
MIT - do whatever you want with it. See [LICENSE.md](LICENSE.md).
## Disclaimer
Steamify CachyOS is an unofficial community project. It is not affiliated
with, endorsed by, or sponsored by Valve Corporation or the CachyOS project.
Steam, SteamOS and the Steam logo are trademarks of Valve Corporation. CachyOS
is the name of its respective project. They are used here only to describe
what this script works with.
+50
View File
@@ -192,6 +192,13 @@ devices found. Also turn on CEC on the TV
`steamos-manager` writes cecd's settings from Steam's (wake the TV, put it to
sleep), in `~/.config/cecd/config.d/`, and Steam shows its HDMI-CEC
settings; the wizard restarts steamos-manager so that happens right away.
CachyOS's gaming mode script (`/usr/lib/steamos/gamescope-session`) sets
`STEAM_ENABLE_CEC=0`, which hides those settings; Steam reads the script's
variables from `$XDG_RUNTIME_DIR/gamescope-environment`. HDMI-CEC adds a
drop-in for `steam-launcher.service`
(`/etc/systemd/user/steam-launcher.service.d/10-steamify-cec.conf`) with a
second `EnvironmentFile=` (`/etc/steamify/steam-cec.env`,
`STEAM_ENABLE_CEC=1`), which overrides it from the next gaming mode start.
It's experimental: on SteamOS itself CEC can wake the Steam Machine right
after it goes to sleep (Samsung TVs,
@@ -222,6 +229,48 @@ into the kernel directory yourself.
Unticking it removes the pin and runs `sudo pacman -Syu`, which brings the
kernel back to CachyOS's current version; the files stay for next time.
## HDMI refresh boost (Steam Machine)
With the pinned kernel (7.1.6), HDMI displays often stay at 60 Hz. Two
reasons:
- Monitors list their fast modes in an extra EDID block, announced by the
HDMI Forum EEODB data block. 7.1.6 only reads the first extension block,
so it never sees them. Newer kernels do.
- Their fastest modes need HDMI 2.1 (FRL). 7.1.6's amdgpu only does HDMI
2.0 (TMDS, at most 600 MHz), but a mode with the display's own shortest
blanking at a slightly lower rate often fits.
The menu item (only on a Steam Machine with the pinned kernel, never
ticked by default, run from the desktop in Konsole):
1. Takes the desktop resolution from KDE (`kscreen-doctor -j`) and reads the
display's complete EDID over DDC (`i2ctransfer`, segment pointer 0x30).
A live EDID left by an earlier test is cleared first.
2. Calculates the highest rate that fits: the display's TMDS limit (HDMI
Forum VSDB, capped at amdgpu's 600 MHz), its shortest blanking at that
resolution and its maximum refresh (range limits, VRR maximum). Steps:
that rate rounded down to ten, and the hundred below it as a safe option.
Rates the display already lists, or that aren't faster than what works
now, are left out.
3. Builds the EDID: all of the display's blocks, the block count and EEODB
fixed, plus a DisplayID block with the steps.
4. Loads it live (debugfs `edid_override`, `trigger_hotplug`) and switches
to each step, lowest first. Each one needs a "y" within 15 s
(`WIZARD_HDMI_CONFIRM_SECONDS` for tests); anything else switches back
and stops.
5. Makes it permanent with only the confirmed steps:
`/usr/lib/firmware/edid/steamify-<connector>.bin`, in the initramfs via
`/etc/mkinitcpio.conf.d/90-steamify-edid.conf` (amdgpu loads from there),
and `drm.edid_firmware=<connector>:edid/steamify-<connector>.bin` on the
kernel command line: `/etc/default/limine` (`limine-mkinitcpio`),
`/etc/sdboot-manage.conf` or `/etc/default/grub`.
The display's manufacturer, model and serial are stored; re-applying with
another display connected tests that one instead. Turning it off removes
the files and the parameter and rebuilds. Untick it before removing the
kernel pin: newer kernels read the EDID themselves and can do HDMI 2.1.
## BIOS updates (Steam Machine)
The **Update BIOS** item is only shown on a Steam Machine and is never ticked
@@ -291,6 +340,7 @@ immediately, which can turn into a loop - see
| `lib/bios.sh` | Update BIOS (Steam Machine, opt-in): current/newest version, double confirmation, fwupd |
| `lib/cec.sh` | HDMI-CEC: Valve's `cecd` and friends from its `holo` repository |
| `lib/steam-machine.sh` | Steam Machine support: LED driver, LED access, steamos-manager; kernel pin |
| `lib/hdmi-refresh.sh` | HDMI refresh boost (Steam Machine, pinned kernel): EDID over DDC, calculated steps, live test, `drm.edid_firmware` |
| `.github/tools/bundle.sh` | Builds the single-file version (`dist/steamify.sh`) |
| `.github/workflows/bundle.yml` | Builds and checks it on every push; publishes it on `main` |
+209
View File
@@ -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
View File
@@ -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; }
+34 -12
View File
@@ -11,16 +11,32 @@ CEC_PKGS=(cecd cec-audio-control inputattach-cec-units)
# steamos-manager checks once, at its start, whether cecd runs; order it
# after cecd so it never misses it at login.
CEC_ORDER_DROPIN=/etc/systemd/user/steamos-manager.service.d/10-steamify-after-cecd.conf
# CachyOS's gaming mode script exports STEAM_ENABLE_CEC=0, which hides
# Steam's HDMI-CEC settings; Steam reads it from the gamescope environment
# file. A later EnvironmentFile= overrides it.
CEC_STEAM_ENV=/etc/steamify/steam-cec.env
CEC_STEAM_DROPIN=/etc/systemd/user/steam-launcher.service.d/10-steamify-cec.conf
restart_steamos_manager() {
# steamos-manager only offers its HDMI-CEC interface (Steam's CEC
# settings) when cecd was there at its start.
systemctl --user is-active -q steamos-manager.service 2>/dev/null &&
systemctl --user restart steamos-manager.service
cec_link_steamos_manager() {
# On a Steam Machine: steamos-manager writes cecd's config from Steam's
# CEC settings (its configure-cecd unit, run before cecd), and only
# offers Steam those settings when cecd was there at its start. Also
# called by Steam Machine support, which installs steamos-manager after
# this item ran.
pacman -Q steamos-manager >/dev/null 2>&1 || return 0
systemctl --user daemon-reload
systemctl --user enable steamos-manager-configure-cecd.service 2>/dev/null
systemctl --user start steamos-manager-configure-cecd.service 2>/dev/null
systemctl --user restart cecd.service 2>/dev/null
systemctl --user restart steamos-manager.service 2>/dev/null
return 0
}
cec_status() { pacman -Q "${CEC_PKGS[@]}" >/dev/null 2>&1; }
cec_installed() { pacman -Q "${CEC_PKGS[@]}" >/dev/null 2>&1; }
# On = installed and Steam told to show its CEC settings. An install from
# before 1.1.3 lacks the latter; the menu then ticks it (see cec_repair).
cec_status() { cec_installed && [[ -f "$CEC_STEAM_DROPIN" ]]; }
cec_repair() { cec_installed && ! cec_status; }
fetch_holo_pkg() {
# fetch_holo_pkg <dir> <package>: download the newest <package> from
@@ -70,12 +86,17 @@ cec_enable() {
sudo mkdir -p "$(dirname "$CEC_ORDER_DROPIN")"
printf '%s\n' "# Written by Steamify: steamos-manager only sees cecd if it runs at its start." \
'[Unit]' 'After=cecd.service' | sudo tee "$CEC_ORDER_DROPIN" >/dev/null
sudo mkdir -p "$(dirname "$CEC_STEAM_ENV")" "$(dirname "$CEC_STEAM_DROPIN")"
printf '%s\n' "# Written by Steamify: Steam's HDMI-CEC settings in gaming mode." 'STEAM_ENABLE_CEC=1' |
sudo tee "$CEC_STEAM_ENV" >/dev/null
printf '%s\n' "# Written by Steamify: overrides STEAM_ENABLE_CEC=0 from the gaming mode script." \
'[Service]' "EnvironmentFile=-$CEC_STEAM_ENV" | sudo tee "$CEC_STEAM_DROPIN" >/dev/null
systemctl --user daemon-reload
systemctl --user enable steamos-manager-configure-cecd.service 2>/dev/null
cec_link_steamos_manager
if compgen -G "/dev/cec*" >/dev/null; then
systemctl --user restart cecd.service 2>/dev/null
restart_steamos_manager
ok "HDMI-CEC on ($(cd /dev && echo cec*)). Turn on CEC on your TV too (e.g. Sony: BRAVIA Sync, Samsung: Anynet+, LG: SimpLink)."
ok "HDMI-CEC on ($(cd /dev && echo cec*)); Steam shows its settings from the next gaming mode start."
info "Turn on CEC on your TV too (e.g. Sony: BRAVIA Sync, Samsung: Anynet+, LG: SimpLink)."
else
warn "No CEC device (/dev/cec*) found: your GPU may not support CEC. A USB CEC adapter"
warn "(e.g. Pulse-Eight) works too. Check again after a restart with: ls /dev/cec*"
@@ -87,15 +108,16 @@ cec_disable() {
systemctl --user disable --now cecd.service cec-audio-control.socket cec-audio-control.service 2>/dev/null
systemctl --user disable steamos-manager-configure-cecd.service 2>/dev/null
sudo pacman -Rns --noconfirm "${CEC_PKGS[@]}" 2>/dev/null
sudo rm -f "$CEC_ORDER_DROPIN"
sudo rmdir "$(dirname "$CEC_ORDER_DROPIN")" 2>/dev/null
sudo rm -f "$CEC_ORDER_DROPIN" "$CEC_STEAM_DROPIN" "$CEC_STEAM_ENV"
sudo rmdir "$(dirname "$CEC_ORDER_DROPIN")" "$(dirname "$CEC_STEAM_DROPIN")" "$(dirname "$CEC_STEAM_ENV")" 2>/dev/null
systemctl --user daemon-reload
if [[ -n "$(state_get cec installed_linuxconsole)" ]]; then
sudo pacman -Rns --noconfirm linuxconsole 2>/dev/null
state_clear cec
fi
sudo udevadm control --reload
restart_steamos_manager
systemctl --user is-active -q steamos-manager.service 2>/dev/null &&
systemctl --user restart steamos-manager.service
ok "HDMI-CEC removed."
}
+559
View File
@@ -0,0 +1,559 @@
#!/bin/bash
# "HDMI refresh boost" menu item, Steam Machine (Fremont) only: finds the
# highest refresh rate the display runs at the desktop resolution over HDMI,
# tests it live and makes it permanent with an EDID override
# (drm.edid_firmware). Two things keep displays below what they can do:
# - Monitors put their fast modes in an extra EDID block (HDMI Forum EEODB);
# the pinned kernel (7.1.6) only reads the first extension. Newer kernels
# do, and do HDMI 2.1, so it's only offered with the pinned kernel.
# - Their fastest modes need HDMI 2.1 (FRL), which amdgpu doesn't do on
# every kernel; a mode with the display's shortest blanking at a slightly
# lower rate often still fits HDMI 2.0's TMDS limit (600 MHz).
# What a display claims isn't what it accepts: every step is shown and must
# be confirmed, and no answer within 15 s switches back (the screen may be
# black). Sourced by steamify.sh; not meant to be run on its own.
HDMI_FW_DIR=/usr/lib/firmware/edid
HDMI_INITRAMFS_CONF=/etc/mkinitcpio.conf.d/90-steamify-edid.conf
# No answer within this time switches back; longer for scripted tests.
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).
local d
for d in /sys/class/drm/card*-HDMI-A-*; do
[[ "$(cat "$d/status" 2>/dev/null)" == connected ]] && basename "$d"
done
}
hdmi_boot_file() {
# The file holding the kernel command line, per boot loader.
if [[ -f /etc/default/limine ]]; then echo /etc/default/limine
elif [[ -f /etc/sdboot-manage.conf ]] && command -v sdboot-manage >/dev/null; then echo /etc/sdboot-manage.conf
elif [[ -f /etc/default/grub ]]; then echo /etc/default/grub
fi
}
hdmi_cmdline_param() {
# The drm.edid_firmware= value we set, or nothing.
local f; f="$(hdmi_boot_file)"
[[ -n "$f" ]] && grep -oE 'drm\.edid_firmware=[^ "]*steamify-[^ "]*' "$f" 2>/dev/null | head -n 1
}
hdmi_available() {
# Stays available while on, so it can be turned off after the pin is gone.
detect_valve_fremont || return 1
hdmi_status || { pinned_kernel_installed && [[ -n "$(hdmi_connectors)" && -n "$(hdmi_boot_file)" ]]; }
}
hdmi_status() { [[ -n "$(hdmi_cmdline_param)" ]] && compgen -G "$HDMI_FW_DIR/steamify-*.bin" >/dev/null; }
hdmi_edid_tool() {
# hdmi_edid_tool <command> ...: EDID parsing and building (python, as
# it's byte work). Commands:
# blocks <edid> number of blocks the display says it has
# id <edid> vendor/product/serial, to recognise it
# plan <edid> <w> <h> <cur_hz> "<limit_hz> <best> <step>..." for w x h
# build <in> <out> <w> <h> <hz>... full EDID plus a DisplayID block
# with those rates at the shortest blanking
python3 - "$HDMI_TMDS_MAX_KHZ" "$@" <<'PY'
import sys
src_khz = int(sys.argv[1]); cmd = sys.argv[2]; args = sys.argv[3:]
def load(p): return bytearray(open(p, 'rb').read())
def le(b): return int.from_bytes(b, 'little')
def cta_blocks(e):
for i in range(128, len(e), 128):
if e[i] == 0x02: yield i
def data_blocks(e, i):
# CTA data block collection: (tag, extended tag, offset, length)
end, p = e[i + 2], i + 4
while p < i + end and p < i + 127:
tag, n = e[p] >> 5, e[p] & 0x1f
yield tag, (e[p + 1] if tag == 7 and n else None), p, n
p += n + 1
def eeodb(e):
for i in cta_blocks(e):
for tag, ext, p, n in data_blocks(e, i):
if tag == 7 and ext == 0x78: return p + 2
return None
def nblocks(e):
p = eeodb(e)
return 1 + max(e[126], e[p] if p and p < len(e) else 0)
def timings(e):
# (hz, clk_khz, ha, hb, hf, hs, hpos, va, vb, vf, vs, vpos)
out = []
def dtd(b):
clk = le(b[0:2]) * 10
if not clk: return
ha = b[2] | (b[4] >> 4) << 8; hb = b[3] | (b[4] & 15) << 8
va = b[5] | (b[7] >> 4) << 8; vb = b[6] | (b[7] & 15) << 8
hf = b[8] | (b[11] >> 6 & 3) << 8; hs = b[9] | (b[11] >> 4 & 3) << 8
vf = (b[10] >> 4) | (b[11] >> 2 & 3) << 4; vs = (b[10] & 15) | (b[11] & 3) << 4
if b[17] & 0x80: return # interlaced
out.append((clk * 1000 / ((ha + hb) * (va + vb)), clk, ha, hb, hf, hs, b[17] >> 1 & 1,
va, vb, vf, vs, b[17] >> 2 & 1))
for o in range(54, 126, 18): dtd(e[o:o + 18])
for i in cta_blocks(e):
d = e[i + 2]
if d >= 4:
for o in range(i + d, i + 127 - 17, 18): dtd(e[o:o + 18])
for i in range(128, len(e), 128):
if e[i] != 0x70: continue
p, end = i + 5, i + 5 + e[i + 2]
while p + 3 <= end:
tag, n = e[p], e[p + 2]
if tag == 0 and n == 0: break
if tag == 0x03:
for o in range(p + 3, p + 3 + n - 19, 20):
b = e[o:o + 20]
clk = (le(b[0:3]) + 1) * 10
hf, vf = le(b[8:10]), le(b[16:18])
ha, hb, va, vb = le(b[4:6]) + 1, le(b[6:8]) + 1, le(b[12:14]) + 1, le(b[14:16]) + 1
out.append((clk * 1000 / ((ha + hb) * (va + vb)), clk, ha, hb, (hf & 0x7fff) + 1,
le(b[10:12]) + 1, hf >> 15, va, vb, (vf & 0x7fff) + 1, le(b[18:20]) + 1, vf >> 15))
p += 3 + n
return out
def tmds_khz(e):
# The display's own TMDS limit: HDMI Forum VSDB, else the HDMI 1.4 VSDB.
hf = h14 = 0
for i in cta_blocks(e):
for tag, ext, p, n in data_blocks(e, i):
if tag != 3 or n < 3: continue
oui = e[p + 3] << 16 | e[p + 2] << 8 | e[p + 1]
if oui == 0xC45DD8 and n >= 5: hf = e[p + 5] * 5000
if oui == 0x000C03 and n >= 7: h14 = e[p + 7] * 5000
lim = hf or h14 or 165000
return min(lim, src_khz)
def max_vrefresh(e):
# Range limits descriptor, the HDMI Forum / AMD VRR maximum.
best = 0
for o in range(54, 126, 18):
b = e[o:o + 18]
if b[0:3] == b'\0\0\0' and b[3] == 0xfd:
best = max(best, b[6] + (255 if b[4] & 2 else 0))
for i in cta_blocks(e):
for tag, ext, p, n in data_blocks(e, i):
if tag != 3 or n < 3: continue
oui = e[p + 3] << 16 | e[p + 2] << 8 | e[p + 1]
if oui == 0xC45DD8 and n >= 10: best = max(best, (e[p + 9] & 0xc0) << 2 | e[p + 10])
if oui == 0x00001A and n >= 7: best = max(best, e[p + 7])
return best
def shortest(e, w, h):
ts = [t for t in timings(e) if t[2] == w and t[7] == h]
return min(ts, key=lambda t: (t[2] + t[3]) * (t[7] + t[8])) if ts else None
if cmd == 'blocks':
print(nblocks(load(args[0])))
elif cmd == 'id':
print(load(args[0])[8:18].hex())
elif cmd == 'plan':
e = load(args[0]); w, h, cur = int(args[1]), int(args[2]), float(args[3])
lim, t = tmds_khz(e), shortest(e, w, h)
if not t: sys.exit(1)
listed = [x[0] for x in timings(e) if x[2] == w and x[7] == h and x[1] <= lim]
best = max(listed) if listed else cur
top = int(lim * 1000 // ((t[2] + t[3]) * (t[7] + t[8])))
vmax = max_vrefresh(e) or int(max(x[0] for x in timings(e) if x[2] == w and x[7] == h))
top = min(top, vmax)
# Not right at the limit: the top rounded down to ten, and the hundred
# below it as a safe option. Rates the display already lists are left
# out (its own timing is the safer one), and so is anything not faster
# than what already works.
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)
n = nblocks(e)
e = e[:n * 128]
out = b''
for hz in rates:
_, _, ha, hb, hf, hs, hp, va, vb, vf, vs, vp = t
clk = round((ha + hb) * (va + vb) * hz / 1e4)
x = (clk - 1).to_bytes(3, 'little') + bytes([0x08])
for v, pol in ((ha, 0), (hb, 0), (hf, hp), (hs, 0), (va, 0), (vb, 0), (vf, vp), (vs, 0)):
x += ((v - 1) | pol << 15).to_bytes(2, 'little')
out += x
if out:
blk = bytearray(128)
blk[0:5] = bytes([0x70, 0x12, 121, 0x00, 0x00])
blk[5:8] = bytes([0x03, 0x00, len(out)])
blk[8:8 + len(out)] = out
blk[126] = (-sum(blk[1:126])) % 256
blk[127] = (-sum(blk[:127])) % 256
e += blk
count = len(e) // 128 - 1
e[126] = count
e[127] = (-sum(e[:127])) % 256
p = eeodb(e)
if p:
e[p] = count
i = p - p % 128
e[i + 127] = (-sum(e[i:i + 127])) % 256
open(args[1], 'wb').write(e)
PY
}
hdmi_read_edid() {
# hdmi_read_edid <card-connector> <out>: the display's complete EDID,
# read over DDC block by block (the kernel's copy may miss blocks).
local conn="$1" out="$2" bus n i tmp
bus="$(basename "$(readlink -f "/sys/class/drm/$conn/ddc")")"; bus="${bus#i2c-}"
tmp="$(mktemp)"
cat "/sys/class/drm/$conn/edid" > "$out"
if [[ -n "$bus" ]] && sudo modprobe i2c-dev 2>/dev/null; then
n="$(hdmi_edid_tool blocks "$out")"
: > "$tmp"
for (( i = 0; i < n; i++ )); do
# Segment pointer (0x30) selects each pair of blocks.
sudo i2ctransfer -y "$bus" w1@0x30 $(( i / 2 )) w1@0x50 $(( (i % 2) * 128 )) r128@0x50 2>/dev/null |
xxd -r -p >> "$tmp" 2>/dev/null ||
{ [[ $i -lt 2 ]] && { sudo i2ctransfer -y "$bus" w1@0x50 $(( i * 128 )) r128@0x50 | xxd -r -p >> "$tmp"; }; } || break
done
# Keep the DDC copy only when it's whole and the same display as the
# kernel's (not the whole block: a live override changes it).
if [[ "$(stat -c %s "$tmp")" == $(( n * 128 )) &&
"$(hdmi_edid_tool id "$tmp")" == "$(hdmi_edid_tool id "$out")" ]]; then
cat "$tmp" > "$out"
fi
fi
rm -f "$tmp"
}
hdmi_debugfs() {
# debugfs directory of a connector (one of several paths to the same
# GPU). Only root can list debugfs, so the glob runs under sudo.
local d
d="$(sudo sh -c 'for d in /sys/kernel/debug/dri/*/"$1"; do [ -e "$d/edid_override" ] && { echo "$d"; break; }; done' _ "${1#card*-}")"
[[ -n "$d" ]] && echo "$d"
}
hdmi_override_live() {
# hdmi_override_live <card-connector> <edid|reset>: swap the EDID the
# kernel uses right now, without a restart.
local d; d="$(hdmi_debugfs "$1")" || return 1
# The kernel takes exactly "reset", without a newline.
if [[ "$2" == reset ]]; then printf reset | sudo tee "$d/edid_override" >/dev/null
else sudo tee "$d/edid_override" < "$2" >/dev/null; fi || return 1
echo 1 | sudo tee "$d/trigger_hotplug" >/dev/null
sleep 3
}
hdmi_kscreen() {
# hdmi_kscreen current|mode <output> [<w> <h> <hz>]: KDE's view of the
# output: "w h hz" of the current mode, or set the mode closest to hz.
kscreen-doctor -j 2>/dev/null | python3 -c '
import json, sys
what, name = sys.argv[1], sys.argv[2]
for o in json.load(sys.stdin)["outputs"]:
if o["name"] != name: continue
modes = {m["id"]: m for m in o["modes"]}
if what == "current":
m = modes[o["currentModeId"]]
print(m["size"]["width"], m["size"]["height"], round(m["refreshRate"], 2))
else:
w, h, hz = int(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5])
ms = [m for m in o["modes"] if m["size"]["width"] == w and m["size"]["height"] == h]
if ms: print(min(ms, key=lambda m: abs(m["refreshRate"] - hz))["id"])
' "$@"
}
hdmi_set_mode() {
# hdmi_set_mode <output> <w> <h> <hz>: returns 1 if KDE has no such mode.
local id rate
id="$(hdmi_kscreen mode "$@")" || return 1
[[ -n "$id" ]] || return 1
kscreen-doctor "output.$1.mode.$id" >/dev/null 2>&1 || return 1
sleep 2
rate="$(hdmi_kscreen current "$1" | cut -d' ' -f3)"
# Whole hertz: the rate asked for can be e.g. 59.97.
[[ "${rate%.*}" -ge $(( ${4%.*} - 1 )) ]]
}
hdmi_confirm() {
# No answer (black screen, nobody there) is a no.
local reply
read -rt "$HDMI_CONFIRM_SECONDS" -p "$(echo -e "${c_bold}$1${c_reset} [y/N, ${HDMI_CONFIRM_SECONDS}s] ")" reply || { echo; return 1; }
[[ "$reply" =~ ^[Yy]$ ]]
}
hdmi_boot_param() {
# hdmi_boot_param <value|"">: set or remove our drm.edid_firmware= in
# the boot loader's command line, then rebuild the initramfs and entries.
local f value="$1"
f="$(hdmi_boot_file)" || return 1
[[ -n "$f" ]] || { err "No supported boot loader (Limine, systemd-boot with sdboot-manage, GRUB) found."; return 1; }
backup_file "$f"
sudo sed -i -E 's/drm\.edid_firmware=[^ "]*steamify-[^ "]* ?//' "$f"
if [[ -n "$value" ]]; then
case "$f" in
# | as delimiter: the value holds a path.
/etc/default/limine) sudo sed -i -E "0,\|^KERNEL_CMDLINE\[default\]\+?=\"|s||&$value |" "$f" ;;
/etc/sdboot-manage.conf) sudo sed -i -E "0,\|^LINUX_OPTIONS=\"|s||&$value |" "$f" ;;
/etc/default/grub) sudo sed -i -E "0,\|^GRUB_CMDLINE_LINUX_DEFAULT=\"|s||&$value |" "$f" ;;
esac
grep -qF "$value" "$f" || { err "Couldn't add $value to $f."; return 1; }
fi
info "Rebuilding the initramfs and boot entries..."
case "$f" in
/etc/default/limine) sudo limine-mkinitcpio ;;
/etc/sdboot-manage.conf) sudo /usr/bin/mkinitcpio -P && sudo sdboot-manage gen ;;
/etc/default/grub) sudo /usr/bin/mkinitcpio -P && sudo grub-mkconfig -o /boot/grub/grub.cfg ;;
esac
}
hdmi_tune() {
# hdmi_tune <card-connector> <tmpdir>: find and confirm the highest rate
# at the desktop resolution. Sets HDMI_RESULT to "<w> <h> <hz>..." (the
# confirmed new rates) or leaves it empty.
local conn="$1" tmp="$2" out="${1#card*-}" cur w h hz plan lim best steps_str
local -a steps ok_rates=()
HDMI_RESULT=""
# From the display's own EDID: a live one left by an earlier test would
# make its rates look like what the display already does.
hdmi_override_live "$conn" reset 2>/dev/null
cur="$(hdmi_kscreen current "$out")"
[[ -n "$cur" ]] || { warn "$out: KDE doesn't show this output; skipped."; return 0; }
read -r w h hz <<< "$cur"
info "$out: desktop resolution ${w}x${h} at ${hz} Hz. Reading the display's EDID..."
hdmi_read_edid "$conn" "$tmp/$out.orig" || return 1
if ! plan="$(hdmi_edid_tool plan "$tmp/$out.orig" "$w" "$h" "$hz")"; then
warn "$out: the display lists no timing for ${w}x${h}; nothing to calculate."
return 0
fi
read -r lim best steps_str <<< "$plan"
read -ra steps <<< "${steps_str:-}"
info "$out: HDMI limit ${lim} MHz, fastest mode that fits now: ${best} Hz."
if [[ ${#steps[@]} -eq 0 ]]; then
ok "$out: already at the highest rate that fits HDMI here; nothing to change."
return 0
fi
info "$out: steps to try: ${steps[*]} Hz. After each one you're asked whether the"
info "picture is OK; no answer within ${HDMI_CONFIRM_SECONDS} s (e.g. a black screen) switches back."
ask_yn "Start the test?" y || return 0
hdmi_edid_tool build "$tmp/$out.orig" "$tmp/$out.test" "$w" "$h" "${steps[@]}" &&
hdmi_override_live "$conn" "$tmp/$out.test" ||
{ err "$out: couldn't load the test EDID (debugfs)."; return 1; }
local s
for s in "${steps[@]}"; do
info "$out: switching to ${w}x${h} at $s Hz..."
if hdmi_set_mode "$out" "$w" "$h" "$s" && hdmi_confirm "Is the picture OK at $s Hz?"; then
ok "$out: $s Hz works."
ok_rates+=("$s")
else
warn "$out: $s Hz doesn't work; stopping here."
break
fi
done
if [[ ${#ok_rates[@]} -eq 0 ]]; then
hdmi_override_live "$conn" reset
hdmi_set_mode "$out" "$w" "$h" "$hz" >/dev/null
info "$out: back to ${hz} Hz; nothing changed."
return 0
fi
# Without the steps that failed, so KDE and gamescope never pick them.
hdmi_edid_tool build "$tmp/$out.orig" "$tmp/$out.final" "$w" "$h" "${ok_rates[@]}" &&
hdmi_override_live "$conn" "$tmp/$out.final"
hdmi_set_mode "$out" "$w" "$h" "${ok_rates[-1]}" >/dev/null
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() {
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
err "Run this from the Plasma desktop: the test switches the display mode through KDE."
return 1
fi
if hdmi_status; then
local conn stale=false
for conn in $(hdmi_connectors); do
[[ -f "$HDMI_FW_DIR/steamify-${conn#card*-}.bin" ]] || continue
[[ "$(state_get hdmi "${conn#card*-}")" == "$(hdmi_edid_tool id "/sys/class/drm/$conn/edid")" ]] || stale=true
done
if [[ "$stale" == false ]]; then
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; 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 res
tmp="$(mktemp -d)"
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" "${res#*|}"
param+="${param:+,}$out:edid/steamify-$out.bin"
files+=("$HDMI_FW_DIR/steamify-$out.bin")
done
rm -rf "$tmp" "$HDMI_CACHE"
[[ -n "$param" ]] || return $rc
# amdgpu loads from the initramfs (the kms hook), before the root
# filesystem: the EDID file has to be in there too.
sudo mkdir -p "$(dirname "$HDMI_INITRAMFS_CONF")"
printf '# Written by steamify: EDID override for HDMI refresh boost.\nFILES+=(%s)\n' "${files[*]}" |
sudo tee "$HDMI_INITRAMFS_CONF" >/dev/null
if ! hdmi_boot_param "drm.edid_firmware=$param"; then
# Nothing half-done left behind; the live EDID lasts until a restart.
sudo rm -f "$HDMI_INITRAMFS_CONF" "${files[@]}"
for conn in $(hdmi_connectors); do hdmi_override_live "$conn" reset 2>/dev/null; done
state_clear hdmi
err "Making it permanent failed; the display is back on its own EDID."
return 1
fi
ok "HDMI refresh boost is set up; it's active now and after every restart."
return $rc
}
hdmi_disable() {
local f conn
# Drop-in and files first: the rebuild below leaves them out.
sudo rm -f "$HDMI_INITRAMFS_CONF"
for f in "$HDMI_FW_DIR"/steamify-*.bin; do [[ -e "$f" ]] && sudo rm -f "$f"; done
hdmi_boot_param "" || return 1
for conn in $(hdmi_connectors); do hdmi_override_live "$conn" reset 2>/dev/null; done
state_clear hdmi
ok "HDMI refresh boost removed; the display uses its own EDID again."
}
+26 -10
View File
@@ -5,17 +5,18 @@
# Menu order. Components are turned on in this order and off in reverse;
# gaming must come first (single user builds on it).
COMPONENTS=(gaming boot theme glyphs single launcher cec machine kpin bios)
COMPONENTS=(gaming boot theme glyphs single launcher cec machine kpin hdmi bios)
# One-off actions rather than on/off components: never preselected, never
# re-applied, not listed as on or off.
ACTIONS=(bios)
# Sub-options, shown indented under their parent and only while it's ticked.
declare -A PARENT=([boot]=gaming [kpin]=machine)
declare -A PARENT=([boot]=gaming [kpin]=machine [hdmi]=machine [bios]=machine)
# Never preselected on a first run: booting into the desktop is a choice,
# gamescope is the default; HDMI-CEC is opt-in (it can wake the machine or
# upset other devices on the TV, even on SteamOS), except on a Steam Machine,
# which has CEC like on SteamOS.
NO_PRESELECT=(boot cec)
# which has CEC like on SteamOS. HDMI refresh boost needs someone at the
# screen to confirm each step.
NO_PRESELECT=(boot cec hdmi)
declare -A LABEL=(
[gaming]="SteamOS conversion: boot into gaming mode, Steam on the desktop"
@@ -23,10 +24,11 @@ declare -A LABEL=(
[theme]="Install SteamOS theme: Vapor look (cachyos-vapor)"
[glyphs]="Install Steam Deck/Machine icons: Deck button icons in gaming mode"
[single]="Single user mode: no password, lock screen or log out (SDDM)"
[launcher]="Steamify shortcut: desktop icon to run this again"
[launcher]="Steamify shortcut: the app on the desktop, Steamify Terminal in the launcher"
[cec]="HDMI-CEC: use Steam with the TV remote, TV on/off with the PC (experimental)"
[machine]="Steam Machine support: LED bar driver, hardware settings in Steam"
[kpin]="Pin the kernel to $PINNED_KERNEL_VER (fixes rebooting after shutdown)"
[hdmi]="HDMI refresh boost: highest refresh your HDMI display runs"
[bios]="Update BIOS"
)
declare -A CURRENT WANTED
@@ -34,6 +36,7 @@ declare -A CURRENT WANTED
component_available() {
case "$1" in
machine|kpin) machine_available ;;
hdmi) hdmi_available ;;
bios) bios_available ;;
esac
}
@@ -67,6 +70,11 @@ detect_components() {
if "${c}_status"; then CURRENT[$c]=1; any=true; else CURRENT[$c]=0; fi
WANTED[$c]=${CURRENT[$c]}
done
# HDMI-CEC set up by an older version: tick it, so a normal run fixes it.
component_available cec && cec_repair && WANTED[cec]=1
# The terminal-only Steamify shortcut from before 2.0.1: tick it, so a
# normal run replaces it with the app.
launcher_repair && WANTED[launcher]=1
# Shows the current and newest BIOS version.
bios_available && { bios_lookup_newest; LABEL[bios]="$(bios_label)"; }
# First run: preselect the full SteamOS experience (never an action).
@@ -91,6 +99,12 @@ toggle_component() {
# The kernel pin is opt-out: ticked along with Steam Machine support.
if [[ "$c" == machine ]]; then WANTED[kpin]=${WANTED[machine]}; fi
if [[ "$c" == kpin && "${WANTED[kpin]}" == 1 ]]; then WANTED[machine]=1; fi
# HDMI refresh boost and the BIOS update sit under Steam Machine support;
# the boost also needs the pinned kernel (newer kernels don't need it).
if [[ "$c" == machine && "${WANTED[machine]}" == 0 ]]; then WANTED[hdmi]=0; WANTED[bios]=0; fi
if [[ "$c" == kpin && "${WANTED[kpin]}" == 0 ]]; then WANTED[hdmi]=0; fi
if [[ "$c" == hdmi && "${WANTED[hdmi]}" == 1 ]]; then WANTED[machine]=1; WANTED[kpin]=1; fi
if [[ "$c" == bios && "${WANTED[bios]}" == 1 ]]; then WANTED[machine]=1; fi
}
show_menu() {
@@ -113,12 +127,13 @@ show_menu() {
[[ "${CURRENT[gaming]}" == 1 ]] || now="-"
want="gaming"; [[ "${WANTED[boot]}" == 1 ]] && want="desk"
printf " %-3s %-6s %-6s └ %s\n" "$i" "$now" "$want" "$(boot_choice "${WANTED[boot]}")"
elif ! component_selectable "$c"; then
local tree=""; [[ -n "${PARENT[$c]:-}" ]] && tree=" └ "
printf " %b%-3s %-6s %-6s %s%s (not available)%b\n" "$c_dim" "$i" "$now" "$want" "$tree" "${LABEL[$c]}" "$c_reset"
elif [[ -n "${PARENT[$c]:-}" ]]; then
printf " %-3s %b %-6s └ %s\n" "$i" "$now" "$want" "${LABEL[$c]}"
elif component_selectable "$c"; then
printf " %-3s %b %-6s %s\n" "$i" "$now" "$want" "${LABEL[$c]}"
else
printf " %b%-3s %-6s %-6s %s (not available)%b\n" "$c_dim" "$i" "$now" "$want" "${LABEL[$c]}" "$c_reset"
printf " %-3s %b %-6s %s\n" "$i" "$now" "$want" "${LABEL[$c]}"
fi
done
echo
@@ -166,8 +181,9 @@ draw_menu_tui() {
fi
if ! component_selectable "$c"; then
# Greyed out: nothing to do (e.g. BIOS already up to date).
local mark=" "; (( i == cursor )) && mark=" ${c_cyan}>${c_reset} "
echo -e "${mark}${c_dim}[ ] ${LABEL[$c]} (not available)${c_reset}"
local mark=" " tree=""; (( i == cursor )) && mark=" ${c_cyan}>${c_reset} "
[[ -n "${PARENT[$c]:-}" ]] && tree=" └ "
echo -e "${mark}${c_dim}${tree}[ ] ${LABEL[$c]} (not available)${c_reset}"
elif (( i == cursor )); then
# The green x's reset would end the bold too: re-enable it after.
echo -e " ${c_cyan}>${c_reset} ${c_bold}${line//"$c_reset"/"$c_reset$c_bold"}${c_reset}${state}"
+9
View File
@@ -81,6 +81,13 @@ single_wallet_disable() {
ok "Your own wallet is back (the empty one is kept as kdewallet.kwl$WALLET_OURS)."
}
reload_screen_locker() {
# The screen locker only reads kscreenlockerrc when the session starts:
# without this it keeps locking after idling until the next login.
qdbus6 org.freedesktop.ScreenSaver /ScreenSaver org.kde.screensaver.configure >/dev/null 2>&1 ||
busctl --user call org.freedesktop.ScreenSaver /ScreenSaver org.kde.screensaver configure >/dev/null 2>&1 || true
}
single_launcher() {
# Launcher: only Sleep / Restart / Shut Down, no Session dropdown (where
# Log Out lives). Restricting action/logout instead would also hide
@@ -114,6 +121,7 @@ single_enable() {
single_launcher
restart_plasmashell_if_stopped
reload_screen_locker
single_wallet_enable
ok "Single user: no lock screen, user switching or log out."
}
@@ -143,6 +151,7 @@ single_disable() {
done
fi
restart_plasmashell_if_stopped
reload_screen_locker
single_wallet_disable
ok "KDE's normal lock screen and user switching are back."
}
+3 -1
View File
@@ -351,7 +351,7 @@ kernel_overview() {
fi
echo -e " ${running}${line}"
done
cec_status && { echo; echo -e " $(cec_overview)"; }
cec_installed && { echo; echo -e " $(cec_overview)"; }
if [[ "$leds" == true ]]; then
local loaded="$miss" nodes
lsmod | grep -q '^leds_valve' && loaded="$mark"
@@ -395,6 +395,8 @@ EOF
sudo systemctl enable --now inputplumber.service
sudo systemctl enable --now steamos-manager.service
systemctl --user enable steamos-manager.service 2>/dev/null
# HDMI-CEC ran before this and couldn't link cecd to steamos-manager yet.
cec_status && cec_link_steamos_manager
# Console-like power handling, as SteamOS's powerdevilrc (mains-power
# part; there is no battery): the power button sleeps instead of showing
+61 -14
View File
@@ -1,19 +1,29 @@
#!/bin/bash
# "Steamify shortcut" menu item: a desktop icon and an app launcher entry that
# open the newest release of Steamify CachyOS in Konsole (curl | bash, like the
# install command in the README), so it's never out of date.
# "Steamify shortcut" menu item: "Steamify CachyOS" on the desktop and in the
# app launcher opens the newest release of the app (curl | bash of
# steamify-app.sh, like the install command in the README), so it's never out
# of date; "Steamify Terminal" in the launcher opens the terminal menu in
# Konsole the same way.
# Sourced by steamify.sh; not meant to be run on its own.
WIZARD_RELEASE="https://github.com/theupriser/steamify-cachyos/releases/latest/download"
WIZARD_URL="$WIZARD_RELEASE/steamify.sh"
WIZARD_APP_URL="$WIZARD_RELEASE/steamify-app.sh"
# Steam logo with a gear (assets/ in the repo, published with every release).
WIZARD_ICON="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps/cachyos-gamescope-boot-wizard.svg"
WIZARD_LAUNCHER="${XDG_DATA_HOME:-$HOME/.local/share}/cachyos-gamescope-boot/run-wizard"
WIZARD_DESKTOP_NAME="cachyos-gamescope-boot-wizard.desktop"
WIZARD_APP_ENTRY="${XDG_DATA_HOME:-$HOME/.local/share}/applications/$WIZARD_DESKTOP_NAME"
WIZARD_APP_LAUNCHER="${XDG_DATA_HOME:-$HOME/.local/share}/cachyos-gamescope-boot/run-app"
WIZARD_APPS="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
# steamify-ui: the id the app gives itself, so its window belongs to this
# entry. steamify-app.sh leaves an entry with X-Steamify-Shortcut alone.
WIZARD_DESKTOP_NAME="steamify-ui.desktop"
WIZARD_APP_ENTRY="$WIZARD_APPS/$WIZARD_DESKTOP_NAME"
WIZARD_TERMINAL_ENTRY="$WIZARD_APPS/steamify-terminal.desktop"
# Before 2.0.1 the shortcut opened the terminal menu under this name.
WIZARD_OLD_NAME="cachyos-gamescope-boot-wizard.desktop"
wizard_desktop_file() {
echo "$(xdg-user-dir DESKTOP 2>/dev/null || echo "$HOME/Desktop")/$WIZARD_DESKTOP_NAME"
wizard_desktop_dir() {
xdg-user-dir DESKTOP 2>/dev/null || echo "$HOME/Desktop"
}
install_executable() {
@@ -29,11 +39,15 @@ install_executable() {
}
launcher_status() {
[[ -x "$WIZARD_LAUNCHER" && -f "$WIZARD_APP_ENTRY" ]]
[[ -x "$WIZARD_APP_LAUNCHER" && -x "$WIZARD_LAUNCHER" && -f "$WIZARD_APP_ENTRY" && -f "$WIZARD_TERMINAL_ENTRY" ]]
}
# A shortcut from before 2.0.1 (terminal only): the menu ticks the item, so a
# normal run replaces it.
launcher_repair() { [[ -f "$WIZARD_APPS/$WIZARD_OLD_NAME" ]] && ! launcher_status; }
launcher_enable() {
info "Adding the Steamify shortcut (desktop icon and app launcher entry)..."
info "Adding the Steamify shortcut (the app on the desktop and in the launcher, Steamify Terminal in the launcher)..."
install_executable "$WIZARD_LAUNCHER" 755 << EOF
#!/bin/bash
# Installed by Steamify CachyOS: run its newest release.
@@ -53,6 +67,23 @@ for (( i = 10; i > 0; i-- )); do
read -rs -t 1 _ && break
done
echo
EOF
# The app, without a terminal. Only the first time, when PySide6 still
# has to be installed, it runs in Konsole: sudo asks for the password there.
install_executable "$WIZARD_APP_LAUNCHER" 755 << EOF
#!/bin/bash
# Installed by Steamify CachyOS: start the newest release of its app.
set -o pipefail
if [[ "\${1:-}" != --in-terminal ]] && ! python3 -c 'import PySide6.QtQml' 2>/dev/null; then
exec konsole -e "\$0" --in-terminal
fi
if ! curl -fsSL --max-time 30 "$WIZARD_APP_URL" | bash; then
msg="Couldn't download or start Steamify. Are you connected to the internet?"
if [[ "\${1:-}" == --in-terminal ]]; then echo; echo "\$msg"; read -rp "Press Enter to close this window... " _
else kdialog --title Steamify --sorry "\$msg" 2>/dev/null; fi
exit 1
fi
EOF
# The icon comes with the release; without it, Steam's own icon.
@@ -72,18 +103,34 @@ EOF
Type=Application
Name=Steamify CachyOS
Comment=Turn the SteamOS-style parts of this PC on or off
Exec=$WIZARD_APP_LAUNCHER
Icon=$icon
Terminal=false
Categories=Game;System;Settings;
X-Steamify-Shortcut=true"
printf '%s\n' "$entry" | install_executable "$WIZARD_APP_ENTRY" 644
printf '%s\n' "$entry" | install_executable "$(wizard_desktop_dir)/$WIZARD_DESKTOP_NAME" 755
printf '%s\n' "[Desktop Entry]
Type=Application
Name=Steamify Terminal
Comment=Steamify CachyOS's menu in a terminal
Exec=konsole -e $WIZARD_LAUNCHER
Icon=$icon
Terminal=false
Categories=Game;System;Settings;"
printf '%s\n' "$entry" | install_executable "$WIZARD_APP_ENTRY" 644
printf '%s\n' "$entry" | install_executable "$(wizard_desktop_file)" 755
Categories=Game;System;Settings;" | install_executable "$WIZARD_TERMINAL_ENTRY" 644
# The terminal-only shortcut from before 2.0.1.
rm -f "$(wizard_desktop_dir)/$WIZARD_OLD_NAME" "$WIZARD_APPS/$WIZARD_OLD_NAME"
kbuildsycoca6 >/dev/null 2>&1 || true
ok "Steamify shortcut added: \"Steamify CachyOS\" on the desktop and in the launcher."
ok "Steamify shortcut added: \"Steamify CachyOS\" (the app) on the desktop and in the launcher, \"Steamify Terminal\" in the launcher."
}
launcher_disable() {
rm -f "$(wizard_desktop_file)" "$WIZARD_APP_ENTRY" "$WIZARD_LAUNCHER" "$WIZARD_ICON"
# The launcher entry only if it's ours: steamify-app.sh writes its own
# under the same name when there's no shortcut.
grep -q '^X-Steamify-Shortcut=true' "$WIZARD_APP_ENTRY" 2>/dev/null && rm -f "$WIZARD_APP_ENTRY"
rm -f "$(wizard_desktop_dir)/$WIZARD_DESKTOP_NAME" "$WIZARD_TERMINAL_ENTRY" \
"$(wizard_desktop_dir)/$WIZARD_OLD_NAME" "$WIZARD_APPS/$WIZARD_OLD_NAME" \
"$WIZARD_LAUNCHER" "$WIZARD_APP_LAUNCHER" "$WIZARD_ICON"
rmdir "$(dirname "$WIZARD_LAUNCHER")" 2>/dev/null || true
kbuildsycoca6 >/dev/null 2>&1 || true
ok "Steamify shortcut removed."
+19 -5
View File
@@ -15,20 +15,25 @@
set -uo pipefail
# Release version, see CHANGELOG.md.
VERSION=1.1.1
VERSION=2.0.2
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
for lib in common state packages login-manager single-user steam-desktop steam-machine 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
+1011
View File
File diff suppressed because it is too large. Load diff
+74
View File
@@ -0,0 +1,74 @@
#!/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). The
# Steamify shortcut's entry has the same id and always starts the newest
# release: left alone.
mkdir -p "$APPS"
grep -q '^X-Steamify-Shortcut=true' "$APPS/steamify-ui.desktop" 2>/dev/null ||
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
View File
@@ -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())