mirror of
https://github.com/theupriser/steamify-cachyos.git
synced 2026-10-03 17:41:58 +02:00
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.
This commit is contained in:
1 parent
224aede84b
commit
387991dff3
5 files changed
+1059
-1
No files matched your search
@@ -1 +1,2 @@
|
|||||||
dist/
|
dist/
|
||||||
|
__pycache__/
|
||||||
+142
@@ -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] <id>...
|
||||||
|
# turns on the listed components and off the others (like ticking them
|
||||||
|
# in the menu), then streams one JSON object per line: plan, start,
|
||||||
|
# log, done, finished. sudo asks through $SUDO_ASKPASS (no terminal).
|
||||||
|
# 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 <text>: <text> as a JSON string (quotes, backslashes and
|
||||||
|
# control characters escaped; colour codes dropped).
|
||||||
|
local s="$1"
|
||||||
|
s="$(printf '%s' "$s" | sed -E $'s/\x1b\\[[0-9;]*[A-Za-z]//g')"
|
||||||
|
s="${s//\\/\\\\}"; s="${s//\"/\\\"}"
|
||||||
|
s="${s//$'\t'/\\t}"; s="${s//$'\r'/}"; s="${s//$'\n'/\\n}"
|
||||||
|
s="$(printf '%s' "$s" | tr -d '\000-\010\013\014\016-\037')"
|
||||||
|
printf '"%s"' "$s"
|
||||||
|
}
|
||||||
|
|
||||||
|
json_list() {
|
||||||
|
# json_list <item>...: a JSON array of strings.
|
||||||
|
local out="" x
|
||||||
|
for x in "$@"; do out+="${out:+,}$(json_str "$x")"; done
|
||||||
|
printf '[%s]' "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
backend_event() {
|
||||||
|
# backend_event <event> [<json fields without braces>]
|
||||||
|
printf '{"event":%s%s}\n' "$(json_str "$1")" "${2:+,$2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
backend_status() {
|
||||||
|
local c first_run=true items="" kind parent now
|
||||||
|
detect_components
|
||||||
|
for c in "${COMPONENTS[@]}"; do
|
||||||
|
component_available "$c" && ! is_action "$c" && [[ "${CURRENT[$c]}" == 1 ]] && first_run=false
|
||||||
|
done
|
||||||
|
for c in "${COMPONENTS[@]}"; do
|
||||||
|
component_available "$c" || continue
|
||||||
|
kind=toggle; is_action "$c" && kind=action; [[ "$c" == boot ]] && kind=choice
|
||||||
|
parent="${PARENT[$c]:-}"
|
||||||
|
now="${CURRENT[$c]:-0}"
|
||||||
|
items+="${items:+,}{\"id\":$(json_str "$c"),\"label\":$(json_str "${LABEL[$c]%%:*}")"
|
||||||
|
items+=",\"hint\":$(json_str "$( [[ "${LABEL[$c]}" == *:* ]] && echo "${LABEL[$c]#*: }")")"
|
||||||
|
items+=",\"kind\":\"$kind\",\"parent\":$(json_str "$parent")"
|
||||||
|
items+=",\"on\":$( [[ "$now" == 1 ]] && echo true || echo false)"
|
||||||
|
items+=",\"wanted\":$( [[ "${WANTED[$c]:-0}" == 1 ]] && echo true || echo false)"
|
||||||
|
items+=",\"selectable\":$(component_selectable "$c" && echo true || echo false)}"
|
||||||
|
done
|
||||||
|
local cec="" f
|
||||||
|
for f in /dev/cec*; do [[ -e "$f" ]] && cec+="${cec:+ }$(basename "$f")"; done
|
||||||
|
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 <id> <enable|disable>: runs it in this shell
|
||||||
|
# (its variables, e.g. RESTART_FOR_LOGIN, must survive), each output line
|
||||||
|
# as a log event.
|
||||||
|
local c="$1" what="$2" rc
|
||||||
|
backend_event start "\"id\":$(json_str "$c"),\"action\":\"$what\",\"label\":$(json_str "${LABEL[$c]%%:*}")"
|
||||||
|
"${c}_${what}" > >(while IFS= read -r line; do backend_event log "\"id\":$(json_str "$c"),\"line\":$(json_str "$line")"; done) 2>&1
|
||||||
|
rc=$?
|
||||||
|
# Let the log reader finish before the next event.
|
||||||
|
wait $! 2>/dev/null
|
||||||
|
backend_event done "\"id\":$(json_str "$c"),\"ok\":$([[ $rc -eq 0 ]] && echo true || echo false)"
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
backend_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] <id>..."; return 2 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
+15
-1
@@ -19,16 +19,21 @@ VERSION=1.1.4
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
for lib in common state packages login-manager single-user steam-desktop steam-machine cec boot-session vapor-theme steamos-extras bios desktop-shortcut wizard-shortcut menu; do
|
for lib in common state packages login-manager single-user steam-desktop steam-machine cec boot-session vapor-theme steamos-extras bios desktop-shortcut wizard-shortcut menu backend; do
|
||||||
# shellcheck source=/dev/null
|
# shellcheck source=/dev/null
|
||||||
source "$SCRIPT_DIR/lib/$lib.sh"
|
source "$SCRIPT_DIR/lib/$lib.sh"
|
||||||
done
|
done
|
||||||
|
|
||||||
require_root_helper
|
require_root_helper
|
||||||
|
|
||||||
|
BACKEND=false
|
||||||
|
[[ "${1:-}" == --backend ]] && BACKEND=true
|
||||||
|
|
||||||
|
if [[ "$BACKEND" == false ]]; then
|
||||||
echo -e "${c_bold}Steamify CachyOS${c_reset} v$VERSION"
|
echo -e "${c_bold}Steamify CachyOS${c_reset} v$VERSION"
|
||||||
echo "Turn the SteamOS-style parts on or off. The menu shows what is on now;"
|
echo "Turn the SteamOS-style parts on or off. The menu shows what is on now;"
|
||||||
echo "anything you turn off is put back the way it was."
|
echo "anything you turn off is put back the way it was."
|
||||||
|
fi
|
||||||
|
|
||||||
if ! command -v pacman >/dev/null 2>&1; then
|
if ! command -v pacman >/dev/null 2>&1; then
|
||||||
err "This doesn't look like an Arch/CachyOS system (no pacman found). Aborting."
|
err "This doesn't look like an Arch/CachyOS system (no pacman found). Aborting."
|
||||||
@@ -98,6 +103,15 @@ quit_prompt() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The graphical app (steamify-ui) drives the same components through
|
||||||
|
# lib/backend.sh instead of the menu.
|
||||||
|
if [[ "$BACKEND" == true ]]; then
|
||||||
|
RESTART_FOR_LOGIN=false
|
||||||
|
shift
|
||||||
|
backend_main "$@"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
# Menu loop: after each run the menu comes back with the new state, until
|
# Menu loop: after each run the menu comes back with the new state, until
|
||||||
# the user quits; the restart question comes then, once, for everything.
|
# the user quits; the restart question comes then, once, for everything.
|
||||||
RESTART_FOR_LOGIN=false
|
RESTART_FOR_LOGIN=false
|
||||||
|
|||||||
+572
@@ -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() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+329
@@ -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())
|
||||||
Reference in new issue
Block a user