Compare commits

...
30 Commits
Author SHA1 Message Date
theupriser a5e644d71a Merge pull request #24 from theupriser/feat/hdmi-per-display
Feat/hdmi per display
2026-09-26 16:49:56 +02:00
theupriser 929ecfb349 feat(ui): Manage button for HDMI refresh boost
The item's switch becomes a Set up / Manage button. Manage lists the
saved displays, removes one with its Remove button, and sets up the
connected display when it has no saved rates.
2026-09-26 16:48:21 +02:00
theupriser 4cc87aaea6 feat: HDMI refresh boost per display, with a Saved displays screen
Rates are saved per display (EDID ID) and loaded by the hotplug script
for whichever display is connected. The item is on only while the
connected display is boosted; unticking removes just that one. The app
lists the saved displays and removes any of them (hdmi-forget).
2026-09-26 16:13:45 +02:00
theupriser 36d0a9d780 feat: HDMI refresh boost only for the display it was set up for
The EDID override moves off the kernel command line to a hotplug script
(boot unit + udev rule) that loads it only while the display it was
tested on is connected, identified over DDC; other displays get their
own EDID.
2026-09-26 16:04:02 +02:00
theupriser cb6361b49b Merge pull request #23 from theupriser/fix/fremont-cec
fix: HDMI-CEC on the Steam Machine
2026-09-26 11:30:29 +02:00
theupriser c510f9e2e5 fix: HDMI-CEC on the Steam Machine 2026-09-26 11:29:34 +02:00
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
17 changed files with 2980 additions and 86 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: # 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/latest/download/steamify.sh | bash
# curl -fsSL https://github.com/theupriser/steamify-cachyos/releases/download/latest/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. # Pull requests only build and check it, and warn when VERSION is already released.
name: Single-file build name: Single-file build
on: on:
push: push:
branches: [main] branches: [main, feat/v2-ui]
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
@@ -28,17 +34,26 @@ jobs:
- name: Build - name: Build
run: | run: |
.github/tools/bundle.sh dist/steamify.sh .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 - name: Check
run: | run: |
bash -n dist/steamify.sh bash -n dist/steamify.sh
# SC2034/SC2154: variables used across the inlined modules. # SC2034/SC2154: variables used across the inlined modules.
shellcheck -S warning -e SC2034,SC2154 dist/steamify.sh 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 - uses: actions/upload-artifact@v4
with: with:
name: steamify name: steamify
path: dist/steamify.sh path: |
dist/steamify.sh
dist/steamify-app.tar.gz
dist/steamify-app.sh
- name: Version - name: Version
id: version id: version
@@ -71,9 +86,13 @@ jobs:
curl -fsSL https://github.com/${{ github.repository }}/releases/latest/download/steamify.sh | bash 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 in v$VERSION
$changes" $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 --target "$GITHUB_SHA" --title "Steamify CachyOS v$VERSION" --notes "$notes" --latest
# "latest" follows the newest version tag, for the older install URL # "latest" follows the newest version tag, for the older install URL
@@ -86,4 +105,24 @@ jobs:
else else
gh release create latest --title "Steamify CachyOS - latest (v$VERSION)" --notes "$latest_notes" --latest=false gh release create latest --title "Steamify CachyOS - latest (v$VERSION)" --notes "$latest_notes" --latest=false
fi 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/ dist/
__pycache__/
+37 -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 `steam-set-session plasma.desktop` plus the plasmalogin sync bridge when it
exists, at every boot. CachyOS's `cachyos-gamescope-autologin` still sets exists, at every boot. CachyOS's `cachyos-gamescope-autologin` still sets
gamescope during each desktop session; the unit corrects it at boot. 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 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. 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 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 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 again. Scripted input that runs out quits without restarting (never
restart on EOF: `ask_yn` would take its default). restart on EOF: `ask_yn` would take its default).
- Steamify shortcut (`launcher`): the icon runs `curl | bash` of - Steamify shortcut (`launcher`): "Steamify CachyOS" (desktop and launcher,
`releases/latest/download/steamify.sh` in Konsole, so it's id `steamify-ui.desktop`, the app's own id) runs `curl | bash` of
always the newest release. Its icon, `assets/steam-gaming-settings.svg` `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 (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 downloaded from there (Steam's icon if that fails). Desktop files are
written with their mode already set (`install_executable`): Plasma opens a written with their mode already set (`install_executable`): Plasma opens a
@@ -171,6 +176,34 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine
The start script closes its window after a 10-second countdown on success 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 and waits for Enter after an error; it uses `pipefail`, or a failed
download would run an empty script and count as success. 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. EDIDs are saved per display
(`steamify-<id>.bin`, id = EDID bytes 8-17, listed in
`/etc/steamify/hdmi-edid.conf`), never on the kernel command line (that
applied to any display on the port): `steamify-edid-hotplug` (boot unit +
udev drm hotplug rule) loads the connected display's file, else resets. It
records what's loaded in `/run/steamify-edid` before its own
`trigger_hotplug`, whose event runs it again. `hdmi_status` is on only
while the connected display is boosted; disable forgets only that display
(`kpin_disable` forgets all). The app manages the list (`hdmi-forget`,
`hdmiDisplays` in status). Pre-2.1.0 setups are moved by `hdmi_migrate`.
- Steam Machine CEC driver (`cec_driver_enable`, `lib/cec.sh`): mainline
`cros_ec_cec` lacks Fremont, so HDMI-CEC builds Valve's copy (evlaV
`linux-integration`, pinned commit + SHA-256) with DKMS for every kernel.
It's patched to register its notifier without a port name when the board
has one CEC port: amdgpu registers its HDMI notifier nameless, and the
kernel only pairs that with a named lookup ("Port C") when the CEC driver
registered first, which never happens since amdgpu loads from the
initramfs. Without it `/dev/cec0` exists but stays at `f.f.f.f`.
- `Relogin=true` means a gamescope that fails to start is relaunched in a - `Relogin=true` means a gamescope that fails to start is relaunched in a
tight loop; keep that in mind when changing session handling. tight loop; keep that in mind when changing session handling.
+79
View File
@@ -5,6 +5,85 @@ All notable changes, per version and per commit. Versions follow
`steamify.sh`. Versions before 0.7.0 were numbered afterwards, `steamify.sh`. Versions before 0.7.0 were numbered afterwards,
one per merged pull request. one per merged pull request.
## 2.1.0 - 2026-09-26
- **feat(ui): Manage button for HDMI refresh boost**
- The item's switch is a button: Set up… while no display is saved,
Manage after that. Manage lists the saved displays (connected or not),
removes one with its Remove button, and sets up the connected display
when it has no saved rates yet.
- **4cc87aa feat: HDMI refresh boost per display, with a Saved displays screen**
- **36d0a9d feat: HDMI refresh boost only for the display it was set up for**
- The EDID override was on the kernel command line, so any display on that
HDMI port got the first display's timings. Rates are now saved per
display and loaded at boot and at every hotplug for the display that's
connected (its ID read over DDC); a display without saved rates, or
unplugging, puts the port back on the display's own EDID. No initramfs
or boot loader change any more. An existing setup is moved over when
re-applied (`a`).
- The item shows on only while the connected display is boosted, so a new
display can be set up by ticking it; unticking removes only that one.
## 2.0.3 - 2026-09-26
- **fix: HDMI-CEC on the Steam Machine**
- Mainline's `cros_ec_cec` driver doesn't know the Steam Machine, so there
was no `/dev/cec0`. HDMI-CEC now builds Valve's version of the driver
with DKMS (`steamify-cros-ec-cec`) for every installed kernel, with a fix
so it finds amdgpu's HDMI port even though amdgpu loads first. An
existing HDMI-CEC install is ticked in the menu to add it.
## 2.0.2 - 2026-09-26
- **badfd84 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 ## 1.1.4 - 2026-09-25
- **fix: No lock screen in single user mode before a restart** - **fix: No lock screen in single user mode before a restart**
+17 -2
View File
@@ -25,8 +25,9 @@ Steam Machine.
KDE wallet password prompts (e.g. from Brave). Typing a password with a KDE wallet password prompts (e.g. from Brave). Typing a password with a
controller is no fun controller is no fun
([details](TECHNICAL.md#single-user-mode-sddm-no-locking)). ([details](TECHNICAL.md#single-user-mode-sddm-no-locking)).
5. **Steamify shortcut** - an icon on the desktop and in the launcher that 5. **Steamify shortcut** - "Steamify CachyOS" on the desktop and in the
opens the newest Steamify, so you don't need the install command again. 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 6. **HDMI-CEC** (experimental; off by default, on by default on a Steam
Machine) - use Steam with your TV's Machine) - use Steam with your TV's
remote, and the TV turns on and off with the PC. Needs a PC with CEC (like remote, and the TV turns on and off with the PC. Needs a PC with CEC (like
@@ -50,6 +51,12 @@ What Steamify can do on a Valve Steam Machine:
[release](https://github.com/theupriser/steamify-cachyos/releases/tag/kernel-7.1.6-1), [release](https://github.com/theupriser/steamify-cachyos/releases/tag/kernel-7.1.6-1),
checked against CachyOS's signature checked against CachyOS's signature
([details](TECHNICAL.md#kernel-pin-steam-machine)). ([details](TECHNICAL.md#kernel-pin-steam-machine)).
- **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 - **Update BIOS** - installs Valve's newest Steam Machine BIOS. Never ticked
by default, at your own risk, and only after two warnings by default, at your own risk, and only after two warnings
([details](TECHNICAL.md#bios-updates-steam-machine)). ([details](TECHNICAL.md#bios-updates-steam-machine)).
@@ -124,3 +131,11 @@ Contributing (or an AI agent)? Read [AGENTS.md](AGENTS.md).
## License ## License
MIT - do whatever you want with it. See [LICENSE.md](LICENSE.md). 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.
+59
View File
@@ -229,6 +229,64 @@ into the kernel directory yourself.
Unticking it removes the pin and runs `sudo pacman -Syu`, which brings the 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. 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. Saves the confirmed steps for that display: the EDID as
`/usr/lib/firmware/edid/steamify-<id>.bin`, where `<id>` is the display's
manufacturer, model, serial and date (EDID bytes 8-17), and a line in
`/etc/steamify/hdmi-edid.conf` (id, name, mode, rates). Other saved
displays are kept.
`steamify-edid.service` (at boot, before the login manager) and a udev rule
(`90-steamify-edid.rules`, every drm hotplug) run
`/usr/local/bin/steamify-edid-hotplug`. Per HDMI port it reads the connected
display's ID over DDC (the real display, even while an override is loaded)
and loads that display's saved EDID through debugfs, or resets the port to
the display's own EDID when there is none, or no display. What's loaded per
port is kept in `/run/steamify-edid`, so the hotplug the script triggers
itself doesn't loop.
The item is on when the connected display runs on its saved EDID; with
another display it's off, and ticking it sets that one up. Turning it off
removes the connected display's EDID; the unit and rule go with the last
one. In the app the item is a **Set up…** button, and **Manage** once a
display is saved: it lists every saved display, removes any of them, and
sets up the connected display when it has none. Unpinning the kernel
removes them all.
Versions before 2.1.0 used `drm.edid_firmware=` on the kernel command line
(and the initramfs), which applied to any display on that port; re-applying
saves such a setup per display and removes the parameter. Untick it before removing the
kernel pin: newer kernels read the EDID themselves and can do HDMI 2.1.
## BIOS updates (Steam Machine) ## BIOS updates (Steam Machine)
The **Update BIOS** item is only shown on a Steam Machine and is never ticked The **Update BIOS** item is only shown on a Steam Machine and is never ticked
@@ -298,6 +356,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/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/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/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/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` | | `.github/workflows/bundle.yml` | Builds and checks it on every push; publishes it on `main` |
+222
View File
@@ -0,0 +1,222 @@
#!/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>
# steamify.sh --backend hdmi-forget <display id>
# removes a saved display (hdmi-forgotten event); status lists them.
# 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
# HDMI refresh boost's saved displays; "active" = connected and boosted.
local hdmi_list="" active hid hname hmode hrates
active=" $(hdmi_active_ids | tr '\n' ' ') "
while IFS=$'\t' read -r hid hname hmode hrates; do
[[ -n "$hid" ]] || continue
hdmi_list+="${hdmi_list:+,}{\"id\":$(json_str "$hid"),\"name\":$(json_str "$hname"),\"mode\":$(json_str "$hmode"),\"rates\":$(json_str "$hrates"),\"active\":$([[ "$active" == *" $hid "* ]] && echo true || echo false)}"
done < <(hdmi_saved)
printf '{"version":%s,"firstRun":%s,"steamMachine":%s,"kernel":%s,"pinnedKernel":%s,"cecDevices":%s,"leds":%s,"bios":%s,"hdmiDisplays":[%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" "$hdmi_list" "$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|forget [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 ;;
forget)
[[ "${1:-}" =~ ^[0-9a-f]{20}$ ]] && hdmi_forget "$1" >/dev/null 2>&1; rc=$?
backend_event hdmi-forgotten "\"id\":$(json_str "${1:-}"),\"ok\":$([[ $rc -eq 0 ]] && echo true || echo false)" ;;
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 "$@" ;;
hdmi-forget) shift; backend_hdmi forget "$@" ;;
*) 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" 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 if ! bios_lookup_newest; then
err "Couldn't find the newest Steam Machine BIOS on Valve's mirror ($VALVE_MIRROR)." err "Couldn't find the newest Steam Machine BIOS on Valve's mirror ($VALVE_MIRROR)."
return 1 return 1
fi fi
local current tmp compatible
current="$(bios_current)"
[[ -n "$BIOS_DRY_RUN" ]] && warn "DRY RUN (WIZARD_BIOS_DRY_RUN): nothing will be flashed." [[ -n "$BIOS_DRY_RUN" ]] && warn "DRY RUN (WIZARD_BIOS_DRY_RUN): nothing will be flashed."
if [[ "$current" == "$BIOS_NEWEST" ]]; then if [[ "$(bios_current)" == "$BIOS_NEWEST" ]]; then
ok "The BIOS is already the newest version ($current); nothing to do." ok "The BIOS is already the newest version ($BIOS_NEWEST); nothing to do."
return 0 return 2
fi 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 || pacman -Q fwupd >/dev/null 2>&1 || sudo pacman -S --needed --noconfirm fwupd ||
{ err "Installing fwupd failed."; return 1; } { 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)..." info "Downloading $BIOS_PKG ($BIOS_REPO)..."
if ! curl -fsSL "$VALVE_MIRROR/$BIOS_REPO/os/x86_64/$BIOS_PKG" -o "$tmp/pkg.tar.zst" || if ! curl -fsSL "$VALVE_MIRROR/$BIOS_REPO/os/x86_64/$BIOS_PKG" -o "$BIOS_STAGE_DIR/pkg.tar.zst" ||
! echo "$BIOS_SHA256 $tmp/pkg.tar.zst" | sha256sum -c --quiet - || ! echo "$BIOS_SHA256 $BIOS_STAGE_DIR/pkg.tar.zst" | sha256sum -c --quiet - ||
! tar -I unzstd -xf "$tmp/pkg.tar.zst" -C "$tmp" "$BIOS_CAB"; then ! 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." err "Downloading or verifying $BIOS_PKG failed; the BIOS was not touched."
rm -rf "$tmp" rm -rf "$BIOS_STAGE_DIR"
return 1 return 1
fi fi
ok "Checksum OK: this is Valve's $BIOS_PKG." 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 if [[ -n "$BIOS_DRY_RUN" ]]; then
warn "Dry run: skipping fwupd's check that the firmware fits this machine." warn "Dry run: skipping fwupd's check that the firmware fits this machine."
compatible="${c_yellow}${b}not checked${n} (dry run)" BIOS_COMPATIBLE=dry-run
elif bios_fits_device "$tmp/$BIOS_CAB"; then elif bios_fits_device "$BIOS_STAGE_DIR/$BIOS_CAB"; then
ok "fwupd confirms BIOS $BIOS_NEWEST is firmware for this machine." ok "fwupd confirms BIOS $BIOS_NEWEST is firmware for this machine."
compatible="${yes}yes${n} (checked by fwupd)" BIOS_COMPATIBLE=yes
else else
err "fwupd says BIOS $BIOS_NEWEST is not for this machine's hardware; not installing it." 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 return 1
fi 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" \ bios_disclaimer "WARNING: BIOS UPDATE - ENTIRELY AT YOUR OWN RISK" \
"Current BIOS: ${b}$current${n}" \ "Current BIOS: ${b}$current${n}" \
"New BIOS: ${b}$BIOS_NEWEST${n}" \ "New BIOS: ${b}$BIOS_NEWEST${n}" \
"Checksum: ${yes}OK${n} (Valve's package)" \ "Checksum: ${yes}OK${n} (Valve's package)" \
"Compatible: $compatible" "Compatible: $compatible"
if ! ask_yn "Do you understand the risks and want to continue?" n; then 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 fi
bios_disclaimer "LAST CHANCE: THIS FLASHES BIOS $BIOS_NEWEST" \ bios_disclaimer "LAST CHANCE: THIS FLASHES BIOS $BIOS_NEWEST" \
"After this, keep the power on until the machine has fully" \ "After this, keep the power on until the machine has fully" \
@@ -176,28 +224,10 @@ bios_enable() {
local reply local reply
read -rp "$(echo -e "${c_red}${c_bold}Type UPDATE (in capitals) to flash the BIOS, anything else cancels:${c_reset} ")" 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 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 fi
bios_flash
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."
} }
# For the app (lib/backend.sh): the flash step alone, after its warnings.
bios_flash_only() { bios_flash; }
+77 -5
View File
@@ -3,8 +3,9 @@
# volume control (cec-audio-control) and the units that attach USB CEC # volume control (cec-audio-control) and the units that attach USB CEC
# adapters (Pulse-Eight, RainShadow) with inputattach. They're only in # adapters (Pulse-Eight, RainShadow) with inputattach. They're only in
# Valve's SteamOS (holo) repository, not in CachyOS or the AUR. Works with # Valve's SteamOS (holo) repository, not in CachyOS or the AUR. Works with
# any /dev/cec*: a GPU with CEC (e.g. the Steam Machine) or a USB adapter. # any /dev/cec*: a GPU with CEC or a USB adapter. On a Steam Machine it also
# On a Steam Machine, steamos-manager configures cecd from Steam's settings. # builds Valve's CEC driver (see cec_driver_enable), and steamos-manager
# configures cecd from Steam's settings.
# Sourced by steamify.sh; not meant to be run on its own. # Sourced by steamify.sh; not meant to be run on its own.
CEC_PKGS=(cecd cec-audio-control inputattach-cec-units) CEC_PKGS=(cecd cec-audio-control inputattach-cec-units)
@@ -16,6 +17,15 @@ CEC_ORDER_DROPIN=/etc/systemd/user/steamos-manager.service.d/10-steamify-after-c
# file. A later EnvironmentFile= overrides it. # file. A later EnvironmentFile= overrides it.
CEC_STEAM_ENV=/etc/steamify/steam-cec.env CEC_STEAM_ENV=/etc/steamify/steam-cec.env
CEC_STEAM_DROPIN=/etc/systemd/user/steam-launcher.service.d/10-steamify-cec.conf CEC_STEAM_DROPIN=/etc/systemd/user/steam-launcher.service.d/10-steamify-cec.conf
# The Steam Machine's CEC runs through its embedded controller (cros_ec_cec).
# Mainline's driver doesn't list Fremont, so there's no /dev/cec0; Valve's
# kernel does. We build Valve's copy with DKMS for every kernel, pinned to a
# reviewed commit.
CEC_DKMS_NAME=steamify-cros-ec-cec
CEC_DKMS_VER=1
CEC_DKMS_SRC="/usr/src/$CEC_DKMS_NAME-$CEC_DKMS_VER"
CEC_DRIVER_URL="https://raw.githubusercontent.com/evlaV/linux-integration/10c8c8800ccd3ae359203b4eefb6479f613b3b8e/drivers/media/cec/platform/cros-ec/cros-ec-cec.c"
CEC_DRIVER_SHA256=e89fd4e87fceb32d713ffc59b64794779b3c2b3c012b0e2f2e9f0c40b42f4373
cec_link_steamos_manager() { cec_link_steamos_manager() {
# On a Steam Machine: steamos-manager writes cecd's config from Steam's # On a Steam Machine: steamos-manager writes cecd's config from Steam's
@@ -33,9 +43,69 @@ cec_link_steamos_manager() {
} }
cec_installed() { 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 cec_driver_ok() { ! detect_valve_fremont || [[ -d "$CEC_DKMS_SRC" ]]; }
# before 1.1.3 lacks the latter; the menu then ticks it (see cec_repair). # On = installed, Steam told to show its CEC settings and, on a Steam
cec_status() { cec_installed && [[ -f "$CEC_STEAM_DROPIN" ]]; } # Machine, the driver built. An install from before 1.1.3 (settings) or
# 2.0.3 (driver) lacks one; the menu then ticks it (see cec_repair).
cec_status() { cec_installed && [[ -f "$CEC_STEAM_DROPIN" ]] && cec_driver_ok; }
cec_reload_driver() {
# cecd keeps /dev/cec0 open, so the old module can't be unloaded under it.
systemctl --user stop cecd.service 2>/dev/null
sudo modprobe -r cros_ec_cec 2>/dev/null
sudo modprobe cros_ec_cec 2>/dev/null
systemctl --user start cecd.service 2>/dev/null
}
cec_driver_enable() {
detect_valve_fremont || return 0
local tmp k
if ! pacman -Q dkms >/dev/null 2>&1; then
sudo pacman -S --needed --noconfirm dkms || { err "Installing dkms failed."; return 1; }
fi
install_kernel_headers || return 1
tmp="$(mktemp -d)"
info "Downloading Valve's Steam Machine CEC driver..."
if ! curl -fsL "$CEC_DRIVER_URL" -o "$tmp/cros-ec-cec.c" ||
! echo "$CEC_DRIVER_SHA256 $tmp/cros-ec-cec.c" | sha256sum -c --quiet -; then
rm -rf "$tmp"
err "Downloading Valve's CEC driver failed (or its checksum didn't match)."
return 1
fi
# amdgpu registers its HDMI notifier without a port name, which only
# matches the driver's named lookup ("Port C") if the driver loaded
# first; amdgpu loads from the initramfs, so it never does. With a
# single CEC port, look it up by device alone.
sed -i 's/cec_notifier_cec_adap_register(hdmi_dev, conns\[port_num\],/cec_notifier_cec_adap_register(hdmi_dev, conns[1] ? conns[port_num] : NULL,/' \
"$tmp/cros-ec-cec.c"
grep -q 'conns\[1\] ? conns\[port_num\] : NULL' "$tmp/cros-ec-cec.c" ||
{ rm -rf "$tmp"; err "Patching Valve's CEC driver failed."; return 1; }
echo 'obj-m += cros-ec-cec.o' >"$tmp/Makefile"
printf '%s\n' "# Written by Steamify: Valve's cros_ec_cec, which knows the Steam Machine." \
"PACKAGE_NAME=\"$CEC_DKMS_NAME\"" "PACKAGE_VERSION=\"$CEC_DKMS_VER\"" \
'BUILT_MODULE_NAME[0]="cros-ec-cec"' 'DEST_MODULE_LOCATION[0]="/updates/dkms"' \
'AUTOINSTALL="yes"' >"$tmp/dkms.conf"
sudo dkms remove "$CEC_DKMS_NAME/$CEC_DKMS_VER" --all >/dev/null 2>&1
sudo rm -rf "$CEC_DKMS_SRC"
sudo install -d "$CEC_DKMS_SRC" && sudo install -m644 "$tmp"/{cros-ec-cec.c,Makefile,dkms.conf} "$CEC_DKMS_SRC/"
rm -rf "$tmp"
sudo dkms add "$CEC_DKMS_NAME/$CEC_DKMS_VER" >/dev/null || { err "Adding the CEC driver to DKMS failed."; return 1; }
# Every kernel with headers; one that fails to build only lacks CEC.
for k in /usr/lib/modules/*/build; do
k="$(basename "$(dirname "$k")")"
info "Building the CEC driver for $k..."
sudo dkms install "$CEC_DKMS_NAME/$CEC_DKMS_VER" -k "$k" >/dev/null ||
warn "Building the CEC driver for $k failed; that kernel has no HDMI-CEC."
done
cec_reload_driver
}
cec_driver_disable() {
[[ -d "$CEC_DKMS_SRC" ]] || return 0
sudo dkms remove "$CEC_DKMS_NAME/$CEC_DKMS_VER" --all >/dev/null 2>&1
sudo rm -rf "$CEC_DKMS_SRC"
cec_reload_driver
}
cec_repair() { cec_installed && ! cec_status; } cec_repair() { cec_installed && ! cec_status; }
fetch_holo_pkg() { fetch_holo_pkg() {
@@ -79,6 +149,7 @@ cec_enable() {
return 1 return 1
fi fi
rm -rf "$tmp" rm -rf "$tmp"
cec_driver_enable || return 1
# Its udev rule gives the user /dev/cec*; cecd starts with the graphical # Its udev rule gives the user /dev/cec*; cecd starts with the graphical
# session, after steamos-manager wrote its config from Steam's settings. # session, after steamos-manager wrote its config from Steam's settings.
sudo udevadm control --reload sudo udevadm control --reload
@@ -108,6 +179,7 @@ cec_disable() {
systemctl --user disable --now cecd.service cec-audio-control.socket cec-audio-control.service 2>/dev/null 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 systemctl --user disable steamos-manager-configure-cecd.service 2>/dev/null
sudo pacman -Rns --noconfirm "${CEC_PKGS[@]}" 2>/dev/null sudo pacman -Rns --noconfirm "${CEC_PKGS[@]}" 2>/dev/null
cec_driver_disable
sudo rm -f "$CEC_ORDER_DROPIN" "$CEC_STEAM_DROPIN" "$CEC_STEAM_ENV" 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 sudo rmdir "$(dirname "$CEC_ORDER_DROPIN")" "$(dirname "$CEC_STEAM_DROPIN")" "$(dirname "$CEC_STEAM_ENV")" 2>/dev/null
systemctl --user daemon-reload systemctl --user daemon-reload
+703
View File
@@ -0,0 +1,703 @@
#!/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 saves an EDID override per display, loaded only while
# that display is connected (see hdmi_install_hotplug). 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
# Before 2.1.0 the override was on the kernel command line; only removed now.
HDMI_INITRAMFS_CONF=/etc/mkinitcpio.conf.d/90-steamify-edid.conf
# The saved displays, one per line: <id> TAB <name> TAB <w>x<h> TAB <rates>.
# Their EDIDs are $HDMI_FW_DIR/steamify-<id>.bin.
HDMI_MAP=/etc/steamify/hdmi-edid.conf
# Per output, the ID of the display whose EDID is loaded, or "reset".
HDMI_RUN=/run/steamify-edid
HDMI_HOTPLUG=/usr/local/bin/steamify-edid-hotplug
HDMI_UNIT_NAME=steamify-edid.service
HDMI_UNIT="/etc/systemd/system/$HDMI_UNIT_NAME"
HDMI_UDEV_RULE=/etc/udev/rules.d/90-steamify-edid.rules
# 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
# Saved displays too, so they can be removed without the pin.
hdmi_status || [[ -n "$(hdmi_saved)" ]] || { pinned_kernel_installed && [[ -n "$(hdmi_connectors)" ]]; }
}
hdmi_status() {
# On = the connected display runs on its saved EDID. Another display on
# the port is off, so ticking it sets that one up. The command line: set
# up by an older version, re-applying moves it over.
[[ -n "$(hdmi_active_ids)" ]] ||
{ [[ -n "$(hdmi_cmdline_param)" ]] && compgen -G "$HDMI_FW_DIR/steamify-*.bin" >/dev/null; }
}
hdmi_saved() { [[ -f "$HDMI_MAP" ]] && grep -v '^#' "$HDMI_MAP"; }
hdmi_active_ids() {
# IDs of the connected displays whose saved EDID is loaded.
local f
for f in "$HDMI_RUN"/*; do [[ -f "$f" ]] && grep -vx reset "$f"; done
return 0
}
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 == 'name':
e = load(args[0])
for o in range(54, 126, 18):
if e[o:o + 3] == b'\0\0\0' and e[o + 3] == 0xfc:
print(e[o + 5:o + 18].split(b'\n')[0].decode('ascii', 'replace').strip())
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_remove_boot_param() {
# Removes the drm.edid_firmware= older versions put on the kernel command
# line (it applied to whatever display was on that port), then rebuilds
# the initramfs and boot entries.
local f
[[ -n "$(hdmi_cmdline_param)" ]] || { sudo rm -f "$HDMI_INITRAMFS_CONF"; return 0; }
f="$(hdmi_boot_file)"
backup_file "$f"
sudo sed -i -E 's/drm\.edid_firmware=[^ "]*steamify-[^ "]* ?//' "$f"
sudo rm -f "$HDMI_INITRAMFS_CONF"
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_write_map() {
# hdmi_write_map <lines>: the saved displays list.
sudo mkdir -p "$(dirname "$HDMI_MAP")"
{ printf '# Written by steamify: HDMI refresh boost, one display per line:\n'
printf '# <id>\t<name>\t<w>x<h>\t<rates>\n'
[[ -n "$1" ]] && printf '%s\n' "$1"; } | sudo tee "$HDMI_MAP" >/dev/null
}
hdmi_save() {
# hdmi_save <edid> <w>x<h> <rates>: keep an EDID for the display it was
# built from (block 0 is the display's own, so is its ID), replacing
# what was saved for that display before.
local id name
id="$(hdmi_edid_tool id "$1")" && [[ -n "$id" ]] || return 1
name="$(hdmi_edid_tool name "$1")"
sudo install -Dm644 "$1" "$HDMI_FW_DIR/steamify-$id.bin" || return 1
hdmi_write_map "$(hdmi_saved | awk -F'\t' -v i="$id" '$1 != i'
printf '%s\t%s\t%s\t%s' "$id" "${name:-HDMI display}" "$2" "$3")"
}
hdmi_forget() {
# hdmi_forget <id>|all: remove saved displays; the hotplug script puts
# a connected one back on its own EDID, and goes when none are left.
local rest=""
if [[ "$1" == all ]]; then
sudo rm -f "$HDMI_FW_DIR"/steamify-*.bin
else
rest="$(hdmi_saved | awk -F'\t' -v i="$1" '$1 != i')"
sudo rm -f "$HDMI_FW_DIR/steamify-$1.bin"
fi
if [[ -z "$rest" ]]; then hdmi_remove_hotplug; return; fi
hdmi_write_map "$rest"
sudo systemctl restart "$HDMI_UNIT_NAME"
}
hdmi_migrate() {
# Setups before 2.1.0: steamify-<output>.bin on the kernel command line,
# the display's ID and mode in the state file. Saved per display now.
local f out
local -a smode
for f in "$HDMI_FW_DIR"/steamify-*-*.bin; do
[[ -e "$f" ]] || continue
out="${f##*/steamify-}"; out="${out%.bin}"
read -ra smode <<< "$(state_get hdmi "$out-mode")"
[[ ${#smode[@]} -ge 2 ]] && hdmi_save "$f" "${smode[0]}x${smode[1]}" "${smode[*]:2}"
sudo rm -f "$f"
done
state_clear hdmi
hdmi_remove_boot_param
}
hdmi_install_hotplug() {
# A saved EDID is only loaded while its display is connected: a udev
# rule runs the script at every hotplug (and a unit at boot, before the
# login manager). The script reads the display's ID over DDC, which
# shows the real display even while an override is loaded, and loads
# that display's file or resets to its own EDID. Unplugging resets too,
# so the next display never starts on the wrong one.
sudo tee "$HDMI_HOTPLUG" > /dev/null << 'EOF'
#!/bin/bash
# Steamify CachyOS, HDMI refresh boost: load a display's saved EDID only
# while it is connected. Runs as root (debugfs).
FW=/usr/lib/firmware/edid RUN=/run/steamify-edid
modprobe i2c-dev 2>/dev/null
mkdir -p "$RUN"
# Hotplug events come in bursts, and the link needs a moment before DDC works.
sleep 1
for c in /sys/class/drm/card*-HDMI-A-*; do
[ -e "$c" ] || continue
out="${c##*/}"; out="${out#card*-}"
new=reset id=""
if [ "$(cat "$c/status")" = connected ]; then
bus="$(basename "$(readlink -f "$c/ddc")")"
for i in 1 2 3 4 5; do
# Bytes 8-17 of block 0: manufacturer, product, serial, date.
id="$(i2ctransfer -y "${bus#i2c-}" w1@0x50 0 r18@0x50 2>/dev/null | tr -d ' ' | sed 's/0x//g')"
[ ${#id} -eq 36 ] && break
sleep 1
done
[ ${#id} -eq 36 ] && [ -f "$FW/steamify-${id:16}.bin" ] && new="${id:16}"
fi
[ "$(cat "$RUN/$out" 2>/dev/null || echo reset)" = "$new" ] && continue
for d in /sys/kernel/debug/dri/*/"$out"; do [ -e "$d/edid_override" ] && break; done
[ -e "$d/edid_override" ] || continue
if [ "$new" = reset ]; then printf reset > "$d/edid_override"
else cat "$FW/steamify-$new.bin" > "$d/edid_override"; fi
# Before the hotplug below, whose own event runs this again.
echo "$new" > "$RUN/$out"
echo 1 > "$d/trigger_hotplug"
done
exit 0
EOF
sudo chmod 755 "$HDMI_HOTPLUG"
# StartLimitIntervalSec=0: a burst of hotplugs must not get it rate-limited.
sudo tee "$HDMI_UNIT" > /dev/null << EOF
[Unit]
Description=Steamify CachyOS: HDMI refresh boost EDID for the connected display
StartLimitIntervalSec=0
Wants=sys-kernel-debug.mount
After=sys-kernel-debug.mount
Before=display-manager.service sddm.service plasmalogin.service
[Service]
Type=oneshot
ExecStart=$HDMI_HOTPLUG
[Install]
WantedBy=graphical.target
EOF
# restart, not start: a hotplug during a run must still be looked at.
printf '%s\n' '# Written by steamify: HDMI refresh boost, check the display at every hotplug.' \
"ACTION==\"change\", SUBSYSTEM==\"drm\", ENV{HOTPLUG}==\"1\", RUN+=\"/usr/bin/systemctl --no-block restart $HDMI_UNIT_NAME\"" |
sudo tee "$HDMI_UDEV_RULE" >/dev/null || return 1
sudo systemctl daemon-reload
sudo udevadm control --reload
sudo systemctl enable "$HDMI_UNIT_NAME" >/dev/null 2>&1 || { err "Enabling $HDMI_UNIT_NAME failed."; return 1; }
sudo systemctl restart "$HDMI_UNIT_NAME"
}
hdmi_remove_hotplug() {
local conn
sudo systemctl disable "$HDMI_UNIT_NAME" >/dev/null 2>&1
sudo rm -f "$HDMI_UDEV_RULE" "$HDMI_UNIT" "$HDMI_HOTPLUG" "$HDMI_MAP"
sudo rm -rf "$HDMI_RUN"
sudo systemctl daemon-reload
sudo udevadm control --reload
for conn in $(hdmi_connectors); do hdmi_override_live "$conn" reset 2>/dev/null; done
return 0
}
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 [[ -n "$(hdmi_cmdline_param)" ]]; then
info "Moving the EDID override off the kernel command line, so it only applies to its display..."
hdmi_migrate && hdmi_install_hotplug || { err "Moving it failed."; return 1; }
ok "HDMI refresh boost now only applies to the display it was set up for."
return 0
fi
if hdmi_status; then
ok "Already set up for the connected display. Untick and tick it again to re-test."
return 0
fi
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
sudo pacman -S --needed --noconfirm i2c-tools python >/dev/null 2>&1 || { err "Installing i2c-tools failed."; return 1; }
local tmp conn out res saved=false rc=0
local -a smode
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%%|*}"; read -ra smode <<< "${res#*|}"
hdmi_save "$tmp/$out.final" "${smode[0]}x${smode[1]}" "${smode[*]:2}" && saved=true || rc=1
done
rm -rf "$tmp" "$HDMI_CACHE"
[[ "$saved" == true ]] || return $rc
if ! hdmi_install_hotplug; then
# Nothing half-done left behind; the live EDID lasts until a restart.
hdmi_remove_hotplug
err "Making it permanent failed; the display is back on its own EDID."
return 1
fi
ok "HDMI refresh boost is saved for this display; other displays keep their own settings."
return $rc
}
hdmi_disable() {
# For the connected display; other saved displays stay (the app lists
# them). Without the pinned kernel none may stay: newer kernels read the
# EDID themselves.
local id
if [[ -n "$(hdmi_cmdline_param)" ]]; then
hdmi_forget all; hdmi_remove_boot_param || return 1; state_clear hdmi
elif [[ "${WANTED[kpin]:-1}" == 0 ]] || ! pinned_kernel_installed; then
hdmi_forget all
else
for id in $(hdmi_active_ids); do hdmi_forget "$id"; done
fi
ok "HDMI refresh boost removed for the connected display; it uses its own EDID again."
}
+24 -10
View File
@@ -5,17 +5,18 @@
# Menu order. Components are turned on in this order and off in reverse; # Menu order. Components are turned on in this order and off in reverse;
# gaming must come first (single user builds on it). # 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 # One-off actions rather than on/off components: never preselected, never
# re-applied, not listed as on or off. # re-applied, not listed as on or off.
ACTIONS=(bios) ACTIONS=(bios)
# Sub-options, shown indented under their parent and only while it's ticked. # 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, # 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 # 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, # upset other devices on the TV, even on SteamOS), except on a Steam Machine,
# which has CEC like on SteamOS. # which has CEC like on SteamOS. HDMI refresh boost needs someone at the
NO_PRESELECT=(boot cec) # screen to confirm each step.
NO_PRESELECT=(boot cec hdmi)
declare -A LABEL=( declare -A LABEL=(
[gaming]="SteamOS conversion: boot into gaming mode, Steam on the desktop" [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)" [theme]="Install SteamOS theme: Vapor look (cachyos-vapor)"
[glyphs]="Install Steam Deck/Machine icons: Deck button icons in gaming mode" [glyphs]="Install Steam Deck/Machine icons: Deck button icons in gaming mode"
[single]="Single user mode: no password, lock screen or log out (SDDM)" [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)" [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" [machine]="Steam Machine support: LED bar driver, hardware settings in Steam"
[kpin]="Pin the kernel to $PINNED_KERNEL_VER (fixes rebooting after shutdown)" [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" [bios]="Update BIOS"
) )
declare -A CURRENT WANTED declare -A CURRENT WANTED
@@ -34,6 +36,7 @@ declare -A CURRENT WANTED
component_available() { component_available() {
case "$1" in case "$1" in
machine|kpin) machine_available ;; machine|kpin) machine_available ;;
hdmi) hdmi_available ;;
bios) bios_available ;; bios) bios_available ;;
esac esac
} }
@@ -69,6 +72,9 @@ detect_components() {
done done
# HDMI-CEC set up by an older version: tick it, so a normal run fixes it. # HDMI-CEC set up by an older version: tick it, so a normal run fixes it.
component_available cec && cec_repair && WANTED[cec]=1 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. # Shows the current and newest BIOS version.
bios_available && { bios_lookup_newest; LABEL[bios]="$(bios_label)"; } bios_available && { bios_lookup_newest; LABEL[bios]="$(bios_label)"; }
# First run: preselect the full SteamOS experience (never an action). # First run: preselect the full SteamOS experience (never an action).
@@ -93,6 +99,12 @@ toggle_component() {
# The kernel pin is opt-out: ticked along with Steam Machine support. # The kernel pin is opt-out: ticked along with Steam Machine support.
if [[ "$c" == machine ]]; then WANTED[kpin]=${WANTED[machine]}; fi if [[ "$c" == machine ]]; then WANTED[kpin]=${WANTED[machine]}; fi
if [[ "$c" == kpin && "${WANTED[kpin]}" == 1 ]]; then WANTED[machine]=1; 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() { show_menu() {
@@ -115,12 +127,13 @@ show_menu() {
[[ "${CURRENT[gaming]}" == 1 ]] || now="-" [[ "${CURRENT[gaming]}" == 1 ]] || now="-"
want="gaming"; [[ "${WANTED[boot]}" == 1 ]] && want="desk" want="gaming"; [[ "${WANTED[boot]}" == 1 ]] && want="desk"
printf " %-3s %-6s %-6s └ %s\n" "$i" "$now" "$want" "$(boot_choice "${WANTED[boot]}")" 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 elif [[ -n "${PARENT[$c]:-}" ]]; then
printf " %-3s %b %-6s └ %s\n" "$i" "$now" "$want" "${LABEL[$c]}" 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 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 fi
done done
echo echo
@@ -168,8 +181,9 @@ draw_menu_tui() {
fi fi
if ! component_selectable "$c"; then if ! component_selectable "$c"; then
# Greyed out: nothing to do (e.g. BIOS already up to date). # Greyed out: nothing to do (e.g. BIOS already up to date).
local mark=" "; (( i == cursor )) && mark=" ${c_cyan}>${c_reset} " local mark=" " tree=""; (( i == cursor )) && mark=" ${c_cyan}>${c_reset} "
echo -e "${mark}${c_dim}[ ] ${LABEL[$c]} (not available)${c_reset}" [[ -n "${PARENT[$c]:-}" ]] && tree=" └ "
echo -e "${mark}${c_dim}${tree}[ ] ${LABEL[$c]} (not available)${c_reset}"
elif (( i == cursor )); then elif (( i == cursor )); then
# The green x's reset would end the bold too: re-enable it after. # 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}" echo -e " ${c_cyan}>${c_reset} ${c_bold}${line//"$c_reset"/"$c_reset$c_bold"}${c_reset}${state}"
+6 -1
View File
@@ -163,7 +163,12 @@ kpin_enable() {
fi fi
} }
kpin_disable() { remove_kernel_pin; } kpin_disable() {
# Saved HDMI refresh boost EDIDs are for the pinned kernel only (newer
# ones read the whole EDID), also those of displays not connected now.
[[ -n "$(hdmi_saved)" ]] && hdmi_forget all
remove_kernel_pin
}
install_kernel_headers() { install_kernel_headers() {
# DKMS can only build leds-valve against kernels whose headers are # DKMS can only build leds-valve against kernels whose headers are
+61 -14
View File
@@ -1,19 +1,29 @@
#!/bin/bash #!/bin/bash
# "Steamify shortcut" menu item: a desktop icon and an app launcher entry that # "Steamify shortcut" menu item: "Steamify CachyOS" on the desktop and in the
# open the newest release of Steamify CachyOS in Konsole (curl | bash, like the # app launcher opens the newest release of the app (curl | bash of
# install command in the README), so it's never out of date. # 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. # Sourced by steamify.sh; not meant to be run on its own.
WIZARD_RELEASE="https://github.com/theupriser/steamify-cachyos/releases/latest/download" WIZARD_RELEASE="https://github.com/theupriser/steamify-cachyos/releases/latest/download"
WIZARD_URL="$WIZARD_RELEASE/steamify.sh" 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). # 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_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_LAUNCHER="${XDG_DATA_HOME:-$HOME/.local/share}/cachyos-gamescope-boot/run-wizard"
WIZARD_DESKTOP_NAME="cachyos-gamescope-boot-wizard.desktop" WIZARD_APP_LAUNCHER="${XDG_DATA_HOME:-$HOME/.local/share}/cachyos-gamescope-boot/run-app"
WIZARD_APP_ENTRY="${XDG_DATA_HOME:-$HOME/.local/share}/applications/$WIZARD_DESKTOP_NAME" 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() { wizard_desktop_dir() {
echo "$(xdg-user-dir DESKTOP 2>/dev/null || echo "$HOME/Desktop")/$WIZARD_DESKTOP_NAME" xdg-user-dir DESKTOP 2>/dev/null || echo "$HOME/Desktop"
} }
install_executable() { install_executable() {
@@ -29,11 +39,15 @@ install_executable() {
} }
launcher_status() { 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() { 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 install_executable "$WIZARD_LAUNCHER" 755 << EOF
#!/bin/bash #!/bin/bash
# Installed by Steamify CachyOS: run its newest release. # Installed by Steamify CachyOS: run its newest release.
@@ -53,6 +67,23 @@ for (( i = 10; i > 0; i-- )); do
read -rs -t 1 _ && break read -rs -t 1 _ && break
done done
echo 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 EOF
# The icon comes with the release; without it, Steam's own icon. # The icon comes with the release; without it, Steam's own icon.
@@ -72,18 +103,34 @@ EOF
Type=Application Type=Application
Name=Steamify CachyOS Name=Steamify CachyOS
Comment=Turn the SteamOS-style parts of this PC on or off 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 Exec=konsole -e $WIZARD_LAUNCHER
Icon=$icon Icon=$icon
Terminal=false Terminal=false
Categories=Game;System;Settings;" Categories=Game;System;Settings;" | install_executable "$WIZARD_TERMINAL_ENTRY" 644
printf '%s\n' "$entry" | install_executable "$WIZARD_APP_ENTRY" 644 # The terminal-only shortcut from before 2.0.1.
printf '%s\n' "$entry" | install_executable "$(wizard_desktop_file)" 755 rm -f "$(wizard_desktop_dir)/$WIZARD_OLD_NAME" "$WIZARD_APPS/$WIZARD_OLD_NAME"
kbuildsycoca6 >/dev/null 2>&1 || true 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() { 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 rmdir "$(dirname "$WIZARD_LAUNCHER")" 2>/dev/null || true
kbuildsycoca6 >/dev/null 2>&1 || true kbuildsycoca6 >/dev/null 2>&1 || true
ok "Steamify shortcut removed." ok "Steamify shortcut removed."
+16 -2
View File
@@ -15,20 +15,25 @@
set -uo pipefail set -uo pipefail
# Release version, see CHANGELOG.md. # Release version, see CHANGELOG.md.
VERSION=1.1.4 VERSION=2.1.0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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 # shellcheck source=/dev/null
source "$SCRIPT_DIR/lib/$lib.sh" source "$SCRIPT_DIR/lib/$lib.sh"
done done
require_root_helper require_root_helper
BACKEND=false
[[ "${1:-}" == --backend ]] && BACKEND=true
if [[ "$BACKEND" == false ]]; then
echo -e "${c_bold}Steamify CachyOS${c_reset} v$VERSION" 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 "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." echo "anything you turn off is put back the way it was."
fi
if ! command -v pacman >/dev/null 2>&1; then if ! command -v pacman >/dev/null 2>&1; then
err "This doesn't look like an Arch/CachyOS system (no pacman found). Aborting." err "This doesn't look like an Arch/CachyOS system (no pacman found). Aborting."
@@ -98,6 +103,15 @@ quit_prompt() {
return 0 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 # 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. # the user quits; the restart question comes then, once, for everything.
RESTART_FOR_LOGIN=false RESTART_FOR_LOGIN=false
+1113
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
+374
View File
@@ -0,0 +1,374 @@
#!/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)
@Slot(str, str)
def hdmiForget(self, password, display):
"""Removes a saved display (hdmi-forgotten event)."""
self._start(["hdmi-forget", display], 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())