mirror of
https://github.com/theupriser/steamify-cachyos.git
synced 2026-10-03 17:41:58 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5e644d71a | ||
|
|
929ecfb349 | ||
|
|
4cc87aaea6 | ||
|
|
36d0a9d780 | ||
|
|
cb6361b49b | ||
|
|
c510f9e2e5 | ||
|
|
05a7d50c3f | ||
|
|
badfd846d8 | ||
|
|
ff364ffd69 | ||
|
|
2b3f1de75a | ||
|
|
6d7d5861d6 | ||
|
|
444ce29e57 | ||
|
|
23ef5ef7ab | ||
|
|
178ee3405e | ||
|
|
601c05329d | ||
|
|
8ae9673eb2 | ||
|
|
dbafc43f2e | ||
|
|
ae8ed85fee | ||
|
|
00bcaea712 | ||
|
|
a898d23b13 | ||
|
|
3e53257fe2 | ||
|
|
a1eab9ac79 | ||
|
|
c9c16b9eec | ||
|
|
b17addd60f | ||
|
|
ac613f05b3 | ||
|
|
3a0eefab11 | ||
|
|
96558a9ca8 | ||
|
|
7c48b3d806 | ||
|
|
396459b511 | ||
|
|
43a9541104 |
No files matched your search
@@ -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 +1,2 @@
|
|||||||
dist/
|
dist/
|
||||||
|
__pycache__/
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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,9 +51,15 @@ 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)).
|
||||||
- **Update BIOS** - installs Valve's newest Steam Machine BIOS. Never ticked
|
- **HDMI refresh boost** - only with the pinned kernel, never ticked by
|
||||||
by default, at your own risk, and only after two warnings
|
default: gets the highest refresh rate your HDMI display runs at the
|
||||||
([details](TECHNICAL.md#bios-updates-steam-machine)).
|
desktop resolution (e.g. 110 Hz instead of 60 on a 3440x1440 monitor).
|
||||||
|
Each step is shown and you confirm it; no answer within 15 s switches
|
||||||
|
back. For more (e.g. 175 Hz), use DisplayPort
|
||||||
|
([details](TECHNICAL.md#hdmi-refresh-boost-steam-machine)).
|
||||||
|
- **Update BIOS** - installs Valve's newest Steam Machine BIOS. Never ticked
|
||||||
|
by default, at your own risk, and only after two warnings
|
||||||
|
([details](TECHNICAL.md#bios-updates-steam-machine)).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}"
|
||||||
|
|||||||
@@ -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
@@ -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."
|
||||||
|
|||||||
+19
-5
@@ -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
|
||||||
|
|
||||||
echo -e "${c_bold}Steamify CachyOS${c_reset} v$VERSION"
|
BACKEND=false
|
||||||
echo "Turn the SteamOS-style parts on or off. The menu shows what is on now;"
|
[[ "${1:-}" == --backend ]] && BACKEND=true
|
||||||
echo "anything you turn off is put back the way it was."
|
|
||||||
|
if [[ "$BACKEND" == false ]]; then
|
||||||
|
echo -e "${c_bold}Steamify CachyOS${c_reset} v$VERSION"
|
||||||
|
echo "Turn the SteamOS-style parts on or off. The menu shows what is on now;"
|
||||||
|
echo "anything you turn off is put back the way it was."
|
||||||
|
fi
|
||||||
|
|
||||||
if ! command -v pacman >/dev/null 2>&1; then
|
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
File diff suppressed because it is too large.
Load diff
Executable
+74
@@ -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
@@ -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())
|
||||||
Reference in new issue
Block a user