From 387991dff3080473b9476a3188637569bf2ecf40 Mon Sep 17 00:00:00 2001 From: Rick Peters Date: Fri, 25 Sep 2026 15:46:02 +0200 Subject: [PATCH 01/15] feat: Steamify app (v2 UI), first working version ui/steamify-ui (PySide6 + QML): menu, review, password, applying and done screens from the design, scaled to the window. Controller via evdev, keyboard, TV remote (cecd). steamify.sh --backend status|apply (lib/backend.sh) drives the same components; sudo asks through an askpass helper. --- .gitignore | 1 + lib/backend.sh | 142 ++++++++++++ steamify.sh | 22 +- ui/qml/Main.qml | 572 ++++++++++++++++++++++++++++++++++++++++++++++++ ui/steamify-ui | 329 ++++++++++++++++++++++++++++ 5 files changed, 1062 insertions(+), 4 deletions(-) create mode 100644 lib/backend.sh create mode 100644 ui/qml/Main.qml create mode 100755 ui/steamify-ui diff --git a/.gitignore b/.gitignore index 849ddff..2ead01d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ dist/ +__pycache__/ diff --git a/lib/backend.sh b/lib/backend.sh new file mode 100644 index 0000000..b585c7d --- /dev/null +++ b/lib/backend.sh @@ -0,0 +1,142 @@ +#!/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] ... +# 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). +# The BIOS update needs its typed confirmations: it stays in the terminal. +# Sourced by steamify.sh; not meant to be run on its own. + +json_str() { + # json_str : 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 ...: 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 [] + 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 + printf '{"version":%s,"firstRun":%s,"steamMachine":%s,"kernel":%s,"pinnedKernel":%s,"cecDevices":%s,"leds":%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)" \ + "$items" +} + +backend_run_component() { + # backend_run_component : 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_apply() { + local reapply=false boot="" id c + local -a want=() + while [[ $# -gt 0 ]]; do + case "$1" in + --reapply) reapply=true ;; + --boot) boot="$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 + 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 + + # 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 & + + 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_main() { + case "${1:-}" in + status) backend_status ;; + apply) shift; backend_apply "$@" ;; + *) err "Usage: steamify.sh --backend status | apply [--reapply] [--boot gamescope|desktop] ..."; return 2 ;; + esac +} diff --git a/steamify.sh b/steamify.sh index c66b9fb..9c412b8 100755 --- a/steamify.sh +++ b/steamify.sh @@ -19,16 +19,21 @@ VERSION=1.1.4 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 cec boot-session vapor-theme steamos-extras bios desktop-shortcut wizard-shortcut menu backend; do # shellcheck source=/dev/null source "$SCRIPT_DIR/lib/$lib.sh" done require_root_helper -echo -e "${c_bold}Steamify CachyOS${c_reset} v$VERSION" -echo "Turn the SteamOS-style parts on or off. The menu shows what is on now;" -echo "anything you turn off is put back the way it was." +BACKEND=false +[[ "${1:-}" == --backend ]] && BACKEND=true + +if [[ "$BACKEND" == false ]]; then + echo -e "${c_bold}Steamify CachyOS${c_reset} v$VERSION" + echo "Turn the SteamOS-style parts on or off. The menu shows what is on now;" + echo "anything you turn off is put back the way it was." +fi if ! command -v pacman >/dev/null 2>&1; then err "This doesn't look like an Arch/CachyOS system (no pacman found). Aborting." @@ -98,6 +103,15 @@ quit_prompt() { return 0 } +# The graphical app (steamify-ui) drives the same components through +# lib/backend.sh instead of the menu. +if [[ "$BACKEND" == true ]]; then + RESTART_FOR_LOGIN=false + shift + backend_main "$@" + exit $? +fi + # Menu loop: after each run the menu comes back with the new state, until # the user quits; the restart question comes then, once, for everything. RESTART_FOR_LOGIN=false diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml new file mode 100644 index 0000000..295baf5 --- /dev/null +++ b/ui/qml/Main.qml @@ -0,0 +1,572 @@ +// Steamify v2: menu -> review -> password -> applying -> done. +// Designed at 1280x720 and scaled to the window, so it fits a TV too. +import QtQuick +import QtQuick.Window +import QtQuick.Controls.Basic + +ApplicationWindow { + id: win + required property var backend + required property var gamepad + required property bool fullscreen + required property string iconUrl + + width: 1280; height: 720 + minimumWidth: 960; minimumHeight: 540 + visibility: fullscreen ? Window.FullScreen : Window.Windowed + title: "Steamify" + color: t.bg + + // --- Look (from the design) --- + QtObject { + id: t + readonly property color bg: "#0f1319" + readonly property color bar: "#141a22" + readonly property color line: "#232c38" + readonly property color card: "#161d27" + readonly property color cardSel: "#1f2a3a" + readonly property color text: "#e6ebf2" + readonly property color textHi: "#eef2f7" + readonly property color soft: "#c3ccd8" + readonly property color mute: "#97a3b4" + readonly property color faint: "#8b97a8" + readonly property color accent: "#3ea6ff" + readonly property color ink: "#06121f" + readonly property color good: "#7fd1a8" + readonly property color goodBg: "#173226" + readonly property color warn: "#f2a33a" + readonly property color warnBg: "#2a2116" + readonly property color bad: "#ff9a8a" + readonly property color badBg: "#2a1c1c" + readonly property color key: "#2a3444" + readonly property string display: "Barlow Semi Condensed" + readonly property string body: "IBM Plex Sans" + readonly property string mono: "IBM Plex Mono" + } + + // --- Texts per item (the backend's labels are the fallback) --- + readonly property var texts: ({ + gaming: { label: "SteamOS conversion", hint: "Boot into gaming mode, Steam on the desktop", + body: "Boots straight into gaming mode. Switch to Desktop in Steam works, and Return to Gaming Mode on the desktop brings you back.", + changes: ["Autologin into gamescope", "Return to Gaming Mode shortcut", "Steam starts silently on the desktop"] }, + boot: { label: "Boot into", hint: "Where the PC starts", + body: "Where the PC starts after a restart. Switching back and forth works the same either way.", + changes: ["Desktop: the Plasma session is set before login", "Gaming: the SteamOS default"] }, + theme: { label: "SteamOS theme", hint: "Vapor look for the desktop, Add to Steam", + body: "The Vapor look of SteamOS for the desktop, with its panel, launcher icon and wallpaper.", + changes: ["cachyos-vapor global theme", "Add to Steam in right-click menus", "Nested Desktop, Steam keyboard window rule"] }, + glyphs: { label: "Steam Deck/Machine icons", hint: "Deck button icons in gaming mode", + body: "Steam shows Steam Deck button icons in gaming mode.", changes: ["STEAM_GAMEPADUI_ARGS -steamos3"] }, + single: { label: "Single user mode", hint: "No password, lock screen or wallet prompts", + body: "Like SteamOS: never a login or lock screen, and no KDE wallet password prompts. Typing a password with a controller is no fun.", + changes: ["SDDM autologin, no lock screen or user switching", "Valve's empty wallet; your own is kept aside", "Launcher shows Sleep, Restart, Shut Down only"] }, + launcher: { label: "Steamify shortcut", hint: "Icon on the desktop and in the launcher", + body: "Opens the newest Steamify, so you never need the install command again.", changes: ["Desktop icon and launcher entry"] }, + cec: { label: "HDMI-CEC", hint: "Use Steam with the TV remote, TV on/off with the PC", experimental: true, + body: "Use Steam with your TV remote, and the TV turns on and off with the PC. Turn on CEC on the TV too (Sony: BRAVIA Sync, Samsung: Anynet+, LG: SimpLink).", + changes: ["Valve's cecd and cec-audio-control", "HDMI CEC section in Steam's Display settings", "Volume buttons for the TV in the Quick Access menu"] }, + machine: { label: "Steam Machine support", hint: "LED bar, fan and performance settings in Steam", + body: "The front LED bar works, Steam's hardware settings work, and the power button puts it to sleep like a console.", + changes: ["leds-valve driver for every kernel (DKMS)", "steamos-manager for Steam's settings", "Console-like power handling"] }, + kpin: { label: "Pin the kernel", hint: "Fixes rebooting after shutdown", + body: "Newer CachyOS kernels make the Steam Machine reboot instead of shutting down. Untick once CachyOS fixes that.", + changes: ["linux-cachyos from Steamify's release (signature checked)", "Kept in /var/cache/steamify/kernel", "Added to IgnorePkg"] }, + bios: { label: "Update BIOS", hint: "Runs in the terminal version (two warnings, typed confirmation)", + body: "Installs Valve's newest Steam Machine BIOS, at your own risk. The app leaves this to the terminal version, which asks twice and wants UPDATE typed.", + changes: ["Run steamify.sh in Konsole for this"] } + }) + + // --- State --- + property string screen: "menu" // menu review password applying done + property string inputType: "keyboard" // controller keyboard remote + property double remoteAt: 0 + property var want: ({}) + property string boot: "gamescope" + property int sel: 0 // row index; rows.length = the Apply button + property bool reapply: false + property var plan: [] // [{id, action}] + property var steps: ({}) // id -> "wait"|"run"|"ok"|"fail" + property var failed: [] + property bool restartNeeded: false + property string runError: "" + property bool wrongPassword: false + property int doneCount: 0 + ListModel { id: logModel } + + readonly property var status: backend.status || ({}) + readonly property var items: status.items || [] + readonly property var rows: items.filter(function (i) { return !i.parent || want[i.parent]; }) + + function nowOn(id) { + for (var i = 0; i < items.length; i++) if (items[i].id === id) return items[i].on; + return false; + } + function label(it) { return (texts[it.id] && texts[it.id].label) || it.label; } + function syncFromStatus() { + var w = {}; + for (var i = 0; i < items.length; i++) w[items[i].id] = items[i].kind === "action" ? false : items[i].wanted; + want = w; + boot = nowOn("boot") ? "desktop" : "gamescope"; + if (sel > rows.length) sel = 0; + } + Connections { target: backend; function onStatusChanged() { if (screen === "menu" || screen === "done") syncFromStatus(); } } + + function toggle(id) { + var w = Object.assign({}, want); + w[id] = !w[id]; + if (id === "gaming" && !w.gaming) { w.single = false; } + if (id === "single" && w.single) w.gaming = true; + if (id === "machine") w.kpin = w.machine; + if (id === "kpin" && w.kpin) w.machine = true; + want = w; + } + function computePlan() { + var p = []; + for (var i = 0; i < items.length; i++) { + var it = items[i]; + if (it.kind !== "toggle") continue; + if (it.parent && !want[it.parent]) { if (it.on) p.push({ id: it.id, action: "off" }); continue; } + if (want[it.id] && !it.on) p.push({ id: it.id, action: "on" }); + else if (!want[it.id] && it.on) p.push({ id: it.id, action: "off" }); + else if (want[it.id] && reapply) p.push({ id: it.id, action: "again" }); + } + var bootNow = nowOn("boot") ? "desktop" : "gamescope"; + if (want.gaming && boot !== bootNow) p.push({ id: "boot", action: boot === "desktop" ? "desktop" : "gaming" }); + return p; + } + function goReview(again) { + reapply = again; plan = computePlan(); screen = "review"; + } + function wantedIds() { + var ids = []; + for (var i = 0; i < items.length; i++) + if (items[i].kind === "toggle" && want[items[i].id] && (!items[i].parent || want[items[i].parent])) ids.push(items[i].id); + return ids; + } + function startApply(password) { + steps = {}; failed = []; restartNeeded = false; runError = ""; doneCount = 0; logModel.clear(); + var s = {}; + for (var i = 0; i < plan.length; i++) s[plan[i].id] = "wait"; + steps = s; + screen = "applying"; + backend.apply(wantedIds(), want.gaming ? boot : "", reapply, password || ""); + } + function onApplyPressed() { + if (plan.length === 0) return; + if (backend.needsPassword()) { wrongPassword = false; pw.text = ""; screen = "password"; pw.forceActiveFocus(); } + else startApply(""); + } + function submitPassword() { + if (!backend.checkPassword(pw.text)) { wrongPassword = true; pw.selectAll(); return; } + var p = pw.text; pw.text = ""; + startApply(p); + } + Connections { + target: backend + function onEvent(ev) { + var s = Object.assign({}, steps); + if (ev.event === "start") { s[ev.id] = "run"; steps = s; } + else if (ev.event === "done") { s[ev.id] = ev.ok ? "ok" : "fail"; steps = s; doneCount++; } + else if (ev.event === "log") { logModel.append({ line: ev.line }); if (logModel.count > 400) logModel.remove(0); } + else if (ev.event === "finished") { + failed = ev.failed || []; restartNeeded = !!ev.restart; + runError = ev.error === "wrong-password" ? "The password didn't work." : (ev.error ? "sudo isn't available." : ""); + screen = "done"; + } + } + function onRemotePressed() { remoteAt = Date.now(); inputType = "remote"; } + } + + // --- Input: one set of actions for controller, keyboard and remote --- + function act(a) { + if (screen === "menu") { + if (a === "up") sel = Math.max(0, sel - 1); + else if (a === "down") sel = Math.min(rows.length, sel + 1); + else if (a === "accept") { + if (sel === rows.length) { goReview(false); return; } + var r = rows[sel]; + if (r.kind === "choice") boot = boot === "gamescope" ? "desktop" : "gamescope"; + else if (r.kind === "toggle") toggle(r.id); + } + else if ((a === "left" || a === "right") && sel < rows.length && rows[sel].kind === "choice") + boot = a === "left" ? "gamescope" : "desktop"; + else if (a === "apply") goReview(false); + else if (a === "reapply") goReview(true); + else if (a === "back") Qt.quit(); + } else if (screen === "review") { + if (a === "accept" || a === "apply") onApplyPressed(); + else if (a === "back") screen = "menu"; + } else if (screen === "password") { + if (a === "accept" || a === "apply") submitPassword(); + else if (a === "back") { pw.text = ""; screen = "review"; } + } else if (screen === "done") { + if (a === "accept" && restartNeeded) backend.restart(); + else if (a === "back" || a === "accept") { syncFromStatus(); screen = "menu"; } + } + } + Connections { + target: gamepad + function onButton(b) { + inputType = "controller"; + var map = { a: "accept", b: "back", x: "apply", y: "reapply", start: "apply", up: "up", down: "down", left: "left", right: "right" }; + if (map[b]) act(map[b]); + } + } + function keyAct(e) { + var fromRemote = Date.now() - remoteAt < 400; + if (!fromRemote) inputType = "keyboard"; + var k = e.key; + if (k === Qt.Key_Up) act("up"); + else if (k === Qt.Key_Down) act("down"); + else if (k === Qt.Key_Left) act("left"); + else if (k === Qt.Key_Right) act("right"); + else if (k === Qt.Key_Space) act("accept"); + else if (k === Qt.Key_Return || k === Qt.Key_Enter) act(inputType === "remote" || screen !== "menu" ? "accept" : "apply"); + else if (k === Qt.Key_Escape || k === Qt.Key_Back || k === Qt.Key_Backspace) act("back"); + else if (k === Qt.Key_R) act("reapply"); + else return; + e.accepted = true; + } + + readonly property var glyphs: ({ + controller: { toggle: "A", choose: "◀ ▶", reapply: "Y", quit: "B", apply: "X", ok: "A", back: "B" }, + keyboard: { toggle: "Space", choose: "← →", reapply: "R", quit: "Esc", apply: "Enter", ok: "Enter", back: "Esc" }, + remote: { toggle: "OK", choose: "◀ ▶", reapply: "Red", quit: "Back", apply: "↓ Apply", ok: "OK", back: "Back" } + }) + readonly property var g: glyphs[inputType] + + component Glyph: Rectangle { + property string k + property bool dark: false + height: 26; width: Math.max(26, gt.implicitWidth + 14) + radius: inputType === "controller" && k.length === 1 ? 13 : 7 + color: dark ? t.ink : t.key + Text { id: gt; anchors.centerIn: parent; text: parent.k; color: parent.dark ? t.accent : t.textHi; font.family: t.body; font.pixelSize: 12; font.weight: Font.DemiBold } + } + component Btn: Rectangle { + property string k + property string text + property bool primary: false + property bool focusRing: false + signal clicked + height: 48; radius: 12; width: br.implicitWidth + 36 + color: primary ? t.accent : "#232c38" + border.width: focusRing ? 2 : 0; border.color: t.textHi + Row { id: br; anchors.centerIn: parent; spacing: 10 + Glyph { k: parent.parent.k; dark: parent.parent.primary; anchors.verticalCenter: parent.verticalCenter } + Text { text: parent.parent.text; color: parent.parent.primary ? t.ink : t.text; font.family: t.body; font.pixelSize: 15; font.weight: Font.DemiBold; anchors.verticalCenter: parent.verticalCenter } + } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: parent.clicked() } + } + component Chip: Rectangle { + property string text + property color fg: t.good + property color bgc: t.goodBg + height: 24; radius: 6; width: ct.implicitWidth + 16; color: bgc + Text { id: ct; anchors.centerIn: parent; text: parent.text.toUpperCase(); color: parent.fg; font.family: t.body; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.6 } + } + + // --- The 1280x720 stage, scaled to the window --- + Item { + id: stage + width: 1280; height: 720 + anchors.centerIn: parent + scale: Math.min(win.width / 1280, win.height / 720) + focus: true + Keys.onPressed: function (e) { if (!(screen === "password" && pw.activeFocus && e.key !== Qt.Key_Escape && e.key !== Qt.Key_Return && e.key !== Qt.Key_Enter)) keyAct(e); } + + // Header + Rectangle { + id: header; width: parent.width; height: 72; color: t.bar + Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: t.line } + Row { + anchors.left: parent.left; anchors.leftMargin: 40; anchors.verticalCenter: parent.verticalCenter; spacing: 14 + Image { source: iconUrl; width: 40; height: 40; sourceSize: Qt.size(80, 80); anchors.verticalCenter: parent.verticalCenter } + Text { text: "Steamify"; color: t.text; font.family: t.display; font.pixelSize: 28; font.weight: Font.Bold; anchors.verticalCenter: parent.verticalCenter } + Text { text: screen === "menu" ? "v" + (status.version || "") : "/ " + ({review: "Review", password: "Password", applying: "Applying", done: "Done"})[screen] + color: t.faint; font.family: screen === "menu" ? t.mono : t.body; font.pixelSize: screen === "menu" ? 13 : 15; anchors.verticalCenter: parent.verticalCenter } + } + Rectangle { + visible: status.steamMachine === true + anchors.right: parent.right; anchors.rightMargin: 40; anchors.verticalCenter: parent.verticalCenter + height: 34; radius: 17; color: "#1b2330"; width: mt.implicitWidth + 28 + Text { id: mt; anchors.centerIn: parent; text: "Steam Machine · CachyOS"; color: "#b8c3d1"; font.family: t.body; font.pixelSize: 14 } + } + } + + // ================= MENU ================= + Item { + visible: screen === "menu" + anchors.top: header.bottom; anchors.bottom: parent.bottom; width: parent.width + + Text { + visible: items.length === 0 + anchors.centerIn: parent; color: t.mute; font.family: t.body; font.pixelSize: 18 + text: status.error ? "Couldn't read the current state:\n" + status.error : "Checking what's on…" + horizontalAlignment: Text.AlignHCenter + } + + Item { + id: menuBody; visible: items.length > 0 + x: 40; y: 28; width: 1200; height: parent.height - 28 - 64 - 20 + // Left: the list + Column { + id: listCol; width: 740; height: parent.height; spacing: 10 + Row { width: parent.width + Text { text: "What do you want?"; color: t.text; font.family: t.display; font.pixelSize: 30; font.weight: Font.DemiBold } + } + ListView { + id: list; width: parent.width; height: parent.height - 50; clip: true; spacing: 6 + model: rows + currentIndex: Math.min(sel, rows.length - 1) + highlightFollowsCurrentItem: false + onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain) + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + delegate: Rectangle { + id: row + required property var modelData + required property int index + readonly property bool selected: index === sel + readonly property bool on: modelData.kind === "choice" ? true : !!want[modelData.id] + width: list.width - 12; height: 56; radius: 12 + x: 2 + color: selected ? t.cardSel : t.card + border.width: selected ? 2 : 0; border.color: t.accent + opacity: modelData.kind === "action" ? 0.6 : 1 + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: { inputType = "keyboard"; if (sel === row.index) act("accept"); else sel = row.index; } } + Row { + anchors.fill: parent; anchors.leftMargin: modelData.parent ? 22 : 16; anchors.rightMargin: 16; spacing: 14 + Text { visible: !!modelData.parent; text: "└"; color: "#56627a"; font.pixelSize: 18; anchors.verticalCenter: parent.verticalCenter } + Column { anchors.verticalCenter: parent.verticalCenter; spacing: 2 + width: parent.width - (modelData.parent ? 40 : 0) - (modelData.kind === "choice" ? 200 : 150) + Text { text: label(modelData); color: t.textHi; font.family: t.body; font.pixelSize: 17; font.weight: Font.DemiBold; elide: Text.ElideRight; width: parent.width } + Text { text: (texts[modelData.id] && texts[modelData.id].hint) || modelData.hint; color: t.mute; font.family: t.body; font.pixelSize: 13; elide: Text.ElideRight; width: parent.width } + } + // choice + Rectangle { visible: modelData.kind === "choice"; anchors.verticalCenter: parent.verticalCenter + width: 170; height: 32; radius: 10; color: t.bg + Row { anchors.centerIn: parent; spacing: 4 + Repeater { model: [["gamescope", "Gaming"], ["desktop", "Desktop"]] + Rectangle { required property var modelData + width: 80; height: 26; radius: 7; color: boot === modelData[0] ? t.accent : "transparent" + Text { anchors.centerIn: parent; text: parent.modelData[1]; color: boot === parent.modelData[0] ? t.ink : t.mute; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold } + MouseArea { anchors.fill: parent; onClicked: boot = parent.modelData[0] } } } } } + // toggle + Row { visible: modelData.kind === "toggle"; anchors.verticalCenter: parent.verticalCenter; spacing: 12 + Text { text: modelData.on ? "now on" : "now off"; color: modelData.on ? t.good : t.faint; font.family: t.mono; font.pixelSize: 12; width: 56; horizontalAlignment: Text.AlignRight; anchors.verticalCenter: parent.verticalCenter } + Rectangle { width: 46; height: 26; radius: 13; color: row.on ? t.accent : "#343f50"; anchors.verticalCenter: parent.verticalCenter + Rectangle { width: 20; height: 20; radius: 10; color: "#f4f7fb"; y: 3; x: row.on ? 23 : 3; Behavior on x { NumberAnimation { duration: 120 } } } } } + // action + Rectangle { visible: modelData.kind === "action"; anchors.verticalCenter: parent.verticalCenter + width: at.implicitWidth + 24; height: 30; radius: 8; color: t.warnBg + Text { id: at; anchors.centerIn: parent; text: "In the terminal"; color: t.warn; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold } } + } + } + } + } + // Right: details + system + Column { + x: 772; width: parent.width - 772; height: parent.height; spacing: 16 + Rectangle { + width: parent.width; height: parent.height - sys.height - 16; radius: 16; color: t.card + readonly property var it: sel < rows.length ? rows[sel] : null + readonly property var tx: it ? (texts[it.id] || {}) : {} + Column { + anchors.fill: parent; anchors.margins: 24; spacing: 14 + visible: parent.it !== null + Row { spacing: 10 + Chip { text: parent.parent.parent.it ? (parent.parent.parent.it.kind === "choice" ? (boot === "desktop" ? "Desktop" : "Gaming") : (parent.parent.parent.it.kind === "action" ? "Opt-in" : (parent.parent.parent.it.on ? "On" : "Off"))) : "" + fg: parent.parent.parent.it && parent.parent.parent.it.on ? t.good : "#b8c3d1"; bgc: parent.parent.parent.it && parent.parent.parent.it.on ? t.goodBg : t.line } + Chip { visible: !!parent.parent.parent.tx.experimental; text: "Experimental"; fg: t.warn; bgc: t.warnBg } + } + Text { text: parent.parent.it ? label(parent.parent.it) : ""; color: t.text; font.family: t.display; font.pixelSize: 26; font.weight: Font.DemiBold; wrapMode: Text.WordWrap; width: parent.width } + Text { text: parent.parent.tx.body || ""; color: t.soft; font.family: t.body; font.pixelSize: 15; lineHeight: 1.4; wrapMode: Text.WordWrap; width: parent.width } + Text { text: "WHAT IT CHANGES"; color: t.faint; font.family: t.body; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.8; topPadding: 4 } + Repeater { model: parent.parent.tx.changes || [] + Text { required property string modelData; text: "• " + modelData; color: t.soft; font.family: t.body; font.pixelSize: 14; wrapMode: Text.WordWrap; width: parent.width } } + } + Column { + anchors.centerIn: parent; spacing: 8; visible: parent.it === null + Text { text: plan.length === 0 && computePlan().length === 0 ? "Everything is the way you want it" : "Review what will change"; color: t.text; font.family: t.display; font.pixelSize: 24; anchors.horizontalCenter: parent.horizontalCenter } + Text { text: "then apply it"; color: t.mute; font.family: t.body; font.pixelSize: 15; anchors.horizontalCenter: parent.horizontalCenter } + } + } + Rectangle { + id: sys; width: parent.width; height: 82; radius: 16; color: t.card + Grid { anchors.fill: parent; anchors.margins: 16; anchors.leftMargin: 20; anchors.rightMargin: 20; columns: 2; columnSpacing: 20; rowSpacing: 10 + Repeater { + model: [["Kernel", (status.kernel || "").replace("-cachyos", "")], ["LED bar", status.steamMachine ? (status.leds || 0) + " LEDs" : "—"], + ["HDMI-CEC", status.cecDevices ? "/dev/" + status.cecDevices.split(" ")[0] : "none"], ["Version", status.version || ""]] + Row { required property var modelData; width: (sys.width - 60) / 2 + Text { text: parent.modelData[0]; color: t.faint; font.family: t.body; font.pixelSize: 13; width: parent.width / 2 } + Text { text: parent.modelData[1]; color: t.text; font.family: t.mono; font.pixelSize: 13; width: parent.width / 2; horizontalAlignment: Text.AlignRight; elide: Text.ElideLeft } } + } + } + } + } + } + + // Footer + Rectangle { + anchors.bottom: parent.bottom; width: parent.width; height: 64; color: t.bar + Rectangle { width: parent.width; height: 1; color: t.line } + Row { + anchors.left: parent.left; anchors.leftMargin: 40; anchors.verticalCenter: parent.verticalCenter; spacing: 28 + Repeater { + model: [[g.toggle, "Toggle"], [g.choose, "Choose"], [g.reapply, "Re-apply what's on"], [g.quit, "Quit"]] + Row { required property var modelData; spacing: 8 + Glyph { k: parent.modelData[0]; anchors.verticalCenter: parent.verticalCenter } + Text { text: parent.modelData[1]; color: "#b8c3d1"; font.family: t.body; font.pixelSize: 14; anchors.verticalCenter: parent.verticalCenter } } + } + } + Row { + anchors.right: parent.right; anchors.rightMargin: 40; anchors.verticalCenter: parent.verticalCenter; spacing: 16 + Text { readonly property int n: computePlan().length; text: n === 0 ? "Everything is the way you want it" : n + (n === 1 ? " change" : " changes"); color: t.faint; font.family: t.body; font.pixelSize: 14; anchors.verticalCenter: parent.verticalCenter } + Btn { k: g.apply; text: "Review & apply"; primary: true; focusRing: sel === rows.length; height: 44; onClicked: goReview(false) } + } + } + } + + // ================= REVIEW ================= + Item { + visible: screen === "review" + anchors.top: header.bottom; anchors.bottom: parent.bottom; width: parent.width + Column { + x: 40; y: 32; width: 740; spacing: 14 + Text { text: plan.length === 0 ? "Nothing to change" : "This will"; color: t.text; font.family: t.display; font.pixelSize: 34; font.weight: Font.DemiBold } + Repeater { + model: plan + Rectangle { required property var modelData; width: 740; height: 56; radius: 12; color: t.card + readonly property var st: ({ on: ["Turn on", t.good, t.goodBg], off: ["Turn off", t.bad, t.badBg], again: ["Re-apply", "#7cc4ff", "#1b2b40"], + desktop: ["Desktop", "#7cc4ff", "#1b2b40"], gaming: ["Gaming", "#7cc4ff", "#1b2b40"] })[modelData.action] + Row { anchors.fill: parent; anchors.leftMargin: 18; spacing: 14 + Chip { text: parent.parent.st[0]; fg: parent.parent.st[1]; bgc: parent.parent.st[2]; width: 84; anchors.verticalCenter: parent.verticalCenter } + Text { text: (texts[parent.parent.modelData.id] || {}).label || parent.parent.modelData.id; color: t.textHi; font.family: t.body; font.pixelSize: 17; font.weight: Font.DemiBold; anchors.verticalCenter: parent.verticalCenter } + } + } + } + Rectangle { width: 740; height: 52; radius: 12; color: "#1a2230" + Text { anchors.verticalCenter: parent.verticalCenter; x: 18; text: "Your password is asked once. Changes to how the PC starts need a restart."; color: t.soft; font.family: t.body; font.pixelSize: 14 } } + } + Rectangle { + x: 812; y: 32; width: 428; height: 210; radius: 16; color: t.card + Column { anchors.fill: parent; anchors.margins: 24; spacing: 12 + Text { text: "SUMMARY"; color: t.faint; font.family: t.body; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } + Repeater { model: [["Turn on", "on"], ["Re-apply", "again"], ["Turn off", "off"]] + Row { required property var modelData; width: 380 + Text { text: parent.modelData[0]; color: t.soft; font.family: t.body; font.pixelSize: 15; width: 300 } + Text { text: plan.filter(function (p) { return p.action === parent.modelData[1]; }).length; color: t.text; font.family: t.mono; font.pixelSize: 15; width: 80; horizontalAlignment: Text.AlignRight } } } + } + } + Rectangle { + anchors.bottom: parent.bottom; width: parent.width; height: 64; color: t.bar + Rectangle { width: parent.width; height: 1; color: t.line } + Btn { anchors.left: parent.left; anchors.leftMargin: 40; anchors.verticalCenter: parent.verticalCenter; k: g.back; text: "Back to the menu"; onClicked: screen = "menu" } + Btn { anchors.right: parent.right; anchors.rightMargin: 40; anchors.verticalCenter: parent.verticalCenter; k: g.ok; text: "Apply"; primary: true; visible: plan.length > 0; onClicked: onApplyPressed() } + } + } + + // ================= PASSWORD ================= + Rectangle { + visible: screen === "password" + anchors.top: header.bottom; anchors.bottom: parent.bottom; width: parent.width; color: "#0c1016" + Rectangle { + anchors.centerIn: parent; width: 560; height: pwCol.implicitHeight + 72; radius: 20; color: t.card; border.width: 1; border.color: t.line + Column { + id: pwCol; anchors.fill: parent; anchors.margins: 36; anchors.leftMargin: 40; anchors.rightMargin: 40; spacing: 18 + Row { spacing: 14 + Rectangle { width: 48; height: 48; radius: 14; color: "#1b2b40" + Canvas { anchors.centerIn: parent; width: 24; height: 24 + onPaint: { var c = getContext("2d"); c.strokeStyle = "#7cc4ff"; c.lineWidth = 1.8; c.beginPath(); c.roundedRect(4.5, 10.5, 15, 10, 2.5, 2.5); c.stroke(); + c.beginPath(); c.moveTo(8, 10.5); c.lineTo(8, 8); c.arc(12, 8, 4, Math.PI, 0); c.lineTo(16, 10.5); c.stroke(); } } } + Column { spacing: 2; anchors.verticalCenter: parent.verticalCenter + Text { text: "Your password, once"; color: t.text; font.family: t.display; font.pixelSize: 30; font.weight: Font.DemiBold } + Text { text: "Steamify needs it to change system settings."; color: t.mute; font.family: t.body; font.pixelSize: 14 } } + } + Text { text: "Password for " + backend.user(); color: t.soft; font.family: t.body; font.pixelSize: 14; font.weight: Font.DemiBold } + TextField { + id: pw; width: parent.width; height: 52; echoMode: shown ? TextInput.Normal : TextInput.Password + property bool shown: false + color: t.textHi; font.family: t.body; font.pixelSize: 18; leftPadding: 16; rightPadding: 56 + placeholderText: "Password"; placeholderTextColor: "#6b778a" + background: Rectangle { radius: 12; color: t.bg; border.width: 2; border.color: wrongPassword ? t.bad : t.accent } + Keys.onReturnPressed: submitPassword() + Keys.onEnterPressed: submitPassword() + Keys.onEscapePressed: act("back") + Text { anchors.right: parent.right; anchors.rightMargin: 16; anchors.verticalCenter: parent.verticalCenter; text: pw.shown ? "Hide" : "Show"; color: "#b8c3d1"; font.family: t.body; font.pixelSize: 13 + MouseArea { anchors.fill: parent; anchors.margins: -10; onClicked: pw.shown = !pw.shown } } + } + Text { visible: wrongPassword; text: "That password didn't work. Try again."; color: t.bad; font.family: t.body; font.pixelSize: 14 } + Rectangle { width: parent.width; height: 46; radius: 12; color: "#1a2230" + Text { anchors.verticalCenter: parent.verticalCenter; x: 14; color: t.soft; font.family: t.body; font.pixelSize: 14 + text: ({ controller: "Press Steam + X for the on-screen keyboard.", keyboard: "Type your password and press Enter.", remote: "Select the field for the on-screen keyboard." })[inputType] } } + Item { width: parent.width; height: 48 + Btn { k: g.back; text: "Back"; onClicked: act("back") } + Btn { anchors.right: parent.right; k: g.ok; text: "Apply"; primary: true; onClicked: submitPassword() } } + Text { text: "Only used for this run. Steamify never stores it."; color: t.faint; font.family: t.body; font.pixelSize: 12; anchors.horizontalCenter: parent.horizontalCenter } + } + } + } + + // ================= APPLYING ================= + Item { + visible: screen === "applying" + anchors.top: header.bottom; anchors.bottom: parent.bottom; width: parent.width + Column { + x: 40; y: 32; width: 560; spacing: 18 + Text { text: "Applying your changes"; color: t.text; font.family: t.display; font.pixelSize: 34; font.weight: Font.DemiBold } + Rectangle { width: 560; height: 10; radius: 5; color: t.line + Rectangle { height: 10; radius: 5; color: t.accent; width: plan.length ? parent.width * Math.min(1, (doneCount + 0.3) / plan.length) : 0; Behavior on width { NumberAnimation { duration: 300 } } } } + Row { width: 560 + Text { text: "Step " + Math.min(plan.length, doneCount + 1) + " of " + plan.length; color: t.mute; font.family: t.body; font.pixelSize: 14; width: 280 } + Text { text: "Don't turn off the PC"; color: t.mute; font.family: t.body; font.pixelSize: 14; width: 280; horizontalAlignment: Text.AlignRight } } + Repeater { + model: plan + Rectangle { required property var modelData; readonly property string st: steps[modelData.id] || "wait" + width: 560; height: 54; radius: 12; color: st === "run" ? t.cardSel : t.card; border.width: st === "run" ? 2 : 0; border.color: t.accent + Row { anchors.fill: parent; anchors.leftMargin: 18; anchors.rightMargin: 18; spacing: 14 + Rectangle { width: 22; height: 22; radius: 11; anchors.verticalCenter: parent.verticalCenter + color: parent.parent.st === "ok" ? t.goodBg : (parent.parent.st === "fail" ? t.badBg : "transparent") + border.width: parent.parent.st === "wait" || parent.parent.st === "run" ? 2 : 0; border.color: parent.parent.st === "run" ? t.accent : "#343f50" + Text { anchors.centerIn: parent; text: parent.parent.parent.st === "ok" ? "✓" : (parent.parent.parent.st === "fail" ? "!" : ""); color: parent.parent.parent.st === "ok" ? t.good : t.bad; font.pixelSize: 13; font.weight: Font.Bold } + RotationAnimator on rotation { running: parent.parent.parent.st === "run"; from: 0; to: 360; duration: 1000; loops: Animation.Infinite } } + Text { text: ({ on: "Turn on ", off: "Turn off ", again: "Re-apply ", desktop: "Boot into ", gaming: "Boot into " })[parent.parent.modelData.action] + ((texts[parent.parent.modelData.id] || {}).label || parent.parent.modelData.id) + color: parent.parent.st === "wait" ? t.faint : t.textHi; font.family: t.body; font.pixelSize: 16; font.weight: Font.DemiBold; anchors.verticalCenter: parent.verticalCenter; width: 380; elide: Text.ElideRight } + Text { text: ({ wait: "Waiting", run: "Working…", ok: "Done", fail: "Problem" })[parent.parent.st]; color: parent.parent.st === "ok" ? t.good : (parent.parent.st === "fail" ? t.bad : "#b8c3d1"); font.family: t.body; font.pixelSize: 13; anchors.verticalCenter: parent.verticalCenter } + } + } + } + } + Column { + x: 632; y: 32; width: 608; height: parent.height - 64; spacing: 10 + Text { text: "DETAILS"; color: t.faint; font.family: t.body; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } + Rectangle { width: parent.width; height: parent.height - 30; radius: 16; color: "#0b0e13"; border.width: 1; border.color: "#1d2531" + ListView { id: logView; anchors.fill: parent; anchors.margins: 18; clip: true; model: logModel + onCountChanged: positionViewAtEnd() + delegate: Text { required property string line; width: logView.width; text: line; color: "#aab5c4"; font.family: t.mono; font.pixelSize: 13; wrapMode: Text.WrapAnywhere } } } + } + } + + // ================= DONE ================= + Item { + visible: screen === "done" + anchors.top: header.bottom; anchors.bottom: parent.bottom; width: parent.width + Column { + anchors.centerIn: parent; width: 760; spacing: 20 + Rectangle { width: 72; height: 72; radius: 36; anchors.horizontalCenter: parent.horizontalCenter + color: failed.length || runError ? t.warnBg : t.goodBg + Text { anchors.centerIn: parent; text: failed.length || runError ? "!" : "✓"; color: failed.length || runError ? t.warn : t.good; font.pixelSize: 36; font.weight: Font.Bold } } + Text { text: runError ? "Nothing changed" : (failed.length ? "Done, with a problem" : "All done"); color: t.text; font.family: t.display; font.pixelSize: 40; font.weight: Font.DemiBold; anchors.horizontalCenter: parent.horizontalCenter } + Text { width: parent.width; horizontalAlignment: Text.AlignHCenter; wrapMode: Text.WordWrap; color: t.soft; font.family: t.body; font.pixelSize: 17 + text: runError ? runError : (plan.length + (plan.length === 1 ? " change" : " changes") + " applied." + (restartNeeded ? " Some take effect after a restart." : "")) } + Rectangle { visible: failed.length > 0; anchors.horizontalCenter: parent.horizontalCenter; width: ft.implicitWidth + 32; height: 44; radius: 12; color: t.warnBg + Text { id: ft; anchors.centerIn: parent; color: "#f2c27a"; font.family: t.body; font.pixelSize: 14 + text: "Had a problem: " + failed.map(function (id) { return (texts[id] || {}).label || id; }).join(", ") + ". See the details." } } + Row { anchors.horizontalCenter: parent.horizontalCenter; spacing: 14; topPadding: 8 + Btn { k: g.back; text: "Back to the menu"; height: 52; onClicked: act("back") } + Btn { visible: restartNeeded; k: g.ok; text: "Restart now"; primary: true; height: 52; onClicked: backend.restart() } } + } + } + } +} diff --git a/ui/steamify-ui b/ui/steamify-ui new file mode 100755 index 0000000..8fd4337 --- /dev/null +++ b/ui/steamify-ui @@ -0,0 +1,329 @@ +#!/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.""" + 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 value == 1 and code in BUTTONS: + self.button.emit(BUTTONS[code]) + 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 + + @Slot("QVariant", str, bool, str) + def apply(self, ids, boot, reapply, password): + if self._busy: + return + args = [self._script, "--backend", "apply"] + if reapply: + args.append("--reapply") + if boot: + args += ["--boot", boot] + args += list(ids) + 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(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() + + 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()) From 232b51e37943aee1f1b69145a691998c87ca8f93 Mon Sep 17 00:00:00 2001 From: Rick Peters Date: Fri, 25 Sep 2026 15:48:38 +0200 Subject: [PATCH 02/15] fix(ui): Controls aligned on the right, sub-items fold instead of the list jumping --- ui/qml/Main.qml | 103 ++++++++++++++++++++++++++++++------------------ 1 file changed, 64 insertions(+), 39 deletions(-) diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index 295baf5..434beb6 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -316,51 +316,76 @@ ApplicationWindow { Text { text: "What do you want?"; color: t.text; font.family: t.display; font.pixelSize: 30; font.weight: Font.DemiBold } } ListView { - id: list; width: parent.width; height: parent.height - 50; clip: true; spacing: 6 - model: rows - currentIndex: Math.min(sel, rows.length - 1) - highlightFollowsCurrentItem: false - onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain) + id: list; width: parent.width; height: parent.height - 50; clip: true; spacing: 0 + // A fixed model: sub-items fold in and out instead of the + // list being rebuilt (and jumping) on every tick. + model: items ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } - delegate: Rectangle { + function showSelected() { + for (var i = 0; i < count; i++) { + var d = itemAtIndex(i); + if (d && d.selected) { positionViewAtIndex(i, ListView.Contain); return; } + } + } + delegate: Item { id: row required property var modelData required property int index - readonly property bool selected: index === sel + readonly property bool shown: !modelData.parent || !!want[modelData.parent] + readonly property int rowIndex: rows.findIndex(function (r) { return r.id === modelData.id; }) + readonly property bool selected: shown && rowIndex === sel readonly property bool on: modelData.kind === "choice" ? true : !!want[modelData.id] - width: list.width - 12; height: 56; radius: 12 - x: 2 - color: selected ? t.cardSel : t.card - border.width: selected ? 2 : 0; border.color: t.accent - opacity: modelData.kind === "action" ? 0.6 : 1 - MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor - onClicked: { inputType = "keyboard"; if (sel === row.index) act("accept"); else sel = row.index; } } - Row { - anchors.fill: parent; anchors.leftMargin: modelData.parent ? 22 : 16; anchors.rightMargin: 16; spacing: 14 - Text { visible: !!modelData.parent; text: "└"; color: "#56627a"; font.pixelSize: 18; anchors.verticalCenter: parent.verticalCenter } - Column { anchors.verticalCenter: parent.verticalCenter; spacing: 2 - width: parent.width - (modelData.parent ? 40 : 0) - (modelData.kind === "choice" ? 200 : 150) - Text { text: label(modelData); color: t.textHi; font.family: t.body; font.pixelSize: 17; font.weight: Font.DemiBold; elide: Text.ElideRight; width: parent.width } - Text { text: (texts[modelData.id] && texts[modelData.id].hint) || modelData.hint; color: t.mute; font.family: t.body; font.pixelSize: 13; elide: Text.ElideRight; width: parent.width } + onSelectedChanged: if (selected) Qt.callLater(list.showSelected) + width: list.width - 12; x: 2 + height: shown ? 62 : 0 + opacity: shown ? 1 : 0 + clip: true + Behavior on height { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + Behavior on opacity { NumberAnimation { duration: 160 } } + Rectangle { + id: card + width: parent.width; height: 56; radius: 12 + color: row.selected ? t.cardSel : t.card + border.width: row.selected ? 2 : 0; border.color: t.accent + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: { inputType = "keyboard"; if (row.selected) act("accept"); else sel = row.rowIndex; } } + // Left: indent, name and hint + Text { id: branch; visible: !!row.modelData.parent; x: 20; anchors.verticalCenter: parent.verticalCenter + text: "└"; color: "#56627a"; font.pixelSize: 18 } + Column { + anchors.verticalCenter: parent.verticalCenter; spacing: 2 + x: row.modelData.parent ? 46 : 16 + width: controls.x - x - 16 + opacity: row.modelData.kind === "action" ? 0.6 : 1 + Text { text: label(row.modelData); color: t.textHi; font.family: t.body; font.pixelSize: 17; font.weight: Font.DemiBold; elide: Text.ElideRight; width: parent.width } + Text { text: (texts[row.modelData.id] && texts[row.modelData.id].hint) || row.modelData.hint; color: t.mute; font.family: t.body; font.pixelSize: 13; elide: Text.ElideRight; width: parent.width } + } + // Right: every control ends on the same edge + Item { + id: controls + anchors.right: parent.right; anchors.rightMargin: 16; anchors.verticalCenter: parent.verticalCenter + width: 180; height: 32 + // choice + Rectangle { visible: row.modelData.kind === "choice"; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter + width: 172; height: 32; radius: 10; color: t.bg + Row { anchors.centerIn: parent; spacing: 4 + Repeater { model: [["gamescope", "Gaming"], ["desktop", "Desktop"]] + Rectangle { required property var modelData + width: 80; height: 26; radius: 7; color: boot === modelData[0] ? t.accent : "transparent" + Text { anchors.centerIn: parent; text: parent.modelData[1]; color: boot === parent.modelData[0] ? t.ink : t.mute; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold } + MouseArea { anchors.fill: parent; onClicked: boot = parent.modelData[0] } } } } } + // toggle: "now on/off" left of the switch, switch on the edge + Rectangle { id: sw; visible: row.modelData.kind === "toggle"; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter + width: 46; height: 26; radius: 13; color: row.on ? t.accent : "#343f50" + Behavior on color { ColorAnimation { duration: 120 } } + Rectangle { width: 20; height: 20; radius: 10; color: "#f4f7fb"; y: 3; x: row.on ? 23 : 3; Behavior on x { NumberAnimation { duration: 120 } } } } + Text { visible: row.modelData.kind === "toggle"; anchors.right: sw.left; anchors.rightMargin: 12; anchors.verticalCenter: parent.verticalCenter + text: row.modelData.on ? "now on" : "now off"; color: row.modelData.on ? t.good : t.faint; font.family: t.mono; font.pixelSize: 12 } + // action + Rectangle { visible: row.modelData.kind === "action"; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter + width: at.implicitWidth + 24; height: 30; radius: 8; color: t.warnBg + Text { id: at; anchors.centerIn: parent; text: "In the terminal"; color: t.warn; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold } } } - // choice - Rectangle { visible: modelData.kind === "choice"; anchors.verticalCenter: parent.verticalCenter - width: 170; height: 32; radius: 10; color: t.bg - Row { anchors.centerIn: parent; spacing: 4 - Repeater { model: [["gamescope", "Gaming"], ["desktop", "Desktop"]] - Rectangle { required property var modelData - width: 80; height: 26; radius: 7; color: boot === modelData[0] ? t.accent : "transparent" - Text { anchors.centerIn: parent; text: parent.modelData[1]; color: boot === parent.modelData[0] ? t.ink : t.mute; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold } - MouseArea { anchors.fill: parent; onClicked: boot = parent.modelData[0] } } } } } - // toggle - Row { visible: modelData.kind === "toggle"; anchors.verticalCenter: parent.verticalCenter; spacing: 12 - Text { text: modelData.on ? "now on" : "now off"; color: modelData.on ? t.good : t.faint; font.family: t.mono; font.pixelSize: 12; width: 56; horizontalAlignment: Text.AlignRight; anchors.verticalCenter: parent.verticalCenter } - Rectangle { width: 46; height: 26; radius: 13; color: row.on ? t.accent : "#343f50"; anchors.verticalCenter: parent.verticalCenter - Rectangle { width: 20; height: 20; radius: 10; color: "#f4f7fb"; y: 3; x: row.on ? 23 : 3; Behavior on x { NumberAnimation { duration: 120 } } } } } - // action - Rectangle { visible: modelData.kind === "action"; anchors.verticalCenter: parent.verticalCenter - width: at.implicitWidth + 24; height: 30; radius: 8; color: t.warnBg - Text { id: at; anchors.centerIn: parent; text: "In the terminal"; color: t.warn; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold } } } } } From 23a171c9374e93619825a96a05f8bcf0bc8b07d7 Mon Sep 17 00:00:00 2001 From: Rick Peters Date: Fri, 25 Sep 2026 15:53:30 +0200 Subject: [PATCH 03/15] feat: BIOS update in the app, with both warnings bios_enable split into bios_prepare (download, checksum, fwupd check) and bios_flash; the terminal flow is unchanged. The app: check, warning 1, warning 2 (type UPDATE, or hold A/OK for 5 seconds), flash, restart. --- lib/backend.sh | 64 ++++++++++++++----- lib/bios.sh | 118 ++++++++++++++++++++++------------- ui/qml/Main.qml | 162 +++++++++++++++++++++++++++++++++++++++++++----- ui/steamify-ui | 39 ++++++++---- 4 files changed, 298 insertions(+), 85 deletions(-) diff --git a/lib/backend.sh b/lib/backend.sh index b585c7d..ef0c805 100644 --- a/lib/backend.sh +++ b/lib/backend.sh @@ -8,7 +8,9 @@ # 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). -# The BIOS update needs its typed confirmations: it stays in the terminal. +# 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() { @@ -54,13 +56,17 @@ backend_status() { done local cec="" f for f in /dev/cec*; do [[ -e "$f" ]] && cec+="${cec:+ }$(basename "$f")"; done - printf '{"version":%s,"firstRun":%s,"steamMachine":%s,"kernel":%s,"pinnedKernel":%s,"cecDevices":%s,"leds":%s,"items":[%s]}\n' \ + local bios='null' + if bios_available; then + bios="{\"current\":$(json_str "$(bios_current)"),\"newest\":$(json_str "${BIOS_NEWEST:-}"),\"selectable\":$(bios_selectable && echo true || echo false),\"dryRun\":$([[ -n "$BIOS_DRY_RUN" ]] && echo true || echo false)}" + fi + printf '{"version":%s,"firstRun":%s,"steamMachine":%s,"kernel":%s,"pinnedKernel":%s,"cecDevices":%s,"leds":%s,"bios":%s,"items":[%s]}\n' \ "$(json_str "$VERSION")" "$first_run" \ "$(detect_valve_fremont && echo true || echo false)" \ "$(json_str "$(uname -r)")" "$(json_str "${PINNED_KERNEL_VER:-}")" \ "$(json_str "$cec")" \ "$(compgen -G '/sys/class/leds/valve-leds*' | wc -l)" \ - "$items" + "$bios" "$items" } backend_run_component() { @@ -77,6 +83,43 @@ backend_run_component() { 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=() @@ -111,18 +154,7 @@ backend_apply() { return 0 fi - # 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_sudo || return 1 LOGIN_MANAGER=plasmalogin [[ "${WANTED[single]}" == 1 ]] && LOGIN_MANAGER=sddm @@ -137,6 +169,8 @@ backend_main() { case "${1:-}" in status) backend_status ;; apply) shift; backend_apply "$@" ;; + bios-prepare) backend_bios_prepare ;; + bios-flash) backend_bios_flash ;; *) err "Usage: steamify.sh --backend status | apply [--reapply] [--boot gamescope|desktop] ..."; return 2 ;; esac } diff --git a/lib/bios.sh b/lib/bios.sh index fc78057..ce91d84 100644 --- a/lib/bios.sh +++ b/lib/bios.sh @@ -121,54 +121,102 @@ bios_fits_device() { grep -q '"Guid"' <<< "$details" && ! grep -q '"UpdateError"' <<< "$details" } -bios_enable() { +# The checked firmware waits here between bios_prepare and bios_flash, so the +# app can show its warnings in between (and the terminal flow uses it too). +BIOS_STAGE_DIR="${XDG_RUNTIME_DIR:-/tmp}/steamify-bios" + +bios_prepare() { + # Downloads Valve's package, checks its SHA-256 and extracts the .cab to + # $BIOS_STAGE_DIR, then asks fwupd whether it fits this machine. Sets + # BIOS_COMPATIBLE (yes|dry-run). 0 = ready to flash, 2 = nothing to do + # (already the newest), 1 = error (nothing was touched). if ! bios_lookup_newest; then err "Couldn't find the newest Steam Machine BIOS on Valve's mirror ($VALVE_MIRROR)." return 1 fi - local current tmp compatible - current="$(bios_current)" [[ -n "$BIOS_DRY_RUN" ]] && warn "DRY RUN (WIZARD_BIOS_DRY_RUN): nothing will be flashed." - if [[ "$current" == "$BIOS_NEWEST" ]]; then - ok "The BIOS is already the newest version ($current); nothing to do." - return 0 + if [[ "$(bios_current)" == "$BIOS_NEWEST" ]]; then + ok "The BIOS is already the newest version ($BIOS_NEWEST); nothing to do." + return 2 fi - - # Download and check everything before asking: the warnings only come - # when the file is Valve's (SHA-256 from Valve's repo database) and fwupd - # confirms it's firmware for this very machine. pacman -Q fwupd >/dev/null 2>&1 || sudo pacman -S --needed --noconfirm fwupd || { err "Installing fwupd failed."; return 1; } - tmp="$(mktemp -d)" + rm -rf "$BIOS_STAGE_DIR"; mkdir -p "$BIOS_STAGE_DIR"; chmod 700 "$BIOS_STAGE_DIR" info "Downloading $BIOS_PKG ($BIOS_REPO)..." - if ! curl -fsSL "$VALVE_MIRROR/$BIOS_REPO/os/x86_64/$BIOS_PKG" -o "$tmp/pkg.tar.zst" || - ! echo "$BIOS_SHA256 $tmp/pkg.tar.zst" | sha256sum -c --quiet - || - ! tar -I unzstd -xf "$tmp/pkg.tar.zst" -C "$tmp" "$BIOS_CAB"; then + if ! curl -fsSL "$VALVE_MIRROR/$BIOS_REPO/os/x86_64/$BIOS_PKG" -o "$BIOS_STAGE_DIR/pkg.tar.zst" || + ! echo "$BIOS_SHA256 $BIOS_STAGE_DIR/pkg.tar.zst" | sha256sum -c --quiet - || + ! tar -I unzstd -xf "$BIOS_STAGE_DIR/pkg.tar.zst" -C "$BIOS_STAGE_DIR" "$BIOS_CAB"; then err "Downloading or verifying $BIOS_PKG failed; the BIOS was not touched." - rm -rf "$tmp" + rm -rf "$BIOS_STAGE_DIR" return 1 fi ok "Checksum OK: this is Valve's $BIOS_PKG." - local yes="$c_green$c_bold" b="$c_bold" n="$c_reset" if [[ -n "$BIOS_DRY_RUN" ]]; then warn "Dry run: skipping fwupd's check that the firmware fits this machine." - compatible="${c_yellow}${b}not checked${n} (dry run)" - elif bios_fits_device "$tmp/$BIOS_CAB"; then + BIOS_COMPATIBLE=dry-run + elif bios_fits_device "$BIOS_STAGE_DIR/$BIOS_CAB"; then ok "fwupd confirms BIOS $BIOS_NEWEST is firmware for this machine." - compatible="${yes}yes${n} (checked by fwupd)" + BIOS_COMPATIBLE=yes else err "fwupd says BIOS $BIOS_NEWEST is not for this machine's hardware; not installing it." - rm -rf "$tmp" + rm -rf "$BIOS_STAGE_DIR" return 1 fi + printf '%s\n' "$BIOS_NEWEST" "$BIOS_CAB" > "$BIOS_STAGE_DIR/ready" +} +bios_flash() { + # Hands the firmware bios_prepare checked to fwupd; it is written during + # the next restart. Sets BIOS_NEEDS_RESTART. + local newest cab + { read -r newest; read -r cab; } < "$BIOS_STAGE_DIR/ready" 2>/dev/null + if [[ -z "${cab:-}" || ! -f "$BIOS_STAGE_DIR/$cab" ]]; then + err "No checked BIOS file waiting; nothing was flashed." + return 1 + fi + if [[ -n "$BIOS_DRY_RUN" ]]; then + ok "Dry run: would run: fwupdmgr install -y --no-reboot-check $(basename "$cab")" + ok "Dry run finished; nothing was flashed." + # Treated as staged, so the restart choices that follow a real + # update show up too; restarting only prints (see restart_now). + BIOS_NEEDS_RESTART=1 + rm -rf "$BIOS_STAGE_DIR" + return 0 + fi + info "Handing BIOS $newest to fwupd. Do NOT turn off the power from now on." + # -y: the user confirmed twice; --no-reboot-check: our own restart + # question comes at the end. + if ! sudo fwupdmgr install -y --no-reboot-check "$BIOS_STAGE_DIR/$cab"; then + err "fwupd could not install the BIOS update (see above); the BIOS was not changed." + rm -rf "$BIOS_STAGE_DIR" + return 1 + fi + rm -rf "$BIOS_STAGE_DIR" + BIOS_NEEDS_RESTART=1 + ok "BIOS $newest is staged. It is written during the next restart:" + warn "keep the power on and don't touch the machine until it has fully started again." +} + +bios_enable() { + local rc current compatible + current="$(bios_current)" + bios_prepare; rc=$? + [[ $rc -eq 2 ]] && return 0 + [[ $rc -ne 0 ]] && return 1 + + local yes="$c_green$c_bold" b="$c_bold" n="$c_reset" + if [[ "$BIOS_COMPATIBLE" == dry-run ]]; then + compatible="${c_yellow}${b}not checked${n} (dry run)" + else + compatible="${yes}yes${n} (checked by fwupd)" + fi bios_disclaimer "WARNING: BIOS UPDATE - ENTIRELY AT YOUR OWN RISK" \ "Current BIOS: ${b}$current${n}" \ "New BIOS: ${b}$BIOS_NEWEST${n}" \ "Checksum: ${yes}OK${n} (Valve's package)" \ "Compatible: $compatible" if ! ask_yn "Do you understand the risks and want to continue?" n; then - info "BIOS update cancelled; nothing was changed."; rm -rf "$tmp"; return 0 + info "BIOS update cancelled; nothing was changed."; rm -rf "$BIOS_STAGE_DIR"; return 0 fi bios_disclaimer "LAST CHANCE: THIS FLASHES BIOS $BIOS_NEWEST" \ "After this, keep the power on until the machine has fully" \ @@ -176,28 +224,10 @@ bios_enable() { local reply read -rp "$(echo -e "${c_red}${c_bold}Type UPDATE (in capitals) to flash the BIOS, anything else cancels:${c_reset} ")" reply if [[ "$reply" != UPDATE ]]; then - info "BIOS update cancelled; nothing was changed."; rm -rf "$tmp"; return 0 + info "BIOS update cancelled; nothing was changed."; rm -rf "$BIOS_STAGE_DIR"; return 0 fi - - if [[ -n "$BIOS_DRY_RUN" ]]; then - ok "Dry run: would run: fwupdmgr install -y --no-reboot-check $(basename "$BIOS_CAB")" - ok "Dry run finished; nothing was flashed." - # Treated as staged, so the restart choices that follow a real - # update show up too; restarting only prints (see restart_now). - BIOS_NEEDS_RESTART=1 - rm -rf "$tmp" - return 0 - fi - info "Handing BIOS $BIOS_NEWEST to fwupd. Do NOT turn off the power from now on." - # -y: we already asked twice; --no-reboot-check: the wizard's own - # restart question comes at the end. - if ! sudo fwupdmgr install -y --no-reboot-check "$tmp/$BIOS_CAB"; then - err "fwupd could not install the BIOS update (see above); the BIOS was not changed." - rm -rf "$tmp" - return 1 - fi - rm -rf "$tmp" - BIOS_NEEDS_RESTART=1 - ok "BIOS $BIOS_NEWEST is staged. It is written during the next restart:" - warn "keep the power on and don't touch the machine until it has fully started again." + bios_flash } + +# For the app (lib/backend.sh): the flash step alone, after its warnings. +bios_flash_only() { bios_flash; } diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index 434beb6..84b7d50 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -71,9 +71,9 @@ ApplicationWindow { kpin: { label: "Pin the kernel", hint: "Fixes rebooting after shutdown", body: "Newer CachyOS kernels make the Steam Machine reboot instead of shutting down. Untick once CachyOS fixes that.", changes: ["linux-cachyos from Steamify's release (signature checked)", "Kept in /var/cache/steamify/kernel", "Added to IgnorePkg"] }, - bios: { label: "Update BIOS", hint: "Runs in the terminal version (two warnings, typed confirmation)", - body: "Installs Valve's newest Steam Machine BIOS, at your own risk. The app leaves this to the terminal version, which asks twice and wants UPDATE typed.", - changes: ["Run steamify.sh in Konsole for this"] } + bios: { label: "Update BIOS", hint: "", + body: "Installs Valve's newest Steam Machine BIOS, at your own risk. It checks Valve's checksum and asks fwupd whether the file fits this machine, then warns you twice before anything is written.", + changes: ["Valve's fremont-hw-support package (checksum checked)", "fwupd writes the BIOS during the next restart", "Keep the power on until the machine has fully started again"] } }) // --- State --- @@ -91,6 +91,20 @@ ApplicationWindow { property string runError: "" property bool wrongPassword: false property int doneCount: 0 + property string pending: "apply" // what the password is for: apply | bios + property string sessionPassword: "" // kept between the BIOS steps only + property var biosInfo: ({}) + property bool biosRun: false + property int biosFocus: 0 // warning 1: 0 = Cancel, 1 = continue + property real holdProgress: 0 // warning 2: hold A/OK for 5 s + property bool holding: false + readonly property var bios: status.bios || null + function biosHint() { + if (!bios) return ""; + if (!bios.newest) return "Now " + bios.current + ", newest unknown (offline?)"; + if (bios.current === bios.newest) return bios.current + " is up to date"; + return "Now " + bios.current + ", newest " + bios.newest + " · at your own risk"; + } ListModel { id: logModel } readonly property var status: backend.status || ({}) @@ -151,15 +165,39 @@ ApplicationWindow { screen = "applying"; backend.apply(wantedIds(), want.gaming ? boot : "", reapply, password || ""); } + function askPassword(forWhat) { + pending = forWhat; + if (backend.needsPassword()) { wrongPassword = false; pw.text = ""; screen = "password"; pw.forceActiveFocus(); return true; } + return false; + } function onApplyPressed() { if (plan.length === 0) return; - if (backend.needsPassword()) { wrongPassword = false; pw.text = ""; screen = "password"; pw.forceActiveFocus(); } - else startApply(""); + if (!askPassword("apply")) startApply(""); } function submitPassword() { if (!backend.checkPassword(pw.text)) { wrongPassword = true; pw.selectAll(); return; } var p = pw.text; pw.text = ""; - startApply(p); + if (pending === "bios") { sessionPassword = p; biosCheck(); } + else startApply(p); + } + // --- BIOS: check -> warning 1 -> warning 2 -> flash --- + function startBios() { + if (!bios || !bios.selectable) return; + biosInfo = {}; sessionPassword = ""; + if (!askPassword("bios")) biosCheck(); + } + function biosStep(action) { + steps = {}; failed = []; restartNeeded = false; runError = ""; doneCount = 0; logModel.clear(); + plan = [{ id: "bios", action: action }]; + var s = {}; s.bios = "wait"; steps = s; + biosRun = true; screen = "applying"; + } + function biosCheck() { biosStep("check"); backend.biosPrepare(sessionPassword); } + function biosCancel() { sessionPassword = ""; holdProgress = 0; holding = false; biosRun = false; syncFromStatus(); screen = "menu"; } + function biosFlash() { holdProgress = 0; holding = false; biosStep("flash"); backend.biosFlash(sessionPassword); } + Timer { + interval: 50; repeat: true; running: screen === "bios2" && holding + onTriggered: { holdProgress = Math.min(1, holdProgress + 0.01); if (holdProgress >= 1) biosFlash(); } } Connections { target: backend @@ -168,7 +206,9 @@ ApplicationWindow { if (ev.event === "start") { s[ev.id] = "run"; steps = s; } else if (ev.event === "done") { s[ev.id] = ev.ok ? "ok" : "fail"; steps = s; doneCount++; } else if (ev.event === "log") { logModel.append({ line: ev.line }); if (logModel.count > 400) logModel.remove(0); } + else if (ev.event === "bios-ready") { biosInfo = ev; biosFocus = 0; holdProgress = 0; screen = "bios1"; } else if (ev.event === "finished") { + if (!(biosRun && plan.length && plan[0].action === "check" && !ev.failed.length && !ev.error && !ev.nothing)) sessionPassword = ""; failed = ev.failed || []; restartNeeded = !!ev.restart; runError = ev.error === "wrong-password" ? "The password didn't work." : (ev.error ? "sudo isn't available." : ""); screen = "done"; @@ -185,6 +225,7 @@ ApplicationWindow { else if (a === "accept") { if (sel === rows.length) { goReview(false); return; } var r = rows[sel]; + if (r.kind === "action" && r.id === "bios") { startBios(); return; } if (r.kind === "choice") boot = boot === "gamescope" ? "desktop" : "gamescope"; else if (r.kind === "toggle") toggle(r.id); } @@ -199,21 +240,36 @@ ApplicationWindow { } else if (screen === "password") { if (a === "accept" || a === "apply") submitPassword(); else if (a === "back") { pw.text = ""; screen = "review"; } + } else if (screen === "bios1") { + if (a === "left") biosFocus = 0; + else if (a === "right") biosFocus = 1; + else if (a === "accept") { if (biosFocus === 1) { holdProgress = 0; biosTyped.text = ""; screen = "bios2"; if (inputType === "keyboard") biosTyped.forceActiveFocus(); } else biosCancel(); } + else if (a === "back") biosCancel(); + } else if (screen === "bios2") { + if (a === "back") biosCancel(); + else if (a === "hold") holding = true; + else if (a === "release") { holding = false; if (holdProgress < 1) holdProgress = 0; } } else if (screen === "done") { if (a === "accept" && restartNeeded) backend.restart(); - else if (a === "back" || a === "accept") { syncFromStatus(); screen = "menu"; } + else if (a === "back" || a === "accept") { biosRun = false; syncFromStatus(); screen = "menu"; } } } Connections { target: gamepad function onButton(b) { + if (b === "a_up") { if (screen === "bios2") act("release"); return; } + if (b.slice(-3) === "_up") return; inputType = "controller"; + if (b === "a" && screen === "bios2") { act("hold"); return; } var map = { a: "accept", b: "back", x: "apply", y: "reapply", start: "apply", up: "up", down: "down", left: "left", right: "right" }; if (map[b]) act(map[b]); } } function keyAct(e) { var fromRemote = Date.now() - remoteAt < 400; + if (screen === "bios2" && inputType !== "keyboard" && (e.key === Qt.Key_Return || e.key === Qt.Key_Enter)) { + if (!e.isAutoRepeat) act("hold"); e.accepted = true; return; + } if (!fromRemote) inputType = "keyboard"; var k = e.key; if (k === Qt.Key_Up) act("up"); @@ -266,6 +322,18 @@ ApplicationWindow { Text { id: ct; anchors.centerIn: parent; text: parent.text.toUpperCase(); color: parent.fg; font.family: t.body; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.6 } } + component RiskList: Column { + spacing: 8 + Repeater { + model: ["A failed or interrupted BIOS update can leave the machine unable to start (bricked). Steamify, CachyOS and Valve take no responsibility for that.", + "NEVER turn off the power, unplug the machine or press the power button while it updates, including the restart(s) afterwards.", + "The screen can stay black for several minutes. Wait.", + "Only on mains power, with all other programs closed."] + Row { required property string modelData; spacing: 10; width: parent.width + Rectangle { width: 6; height: 6; radius: 3; color: t.bad; y: 9 } + Text { text: parent.modelData; width: parent.width - 16; wrapMode: Text.WordWrap; color: t.soft; font.family: t.body; font.pixelSize: 15; lineHeight: 1.3 } } + } + } // --- The 1280x720 stage, scaled to the window --- Item { id: stage @@ -274,6 +342,7 @@ ApplicationWindow { scale: Math.min(win.width / 1280, win.height / 720) focus: true Keys.onPressed: function (e) { if (!(screen === "password" && pw.activeFocus && e.key !== Qt.Key_Escape && e.key !== Qt.Key_Return && e.key !== Qt.Key_Enter)) keyAct(e); } + Keys.onReleased: function (e) { if (screen === "bios2" && !e.isAutoRepeat && (e.key === Qt.Key_Return || e.key === Qt.Key_Enter)) act("release"); } // Header Rectangle { @@ -283,7 +352,7 @@ ApplicationWindow { anchors.left: parent.left; anchors.leftMargin: 40; anchors.verticalCenter: parent.verticalCenter; spacing: 14 Image { source: iconUrl; width: 40; height: 40; sourceSize: Qt.size(80, 80); anchors.verticalCenter: parent.verticalCenter } Text { text: "Steamify"; color: t.text; font.family: t.display; font.pixelSize: 28; font.weight: Font.Bold; anchors.verticalCenter: parent.verticalCenter } - Text { text: screen === "menu" ? "v" + (status.version || "") : "/ " + ({review: "Review", password: "Password", applying: "Applying", done: "Done"})[screen] + Text { text: screen === "menu" ? "v" + (status.version || "") : "/ " + ({review: "Review", password: "Password", applying: biosRun ? "BIOS update" : "Applying", done: "Done", bios1: "BIOS update", bios2: "BIOS update"})[screen] color: t.faint; font.family: screen === "menu" ? t.mono : t.body; font.pixelSize: screen === "menu" ? 13 : 15; anchors.verticalCenter: parent.verticalCenter } } Rectangle { @@ -356,9 +425,9 @@ ApplicationWindow { anchors.verticalCenter: parent.verticalCenter; spacing: 2 x: row.modelData.parent ? 46 : 16 width: controls.x - x - 16 - opacity: row.modelData.kind === "action" ? 0.6 : 1 + opacity: row.modelData.kind === "action" && !(bios && bios.selectable) ? 0.6 : 1 Text { text: label(row.modelData); color: t.textHi; font.family: t.body; font.pixelSize: 17; font.weight: Font.DemiBold; elide: Text.ElideRight; width: parent.width } - Text { text: (texts[row.modelData.id] && texts[row.modelData.id].hint) || row.modelData.hint; color: t.mute; font.family: t.body; font.pixelSize: 13; elide: Text.ElideRight; width: parent.width } + Text { text: row.modelData.id === "bios" ? biosHint() : ((texts[row.modelData.id] && texts[row.modelData.id].hint) || row.modelData.hint); color: t.mute; font.family: t.body; font.pixelSize: 13; elide: Text.ElideRight; width: parent.width } } // Right: every control ends on the same edge Item { @@ -384,7 +453,7 @@ ApplicationWindow { // action Rectangle { visible: row.modelData.kind === "action"; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter width: at.implicitWidth + 24; height: 30; radius: 8; color: t.warnBg - Text { id: at; anchors.centerIn: parent; text: "In the terminal"; color: t.warn; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold } } + Text { id: at; anchors.centerIn: parent; text: bios && bios.selectable ? "Update…" : (bios && bios.newest ? "Up to date" : "Unavailable"); color: t.warn; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold } } } } } @@ -540,7 +609,7 @@ ApplicationWindow { anchors.top: header.bottom; anchors.bottom: parent.bottom; width: parent.width Column { x: 40; y: 32; width: 560; spacing: 18 - Text { text: "Applying your changes"; color: t.text; font.family: t.display; font.pixelSize: 34; font.weight: Font.DemiBold } + Text { text: biosRun ? (plan.length && plan[0].action === "check" ? "Checking the BIOS update" : "Installing the BIOS update") : "Applying your changes"; color: t.text; font.family: t.display; font.pixelSize: 34; font.weight: Font.DemiBold } Rectangle { width: 560; height: 10; radius: 5; color: t.line Rectangle { height: 10; radius: 5; color: t.accent; width: plan.length ? parent.width * Math.min(1, (doneCount + 0.3) / plan.length) : 0; Behavior on width { NumberAnimation { duration: 300 } } } } Row { width: 560 @@ -556,7 +625,7 @@ ApplicationWindow { border.width: parent.parent.st === "wait" || parent.parent.st === "run" ? 2 : 0; border.color: parent.parent.st === "run" ? t.accent : "#343f50" Text { anchors.centerIn: parent; text: parent.parent.parent.st === "ok" ? "✓" : (parent.parent.parent.st === "fail" ? "!" : ""); color: parent.parent.parent.st === "ok" ? t.good : t.bad; font.pixelSize: 13; font.weight: Font.Bold } RotationAnimator on rotation { running: parent.parent.parent.st === "run"; from: 0; to: 360; duration: 1000; loops: Animation.Infinite } } - Text { text: ({ on: "Turn on ", off: "Turn off ", again: "Re-apply ", desktop: "Boot into ", gaming: "Boot into " })[parent.parent.modelData.action] + ((texts[parent.parent.modelData.id] || {}).label || parent.parent.modelData.id) + Text { text: ({ on: "Turn on ", off: "Turn off ", again: "Re-apply ", desktop: "Boot into ", gaming: "Boot into ", check: "Download and check the ", flash: "Hand to fwupd: the " })[parent.parent.modelData.action] + ((texts[parent.parent.modelData.id] || {}).label || parent.parent.modelData.id) color: parent.parent.st === "wait" ? t.faint : t.textHi; font.family: t.body; font.pixelSize: 16; font.weight: Font.DemiBold; anchors.verticalCenter: parent.verticalCenter; width: 380; elide: Text.ElideRight } Text { text: ({ wait: "Waiting", run: "Working…", ok: "Done", fail: "Problem" })[parent.parent.st]; color: parent.parent.st === "ok" ? t.good : (parent.parent.st === "fail" ? t.bad : "#b8c3d1"); font.family: t.body; font.pixelSize: 13; anchors.verticalCenter: parent.verticalCenter } } @@ -573,6 +642,71 @@ ApplicationWindow { } } + // ================= BIOS: warning 1 ================= + Item { + visible: screen === "bios1" + anchors.top: header.bottom; anchors.bottom: parent.bottom; width: parent.width + Rectangle { + x: 40; y: 28; width: 1200; height: parent.height - 28 - 64 - 24; radius: 16; color: "#1d1416"; border.width: 2; border.color: "#b33a3a" + Row { anchors.fill: parent; anchors.margins: 32; spacing: 40 + Column { width: 640; spacing: 16 + Row { spacing: 12 + Rectangle { width: 44; height: 44; radius: 12; color: "#3a1c1f"; Text { anchors.centerIn: parent; text: "!"; color: t.bad; font.pixelSize: 26; font.weight: Font.Bold } } + Text { text: "BIOS update – entirely at your own risk"; color: "#ffd9d3"; font.family: t.display; font.pixelSize: 30; font.weight: Font.DemiBold; anchors.verticalCenter: parent.verticalCenter } } + RiskList { width: 640 } + } + Rectangle { width: 440; height: 250; radius: 14; color: "#161d27" + Column { anchors.fill: parent; anchors.margins: 22; spacing: 12 + Repeater { model: [["Current BIOS", biosInfo.current || ""], ["New BIOS", biosInfo.newest || ""], ["Checksum", "OK (Valve's package)"], + ["Compatible", biosInfo.compatible === "yes" ? "Yes (checked by fwupd)" : "Not checked (dry run)"]] + Row { required property var modelData; width: 396 + Text { text: parent.modelData[0]; color: t.faint; font.family: t.body; font.pixelSize: 15; width: 150 } + Text { text: parent.modelData[1]; color: parent.modelData[1].indexOf("Not") === 0 ? t.warn : t.text; font.family: t.mono; font.pixelSize: 15; width: 246; horizontalAlignment: Text.AlignRight; elide: Text.ElideLeft } } } + } } + } + } + Rectangle { + anchors.bottom: parent.bottom; width: parent.width; height: 64; color: t.bar + Rectangle { width: parent.width; height: 1; color: t.line } + Text { anchors.left: parent.left; anchors.leftMargin: 40; anchors.verticalCenter: parent.verticalCenter; text: "Do you understand the risks and want to continue?"; color: t.soft; font.family: t.body; font.pixelSize: 15 } + Row { anchors.right: parent.right; anchors.rightMargin: 40; anchors.verticalCenter: parent.verticalCenter; spacing: 12 + Btn { k: biosFocus === 0 ? g.ok : "◀"; text: "Cancel"; focusRing: biosFocus === 0; onClicked: biosCancel() } + Btn { k: biosFocus === 1 ? g.ok : "▶"; text: "I understand, continue"; focusRing: biosFocus === 1; color: "#8a2c2c" + onClicked: { biosFocus = 1; act("accept"); } } } + } + } + + // ================= BIOS: warning 2 (last chance) ================= + Item { + visible: screen === "bios2" + anchors.top: header.bottom; anchors.bottom: parent.bottom; width: parent.width + Rectangle { + anchors.centerIn: parent; width: 760; height: b2.implicitHeight + 64; radius: 20; color: "#1d1416"; border.width: 2; border.color: "#b33a3a" + Column { id: b2; anchors.fill: parent; anchors.margins: 32; spacing: 18 + Text { text: "Last chance: this flashes BIOS " + (biosInfo.newest || ""); color: "#ffd9d3"; font.family: t.display; font.pixelSize: 32; font.weight: Font.DemiBold } + Text { width: parent.width; wrapMode: Text.WordWrap; color: t.soft; font.family: t.body; font.pixelSize: 16; lineHeight: 1.3 + text: "After this, keep the power on until the machine has fully started again. Don't touch it, even if the screen is black." } + // keyboard: type UPDATE + Column { visible: inputType === "keyboard"; width: parent.width; spacing: 8 + Text { text: "Type UPDATE (in capitals) and press Enter to flash the BIOS:"; color: t.text; font.family: t.body; font.pixelSize: 15; font.weight: Font.DemiBold } + TextField { id: biosTyped; width: 320; height: 50; color: t.textHi; font.family: t.mono; font.pixelSize: 20; leftPadding: 14 + placeholderText: "UPDATE"; placeholderTextColor: "#6b5a5a" + background: Rectangle { radius: 10; color: t.bg; border.width: 2; border.color: biosTyped.text === "UPDATE" ? t.bad : "#5a3a3a" } + Keys.onReturnPressed: if (text === "UPDATE") biosFlash() + Keys.onEnterPressed: if (text === "UPDATE") biosFlash() + Keys.onEscapePressed: biosCancel() } } + // controller / remote: hold A or OK + Column { visible: inputType !== "keyboard"; width: parent.width; spacing: 10 + Text { text: "Hold " + g.ok + " for 5 seconds to flash the BIOS. Let go to stop."; color: t.text; font.family: t.body; font.pixelSize: 15; font.weight: Font.DemiBold } + Rectangle { width: parent.width; height: 14; radius: 7; color: "#3a2224" + Rectangle { height: 14; radius: 7; color: t.bad; width: parent.width * holdProgress } } } + Row { spacing: 12 + Btn { k: g.back; text: "Cancel"; onClicked: biosCancel() } + Text { visible: bios && bios.dryRun; text: "Dry run: nothing will be flashed."; color: t.warn; font.family: t.body; font.pixelSize: 14; anchors.verticalCenter: parent.verticalCenter } } + } + } + } + // ================= DONE ================= Item { visible: screen === "done" @@ -584,7 +718,7 @@ ApplicationWindow { Text { anchors.centerIn: parent; text: failed.length || runError ? "!" : "✓"; color: failed.length || runError ? t.warn : t.good; font.pixelSize: 36; font.weight: Font.Bold } } Text { text: runError ? "Nothing changed" : (failed.length ? "Done, with a problem" : "All done"); color: t.text; font.family: t.display; font.pixelSize: 40; font.weight: Font.DemiBold; anchors.horizontalCenter: parent.horizontalCenter } Text { width: parent.width; horizontalAlignment: Text.AlignHCenter; wrapMode: Text.WordWrap; color: t.soft; font.family: t.body; font.pixelSize: 17 - text: runError ? runError : (plan.length + (plan.length === 1 ? " change" : " changes") + " applied." + (restartNeeded ? " Some take effect after a restart." : "")) } + text: runError ? runError : (biosRun ? (failed.length ? "The BIOS was not changed." : (bios && bios.dryRun ? "Dry run: nothing was flashed. " : "BIOS " + (biosInfo.newest || "") + " is staged and is written during the restart. ") + (failed.length ? "" : "Keep the power on and don't touch the machine until it has fully started again, even if the screen stays black.")) : (plan.length + (plan.length === 1 ? " change" : " changes") + " applied." + (restartNeeded ? " Some take effect after a restart." : ""))) } Rectangle { visible: failed.length > 0; anchors.horizontalCenter: parent.horizontalCenter; width: ft.implicitWidth + 32; height: 44; radius: 12; color: t.warnBg Text { id: ft; anchors.centerIn: parent; color: "#f2c27a"; font.family: t.body; font.pixelSize: 14 text: "Had a problem: " + failed.map(function (id) { return (texts[id] || {}).label || id; }).join(", ") + ". See the details." } } diff --git a/ui/steamify-ui b/ui/steamify-ui index 8fd4337..59f0dea 100755 --- a/ui/steamify-ui +++ b/ui/steamify-ui @@ -81,7 +81,8 @@ def controller_devices(): class Gamepad(QObject): - """Emits button("a"|"b"|"x"|"y"|"up"|...) from any controller.""" + """Emits button("a"|"b"|"x"|"y"|"up"|...) from any controller, and + "_up" when a button is released.""" button = Signal(str) def __init__(self): @@ -119,8 +120,10 @@ class Gamepad(QObject): 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 value == 1 and code in BUTTONS: - self.button.emit(BUTTONS[code]) + 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)]) @@ -221,16 +224,9 @@ class Backend(QObject): shutil.rmtree(self._secret_dir, ignore_errors=True) self._secret_dir = None - @Slot("QVariant", str, bool, str) - def apply(self, ids, boot, reapply, password): + def _start(self, args, password): if self._busy: return - args = [self._script, "--backend", "apply"] - if reapply: - args.append("--reapply") - if boot: - args += ["--boot", boot] - args += list(ids) env = QProcessEnvironment.systemEnvironment() if password: self._secret_dir, askpass, bindir = self._make_askpass(password) @@ -240,7 +236,7 @@ class Backend(QObject): p.setProcessEnvironment(env) p.setProcessChannelMode(QProcess.MergedChannels) p.setProgram("bash") - p.setArguments(args) + p.setArguments([self._script, "--backend"] + args) self._buf = b"" p.readyReadStandardOutput.connect(lambda: self._read(p)) p.finished.connect(lambda *_: self._finished(p)) @@ -249,6 +245,25 @@ class Backend(QObject): self.busyChanged.emit() p.start() + @Slot("QVariant", str, bool, str) + def apply(self, ids, boot, reapply, password): + args = ["apply"] + if reapply: + args.append("--reapply") + if boot: + args += ["--boot", boot] + self._start(args + list(ids), password) + + @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) + def _read(self, p): self._buf += bytes(p.readAllStandardOutput()) while b"\n" in self._buf: From 0c99ba325d1d8208e823c2b4d183addc82689745 Mon Sep 17 00:00:00 2001 From: Rick Peters Date: Fri, 25 Sep 2026 15:55:15 +0200 Subject: [PATCH 04/15] feat(ui): BIOS check runs invisibly, then the warnings, then the progress bar --- ui/qml/Main.qml | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index 84b7d50..f3e173a 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -95,6 +95,7 @@ ApplicationWindow { property string sessionPassword: "" // kept between the BIOS steps only property var biosInfo: ({}) property bool biosRun: false + property bool biosChecking: false // the invisible check before the warnings property int biosFocus: 0 // warning 1: 0 = Cancel, 1 = continue property real holdProgress: 0 // warning 2: hold A/OK for 5 s property bool holding: false @@ -192,7 +193,14 @@ ApplicationWindow { var s = {}; s.bios = "wait"; steps = s; biosRun = true; screen = "applying"; } - function biosCheck() { biosStep("check"); backend.biosPrepare(sessionPassword); } + function biosCheck() { + // Invisible: stay on the menu (the BIOS row says "Checking…"), + // then the first warning. + failed = []; runError = ""; logModel.clear(); + plan = [{ id: "bios", action: "check" }]; + biosRun = true; biosChecking = true; screen = "menu"; + backend.biosPrepare(sessionPassword); + } function biosCancel() { sessionPassword = ""; holdProgress = 0; holding = false; biosRun = false; syncFromStatus(); screen = "menu"; } function biosFlash() { holdProgress = 0; holding = false; biosStep("flash"); backend.biosFlash(sessionPassword); } Timer { @@ -206,7 +214,14 @@ ApplicationWindow { if (ev.event === "start") { s[ev.id] = "run"; steps = s; } else if (ev.event === "done") { s[ev.id] = ev.ok ? "ok" : "fail"; steps = s; doneCount++; } else if (ev.event === "log") { logModel.append({ line: ev.line }); if (logModel.count > 400) logModel.remove(0); } - else if (ev.event === "bios-ready") { biosInfo = ev; biosFocus = 0; holdProgress = 0; screen = "bios1"; } + else if (ev.event === "bios-ready") { biosChecking = false; biosInfo = ev; biosFocus = 0; holdProgress = 0; screen = "bios1"; } + else if (ev.event === "finished" && biosChecking) { + // The check found nothing to do or a problem: say so. + biosChecking = false; sessionPassword = ""; + failed = ev.failed || []; restartNeeded = false; + runError = ev.error === "wrong-password" ? "The password didn't work." : (ev.error ? "sudo isn't available." : (ev.nothing ? "The BIOS is already the newest version." : "")); + screen = "done"; + } else if (ev.event === "finished") { if (!(biosRun && plan.length && plan[0].action === "check" && !ev.failed.length && !ev.error && !ev.nothing)) sessionPassword = ""; failed = ev.failed || []; restartNeeded = !!ev.restart; @@ -219,6 +234,7 @@ ApplicationWindow { // --- Input: one set of actions for controller, keyboard and remote --- function act(a) { + if (screen === "menu" && biosChecking) return; if (screen === "menu") { if (a === "up") sel = Math.max(0, sel - 1); else if (a === "down") sel = Math.min(rows.length, sel + 1); @@ -453,7 +469,7 @@ ApplicationWindow { // action Rectangle { visible: row.modelData.kind === "action"; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter width: at.implicitWidth + 24; height: 30; radius: 8; color: t.warnBg - Text { id: at; anchors.centerIn: parent; text: bios && bios.selectable ? "Update…" : (bios && bios.newest ? "Up to date" : "Unavailable"); color: t.warn; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold } } + Text { id: at; anchors.centerIn: parent; text: biosChecking ? "Checking…" : bios && bios.selectable ? "Update…" : (bios && bios.newest ? "Up to date" : "Unavailable"); color: t.warn; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold } } } } } @@ -533,7 +549,8 @@ ApplicationWindow { model: plan Rectangle { required property var modelData; width: 740; height: 56; radius: 12; color: t.card readonly property var st: ({ on: ["Turn on", t.good, t.goodBg], off: ["Turn off", t.bad, t.badBg], again: ["Re-apply", "#7cc4ff", "#1b2b40"], - desktop: ["Desktop", "#7cc4ff", "#1b2b40"], gaming: ["Gaming", "#7cc4ff", "#1b2b40"] })[modelData.action] + desktop: ["Desktop", "#7cc4ff", "#1b2b40"], gaming: ["Gaming", "#7cc4ff", "#1b2b40"], + check: ["Check", t.warn, t.warnBg], flash: ["Flash", t.bad, t.badBg] })[modelData.action] || ["", t.text, t.card] Row { anchors.fill: parent; anchors.leftMargin: 18; spacing: 14 Chip { text: parent.parent.st[0]; fg: parent.parent.st[1]; bgc: parent.parent.st[2]; width: 84; anchors.verticalCenter: parent.verticalCenter } Text { text: (texts[parent.parent.modelData.id] || {}).label || parent.parent.modelData.id; color: t.textHi; font.family: t.body; font.pixelSize: 17; font.weight: Font.DemiBold; anchors.verticalCenter: parent.verticalCenter } @@ -716,7 +733,7 @@ ApplicationWindow { Rectangle { width: 72; height: 72; radius: 36; anchors.horizontalCenter: parent.horizontalCenter color: failed.length || runError ? t.warnBg : t.goodBg Text { anchors.centerIn: parent; text: failed.length || runError ? "!" : "✓"; color: failed.length || runError ? t.warn : t.good; font.pixelSize: 36; font.weight: Font.Bold } } - Text { text: runError ? "Nothing changed" : (failed.length ? "Done, with a problem" : "All done"); color: t.text; font.family: t.display; font.pixelSize: 40; font.weight: Font.DemiBold; anchors.horizontalCenter: parent.horizontalCenter } + Text { text: runError ? "Nothing changed" : (biosRun && failed.length ? "The BIOS update stopped" : (failed.length ? "Done, with a problem" : "All done")); color: t.text; font.family: t.display; font.pixelSize: 40; font.weight: Font.DemiBold; anchors.horizontalCenter: parent.horizontalCenter } Text { width: parent.width; horizontalAlignment: Text.AlignHCenter; wrapMode: Text.WordWrap; color: t.soft; font.family: t.body; font.pixelSize: 17 text: runError ? runError : (biosRun ? (failed.length ? "The BIOS was not changed." : (bios && bios.dryRun ? "Dry run: nothing was flashed. " : "BIOS " + (biosInfo.newest || "") + " is staged and is written during the restart. ") + (failed.length ? "" : "Keep the power on and don't touch the machine until it has fully started again, even if the screen stays black.")) : (plan.length + (plan.length === 1 ? " change" : " changes") + " applied." + (restartNeeded ? " Some take effect after a restart." : ""))) } Rectangle { visible: failed.length > 0; anchors.horizontalCenter: parent.horizontalCenter; width: ft.implicitWidth + 32; height: 44; radius: 12; color: t.warnBg From fdb49f2503485691494b5ca777cbcba0229adcf1 Mon Sep 17 00:00:00 2001 From: Rick Peters Date: Fri, 25 Sep 2026 15:56:41 +0200 Subject: [PATCH 05/15] fix(ui): Applying got stuck: the ticked items came in as a JS value Convert QML's array; if starting fails anyway, show the done screen with the reason instead of staying on the progress screen. --- ui/qml/Main.qml | 2 +- ui/steamify-ui | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index f3e173a..f6a1f2a 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -225,7 +225,7 @@ ApplicationWindow { else if (ev.event === "finished") { if (!(biosRun && plan.length && plan[0].action === "check" && !ev.failed.length && !ev.error && !ev.nothing)) sessionPassword = ""; failed = ev.failed || []; restartNeeded = !!ev.restart; - runError = ev.error === "wrong-password" ? "The password didn't work." : (ev.error ? "sudo isn't available." : ""); + runError = ev.error === "wrong-password" ? "The password didn't work." : (ev.error === "start" ? "Steamify couldn't start the changes." : (ev.error ? "sudo isn't available." : "")); screen = "done"; } } diff --git a/ui/steamify-ui b/ui/steamify-ui index 59f0dea..8f6a7c3 100755 --- a/ui/steamify-ui +++ b/ui/steamify-ui @@ -247,12 +247,20 @@ class Backend(QObject): @Slot("QVariant", str, bool, str) def apply(self, ids, boot, reapply, password): - args = ["apply"] - if reapply: - args.append("--reapply") - if boot: - args += ["--boot", boot] - self._start(args + list(ids), password) + 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] + 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): From ad3e7ac80ef024eddf3bc4207fcb93a56e482aca Mon Sep 17 00:00:00 2001 From: Rick Peters Date: Fri, 25 Sep 2026 15:59:47 +0200 Subject: [PATCH 06/15] feat: GitHub builds the app; run it with curl steamify-app.tar.gz (ui/, steamify.sh, lib/, icon) and its launcher steamify-app.sh in every release: it installs to ~/.local/share/steamify/app, installs PySide6 if needed, adds a launcher entry and starts the app. STEAMIFY_BRANCH= runs a branch before it's released. --- .github/workflows/bundle.yml | 22 +++++++++++-- ui/steamify-app.sh | 63 ++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) create mode 100755 ui/steamify-app.sh diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index 5cc28d2..0863b45 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -5,6 +5,9 @@ # release are moved to it, so both of these always get the newest version: # curl -fsSL https://github.com/theupriser/steamify-cachyos/releases/latest/download/steamify.sh | bash # curl -fsSL https://github.com/theupriser/steamify-cachyos/releases/download/latest/steamify.sh | bash +# Each release also has the app (v2): steamify-app.tar.gz (ui/, steamify.sh, +# lib/, the icon) and its launcher, run with: +# curl -fsSL https://github.com/theupriser/steamify-cachyos/releases/latest/download/steamify-app.sh | bash # Pull requests only build and check it, and warn when VERSION is already released. name: Single-file build @@ -28,17 +31,26 @@ jobs: - name: Build run: | .github/tools/bundle.sh dist/steamify.sh + # The app: sources, not the single-file build (its backend mode runs + # steamify.sh from its own folder). + tar -czf dist/steamify-app.tar.gz steamify.sh lib ui assets/steam-gaming-settings.svg LICENSE.md + cp ui/steamify-app.sh dist/steamify-app.sh - name: Check run: | bash -n dist/steamify.sh # SC2034/SC2154: variables used across the inlined modules. shellcheck -S warning -e SC2034,SC2154 dist/steamify.sh + shellcheck -S warning dist/steamify-app.sh + python3 -m py_compile ui/steamify-ui - uses: actions/upload-artifact@v4 with: name: steamify - path: dist/steamify.sh + path: | + dist/steamify.sh + dist/steamify-app.tar.gz + dist/steamify-app.sh - name: Version id: version @@ -71,9 +83,13 @@ jobs: curl -fsSL https://github.com/${{ github.repository }}/releases/latest/download/steamify.sh | bash + Or the app (v2): + + curl -fsSL https://github.com/${{ github.repository }}/releases/latest/download/steamify-app.sh | bash + ## Changes in v$VERSION $changes" - gh release create "v$VERSION" dist/steamify.sh assets/steam-gaming-settings.svg \ + gh release create "v$VERSION" dist/steamify.sh dist/steamify-app.sh dist/steamify-app.tar.gz assets/steam-gaming-settings.svg \ --target "$GITHUB_SHA" --title "Steamify CachyOS v$VERSION" --notes "$notes" --latest # "latest" follows the newest version tag, for the older install URL @@ -86,4 +102,4 @@ jobs: else gh release create latest --title "Steamify CachyOS - latest (v$VERSION)" --notes "$latest_notes" --latest=false fi - gh release upload latest dist/steamify.sh assets/steam-gaming-settings.svg --clobber + gh release upload latest dist/steamify.sh dist/steamify-app.sh dist/steamify-app.tar.gz assets/steam-gaming-settings.svg --clobber diff --git a/ui/steamify-app.sh b/ui/steamify-app.sh new file mode 100755 index 0000000..f118b48 --- /dev/null +++ b/ui/steamify-app.sh @@ -0,0 +1,63 @@ +#!/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_BRANCH= takes that branch's source instead of a release +# (for testing before a release). +set -euo pipefail + +REPO="theupriser/steamify-cachyos" +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 + say "Downloading the newest Steamify app..." + curl -fsSL "https://github.com/$REPO/releases/latest/download/steamify-app.tar.gz" -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 "$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 & From 57ec2726cb0986adc8ee68d0c553888d6e615807 Mon Sep 17 00:00:00 2001 From: Rick Peters Date: Fri, 25 Sep 2026 16:00:12 +0200 Subject: [PATCH 07/15] fix: Quote the backend's done event for shellcheck --- lib/backend.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/backend.sh b/lib/backend.sh index ef0c805..68b969c 100644 --- a/lib/backend.sh +++ b/lib/backend.sh @@ -79,7 +79,7 @@ backend_run_component() { 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)" + backend_event "done" "\"id\":$(json_str "$c"),\"ok\":$([[ $rc -eq 0 ]] && echo true || echo false)" return $rc } From 7060a6dae12ef815374477aee204e224c6c09f73 Mon Sep 17 00:00:00 2001 From: Rick Peters Date: Fri, 25 Sep 2026 16:02:19 +0200 Subject: [PATCH 08/15] fix(ui): Review and progress lists scroll; progress follows the running step --- ui/qml/Main.qml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index f6a1f2a..f0e7bf2 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -545,9 +545,12 @@ ApplicationWindow { Column { x: 40; y: 32; width: 740; spacing: 14 Text { text: plan.length === 0 ? "Nothing to change" : "This will"; color: t.text; font.family: t.display; font.pixelSize: 34; font.weight: Font.DemiBold } - Repeater { + ListView { + width: 740; clip: true; spacing: 8 + height: Math.min(contentHeight, stage.height - header.height - 32 - 50 - 14 - 66 - 64 - 32) model: plan - Rectangle { required property var modelData; width: 740; height: 56; radius: 12; color: t.card + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + delegate: Rectangle { required property var modelData; width: 740; height: 56; radius: 12; color: t.card readonly property var st: ({ on: ["Turn on", t.good, t.goodBg], off: ["Turn off", t.bad, t.badBg], again: ["Re-apply", "#7cc4ff", "#1b2b40"], desktop: ["Desktop", "#7cc4ff", "#1b2b40"], gaming: ["Gaming", "#7cc4ff", "#1b2b40"], check: ["Check", t.warn, t.warnBg], flash: ["Flash", t.bad, t.badBg] })[modelData.action] || ["", t.text, t.card] @@ -632,9 +635,16 @@ ApplicationWindow { Row { width: 560 Text { text: "Step " + Math.min(plan.length, doneCount + 1) + " of " + plan.length; color: t.mute; font.family: t.body; font.pixelSize: 14; width: 280 } Text { text: "Don't turn off the PC"; color: t.mute; font.family: t.body; font.pixelSize: 14; width: 280; horizontalAlignment: Text.AlignRight } } - Repeater { + ListView { + id: stepList; width: 560; clip: true; spacing: 8 + height: stage.height - header.height - 32 - 156 - 32 model: plan - Rectangle { required property var modelData; readonly property string st: steps[modelData.id] || "wait" + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + // Follow the progress: the running step stays in view. + readonly property int running: plan.findIndex(function (p) { return steps[p.id] === "run"; }) + onRunningChanged: if (running >= 0) positionViewAtIndex(running, ListView.Contain) + delegate: + Rectangle { required property var modelData; required property int index; readonly property string st: steps[modelData.id] || "wait" width: 560; height: 54; radius: 12; color: st === "run" ? t.cardSel : t.card; border.width: st === "run" ? 2 : 0; border.color: t.accent Row { anchors.fill: parent; anchors.leftMargin: 18; anchors.rightMargin: 18; spacing: 14 Rectangle { width: 22; height: 22; radius: 11; anchors.verticalCenter: parent.verticalCenter From b9999182d543dec32d3f4bf93aaf8ba4e29dfa49 Mon Sep 17 00:00:00 2001 From: Rick Peters Date: Fri, 25 Sep 2026 16:04:59 +0200 Subject: [PATCH 09/15] feat: Preview builds of feat/v2-ui; the app stretches to the window Pushes to feat/v2-ui publish the pre-release v2-ui-preview (never latest); its steamify-app.sh downloads that build (STEAMIFY_RELEASE). The app's 1280x720 layout now stretches to the window's shape, so the bars reach the edges; re-apply resets when back on the menu. --- .github/workflows/bundle.yml | 25 ++++++++++++++++++++++++- ui/qml/Main.qml | 23 ++++++++++++++--------- ui/steamify-app.sh | 16 ++++++++++++---- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index 0863b45..f45e092 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -8,12 +8,15 @@ # Each release also has the app (v2): steamify-app.tar.gz (ui/, steamify.sh, # lib/, the icon) and its launcher, run with: # curl -fsSL https://github.com/theupriser/steamify-cachyos/releases/latest/download/steamify-app.sh | bash +# A push to feat/v2-ui (the app, not released yet) updates the pre-release +# v2-ui-preview instead, whose launcher gets that build: +# curl -fsSL https://github.com/theupriser/steamify-cachyos/releases/download/v2-ui-preview/steamify-app.sh | bash # Pull requests only build and check it, and warn when VERSION is already released. name: Single-file build on: push: - branches: [main] + branches: [main, feat/v2-ui] pull_request: workflow_dispatch: @@ -103,3 +106,23 @@ jobs: gh release create latest --title "Steamify CachyOS - latest (v$VERSION)" --notes "$latest_notes" --latest=false fi 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 diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index f0e7bf2..925d4f5 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -118,6 +118,7 @@ ApplicationWindow { } function label(it) { return (texts[it.id] && texts[it.id].label) || it.label; } function syncFromStatus() { + reapply = false; var w = {}; for (var i = 0; i < items.length; i++) w[items[i].id] = items[i].kind === "action" ? false : items[i].wanted; want = w; @@ -252,7 +253,7 @@ ApplicationWindow { else if (a === "back") Qt.quit(); } else if (screen === "review") { if (a === "accept" || a === "apply") onApplyPressed(); - else if (a === "back") screen = "menu"; + else if (a === "back") { reapply = false; screen = "menu"; } } else if (screen === "password") { if (a === "accept" || a === "apply") submitPassword(); else if (a === "back") { pw.text = ""; screen = "review"; } @@ -353,9 +354,13 @@ ApplicationWindow { // --- The 1280x720 stage, scaled to the window --- Item { id: stage - width: 1280; height: 720 - anchors.centerIn: parent - scale: Math.min(win.width / 1280, win.height / 720) + // Scaled like a 1280x720 screen, but stretched to the window's own + // shape, so the bars reach the edges and the content fills it. + readonly property real k: Math.min(win.width / 1280, win.height / 720) + width: win.width / k; height: win.height / k + x: 0; y: 0 + transformOrigin: Item.TopLeft + scale: k focus: true Keys.onPressed: function (e) { if (!(screen === "password" && pw.activeFocus && e.key !== Qt.Key_Escape && e.key !== Qt.Key_Return && e.key !== Qt.Key_Enter)) keyAct(e); } Keys.onReleased: function (e) { if (screen === "bios2" && !e.isAutoRepeat && (e.key === Qt.Key_Return || e.key === Qt.Key_Enter)) act("release"); } @@ -393,7 +398,7 @@ ApplicationWindow { Item { id: menuBody; visible: items.length > 0 - x: 40; y: 28; width: 1200; height: parent.height - 28 - 64 - 20 + x: 40; y: 28; width: parent.width - 80; height: parent.height - 28 - 64 - 20 // Left: the list Column { id: listCol; width: 740; height: parent.height; spacing: 10 @@ -564,7 +569,7 @@ ApplicationWindow { Text { anchors.verticalCenter: parent.verticalCenter; x: 18; text: "Your password is asked once. Changes to how the PC starts need a restart."; color: t.soft; font.family: t.body; font.pixelSize: 14 } } } Rectangle { - x: 812; y: 32; width: 428; height: 210; radius: 16; color: t.card + x: parent.width - 468; y: 32; width: 428; height: 210; radius: 16; color: t.card Column { anchors.fill: parent; anchors.margins: 24; spacing: 12 Text { text: "SUMMARY"; color: t.faint; font.family: t.body; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } Repeater { model: [["Turn on", "on"], ["Re-apply", "again"], ["Turn off", "off"]] @@ -576,7 +581,7 @@ ApplicationWindow { Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 64; color: t.bar Rectangle { width: parent.width; height: 1; color: t.line } - Btn { anchors.left: parent.left; anchors.leftMargin: 40; anchors.verticalCenter: parent.verticalCenter; k: g.back; text: "Back to the menu"; onClicked: screen = "menu" } + Btn { anchors.left: parent.left; anchors.leftMargin: 40; anchors.verticalCenter: parent.verticalCenter; k: g.back; text: "Back to the menu"; onClicked: act("back") } Btn { anchors.right: parent.right; anchors.rightMargin: 40; anchors.verticalCenter: parent.verticalCenter; k: g.ok; text: "Apply"; primary: true; visible: plan.length > 0; onClicked: onApplyPressed() } } } @@ -660,7 +665,7 @@ ApplicationWindow { } } Column { - x: 632; y: 32; width: 608; height: parent.height - 64; spacing: 10 + x: 632; y: 32; width: parent.width - 672; height: parent.height - 64; spacing: 10 Text { text: "DETAILS"; color: t.faint; font.family: t.body; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } Rectangle { width: parent.width; height: parent.height - 30; radius: 16; color: "#0b0e13"; border.width: 1; border.color: "#1d2531" ListView { id: logView; anchors.fill: parent; anchors.margins: 18; clip: true; model: logModel @@ -674,7 +679,7 @@ ApplicationWindow { visible: screen === "bios1" anchors.top: header.bottom; anchors.bottom: parent.bottom; width: parent.width Rectangle { - x: 40; y: 28; width: 1200; height: parent.height - 28 - 64 - 24; radius: 16; color: "#1d1416"; border.width: 2; border.color: "#b33a3a" + x: 40; y: 28; width: parent.width - 80; height: parent.height - 28 - 64 - 24; radius: 16; color: "#1d1416"; border.width: 2; border.color: "#b33a3a" Row { anchors.fill: parent; anchors.margins: 32; spacing: 40 Column { width: 640; spacing: 16 Row { spacing: 12 diff --git a/ui/steamify-app.sh b/ui/steamify-app.sh index f118b48..4fe1b83 100755 --- a/ui/steamify-app.sh +++ b/ui/steamify-app.sh @@ -4,11 +4,13 @@ # 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_BRANCH= takes that branch's source instead of a release -# (for testing before a release). +# STEAMIFY_RELEASE= takes that release instead of the newest (the +# v2-ui-preview build's copy of this script sets it). STEAMIFY_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" @@ -26,8 +28,14 @@ if [[ -n "${STEAMIFY_BRANCH:-}" ]]; then mkdir "$tmp/app" tar -xzf "$tmp/src.tar.gz" -C "$tmp/app" --strip-components=1 else - say "Downloading the newest Steamify app..." - curl -fsSL "https://github.com/$REPO/releases/latest/download/steamify-app.tar.gz" -o "$tmp/app.tar.gz" || + 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" From f28e880c046a5b9b7685761eb47e288841ef28fc Mon Sep 17 00:00:00 2001 From: rickpeters Date: Fri, 25 Sep 2026 20:30:07 +0200 Subject: [PATCH 10/15] feat: HDMI refresh boost (Steam Machine, pinned kernel) The pinned 7.1.6 kernel doesn't read the extra EDID block (HDMI Forum EEODB) where monitors list their fast modes, and does no HDMI 2.1, so HDMI displays often stay at 60 Hz. The new menu item reads the display's EDID over DDC, calculates the highest rate that fits HDMI 2.0 at the desktop resolution (rounded down to ten, plus the hundred below as a safe option), tests each step live with a confirmation, and makes the confirmed ones permanent through drm.edid_firmware (Limine, sdboot-manage or GRUB). --- AGENTS.md | 8 + CHANGELOG.md | 11 ++ README.md | 6 + TECHNICAL.md | 43 +++++ lib/hdmi-refresh.sh | 438 ++++++++++++++++++++++++++++++++++++++++++++ lib/menu.sh | 9 +- steamify.sh | 4 +- 7 files changed, 514 insertions(+), 5 deletions(-) create mode 100644 lib/hdmi-refresh.sh diff --git a/AGENTS.md b/AGENTS.md index 40a18ce..059de86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,14 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine The start script closes its window after a 10-second countdown on success and waits for Enter after an error; it uses `pipefail`, or a failed download would run an empty script and count as success. +- HDMI refresh boost (`hdmi`, `lib/hdmi-refresh.sh`): only on Fremont with + the pinned kernel (stays visible while on). It needs someone at the + screen: the graphical app's backend (`BACKEND=true`) must refuse it, and every step needs a "y" within 15 s + (`WIZARD_HDMI_CONFIRM_SECONDS` for scripted tests). debugfs is root-only + (glob it under sudo), and `edid_override` takes exactly `reset` with no + newline. Build the EDID from the DDC read, not from sysfs: a live override + replaces the kernel's copy. The kernel command-line value holds a path, so + `sed` uses `|` as its delimiter. - `Relogin=true` means a gamescope that fails to start is relaunched in a tight loop; keep that in mind when changing session handling. diff --git a/CHANGELOG.md b/CHANGELOG.md index c4af0d8..41ae0e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes, per version and per commit. Versions follow `steamify.sh`. Versions before 0.7.0 were numbered afterwards, one per merged pull request. +## 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). + ## 1.1.4 - 2026-09-25 - **fix: No lock screen in single user mode before a restart** diff --git a/README.md b/README.md index a88af9a..5d170d9 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,12 @@ What Steamify can do on a Valve Steam Machine: [release](https://github.com/theupriser/steamify-cachyos/releases/tag/kernel-7.1.6-1), checked against CachyOS's signature ([details](TECHNICAL.md#kernel-pin-steam-machine)). +- **HDMI refresh boost** - only with the pinned kernel, never ticked by + default: gets the highest refresh rate your HDMI display runs at the + desktop resolution (e.g. 110 Hz instead of 60 on a 3440x1440 monitor). + Each step is shown and you confirm it; no answer within 15 s switches + back. For more (e.g. 175 Hz), use DisplayPort + ([details](TECHNICAL.md#hdmi-refresh-boost-steam-machine)). - **Update BIOS** - installs Valve's newest Steam Machine BIOS. Never ticked by default, at your own risk, and only after two warnings ([details](TECHNICAL.md#bios-updates-steam-machine)). diff --git a/TECHNICAL.md b/TECHNICAL.md index 1f52744..ce1b8bb 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -229,6 +229,48 @@ into the kernel directory yourself. Unticking it removes the pin and runs `sudo pacman -Syu`, which brings the kernel back to CachyOS's current version; the files stay for next time. +## HDMI refresh boost (Steam Machine) + +With the pinned kernel (7.1.6), HDMI displays often stay at 60 Hz. Two +reasons: + +- Monitors list their fast modes in an extra EDID block, announced by the + HDMI Forum EEODB data block. 7.1.6 only reads the first extension block, + so it never sees them. Newer kernels do. +- Their fastest modes need HDMI 2.1 (FRL). 7.1.6's amdgpu only does HDMI + 2.0 (TMDS, at most 600 MHz), but a mode with the display's own shortest + blanking at a slightly lower rate often fits. + +The menu item (only on a Steam Machine with the pinned kernel, never +ticked by default, run from the desktop in Konsole): + +1. Takes the desktop resolution from KDE (`kscreen-doctor -j`) and reads the + display's complete EDID over DDC (`i2ctransfer`, segment pointer 0x30). + A live EDID left by an earlier test is cleared first. +2. Calculates the highest rate that fits: the display's TMDS limit (HDMI + Forum VSDB, capped at amdgpu's 600 MHz), its shortest blanking at that + resolution and its maximum refresh (range limits, VRR maximum). Steps: + that rate rounded down to ten, and the hundred below it as a safe option. + Rates the display already lists, or that aren't faster than what works + now, are left out. +3. Builds the EDID: all of the display's blocks, the block count and EEODB + fixed, plus a DisplayID block with the steps. +4. Loads it live (debugfs `edid_override`, `trigger_hotplug`) and switches + to each step, lowest first. Each one needs a "y" within 15 s + (`WIZARD_HDMI_CONFIRM_SECONDS` for tests); anything else switches back + and stops. +5. Makes it permanent with only the confirmed steps: + `/usr/lib/firmware/edid/steamify-.bin`, in the initramfs via + `/etc/mkinitcpio.conf.d/90-steamify-edid.conf` (amdgpu loads from there), + and `drm.edid_firmware=:edid/steamify-.bin` on the + kernel command line: `/etc/default/limine` (`limine-mkinitcpio`), + `/etc/sdboot-manage.conf` or `/etc/default/grub`. + +The display's manufacturer, model and serial are stored; re-applying with +another display connected tests that one instead. Turning it off removes +the files and the parameter and rebuilds. Untick it before removing the +kernel pin: newer kernels read the EDID themselves and can do HDMI 2.1. + ## BIOS updates (Steam Machine) The **Update BIOS** item is only shown on a Steam Machine and is never ticked @@ -298,6 +340,7 @@ immediately, which can turn into a loop - see | `lib/bios.sh` | Update BIOS (Steam Machine, opt-in): current/newest version, double confirmation, fwupd | | `lib/cec.sh` | HDMI-CEC: Valve's `cecd` and friends from its `holo` repository | | `lib/steam-machine.sh` | Steam Machine support: LED driver, LED access, steamos-manager; kernel pin | +| `lib/hdmi-refresh.sh` | HDMI refresh boost (Steam Machine, pinned kernel): EDID over DDC, calculated steps, live test, `drm.edid_firmware` | | `.github/tools/bundle.sh` | Builds the single-file version (`dist/steamify.sh`) | | `.github/workflows/bundle.yml` | Builds and checks it on every push; publishes it on `main` | diff --git a/lib/hdmi-refresh.sh b/lib/hdmi-refresh.sh new file mode 100644 index 0000000..e85fcfc --- /dev/null +++ b/lib/hdmi-refresh.sh @@ -0,0 +1,438 @@ +#!/bin/bash +# "HDMI refresh boost" menu item, Steam Machine (Fremont) only: finds the +# highest refresh rate the display runs at the desktop resolution over HDMI, +# tests it live and makes it permanent with an EDID override +# (drm.edid_firmware). Two things keep displays below what they can do: +# - Monitors put their fast modes in an extra EDID block (HDMI Forum EEODB); +# the pinned kernel (7.1.6) only reads the first extension. Newer kernels +# do, and do HDMI 2.1, so it's only offered with the pinned kernel. +# - Their fastest modes need HDMI 2.1 (FRL), which amdgpu doesn't do on +# every kernel; a mode with the display's shortest blanking at a slightly +# lower rate often still fits HDMI 2.0's TMDS limit (600 MHz). +# What a display claims isn't what it accepts: every step is shown and must +# be confirmed, and no answer within 15 s switches back (the screen may be +# black). Sourced by steamify.sh; not meant to be run on its own. + +HDMI_FW_DIR=/usr/lib/firmware/edid +HDMI_INITRAMFS_CONF=/etc/mkinitcpio.conf.d/90-steamify-edid.conf +# No answer within this time switches back; longer for scripted tests. +HDMI_CONFIRM_SECONDS="${WIZARD_HDMI_CONFIRM_SECONDS:-15}" +# amdgpu's HDMI 2.0 limit; the display's own limit (from its EDID) may be lower. +HDMI_TMDS_MAX_KHZ=600000 + +hdmi_connectors() { + # Connected HDMI outputs, as "-" (e.g. card1-HDMI-A-1). + local d + for d in /sys/class/drm/card*-HDMI-A-*; do + [[ "$(cat "$d/status" 2>/dev/null)" == connected ]] && basename "$d" + done +} + +hdmi_boot_file() { + # The file holding the kernel command line, per boot loader. + if [[ -f /etc/default/limine ]]; then echo /etc/default/limine + elif [[ -f /etc/sdboot-manage.conf ]] && command -v sdboot-manage >/dev/null; then echo /etc/sdboot-manage.conf + elif [[ -f /etc/default/grub ]]; then echo /etc/default/grub + fi +} + +hdmi_cmdline_param() { + # The drm.edid_firmware= value we set, or nothing. + local f; f="$(hdmi_boot_file)" + [[ -n "$f" ]] && grep -oE 'drm\.edid_firmware=[^ "]*steamify-[^ "]*' "$f" 2>/dev/null | head -n 1 +} + +hdmi_available() { + # Stays available while on, so it can be turned off after the pin is gone. + detect_valve_fremont || return 1 + hdmi_status || { pinned_kernel_installed && [[ -n "$(hdmi_connectors)" && -n "$(hdmi_boot_file)" ]]; } +} + +hdmi_status() { [[ -n "$(hdmi_cmdline_param)" ]] && compgen -G "$HDMI_FW_DIR/steamify-*.bin" >/dev/null; } + +hdmi_edid_tool() { + # hdmi_edid_tool ...: EDID parsing and building (python, as + # it's byte work). Commands: + # blocks number of blocks the display says it has + # id vendor/product/serial, to recognise it + # plan " ..." for w x h + # build ... full EDID plus a DisplayID block + # with those rates at the shortest blanking + python3 - "$HDMI_TMDS_MAX_KHZ" "$@" <<'PY' +import sys + +src_khz = int(sys.argv[1]); cmd = sys.argv[2]; args = sys.argv[3:] +def load(p): return bytearray(open(p, 'rb').read()) +def le(b): return int.from_bytes(b, 'little') + +def cta_blocks(e): + for i in range(128, len(e), 128): + if e[i] == 0x02: yield i + +def data_blocks(e, i): + # CTA data block collection: (tag, extended tag, offset, length) + end, p = e[i + 2], i + 4 + while p < i + end and p < i + 127: + tag, n = e[p] >> 5, e[p] & 0x1f + yield tag, (e[p + 1] if tag == 7 and n else None), p, n + p += n + 1 + +def eeodb(e): + for i in cta_blocks(e): + for tag, ext, p, n in data_blocks(e, i): + if tag == 7 and ext == 0x78: return p + 2 + return None + +def nblocks(e): + p = eeodb(e) + return 1 + max(e[126], e[p] if p and p < len(e) else 0) + +def timings(e): + # (hz, clk_khz, ha, hb, hf, hs, hpos, va, vb, vf, vs, vpos) + out = [] + def dtd(b): + clk = le(b[0:2]) * 10 + if not clk: return + ha = b[2] | (b[4] >> 4) << 8; hb = b[3] | (b[4] & 15) << 8 + va = b[5] | (b[7] >> 4) << 8; vb = b[6] | (b[7] & 15) << 8 + hf = b[8] | (b[11] >> 6 & 3) << 8; hs = b[9] | (b[11] >> 4 & 3) << 8 + vf = (b[10] >> 4) | (b[11] >> 2 & 3) << 4; vs = (b[10] & 15) | (b[11] & 3) << 4 + if b[17] & 0x80: return # interlaced + out.append((clk * 1000 / ((ha + hb) * (va + vb)), clk, ha, hb, hf, hs, b[17] >> 1 & 1, + va, vb, vf, vs, b[17] >> 2 & 1)) + for o in range(54, 126, 18): dtd(e[o:o + 18]) + for i in cta_blocks(e): + d = e[i + 2] + if d >= 4: + for o in range(i + d, i + 127 - 17, 18): dtd(e[o:o + 18]) + for i in range(128, len(e), 128): + if e[i] != 0x70: continue + p, end = i + 5, i + 5 + e[i + 2] + while p + 3 <= end: + tag, n = e[p], e[p + 2] + if tag == 0 and n == 0: break + if tag == 0x03: + for o in range(p + 3, p + 3 + n - 19, 20): + b = e[o:o + 20] + clk = (le(b[0:3]) + 1) * 10 + hf, vf = le(b[8:10]), le(b[16:18]) + ha, hb, va, vb = le(b[4:6]) + 1, le(b[6:8]) + 1, le(b[12:14]) + 1, le(b[14:16]) + 1 + out.append((clk * 1000 / ((ha + hb) * (va + vb)), clk, ha, hb, (hf & 0x7fff) + 1, + le(b[10:12]) + 1, hf >> 15, va, vb, (vf & 0x7fff) + 1, le(b[18:20]) + 1, vf >> 15)) + p += 3 + n + return out + +def tmds_khz(e): + # The display's own TMDS limit: HDMI Forum VSDB, else the HDMI 1.4 VSDB. + hf = h14 = 0 + for i in cta_blocks(e): + for tag, ext, p, n in data_blocks(e, i): + if tag != 3 or n < 3: continue + oui = e[p + 3] << 16 | e[p + 2] << 8 | e[p + 1] + if oui == 0xC45DD8 and n >= 5: hf = e[p + 5] * 5000 + if oui == 0x000C03 and n >= 7: h14 = e[p + 7] * 5000 + lim = hf or h14 or 165000 + return min(lim, src_khz) + +def max_vrefresh(e): + # Range limits descriptor, the HDMI Forum / AMD VRR maximum. + best = 0 + for o in range(54, 126, 18): + b = e[o:o + 18] + if b[0:3] == b'\0\0\0' and b[3] == 0xfd: + best = max(best, b[6] + (255 if b[4] & 2 else 0)) + for i in cta_blocks(e): + for tag, ext, p, n in data_blocks(e, i): + if tag != 3 or n < 3: continue + oui = e[p + 3] << 16 | e[p + 2] << 8 | e[p + 1] + if oui == 0xC45DD8 and n >= 10: best = max(best, (e[p + 9] & 0xc0) << 2 | e[p + 10]) + if oui == 0x00001A and n >= 7: best = max(best, e[p + 7]) + return best + +def shortest(e, w, h): + ts = [t for t in timings(e) if t[2] == w and t[7] == h] + return min(ts, key=lambda t: (t[2] + t[3]) * (t[7] + t[8])) if ts else None + +if cmd == 'blocks': + print(nblocks(load(args[0]))) +elif cmd == 'id': + print(load(args[0])[8:18].hex()) +elif cmd == 'plan': + e = load(args[0]); w, h, cur = int(args[1]), int(args[2]), float(args[3]) + lim, t = tmds_khz(e), shortest(e, w, h) + if not t: sys.exit(1) + listed = [x[0] for x in timings(e) if x[2] == w and x[7] == h and x[1] <= lim] + best = max(listed) if listed else cur + top = int(lim * 1000 // ((t[2] + t[3]) * (t[7] + t[8]))) + vmax = max_vrefresh(e) or int(max(x[0] for x in timings(e) if x[2] == w and x[7] == h)) + top = min(top, vmax) + # Not right at the limit: the top rounded down to ten, and the hundred + # below it as a safe option. Rates the display already lists are left + # out (its own timing is the safer one), and so is anything not faster + # than what already works. + steps = sorted({top // 10 * 10, top // 100 * 100}) + steps = [s for s in steps if s > best + 1 and not any(abs(s - x) < 1 for x in listed)] + print(lim // 1000, round(best, 2), *steps) +elif cmd == '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 : 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 : 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 [ ]: 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 : 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)" + [[ "${rate%.*}" -ge $(( $4 - 1 )) ]] +} + +hdmi_confirm() { + # No answer (black screen, nobody there) is a no. + local reply + read -rt "$HDMI_CONFIRM_SECONDS" -p "$(echo -e "${c_bold}$1${c_reset} [y/N, ${HDMI_CONFIRM_SECONDS}s] ")" reply || { echo; return 1; } + [[ "$reply" =~ ^[Yy]$ ]] +} + +hdmi_boot_param() { + # hdmi_boot_param : set or remove our drm.edid_firmware= in + # the boot loader's command line, then rebuild the initramfs and entries. + local f value="$1" + f="$(hdmi_boot_file)" || return 1 + [[ -n "$f" ]] || { err "No supported boot loader (Limine, systemd-boot with sdboot-manage, GRUB) found."; return 1; } + backup_file "$f" + sudo sed -i -E 's/drm\.edid_firmware=[^ "]*steamify-[^ "]* ?//' "$f" + if [[ -n "$value" ]]; then + case "$f" in + # | as delimiter: the value holds a path. + /etc/default/limine) sudo sed -i -E "0,\|^KERNEL_CMDLINE\[default\]\+?=\"|s||&$value |" "$f" ;; + /etc/sdboot-manage.conf) sudo sed -i -E "0,\|^LINUX_OPTIONS=\"|s||&$value |" "$f" ;; + /etc/default/grub) sudo sed -i -E "0,\|^GRUB_CMDLINE_LINUX_DEFAULT=\"|s||&$value |" "$f" ;; + esac + grep -qF "$value" "$f" || { err "Couldn't add $value to $f."; return 1; } + fi + info "Rebuilding the initramfs and boot entries..." + case "$f" in + /etc/default/limine) sudo limine-mkinitcpio ;; + /etc/sdboot-manage.conf) sudo /usr/bin/mkinitcpio -P && sudo sdboot-manage gen ;; + /etc/default/grub) sudo /usr/bin/mkinitcpio -P && sudo grub-mkconfig -o /boot/grub/grub.cfg ;; + esac +} + +hdmi_tune() { + # hdmi_tune : find and confirm the highest rate + # at the desktop resolution. Sets HDMI_RESULT to " ..." (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[*]}" +} + +hdmi_enable() { + if [[ "${BACKEND:-false}" == true ]]; then + err "HDMI refresh boost needs you at the screen: run it from the Steamify menu in Konsole." + return 1 + fi + if [[ "${XDG_CURRENT_DESKTOP:-}" != *KDE* ]] || ! command -v kscreen-doctor >/dev/null; then + err "Run this from the Plasma desktop: the test switches the display mode through KDE." + return 1 + fi + if hdmi_status; then + local conn stale=false + for conn in $(hdmi_connectors); do + [[ -f "$HDMI_FW_DIR/steamify-${conn#card*-}.bin" ]] || continue + [[ "$(state_get hdmi "${conn#card*-}")" == "$(hdmi_edid_tool id "/sys/class/drm/$conn/edid")" ]] || stale=true + done + if [[ "$stale" == false ]]; then + ok "Already set up for the connected display(s). Untick and tick it again to re-test." + return 0 + fi + warn "Set up for another display; testing the one connected now." + hdmi_disable || return 1 + fi + sudo pacman -S --needed --noconfirm i2c-tools python >/dev/null 2>&1 || { err "Installing i2c-tools failed."; return 1; } + + local tmp conn out param="" files=() rc=0 + tmp="$(mktemp -d)" + for conn in $(hdmi_connectors); do + out="${conn#card*-}" + hdmi_tune "$conn" "$tmp" || { rc=1; continue; } + [[ -n "$HDMI_RESULT" ]] || continue + sudo install -Dm644 "$tmp/$out.final" "$HDMI_FW_DIR/steamify-$out.bin" || { rc=1; continue; } + state_set hdmi "$out" "$(hdmi_edid_tool id "$tmp/$out.orig")" + state_set hdmi "$out-mode" "$HDMI_RESULT" + param+="${param:+,}$out:edid/steamify-$out.bin" + files+=("$HDMI_FW_DIR/steamify-$out.bin") + done + rm -rf "$tmp" + [[ -n "$param" ]] || return $rc + + # amdgpu loads from the initramfs (the kms hook), before the root + # filesystem: the EDID file has to be in there too. + sudo mkdir -p "$(dirname "$HDMI_INITRAMFS_CONF")" + printf '# Written by steamify: EDID override for HDMI refresh boost.\nFILES+=(%s)\n' "${files[*]}" | + sudo tee "$HDMI_INITRAMFS_CONF" >/dev/null + if ! hdmi_boot_param "drm.edid_firmware=$param"; then + # Nothing half-done left behind; the live EDID lasts until a restart. + sudo rm -f "$HDMI_INITRAMFS_CONF" "${files[@]}" + for conn in $(hdmi_connectors); do hdmi_override_live "$conn" reset 2>/dev/null; done + state_clear hdmi + err "Making it permanent failed; the display is back on its own EDID." + return 1 + fi + ok "HDMI refresh boost is set up; it's active now and after every restart." + return $rc +} + +hdmi_disable() { + local f conn + # Drop-in and files first: the rebuild below leaves them out. + sudo rm -f "$HDMI_INITRAMFS_CONF" + for f in "$HDMI_FW_DIR"/steamify-*.bin; do [[ -e "$f" ]] && sudo rm -f "$f"; done + hdmi_boot_param "" || return 1 + for conn in $(hdmi_connectors); do hdmi_override_live "$conn" reset 2>/dev/null; done + state_clear hdmi + ok "HDMI refresh boost removed; the display uses its own EDID again." +} diff --git a/lib/menu.sh b/lib/menu.sh index bf8f3cf..cbecec5 100644 --- a/lib/menu.sh +++ b/lib/menu.sh @@ -5,7 +5,7 @@ # Menu order. Components are turned on in this order and off in reverse; # gaming must come first (single user builds on it). -COMPONENTS=(gaming boot theme glyphs single launcher cec machine kpin bios) +COMPONENTS=(gaming boot theme glyphs single launcher cec machine kpin hdmi bios) # One-off actions rather than on/off components: never preselected, never # re-applied, not listed as on or off. ACTIONS=(bios) @@ -14,8 +14,9 @@ declare -A PARENT=([boot]=gaming [kpin]=machine) # Never preselected on a first run: booting into the desktop is a choice, # gamescope is the default; HDMI-CEC is opt-in (it can wake the machine or # upset other devices on the TV, even on SteamOS), except on a Steam Machine, -# which has CEC like on SteamOS. -NO_PRESELECT=(boot cec) +# which has CEC like on SteamOS. HDMI refresh boost needs someone at the +# screen to confirm each step. +NO_PRESELECT=(boot cec hdmi) declare -A LABEL=( [gaming]="SteamOS conversion: boot into gaming mode, Steam on the desktop" @@ -27,6 +28,7 @@ declare -A LABEL=( [cec]="HDMI-CEC: use Steam with the TV remote, TV on/off with the PC (experimental)" [machine]="Steam Machine support: LED bar driver, hardware settings in Steam" [kpin]="Pin the kernel to $PINNED_KERNEL_VER (fixes rebooting after shutdown)" + [hdmi]="HDMI refresh boost: highest refresh your HDMI display runs" [bios]="Update BIOS" ) declare -A CURRENT WANTED @@ -34,6 +36,7 @@ declare -A CURRENT WANTED component_available() { case "$1" in machine|kpin) machine_available ;; + hdmi) hdmi_available ;; bios) bios_available ;; esac } diff --git a/steamify.sh b/steamify.sh index 9c412b8..1924c25 100755 --- a/steamify.sh +++ b/steamify.sh @@ -15,11 +15,11 @@ set -uo pipefail # Release version, see CHANGELOG.md. -VERSION=1.1.4 +VERSION=1.2.0 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 backend; do +for lib in common state packages login-manager single-user steam-desktop steam-machine hdmi-refresh cec boot-session vapor-theme steamos-extras bios desktop-shortcut wizard-shortcut menu backend; do # shellcheck source=/dev/null source "$SCRIPT_DIR/lib/$lib.sh" done From 3974481e9e9af3236e123eb5f411e116439e8726 Mon Sep 17 00:00:00 2001 From: rickpeters Date: Fri, 25 Sep 2026 20:37:47 +0200 Subject: [PATCH 11/15] feat: HDMI refresh boost and Update BIOS under Steam Machine support Both are sub-options of Steam Machine support now, shown only while it is ticked. Unticking it unticks them; unticking the kernel pin unticks the HDMI refresh boost, which only works with the pinned kernel. --- AGENTS.md | 7 ++++--- CHANGELOG.md | 4 ++++ README.md | 18 +++++++++--------- lib/menu.sh | 20 ++++++++++++++------ 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 059de86..683332b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,7 +142,7 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine `steam-set-session plasma.desktop` plus the plasmalogin sync bridge when it exists, at every boot. CachyOS's `cachyos-gamescope-autologin` still sets gamescope during each desktop session; the unit corrects it at boot. -- `bios` is an *action* (`ACTIONS` in `lib/menu.sh`), not an on/off +- `bios` is an *action* (`ACTIONS` in `lib/menu.sh`) and a sub-option of `machine`, not an on/off component: never preselected (not even on a first run), never re-applied by `a`, not listed in the state overview, and `bios_status` is always off. Only selectable when Valve's version differs from the installed one @@ -171,8 +171,9 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine The start script closes its window after a 10-second countdown on success and waits for Enter after an error; it uses `pipefail`, or a failed download would run an empty script and count as success. -- HDMI refresh boost (`hdmi`, `lib/hdmi-refresh.sh`): only on Fremont with - the pinned kernel (stays visible while on). It needs someone at the +- HDMI refresh boost (`hdmi`, `lib/hdmi-refresh.sh`): a sub-option of + `machine` (like `kpin` and `bios`), only on Fremont with the pinned kernel + (stays visible while on); unticking `kpin` unticks it. It needs someone at the screen: the graphical app's backend (`BACKEND=true`) must refuse it, and every step needs a "y" within 15 s (`WIZARD_HDMI_CONFIRM_SECONDS` for scripted tests). debugfs is root-only (glob it under sudo), and `edid_override` takes exactly `reset` with no diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ae0e4..4161285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ one per merged pull request. (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 diff --git a/README.md b/README.md index 5d170d9..6180589 100644 --- a/README.md +++ b/README.md @@ -50,15 +50,15 @@ What Steamify can do on a Valve Steam Machine: [release](https://github.com/theupriser/steamify-cachyos/releases/tag/kernel-7.1.6-1), checked against CachyOS's signature ([details](TECHNICAL.md#kernel-pin-steam-machine)). -- **HDMI refresh boost** - only with the pinned kernel, never ticked by - default: gets the highest refresh rate your HDMI display runs at the - desktop resolution (e.g. 110 Hz instead of 60 on a 3440x1440 monitor). - Each step is shown and you confirm it; no answer within 15 s switches - back. For more (e.g. 175 Hz), use DisplayPort - ([details](TECHNICAL.md#hdmi-refresh-boost-steam-machine)). -- **Update BIOS** - installs Valve's newest Steam Machine BIOS. Never ticked - by default, at your own risk, and only after two warnings - ([details](TECHNICAL.md#bios-updates-steam-machine)). + - **HDMI refresh boost** - only with the pinned kernel, never ticked by + default: gets the highest refresh rate your HDMI display runs at the + desktop resolution (e.g. 110 Hz instead of 60 on a 3440x1440 monitor). + Each step is shown and you confirm it; no answer within 15 s switches + back. For more (e.g. 175 Hz), use DisplayPort + ([details](TECHNICAL.md#hdmi-refresh-boost-steam-machine)). + - **Update BIOS** - installs Valve's newest Steam Machine BIOS. Never ticked + by default, at your own risk, and only after two warnings + ([details](TECHNICAL.md#bios-updates-steam-machine)). ## Requirements diff --git a/lib/menu.sh b/lib/menu.sh index cbecec5..6ed5eb4 100644 --- a/lib/menu.sh +++ b/lib/menu.sh @@ -10,7 +10,7 @@ COMPONENTS=(gaming boot theme glyphs single launcher cec machine kpin hdmi bios) # re-applied, not listed as on or off. ACTIONS=(bios) # Sub-options, shown indented under their parent and only while it's ticked. -declare -A PARENT=([boot]=gaming [kpin]=machine) +declare -A PARENT=([boot]=gaming [kpin]=machine [hdmi]=machine [bios]=machine) # Never preselected on a first run: booting into the desktop is a choice, # gamescope is the default; HDMI-CEC is opt-in (it can wake the machine or # upset other devices on the TV, even on SteamOS), except on a Steam Machine, @@ -96,6 +96,12 @@ toggle_component() { # The kernel pin is opt-out: ticked along with Steam Machine support. if [[ "$c" == machine ]]; then WANTED[kpin]=${WANTED[machine]}; fi if [[ "$c" == kpin && "${WANTED[kpin]}" == 1 ]]; then WANTED[machine]=1; fi + # HDMI refresh boost and the BIOS update sit under Steam Machine support; + # the boost also needs the pinned kernel (newer kernels don't need it). + if [[ "$c" == machine && "${WANTED[machine]}" == 0 ]]; then WANTED[hdmi]=0; WANTED[bios]=0; fi + if [[ "$c" == kpin && "${WANTED[kpin]}" == 0 ]]; then WANTED[hdmi]=0; fi + if [[ "$c" == hdmi && "${WANTED[hdmi]}" == 1 ]]; then WANTED[machine]=1; WANTED[kpin]=1; fi + if [[ "$c" == bios && "${WANTED[bios]}" == 1 ]]; then WANTED[machine]=1; fi } show_menu() { @@ -118,12 +124,13 @@ show_menu() { [[ "${CURRENT[gaming]}" == 1 ]] || now="-" want="gaming"; [[ "${WANTED[boot]}" == 1 ]] && want="desk" printf " %-3s %-6s %-6s └ %s\n" "$i" "$now" "$want" "$(boot_choice "${WANTED[boot]}")" + elif ! component_selectable "$c"; then + local tree=""; [[ -n "${PARENT[$c]:-}" ]] && tree=" └ " + printf " %b%-3s %-6s %-6s %s%s (not available)%b\n" "$c_dim" "$i" "$now" "$want" "$tree" "${LABEL[$c]}" "$c_reset" elif [[ -n "${PARENT[$c]:-}" ]]; then printf " %-3s %b %-6s └ %s\n" "$i" "$now" "$want" "${LABEL[$c]}" - elif component_selectable "$c"; then - printf " %-3s %b %-6s %s\n" "$i" "$now" "$want" "${LABEL[$c]}" else - printf " %b%-3s %-6s %-6s %s (not available)%b\n" "$c_dim" "$i" "$now" "$want" "${LABEL[$c]}" "$c_reset" + printf " %-3s %b %-6s %s\n" "$i" "$now" "$want" "${LABEL[$c]}" fi done echo @@ -171,8 +178,9 @@ draw_menu_tui() { fi if ! component_selectable "$c"; then # Greyed out: nothing to do (e.g. BIOS already up to date). - local mark=" "; (( i == cursor )) && mark=" ${c_cyan}>${c_reset} " - echo -e "${mark}${c_dim}[ ] ${LABEL[$c]} (not available)${c_reset}" + local mark=" " tree=""; (( i == cursor )) && mark=" ${c_cyan}>${c_reset} " + [[ -n "${PARENT[$c]:-}" ]] && tree=" └ " + echo -e "${mark}${c_dim}${tree}[ ] ${LABEL[$c]} (not available)${c_reset}" elif (( i == cursor )); then # The green x's reset would end the bold too: re-enable it after. echo -e " ${c_cyan}>${c_reset} ${c_bold}${line//"$c_reset"/"$c_reset$c_bold"}${c_reset}${state}" From e9cd578c9ba238cf3152584f8ad07c9e8ec660cc Mon Sep 17 00:00:00 2001 From: rickpeters Date: Fri, 25 Sep 2026 20:39:00 +0200 Subject: [PATCH 12/15] feat(ui): HDMI refresh boost in the app, off only The app gets its texts and the same rules as the menu: unticking Steam Machine support or the kernel pin unticks it. It can't be turned on from the app, as each step needs someone at the keyboard to confirm the picture. --- CHANGELOG.md | 2 ++ lib/backend.sh | 2 ++ ui/qml/Main.qml | 8 ++++++++ 3 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4161285..83a7ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ one per merged pull request. - 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. + - In the app, HDMI refresh boost can only be turned off: each step needs + someone at the keyboard, so it's turned on from the menu in Konsole. ## 1.1.4 - 2026-09-25 diff --git a/lib/backend.sh b/lib/backend.sh index 68b969c..1a52934 100644 --- a/lib/backend.sh +++ b/lib/backend.sh @@ -144,6 +144,8 @@ backend_apply() { [[ "${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 diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index 925d4f5..1612eba 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -71,6 +71,9 @@ ApplicationWindow { kpin: { label: "Pin the kernel", hint: "Fixes rebooting after shutdown", body: "Newer CachyOS kernels make the Steam Machine reboot instead of shutting down. Untick once CachyOS fixes that.", changes: ["linux-cachyos from Steamify's release (signature checked)", "Kept in /var/cache/steamify/kernel", "Added to IgnorePkg"] }, + hdmi: { label: "HDMI refresh boost", hint: "Higher refresh over HDMI · turn on from Steamify in Konsole", + body: "The pinned kernel keeps many HDMI displays at 60 Hz. This finds the highest refresh rate your display runs at the desktop resolution. Each step needs you to confirm the picture, so it's turned on from the Steamify menu in Konsole; here you can only turn it off.", + changes: ["The display's EDID with the confirmed rates", "In the initramfs, drm.edid_firmware on the kernel command line", "Off: the display uses its own EDID again"] }, bios: { label: "Update BIOS", hint: "", body: "Installs Valve's newest Steam Machine BIOS, at your own risk. It checks Valve's checksum and asks fwupd whether the file fits this machine, then warns you twice before anything is written.", changes: ["Valve's fremont-hw-support package (checksum checked)", "fwupd writes the BIOS during the next restart", "Keep the power on until the machine has fully started again"] } @@ -132,8 +135,13 @@ ApplicationWindow { w[id] = !w[id]; if (id === "gaming" && !w.gaming) { w.single = false; } if (id === "single" && w.single) w.gaming = true; + // HDMI refresh boost needs someone at the keyboard to confirm each + // step: it's only turned on from the terminal menu. + if (id === "hdmi" && w.hdmi && !nowOn("hdmi")) return; if (id === "machine") w.kpin = w.machine; if (id === "kpin" && w.kpin) w.machine = true; + if (id === "hdmi" && w.hdmi) { w.machine = true; w.kpin = true; } + if (!w.machine || !w.kpin) w.hdmi = false; want = w; } function computePlan() { From fe6798d9517843c84d6ddd72a31401e76c7007d2 Mon Sep 17 00:00:00 2001 From: rickpeters Date: Fri, 25 Sep 2026 20:39:57 +0200 Subject: [PATCH 13/15] chore: Version 2.0.0 for the v2 app --- CHANGELOG.md | 13 +++++++++++-- steamify.sh | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a7ff2..9ba619f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes, per version and per commit. Versions follow `steamify.sh`. Versions before 0.7.0 were numbered afterwards, one per merged pull request. +## 2.0.0 - 2026-09-25 + +- **feat: Steamify app (v2 UI)** + - A graphical app for controller, keyboard and TV remote, driving the same + components through `steamify.sh --backend`; the BIOS update with both + warnings; built by GitHub and run with curl. +- **feat(ui): HDMI refresh boost in the app, off only** + - Each step needs someone at the keyboard, so it's turned on from the + menu in Konsole; the app can only turn it off. Unticking Steam Machine + support or the kernel pin unticks it, as in the menu. + ## 1.2.0 - 2026-09-25 - **feat: HDMI refresh boost (Steam Machine, pinned kernel)** @@ -19,8 +30,6 @@ one per merged pull request. - 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. - - In the app, HDMI refresh boost can only be turned off: each step needs - someone at the keyboard, so it's turned on from the menu in Konsole. ## 1.1.4 - 2026-09-25 diff --git a/steamify.sh b/steamify.sh index 1924c25..5ff1141 100755 --- a/steamify.sh +++ b/steamify.sh @@ -15,7 +15,7 @@ set -uo pipefail # Release version, see CHANGELOG.md. -VERSION=1.2.0 +VERSION=2.0.0 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" From 881330c3a67432b0dd6b1f4293d99d40f5a49862 Mon Sep 17 00:00:00 2001 From: rickpeters Date: Fri, 25 Sep 2026 20:42:08 +0200 Subject: [PATCH 14/15] fix(ui): The details panel scrolls instead of overflowing Long texts (e.g. HDMI refresh boost) ran out of the card. The details are in a Flickable now, which starts at the top for each item. --- ui/qml/Main.qml | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index 1612eba..6510928 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -492,25 +492,35 @@ ApplicationWindow { Column { x: 772; width: parent.width - 772; height: parent.height; spacing: 16 Rectangle { + id: detail width: parent.width; height: parent.height - sys.height - 16; radius: 16; color: t.card readonly property var it: sel < rows.length ? rows[sel] : null readonly property var tx: it ? (texts[it.id] || {}) : {} - Column { - anchors.fill: parent; anchors.margins: 24; spacing: 14 - visible: parent.it !== null - Row { spacing: 10 - Chip { text: parent.parent.parent.it ? (parent.parent.parent.it.kind === "choice" ? (boot === "desktop" ? "Desktop" : "Gaming") : (parent.parent.parent.it.kind === "action" ? "Opt-in" : (parent.parent.parent.it.on ? "On" : "Off"))) : "" - fg: parent.parent.parent.it && parent.parent.parent.it.on ? t.good : "#b8c3d1"; bgc: parent.parent.parent.it && parent.parent.parent.it.on ? t.goodBg : t.line } - Chip { visible: !!parent.parent.parent.tx.experimental; text: "Experimental"; fg: t.warn; bgc: t.warnBg } + // Long texts scroll instead of running out of the card; + // each item starts at the top. + onItChanged: detailFlick.contentY = 0 + Flickable { + id: detailFlick + anchors.fill: parent; anchors.margins: 24; anchors.rightMargin: 12 + visible: detail.it !== null; clip: true + contentHeight: detailCol.implicitHeight; boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + Column { + id: detailCol; width: detailFlick.width - 12; spacing: 14 + Row { spacing: 10 + Chip { text: detail.it ? (detail.it.kind === "choice" ? (boot === "desktop" ? "Desktop" : "Gaming") : (detail.it.kind === "action" ? "Opt-in" : (detail.it.on ? "On" : "Off"))) : "" + fg: detail.it && detail.it.on ? t.good : "#b8c3d1"; bgc: detail.it && detail.it.on ? t.goodBg : t.line } + Chip { visible: !!detail.tx.experimental; text: "Experimental"; fg: t.warn; bgc: t.warnBg } + } + Text { text: detail.it ? label(detail.it) : ""; color: t.text; font.family: t.display; font.pixelSize: 26; font.weight: Font.DemiBold; wrapMode: Text.WordWrap; width: parent.width } + Text { text: detail.tx.body || ""; color: t.soft; font.family: t.body; font.pixelSize: 15; lineHeight: 1.4; wrapMode: Text.WordWrap; width: parent.width } + Text { text: "WHAT IT CHANGES"; color: t.faint; font.family: t.body; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.8; topPadding: 4 } + Repeater { model: detail.tx.changes || [] + Text { required property string modelData; text: "• " + modelData; color: t.soft; font.family: t.body; font.pixelSize: 14; wrapMode: Text.WordWrap; width: detailCol.width } } } - Text { text: parent.parent.it ? label(parent.parent.it) : ""; color: t.text; font.family: t.display; font.pixelSize: 26; font.weight: Font.DemiBold; wrapMode: Text.WordWrap; width: parent.width } - Text { text: parent.parent.tx.body || ""; color: t.soft; font.family: t.body; font.pixelSize: 15; lineHeight: 1.4; wrapMode: Text.WordWrap; width: parent.width } - Text { text: "WHAT IT CHANGES"; color: t.faint; font.family: t.body; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.8; topPadding: 4 } - Repeater { model: parent.parent.tx.changes || [] - Text { required property string modelData; text: "• " + modelData; color: t.soft; font.family: t.body; font.pixelSize: 14; wrapMode: Text.WordWrap; width: parent.width } } } Column { - anchors.centerIn: parent; spacing: 8; visible: parent.it === null + anchors.centerIn: parent; spacing: 8; visible: detail.it === null Text { text: plan.length === 0 && computePlan().length === 0 ? "Everything is the way you want it" : "Review what will change"; color: t.text; font.family: t.display; font.pixelSize: 24; anchors.horizontalCenter: parent.horizontalCenter } Text { text: "then apply it"; color: t.mute; font.family: t.body; font.pixelSize: 15; anchors.horizontalCenter: parent.horizontalCenter } } From 5efe448a51df071cc8876587c01e63a0e613c998 Mon Sep 17 00:00:00 2001 From: rickpeters Date: Fri, 25 Sep 2026 21:21:13 +0200 Subject: [PATCH 15/15] feat(ui): HDMI refresh boost in the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set up… opens a screen with the display, its resolution and the rates on offer. The display's own faster mode is switched to right away; the other rates can each be tried live (wait for the picture, keep within 15 s or it switches back) and ticked; Install installs the ticked ones through apply --hdmi. All rates are loaded when the screen opens, so a try is only a mode switch. Also fixes switching back to a rate like 59.97 Hz. --- AGENTS.md | 9 +- CHANGELOG.md | 17 ++- lib/backend.sh | 31 ++++++ lib/hdmi-refresh.sh | 143 ++++++++++++++++++++++-- ui/qml/Main.qml | 257 +++++++++++++++++++++++++++++++++++++++++--- ui/steamify-ui | 21 +++- 6 files changed, 444 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 683332b..4d60d51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,9 +173,12 @@ gamescope and the Plasma desktop. Primary target: the Valve Steam Machine download would run an empty script and count as success. - HDMI refresh boost (`hdmi`, `lib/hdmi-refresh.sh`): a sub-option of `machine` (like `kpin` and `bios`), only on Fremont with the pinned kernel - (stays visible while on); unticking `kpin` unticks it. It needs someone at the - screen: the graphical app's backend (`BACKEND=true`) must refuse it, and every step needs a "y" within 15 s - (`WIZARD_HDMI_CONFIRM_SECONDS` for scripted tests). debugfs is root-only + (stays visible while on); unticking `kpin` unticks it. It needs someone at + the screen. Terminal: every step needs a "y" within 15 s + (`WIZARD_HDMI_CONFIRM_SECONDS` for scripted tests). App: its own screen + (`hdmi-options`, `hdmi-try`, `hdmi-reset` backend commands, the EDID cached + in `$XDG_RUNTIME_DIR/steamify-hdmi`), then `apply --hdmi + =x:`; without `--hdmi` the backend refuses it. debugfs is root-only (glob it under sudo), and `edid_override` takes exactly `reset` with no newline. Build the EDID from the DDC read, not from sysfs: a live override replaces the kernel's copy. The kernel command-line value holds a path, so diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba619f..7a28907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,19 @@ one per merged pull request. - 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, off only** - - Each step needs someone at the keyboard, so it's turned on from the - menu in Konsole; the app can only turn it off. Unticking Steam Machine - support or the kernel pin unticks it, as in the menu. +- **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 diff --git a/lib/backend.sh b/lib/backend.sh index 1a52934..7a953ca 100644 --- a/lib/backend.sh +++ b/lib/backend.sh @@ -8,6 +8,14 @@ # 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 =x:,... 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 [...] +# steamify.sh --backend hdmi-reset +# 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). @@ -127,6 +135,7 @@ backend_apply() { case "$1" in --reapply) reapply=true ;; --boot) boot="$2"; shift ;; + --hdmi) HDMI_CHOICE+=("$2"); shift ;; *) want+=("$1") ;; esac shift @@ -167,12 +176,34 @@ backend_apply() { backend_event finished "\"failed\":$(json_list "${failed[@]}"),\"restart\":$(restart_needed && echo true || echo false)" } +backend_hdmi() { + # backend_hdmi options|try|reset [args]: the app's HDMI screen. Events + # only (no "finished"): the app goes on from its own screen. + local what="$1" rc + shift + backend_sudo || return 1 + case "$what" in + options) + sudo pacman -S --needed --noconfirm i2c-tools python >/dev/null 2>&1 + backend_event hdmi-options "\"outputs\":$(hdmi_options 2>/dev/null)" ;; + try) + hdmi_try "$@" >/dev/null 2>&1; rc=$? + backend_event hdmi-tried "\"hz\":$4,\"ok\":$([[ $rc -eq 0 ]] && echo true || echo false)" ;; + reset) + hdmi_reset "$@" >/dev/null 2>&1 + backend_event hdmi-reset ;; + esac +} + backend_main() { case "${1:-}" in status) backend_status ;; apply) shift; backend_apply "$@" ;; bios-prepare) backend_bios_prepare ;; bios-flash) backend_bios_flash ;; + hdmi-options) backend_hdmi options ;; + hdmi-try) shift; backend_hdmi try "$@" ;; + hdmi-reset) shift; backend_hdmi reset "$@" ;; *) err "Usage: steamify.sh --backend status | apply [--reapply] [--boot gamescope|desktop] ..."; return 2 ;; esac } diff --git a/lib/hdmi-refresh.sh b/lib/hdmi-refresh.sh index e85fcfc..1dda1b2 100644 --- a/lib/hdmi-refresh.sh +++ b/lib/hdmi-refresh.sh @@ -19,6 +19,9 @@ HDMI_INITRAMFS_CONF=/etc/mkinitcpio.conf.d/90-steamify-edid.conf HDMI_CONFIRM_SECONDS="${WIZARD_HDMI_CONFIRM_SECONDS:-15}" # amdgpu's HDMI 2.0 limit; the display's own limit (from its EDID) may be lower. HDMI_TMDS_MAX_KHZ=600000 +# Rates picked and tested in the app ("=x:,..."), set by +# the backend's apply --hdmi; empty in the terminal menu, which tests itself. +HDMI_CHOICE=() hdmi_connectors() { # Connected HDMI outputs, as "-" (e.g. card1-HDMI-A-1). @@ -173,6 +176,36 @@ elif cmd == 'plan': steps = sorted({top // 10 * 10, top // 100 * 100}) steps = [s for s in steps if s > best + 1 and not any(abs(s - x) < 1 for x in listed)] print(lim // 1000, round(best, 2), *steps) +elif cmd == 'options': + # JSON for the app: the display's name, the HDMI limit and the rates to + # offer at w x h: its own modes the kernel skips ("monitor"), calculated + # tens up to the limit ("calculated", ticked) and the exact limit when + # that's no ten ("limit", not ticked). + import json + e, k = load(args[0]), load(args[1]); w, h, cur = int(args[2]), int(args[3]), float(args[4]) + lim, t = tmds_khz(e), shortest(e, w, h) + name = '' + for o in range(54, 126, 18): + if e[o:o + 3] == b'\0\0\0' and e[o + 3] == 0xfc: + name = e[o + 5:o + 18].split(b'\n')[0].decode('ascii', 'replace').strip() + rates = [] + if t: + fits = [x for x in timings(e) if x[2] == w and x[7] == h and x[1] <= lim] + known = [x[0] for x in timings(k) if x[2] == w and x[7] == h] + listed = [x[0] for x in fits] + best = max(listed + [cur]) + for x in sorted(fits): + if x[0] > cur + 1 and not any(abs(x[0] - y) < 0.5 for y in known): + rates.append({'hz': round(x[0], 2), 'mhz': round(x[1] / 1000, 1), 'kind': 'monitor', 'pick': True}) + size = (t[2] + t[3]) * (t[7] + t[8]) + vmax = max_vrefresh(e) or int(max(listed + [cur])) + top = min(int(lim * 1000 // size), vmax) + tens = [r for r in range(10, top + 1, 10) if r > best + 1 and not any(abs(r - y) < 1 for y in listed)][-3:] + for r in tens: + rates.append({'hz': r, 'mhz': round(size * r / 1e6, 1), 'kind': 'calculated', 'pick': True}) + if top % 10 and top > best + 1: + rates.append({'hz': top, 'mhz': round(size * top / 1e6, 1), 'kind': 'limit', 'pick': False}) + print(json.dumps({'name': name, 'limit': lim // 1000, 'rates': rates})) elif cmd == 'build': e = load(args[0]); w, h = int(args[2]), int(args[3]); rates = [int(r) for r in args[4:]] t = shortest(e, w, h) @@ -278,7 +311,8 @@ hdmi_set_mode() { kscreen-doctor "output.$1.mode.$id" >/dev/null 2>&1 || return 1 sleep 2 rate="$(hdmi_kscreen current "$1" | cut -d' ' -f3)" - [[ "${rate%.*}" -ge $(( $4 - 1 )) ]] + # Whole hertz: the rate asked for can be e.g. 59.97. + [[ "${rate%.*}" -ge $(( ${4%.*} - 1 )) ]] } hdmi_confirm() { @@ -370,9 +404,89 @@ hdmi_tune() { HDMI_RESULT="$w $h ${ok_rates[*]}" } +# --- For the app: options, live tries, and installing what was confirmed --- +# The EDIDs read by hdmi_options, reused by the tries and the install. +HDMI_CACHE="${XDG_RUNTIME_DIR:-/tmp}/steamify-hdmi" + +hdmi_options() { + # JSON array: per connected HDMI output its name, mode and rate options. + local conn out cur w h hz opts sep="" + mkdir -p "$HDMI_CACHE" + printf '[' + for conn in $(hdmi_connectors); do + out="${conn#card*-}" + # A live EDID left by an earlier try would look like the display's own. + hdmi_status || hdmi_override_live "$conn" reset 2>/dev/null + cur="$(hdmi_kscreen current "$out")" + [[ -n "$cur" ]] || continue + read -r w h hz <<< "$cur" + hdmi_read_edid "$conn" "$HDMI_CACHE/$out.orig" || continue + opts="$(hdmi_edid_tool options "$HDMI_CACHE/$out.orig" "/sys/class/drm/$conn/edid" "$w" "$h" "$hz")" || continue + # Every rate on offer loaded now, while the list is on screen: a try + # is then only a mode switch (the current mode stays in the EDID). + hdmi_status || hdmi_preload "$conn" "$w" "$h" "$opts" + printf '%s{"connector":"%s","output":"%s","width":%s,"height":%s,"hz":%s,"info":%s}' "$sep" "$conn" "$out" "$w" "$h" "$hz" "$opts" + sep="," + done + printf ']' +} + +hdmi_preload() { + # hdmi_preload : 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 [...]: 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 : 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 ("=x:,..." from the app, rates it + # confirmed): builds the final EDIDs in like hdmi_tune does. + local tmp="$1" item out mode rates w h + local -a r + for item in "${HDMI_CHOICE[@]}"; do + out="${item%%=*}"; mode="${item#*=}"; rates="${mode#*:}"; mode="${mode%%:*}" + w="${mode%x*}"; h="${mode#*x}" + IFS=',' read -ra r <<< "$rates" + HDMI_CHOSEN_CONN="$(basename /sys/class/drm/card*-"$out")" + if [[ -f "$HDMI_CACHE/$out.orig" ]]; then cp "$HDMI_CACHE/$out.orig" "$tmp/$out.orig" + else hdmi_read_edid "$HDMI_CHOSEN_CONN" "$tmp/$out.orig" || return 1; fi + hdmi_edid_tool build "$tmp/$out.orig" "$tmp/$out.final" "$w" "$h" "${r[@]}" && + hdmi_override_live "$HDMI_CHOSEN_CONN" "$tmp/$out.final" || return 1 + [[ ${#r[@]} -gt 0 ]] && hdmi_set_mode "$out" "$w" "$h" "${r[-1]}" >/dev/null + HDMI_RESULTS+=("$out|$w $h ${r[*]}") + done +} + hdmi_enable() { - if [[ "${BACKEND:-false}" == true ]]; then - err "HDMI refresh boost needs you at the screen: run it from the Steamify menu in Konsole." + local -a HDMI_RESULTS=() + if [[ "${BACKEND:-false}" == true && ${#HDMI_CHOICE[@]} -eq 0 ]]; then + err "HDMI refresh boost needs you at the screen: pick and test the rates in the app first." return 1 fi if [[ "${XDG_CURRENT_DESKTOP:-}" != *KDE* ]] || ! command -v kscreen-doctor >/dev/null; then @@ -389,24 +503,31 @@ hdmi_enable() { ok "Already set up for the connected display(s). Untick and tick it again to re-test." return 0 fi - warn "Set up for another display; testing the one connected now." + warn "Set up for another display; setting up the one connected now." hdmi_disable || return 1 fi sudo pacman -S --needed --noconfirm i2c-tools python >/dev/null 2>&1 || { err "Installing i2c-tools failed."; return 1; } - local tmp conn out param="" files=() rc=0 + local tmp conn out param="" files=() rc=0 res tmp="$(mktemp -d)" - for conn in $(hdmi_connectors); do - out="${conn#card*-}" - hdmi_tune "$conn" "$tmp" || { rc=1; continue; } - [[ -n "$HDMI_RESULT" ]] || continue + if [[ ${#HDMI_CHOICE[@]} -gt 0 ]]; then + # Picked and tested in the app. + hdmi_choose "$tmp" || rc=1 + else + for conn in $(hdmi_connectors); do + hdmi_tune "$conn" "$tmp" || { rc=1; continue; } + [[ -n "$HDMI_RESULT" ]] && HDMI_RESULTS+=("${conn#card*-}|$HDMI_RESULT") + done + fi + for res in "${HDMI_RESULTS[@]}"; do + out="${res%%|*}" sudo install -Dm644 "$tmp/$out.final" "$HDMI_FW_DIR/steamify-$out.bin" || { rc=1; continue; } state_set hdmi "$out" "$(hdmi_edid_tool id "$tmp/$out.orig")" - state_set hdmi "$out-mode" "$HDMI_RESULT" + state_set hdmi "$out-mode" "${res#*|}" param+="${param:+,}$out:edid/steamify-$out.bin" files+=("$HDMI_FW_DIR/steamify-$out.bin") done - rm -rf "$tmp" + rm -rf "$tmp" "$HDMI_CACHE" [[ -n "$param" ]] || return $rc # amdgpu loads from the initramfs (the kms hook), before the root diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml index 6510928..4b17a5a 100644 --- a/ui/qml/Main.qml +++ b/ui/qml/Main.qml @@ -71,9 +71,9 @@ ApplicationWindow { kpin: { label: "Pin the kernel", hint: "Fixes rebooting after shutdown", body: "Newer CachyOS kernels make the Steam Machine reboot instead of shutting down. Untick once CachyOS fixes that.", changes: ["linux-cachyos from Steamify's release (signature checked)", "Kept in /var/cache/steamify/kernel", "Added to IgnorePkg"] }, - hdmi: { label: "HDMI refresh boost", hint: "Higher refresh over HDMI · turn on from Steamify in Konsole", - body: "The pinned kernel keeps many HDMI displays at 60 Hz. This finds the highest refresh rate your display runs at the desktop resolution. Each step needs you to confirm the picture, so it's turned on from the Steamify menu in Konsole; here you can only turn it off.", - changes: ["The display's EDID with the confirmed rates", "In the initramfs, drm.edid_firmware on the kernel command line", "Off: the display uses its own EDID again"] }, + hdmi: { label: "HDMI refresh boost", hint: "Higher refresh rates over HDMI", + body: "The pinned kernel keeps many HDMI displays at 60 Hz. Turning this on shows which refresh rates your display can run at the desktop resolution; you pick them, and each one is tried for 15 seconds so you can check the picture before it's installed.", + changes: ["The display's EDID with the rates you confirmed", "In the initramfs, drm.edid_firmware on the kernel command line", "Off: the display uses its own EDID again"] }, bios: { label: "Update BIOS", hint: "", body: "Installs Valve's newest Steam Machine BIOS, at your own risk. It checks Valve's checksum and asks fwupd whether the file fits this machine, then warns you twice before anything is written.", changes: ["Valve's fremont-hw-support package (checksum checked)", "fwupd writes the BIOS during the next restart", "Keep the power on until the machine has fully started again"] } @@ -101,6 +101,85 @@ ApplicationWindow { property bool biosChecking: false // the invisible check before the warnings property int biosFocus: 0 // warning 1: 0 = Cancel, 1 = continue property real holdProgress: 0 // warning 2: hold A/OK for 5 s + // HDMI refresh boost: try rates one by one, then install the kept ones. + property var hdmi: null // the first HDMI output from hdmi-options + property bool hdmiLoading: false + property var hdmiResult: ({}) // hz -> "ok" | "fail" (tried) + property var hdmiPick: ({}) // hz -> ticked for Install + property int hdmiSel: 0 // rate row; rates.length = the Install button + property real hdmiHz: 0 // the rate being tried (e.g. 99.98) + property real hdmiNow: 0 // the rate on screen: the last one kept + property double hdmiBackAt: 0 // when a test returned to the list + property string hdmiPhase: "" // switching | settle | ask | back + property int hdmiSettle: 8 // seconds for the display to show a picture again + property int hdmiLeft: 15 // seconds to keep it + property string hdmiChoice: "" // for apply: =x: + property string hdmiNote: "" + property var afterBusy: null // a backend call waiting for the last one + function whenIdle(fn) { if (backend.busy) afterBusy = fn; else fn(); } + Connections { target: backend; function onBusyChanged() { if (!backend.busy && afterBusy) { var f = afterBusy; afterBusy = null; f(); } } } + readonly property var hdmiRates: hdmi && hdmi.info ? hdmi.info.rates : [] + function hdmiCalc() { + // The calculated rates (not the display's own): what the test EDID holds. + return hdmiRates.filter(function (r) { return r.kind !== "monitor"; }).map(function (r) { return r.hz; }); + } + function hdmiKept() { + // The ticked calculated rates (the display's own modes always come along). + return hdmiRates.filter(function (r) { return r.kind !== "monitor" && hdmiPick[r.hz]; }) + .map(function (r) { return r.hz; }).sort(function (a, b) { return a - b; }); + } + function hdmiUntried() { return hdmiKept().filter(function (hz) { return hdmiResult[hz] !== "ok"; }); } + function hdmiTick(r) { + if (r.kind === "monitor") return; + var p = Object.assign({}, hdmiPick); p[r.hz] = !p[r.hz]; hdmiPick = p; + } + readonly property bool hdmiCanInstall: hdmiKept().length > 0 || hdmiRates.some(function (r) { return r.kind === "monitor"; }) + function startHdmi() { + hdmi = null; hdmiChoice = ""; hdmiNote = ""; sessionPassword = ""; + if (!askPassword("hdmi")) hdmiLoad(); + } + function hdmiLoad() { + hdmiLoading = true; hdmi = null; hdmiSel = 0; hdmiResult = ({}); hdmiPick = ({}); screen = "hdmi"; + whenIdle(function () { backend.hdmiOptions(sessionPassword); }); + } + function hdmiSwitch(hz) { + whenIdle(function () { backend.hdmiTry(sessionPassword, hdmi.connector, hdmi.width, hdmi.height, hz, hdmiCalc().join(",")); }); + } + function hdmiTry(hz) { + hdmiHz = hz; hdmiPhase = "switching"; screen = "hdmitest"; + hdmiSwitch(hz); + } + function hdmiMark(result) { + var r = Object.assign({}, hdmiResult); r[hdmiHz] = result; hdmiResult = r; + // Kept: ticked for Install; didn't work: unticked. + var p = Object.assign({}, hdmiPick); p[hdmiHz] = result === "ok"; hdmiPick = p; + } + function hdmiKeep() { if (hdmiPhase !== "ask" && hdmiPhase !== "settle") return; hdmiMark("ok"); hdmiNow = hdmiHz; hdmiPhase = ""; hdmiBackAt = Date.now(); screen = "hdmi"; } + function hdmiReject() { + // Back to the last rate that worked (at first: the one from before). + if (hdmiPhase !== "ask" && hdmiPhase !== "settle" && hdmiPhase !== "failed") return; + hdmiMark("fail"); hdmiPhase = "back"; + hdmiSwitch(hdmiNow); + } + function hdmiInstall() { + if (!hdmiCanInstall) return; + hdmiChoice = hdmi.output + "=" + hdmi.width + "x" + hdmi.height + ":" + hdmiKept().join(","); + var w = Object.assign({}, want); w.hdmi = true; w.machine = true; w.kpin = true; want = w; + screen = "menu"; goReview(false); + } + function hdmiCancel() { + var tried = Object.keys(hdmiResult).length > 0; + hdmiPhase = ""; hdmiChoice = ""; + if (tried && hdmi) whenIdle(function () { backend.hdmiReset(sessionPassword, hdmi.connector, hdmi.width, hdmi.height, hdmi.hz); }); + sessionPassword = ""; screen = "menu"; + } + Timer { + interval: 1000; repeat: true; running: screen === "hdmitest" && (hdmiPhase === "settle" || hdmiPhase === "ask") + onTriggered: { + if (hdmiPhase === "settle") { hdmiSettle--; if (hdmiSettle <= 0) hdmiPhase = "ask"; return; } + hdmiLeft--; if (hdmiLeft <= 0) hdmiReject(); + } + } property bool holding: false readonly property var bios: status.bios || null function biosHint() { @@ -131,13 +210,15 @@ ApplicationWindow { Connections { target: backend; function onStatusChanged() { if (screen === "menu" || screen === "done") syncFromStatus(); } } function toggle(id) { + hdmiNote = ""; var w = Object.assign({}, want); w[id] = !w[id]; if (id === "gaming" && !w.gaming) { w.single = false; } if (id === "single" && w.single) w.gaming = true; - // HDMI refresh boost needs someone at the keyboard to confirm each - // step: it's only turned on from the terminal menu. - if (id === "hdmi" && w.hdmi && !nowOn("hdmi")) return; + // Turning HDMI refresh boost on goes through its own screen: pick + // the rates and try each one first. + if (id === "hdmi" && w.hdmi && !nowOn("hdmi") && !hdmiChoice) { startHdmi(); return; } + if (id === "hdmi" && !w.hdmi) hdmiChoice = ""; if (id === "machine") w.kpin = w.machine; if (id === "kpin" && w.kpin) w.machine = true; if (id === "hdmi" && w.hdmi) { w.machine = true; w.kpin = true; } @@ -173,7 +254,8 @@ ApplicationWindow { for (var i = 0; i < plan.length; i++) s[plan[i].id] = "wait"; steps = s; screen = "applying"; - backend.apply(wantedIds(), want.gaming ? boot : "", reapply, password || ""); + backend.apply(wantedIds(), want.gaming ? boot : "", reapply, password || "", want.hdmi ? hdmiChoice : ""); + hdmiChoice = ""; } function askPassword(forWhat) { pending = forWhat; @@ -182,12 +264,15 @@ ApplicationWindow { } function onApplyPressed() { if (plan.length === 0) return; + // Just typed for the HDMI screen: not asked again. + if (hdmiChoice && sessionPassword) { var p = sessionPassword; sessionPassword = ""; startApply(p); return; } if (!askPassword("apply")) startApply(""); } function submitPassword() { if (!backend.checkPassword(pw.text)) { wrongPassword = true; pw.selectAll(); return; } var p = pw.text; pw.text = ""; if (pending === "bios") { sessionPassword = p; biosCheck(); } + else if (pending === "hdmi") { sessionPassword = p; hdmiLoad(); } else startApply(p); } // --- BIOS: check -> warning 1 -> warning 2 -> flash --- @@ -223,6 +308,32 @@ ApplicationWindow { if (ev.event === "start") { s[ev.id] = "run"; steps = s; } else if (ev.event === "done") { s[ev.id] = ev.ok ? "ok" : "fail"; steps = s; doneCount++; } else if (ev.event === "log") { logModel.append({ line: ev.line }); if (logModel.count > 400) logModel.remove(0); } + else if (ev.event === "hdmi-options") { + hdmiLoading = false; hdmiSel = 0; + hdmi = ev.outputs && ev.outputs.length ? ev.outputs[0] : null; + hdmiNow = hdmi ? hdmi.hz : 0; + // Straight to the display's own faster mode (the safe one), with + // the next rate's Try selected for after that. + var rs = hdmi && hdmi.info ? hdmi.info.rates : []; + var own = rs.findIndex(function (r) { return r.kind === "monitor"; }); + if (own >= 0) { hdmiSel = Math.min(own + 1, rs.length - 1); hdmiTry(rs[own].hz); } + } + else if (ev.event === "hdmi-tried") { + if (hdmiPhase === "back") { hdmiPhase = ""; hdmiBackAt = Date.now(); screen = "hdmi"; return; } + // Displays take a few seconds to show a picture after a mode + // change: the countdown starts after that. + // The display's own mode always works: kept without asking. + var ownMode = hdmiRates.some(function (r) { return r.kind === "monitor" && Math.abs(r.hz - hdmiHz) < 0.01; }); + if (ev.ok && ownMode) { hdmiPhase = "ask"; hdmiKeep(); } + else if (ev.ok) { hdmiPhase = "settle"; hdmiSettle = 8; hdmiLeft = 15; } + else { hdmiPhase = "failed"; hdmiReject(); } + } + else if (ev.event === "hdmi-reset") { } + else if (ev.event === "finished" && (screen === "hdmi" || screen === "hdmitest")) { + // Only on an error (sudo): the HDMI commands end without one. + runError = ev.error === "wrong-password" ? "The password didn't work." : "sudo isn't available."; + failed = []; restartNeeded = false; sessionPassword = ""; screen = "done"; + } else if (ev.event === "bios-ready") { biosChecking = false; biosInfo = ev; biosFocus = 0; holdProgress = 0; screen = "bios1"; } else if (ev.event === "finished" && biosChecking) { // The check found nothing to do or a problem: say so. @@ -264,7 +375,7 @@ ApplicationWindow { else if (a === "back") { reapply = false; screen = "menu"; } } else if (screen === "password") { if (a === "accept" || a === "apply") submitPassword(); - else if (a === "back") { pw.text = ""; screen = "review"; } + else if (a === "back") { pw.text = ""; if (pending === "hdmi") hdmiCancel(); else screen = "review"; } } else if (screen === "bios1") { if (a === "left") biosFocus = 0; else if (a === "right") biosFocus = 1; @@ -274,6 +385,20 @@ ApplicationWindow { if (a === "back") biosCancel(); else if (a === "hold") holding = true; else if (a === "release") { holding = false; if (holdProgress < 1) holdProgress = 0; } + } else if (screen === "hdmi") { + if (hdmiLoading) { if (a === "back") hdmiCancel(); return; } + var n = hdmiRates.length; + if (a === "up") hdmiSel = Math.max(0, hdmiSel - 1); + else if (a === "down") hdmiSel = Math.min(n, hdmiSel + 1); + else if (a === "accept") { if (hdmiSel === n) hdmiInstall(); else hdmiTick(hdmiRates[hdmiSel]); } + else if (a === "reapply" && hdmiSel < n && Math.abs(hdmiNow - hdmiRates[hdmiSel].hz) >= 0.5) hdmiTry(hdmiRates[hdmiSel].hz); + else if (a === "apply") hdmiInstall(); + // A second Back meant for the test (it takes a moment to switch + // back) would otherwise leave the whole screen. + else if (a === "back" && Date.now() - hdmiBackAt > 1500) hdmiCancel(); + } else if (screen === "hdmitest") { + if (a === "accept" || a === "apply") hdmiKeep(); + else if (a === "back") hdmiReject(); } else if (screen === "done") { if (a === "accept" && restartNeeded) backend.restart(); else if (a === "back" || a === "accept") { biosRun = false; syncFromStatus(); screen = "menu"; } @@ -303,7 +428,7 @@ ApplicationWindow { else if (k === Qt.Key_Right) act("right"); else if (k === Qt.Key_Space) act("accept"); else if (k === Qt.Key_Return || k === Qt.Key_Enter) act(inputType === "remote" || screen !== "menu" ? "accept" : "apply"); - else if (k === Qt.Key_Escape || k === Qt.Key_Back || k === Qt.Key_Backspace) act("back"); + else if (k === Qt.Key_Escape || k === Qt.Key_Back || k === Qt.Key_Backspace) { if (!e.isAutoRepeat) act("back"); } else if (k === Qt.Key_R) act("reapply"); else return; e.accepted = true; @@ -334,7 +459,7 @@ ApplicationWindow { color: primary ? t.accent : "#232c38" border.width: focusRing ? 2 : 0; border.color: t.textHi Row { id: br; anchors.centerIn: parent; spacing: 10 - Glyph { k: parent.parent.k; dark: parent.parent.primary; anchors.verticalCenter: parent.verticalCenter } + Glyph { k: parent.parent.k; visible: k !== ""; dark: parent.parent.primary; anchors.verticalCenter: parent.verticalCenter } Text { text: parent.parent.text; color: parent.parent.primary ? t.ink : t.text; font.family: t.body; font.pixelSize: 15; font.weight: Font.DemiBold; anchors.verticalCenter: parent.verticalCenter } } MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: parent.clicked() } @@ -381,7 +506,7 @@ ApplicationWindow { anchors.left: parent.left; anchors.leftMargin: 40; anchors.verticalCenter: parent.verticalCenter; spacing: 14 Image { source: iconUrl; width: 40; height: 40; sourceSize: Qt.size(80, 80); anchors.verticalCenter: parent.verticalCenter } Text { text: "Steamify"; color: t.text; font.family: t.display; font.pixelSize: 28; font.weight: Font.Bold; anchors.verticalCenter: parent.verticalCenter } - Text { text: screen === "menu" ? "v" + (status.version || "") : "/ " + ({review: "Review", password: "Password", applying: biosRun ? "BIOS update" : "Applying", done: "Done", bios1: "BIOS update", bios2: "BIOS update"})[screen] + Text { text: screen === "menu" ? "v" + (status.version || "") : "/ " + ({review: "Review", password: "Password", applying: biosRun ? "BIOS update" : "Applying", done: "Done", bios1: "BIOS update", bios2: "BIOS update", hdmi: "HDMI refresh boost", hdmitest: "HDMI refresh boost"})[screen] color: t.faint; font.family: screen === "menu" ? t.mono : t.body; font.pixelSize: screen === "menu" ? 13 : 15; anchors.verticalCenter: parent.verticalCenter } } Rectangle { @@ -433,6 +558,8 @@ ApplicationWindow { readonly property int rowIndex: rows.findIndex(function (r) { return r.id === modelData.id; }) readonly property bool selected: shown && rowIndex === sel readonly property bool on: modelData.kind === "choice" ? true : !!want[modelData.id] + // HDMI refresh boost is set up on its own screen: a button while off. + readonly property bool setup: modelData.id === "hdmi" && !modelData.on onSelectedChanged: if (selected) Qt.callLater(list.showSelected) width: list.width - 12; x: 2 height: shown ? 62 : 0 @@ -473,12 +600,19 @@ ApplicationWindow { Text { anchors.centerIn: parent; text: parent.modelData[1]; color: boot === parent.modelData[0] ? t.ink : t.mute; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold } MouseArea { anchors.fill: parent; onClicked: boot = parent.modelData[0] } } } } } // toggle: "now on/off" left of the switch, switch on the edge - Rectangle { id: sw; visible: row.modelData.kind === "toggle"; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter + Rectangle { id: sw; visible: row.modelData.kind === "toggle" && !row.setup; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter width: 46; height: 26; radius: 13; color: row.on ? t.accent : "#343f50" Behavior on color { ColorAnimation { duration: 120 } } Rectangle { width: 20; height: 20; radius: 10; color: "#f4f7fb"; y: 3; x: row.on ? 23 : 3; Behavior on x { NumberAnimation { duration: 120 } } } } - Text { visible: row.modelData.kind === "toggle"; anchors.right: sw.left; anchors.rightMargin: 12; anchors.verticalCenter: parent.verticalCenter + Text { visible: row.modelData.kind === "toggle" && !row.setup; anchors.right: sw.left; anchors.rightMargin: 12; anchors.verticalCenter: parent.verticalCenter text: row.modelData.on ? "now on" : "now off"; color: row.modelData.on ? t.good : t.faint; font.family: t.mono; font.pixelSize: 12 } + // HDMI refresh boost while off: opens its screen + Rectangle { visible: row.setup; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter + width: sut.implicitWidth + 24; height: 30; radius: 8; color: hdmiChoice && want.hdmi ? t.goodBg : "#1b2b40" + Text { id: sut; anchors.centerIn: parent; font.family: t.body; font.pixelSize: 13; font.weight: Font.DemiBold + color: hdmiChoice && want.hdmi ? t.good : "#7cc4ff" + text: hdmiChoice && want.hdmi ? (hdmiChoice.split(":")[1] ? hdmiChoice.split(":")[1].split(",").join(", ") + " Hz ✓" : "Own modes ✓") : "Set up…" } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: { sel = row.rowIndex; startHdmi(); } } } // action Rectangle { visible: row.modelData.kind === "action"; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter width: at.implicitWidth + 24; height: 30; radius: 8; color: t.warnBg @@ -555,7 +689,7 @@ ApplicationWindow { } Row { anchors.right: parent.right; anchors.rightMargin: 40; anchors.verticalCenter: parent.verticalCenter; spacing: 16 - Text { readonly property int n: computePlan().length; text: n === 0 ? "Everything is the way you want it" : n + (n === 1 ? " change" : " changes"); color: t.faint; font.family: t.body; font.pixelSize: 14; anchors.verticalCenter: parent.verticalCenter } + Text { readonly property int n: computePlan().length; text: hdmiNote ? hdmiNote : (n === 0 ? "Everything is the way you want it" : n + (n === 1 ? " change" : " changes")); color: t.faint; font.family: t.body; font.pixelSize: 14; anchors.verticalCenter: parent.verticalCenter } Btn { k: g.apply; text: "Review & apply"; primary: true; focusRing: sel === rows.length; height: 44; onClicked: goReview(false) } } } @@ -757,6 +891,101 @@ ApplicationWindow { } } + // ================= HDMI: pick the rates ================= + Item { + visible: screen === "hdmi" + anchors.top: header.bottom; anchors.bottom: parent.bottom; width: parent.width + Text { visible: hdmiLoading; anchors.centerIn: parent; text: "Reading your display…"; color: t.mute; font.family: t.body; font.pixelSize: 18 } + Text { visible: !hdmiLoading && !hdmi; anchors.centerIn: parent; horizontalAlignment: Text.AlignHCenter + text: "No HDMI display found at the desktop.\nThis only works from the Plasma desktop, not in gaming mode."; color: t.mute; font.family: t.body; font.pixelSize: 18 } + Column { + visible: !hdmiLoading && !!hdmi + x: 40; y: 28; width: 740; spacing: 12 + Text { text: hdmi ? (hdmi.info.name || "HDMI display") + " · " + hdmi.output : ""; color: t.text; font.family: t.display; font.pixelSize: 30; font.weight: Font.DemiBold } + Text { text: hdmi ? hdmi.width + "×" + hdmi.height : ""; color: t.mute; font.family: t.mono; font.pixelSize: 14 } + Text { visible: hdmiRates.length === 0; width: 740; wrapMode: Text.WordWrap; topPadding: 12; color: t.soft; font.family: t.body; font.pixelSize: 16 + text: "This display already runs its fastest rate that fits HDMI at this resolution. Nothing to add." } + Repeater { + model: hdmiRates + Rectangle { + required property var modelData; required property int index + readonly property bool own: modelData.kind === "monitor" + readonly property string res: own ? "own" : (hdmiResult[modelData.hz] || "") + width: 740; height: 56; radius: 12 + color: hdmiSel === index ? t.cardSel : t.card; border.width: hdmiSel === index ? 2 : 0; border.color: t.accent + readonly property bool ticked: own || !!hdmiPick[modelData.hz] + readonly property bool running: Math.abs(hdmiNow - modelData.hz) < 0.5 + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: { hdmiSel = parent.index; hdmiTick(parent.modelData); } } + Row { anchors.left: parent.left; anchors.leftMargin: 18; anchors.verticalCenter: parent.verticalCenter; spacing: 16 + Rectangle { width: 24; height: 24; radius: 6; anchors.verticalCenter: parent.verticalCenter + color: parent.parent.ticked ? (parent.parent.own ? "#343f50" : t.accent) : "transparent"; border.width: parent.parent.ticked ? 0 : 2; border.color: "#56627a" + Text { anchors.centerIn: parent; text: parent.parent.parent.ticked ? "✓" : ""; color: parent.parent.parent.own ? t.soft : t.ink; font.pixelSize: 15; font.weight: Font.Bold } } + Text { text: parent.parent.modelData.hz + " Hz"; width: 100; color: t.textHi; font.family: t.body; font.pixelSize: 18; font.weight: Font.DemiBold; anchors.verticalCenter: parent.verticalCenter } + Text { width: 380; anchors.verticalCenter: parent.verticalCenter; color: t.soft; font.family: t.body; font.pixelSize: 14 + text: ({ monitor: "The display's own mode · always included", calculated: "Calculated from the display's timing", limit: "Right at the HDMI 2.0 limit · not recommended" })[parent.parent.modelData.kind] } + } + Row { anchors.right: parent.right; anchors.rightMargin: 14; anchors.verticalCenter: parent.verticalCenter; spacing: 12 + Chip { anchors.verticalCenter: parent.verticalCenter; visible: parent.parent.res === "own" || parent.parent.res === "fail" + text: ({ own: "Included", fail: "✗ Didn't work" })[parent.parent.res] || "" + fg: parent.parent.res === "fail" ? t.bad : (parent.parent.res === "ok" ? t.good : "#b8c3d1") + bgc: parent.parent.res === "fail" ? t.badBg : (parent.parent.res === "ok" ? t.goodBg : t.line) } + // The rate on screen now: a green marker instead of Try. + Rectangle { visible: parent.parent.running; height: 38; radius: 12; width: rt.implicitWidth + 32; color: t.goodBg + border.width: hdmiSel === parent.parent.index ? 2 : 0; border.color: t.good; anchors.verticalCenter: parent.verticalCenter + Text { id: rt; anchors.centerIn: parent; text: "Running now"; color: t.good; font.family: t.body; font.pixelSize: 15; font.weight: Font.DemiBold } } + Btn { visible: !parent.parent.running; height: 38; focusRing: hdmiSel === parent.parent.index; anchors.verticalCenter: parent.verticalCenter + k: hdmiSel === parent.parent.index ? g.reapply : ""; text: parent.parent.res === "" || parent.parent.res === "own" ? "Try" : "Try again" + onClicked: { hdmiSel = parent.parent.index; hdmiTry(parent.parent.modelData.hz); } } + } + } + } + } + Rectangle { + visible: !hdmiLoading && !!hdmi + x: parent.width - 468; y: 28; width: 428; height: 250; radius: 16; color: t.card + Column { anchors.fill: parent; anchors.margins: 24; spacing: 12 + Text { text: "HOW IT WORKS"; color: t.faint; font.family: t.body; font.pixelSize: 12; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } + Text { width: parent.width; wrapMode: Text.WordWrap; color: t.soft; font.family: t.body; font.pixelSize: 15; lineHeight: 1.35 + text: "Tick the rates to install (" + g.ok + "). Try one first (" + g.reapply + "): it's shown, and you press " + g.ok + " to keep it. No answer, or a black screen, switches back.\n\nInstall adds the ticked rates and the display's own modes; they stay after a restart." } + } + } + Rectangle { + anchors.bottom: parent.bottom; width: parent.width; height: 64; color: t.bar + Rectangle { width: parent.width; height: 1; color: t.line } + Btn { anchors.left: parent.left; anchors.leftMargin: 40; anchors.verticalCenter: parent.verticalCenter; k: g.back; text: "Back to the menu"; onClicked: hdmiCancel() } + Text { anchors.right: instBtn.left; anchors.rightMargin: 16; anchors.verticalCenter: parent.verticalCenter; visible: !!hdmi && (!hdmiCanInstall || hdmiUntried().length > 0) + text: !hdmiCanInstall ? "Tick a rate first" : "Not tried: " + hdmiUntried().join(", ") + " Hz" + color: hdmiCanInstall ? t.warn : t.faint; font.family: t.body; font.pixelSize: 14 } + Btn { id: instBtn; anchors.right: parent.right; anchors.rightMargin: 40; anchors.verticalCenter: parent.verticalCenter; k: hdmiSel === hdmiRates.length ? g.ok : g.apply + visible: !hdmiLoading && !!hdmi && hdmiRates.length > 0; primary: hdmiCanInstall; opacity: hdmiCanInstall ? 1 : 0.5 + focusRing: hdmiSel === hdmiRates.length + text: "Install" + (hdmiKept().length ? " " + hdmiKept().join(", ") + " Hz" : ""); onClicked: hdmiInstall() } + } + } + + // ================= HDMI: keep this rate? ================= + Item { + visible: screen === "hdmitest" + anchors.top: header.bottom; anchors.bottom: parent.bottom; width: parent.width + Rectangle { + anchors.centerIn: parent; width: 640; height: ht.implicitHeight + 64; radius: 20; color: t.card; border.width: 1; border.color: t.line + Column { id: ht; anchors.fill: parent; anchors.margins: 32; spacing: 18 + Text { text: hdmiPhase === "ask" || hdmiPhase === "settle" ? "Keep " + hdmiHz + " Hz?" : (hdmiPhase === "back" ? "Switching back…" : "Switching to " + hdmiHz + " Hz…") + color: t.text; font.family: t.display; font.pixelSize: 36; font.weight: Font.DemiBold } + Text { width: parent.width; wrapMode: Text.WordWrap; color: t.soft; font.family: t.body; font.pixelSize: 16; lineHeight: 1.3 + text: hdmiPhase === "ask" || hdmiPhase === "settle" ? "Check the picture: sharp, stable, no flicker or dropouts. Your display's menu can show the rate it gets." + : "The screen may go black for a moment." } + Column { visible: hdmiPhase === "ask" || hdmiPhase === "settle"; width: parent.width; spacing: 8 + Text { text: hdmiPhase === "settle" ? "Waiting for the picture… the countdown starts in " + hdmiSettle + " s" : "Switching back in " + hdmiLeft + " s"; color: t.mute; font.family: t.body; font.pixelSize: 14 } + Rectangle { width: parent.width; height: 10; radius: 5; color: t.line + Rectangle { height: 10; radius: 5; color: t.accent; width: parent.width * hdmiLeft / 15; Behavior on width { NumberAnimation { duration: 900 } } } } } + Row { visible: hdmiPhase === "ask" || hdmiPhase === "settle"; spacing: 12 + Btn { k: g.back; text: "Switch back"; onClicked: hdmiReject() } + Btn { k: g.ok; text: "Keep " + hdmiHz + " Hz"; primary: true; onClicked: hdmiKeep() } } + } + } + } + // ================= DONE ================= Item { visible: screen === "done" diff --git a/ui/steamify-ui b/ui/steamify-ui index 8f6a7c3..bd74bcc 100755 --- a/ui/steamify-ui +++ b/ui/steamify-ui @@ -245,8 +245,8 @@ class Backend(QObject): self.busyChanged.emit() p.start() - @Slot("QVariant", str, bool, str) - def apply(self, ids, boot, reapply, password): + @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() @@ -255,6 +255,8 @@ class Backend(QObject): 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. @@ -272,6 +274,21 @@ class Backend(QObject): """Only after both warnings in the app.""" self._start(["bios-flash"], password) + @Slot(str) + def hdmiOptions(self, password): + """Reads the HDMI displays: ends with an hdmi-options event.""" + self._start(["hdmi-options"], password) + + @Slot(str, str, int, int, float, str) + def hdmiTry(self, password, conn, w, h, hz, rates): + """Switches to hz live: an hdmi-tried event says whether it worked.""" + self._start(["hdmi-try", conn, str(w), str(h), "%g" % hz] + [r for r in rates.split(",") if r], password) + + @Slot(str, str, int, int, float) + def hdmiReset(self, password, conn, w, h, hz): + """The display's own EDID and the old rate again (hdmi-reset event).""" + self._start(["hdmi-reset", conn, str(w), str(h), "%g" % hz], password) + def _read(self, p): self._buf += bytes(p.readAllStandardOutput()) while b"\n" in self._buf: