Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
84f3a98
Add nested gaming session for dual-screen devices
brycesub Jul 8, 2026
c2c4471
Boot nested-gaming devices into the persistent desktop session
brycesub Jul 8, 2026
b28793f
Bridge Steam refresh-rate requests to KWin in nested gaming
brycesub Jul 8, 2026
497271c
nested-gaming: advertise panel refresh rates to nested gamescope
brycesub Jul 8, 2026
06ac904
nested-gaming: pin the gamescope window to the primary screen
brycesub Jul 8, 2026
c6072f0
Disable PowerDevil idle suspend
brycesub Jul 8, 2026
08b7760
nested-gaming: supervise Steam and fast-path all mode switches
brycesub Jul 11, 2026
5f549ac
nested-gaming: kill stray gamescopes when switching to desktop
brycesub Jul 11, 2026
0445dd0
Silence the kscreen screen-layout picker OSD
brycesub Jul 12, 2026
e7798f1
setup-dual-screen: make the bottom screen the desktop primary
brycesub Jul 12, 2026
67ba774
fix: enable MangoApp in nested gaming
brycesub Jul 12, 2026
2b0e5cc
fix: wait for nested Xwayland before MangoApp
brycesub Jul 12, 2026
48be0ec
fix: force X11 for nested gamescope clients
brycesub Jul 12, 2026
dfaec95
Add HUD-toggle button device vars for the AYN Thor
brycesub Jul 13, 2026
394f0bf
nested-gaming: run the top MangoApp as armada-hud-top.service
brycesub Jul 13, 2026
444014b
Add the bottom-screen full-stats HUD unit
brycesub Jul 13, 2026
5856968
Add hud-toggle to swap the HUD between screens
brycesub Jul 13, 2026
d4bf431
Listen for the AYN button and toggle the HUD
brycesub Jul 13, 2026
177e4ae
Order the HUD swap stop-before-start
brycesub Jul 13, 2026
cac25c7
Fix bottom HUD placement and lifecycle
brycesub Jul 13, 2026
36c9217
Expand bottom HUD diagnostic metrics
brycesub Jul 13, 2026
30ddbcb
Prune unsupported bottom HUD metrics
brycesub Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions build_files/40-vendor-system-files.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,10 @@ systemctl mask irqbalance.service

# Only plain suspend is supported (via the suspend-dispatch drop-in); mask the rest.
systemctl mask systemd-hibernate.service systemd-hybrid-sleep.service systemd-suspend-then-hibernate.service

# KWin pops the kscreen screen-layout picker OSD whenever two outputs show up
# in a combination it has not stored yet - on dual-screen devices that is the
# very first boot. The layout is handled by desktop-bootstrap; the picker is
# noise on a handheld. KWin's D-Bus call to the OSD service is fire-and-forget,
# so masking the service is safe and Display Settings keep working.
systemctl --global mask plasma-kscreen-osd.service
22 changes: 20 additions & 2 deletions system_files/etc/xdg/kwinrulesrc
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
[General]
count=1
rules=steam-keyboard
count=2
rules=steam-keyboard,armada-hud-bottom

[armada-hud-bottom]
Description=Pin the bottom-screen HUD fullscreen on the secondary output
acceptfocus=false
acceptfocusrule=2
above=true
aboverule=2
fullscreen=true
fullscreenrule=4
position=226,720
positionrule=4
screen=0
screenrule=4
skiptaskbar=true
skiptaskbarrule=2
wmclass=mangoapp overlay window
wmclasscomplete=false
wmclassmatch=1

[steam-keyboard]
Description=Window settings for Steam Keyboard
Expand Down
10 changes: 10 additions & 0 deletions system_files/etc/xdg/powerdevilrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Armada: no automatic idle suspend. PowerDevil's idle detection does not
# count gamepad input as activity, so with the persistent desktop session
# (nested gaming) it would suspend mid-game. Suspend stays available via the
# power button (powerbuttond -> fake-suspend). Users can still override this
# per-user in System Settings.
[AC][SuspendAndShutdown]
AutoSuspendAction=0

[Battery][SuspendAndShutdown]
AutoSuspendAction=0
5 changes: 5 additions & 0 deletions system_files/usr/lib/armada/devices/ayn-thor.conf
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ ARMADA_PRIMARY_CONNECTOR=DSI-2
ARMADA_PRIMARY_BACKLIGHT=ae96000.dsi.0
ARMADA_PRIMARY_TOUCHSCREEN=top_touchscreen
ARMADA_SECONDARY_CONNECTOR=DSI-1
ARMADA_SECONDARY_ORIENTATION=right
ARMADA_SECONDARY_TOUCHSCREEN=bottom_touchscreen
ARMADA_GAMING_SESSION=nested
ARMADA_PANEL_ORIENTATION=right
ARMADA_PANEL_NATIVE_WIDTH=1080
ARMADA_PANEL_NATIVE_HEIGHT=1920
ARMADA_PANEL_REFRESH_RATES=60,120
ARMADA_GAMESCOPE_USE_ROTATION_SHADER=1
ARMADA_GAMESCOPE_FAKE_OUTPUT_MM=177x100
# The AYN button swaps the performance HUD between the screens.
ARMADA_HUD_TOGGLE_BUTTON_DEV=platform-gpio-keys-event
ARMADA_HUD_TOGGLE_BUTTON_KEY=KEY_F24
12 changes: 12 additions & 0 deletions system_files/usr/lib/armada/devices/defaults.conf
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@ ARMADA_PRIMARY_CONNECTOR=
ARMADA_PRIMARY_BACKLIGHT=
# Set on dual-screen devices; the desktop session extends onto this output.
ARMADA_SECONDARY_CONNECTOR=
# Physical mounting of the secondary panel (normal/left/right/inverted);
# setup-dual-screen rotates the output to compensate.
ARMADA_SECONDARY_ORIENTATION=normal
# Input device names used to pin each touchscreen to its own output in the
# desktop session (only needed when a secondary connector is set).
ARMADA_PRIMARY_TOUCHSCREEN=
ARMADA_SECONDARY_TOUCHSCREEN=
# embedded (default): gamescope owns the GPU in gaming mode.
# nested: gaming mode runs as a nested gamescope inside the desktop session
# (dual-screen devices), making mode switches near-instant.
ARMADA_GAMING_SESSION=embedded
ARMADA_PANEL_TYPE=internal
ARMADA_PANEL_ORIENTATION=normal
ARMADA_PANEL_NATIVE_WIDTH=
Expand All @@ -23,3 +30,8 @@ ARMADA_GAMESCOPE_FAKE_OUTPUT_MM=
ARMADA_GAMEPAD_QUIRK=none
# fake = fake-suspend (real suspend hangs most SoCs); mem = suspend-to-RAM.
ARMADA_SUSPEND_MODE=fake
# Physical HUD-toggle button (the AYN button on the Thor): input device name
# under /dev/input/by-path/ plus the key name evtest reports. Unset disables
# the armada-ayn-button listener; see hud-toggle-listener.
ARMADA_HUD_TOGGLE_BUTTON_DEV=
ARMADA_HUD_TOGGLE_BUTTON_KEY=
11 changes: 11 additions & 0 deletions system_files/usr/lib/systemd/user/armada-ayn-button.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[Unit]
Description=HUD-toggle button listener (AYN button)
PartOf=graphical-session.target
After=graphical-session.target

[Service]
ExecStart=/usr/libexec/armada/hud-toggle-listener
# The listener exits 0 on devices without a configured button; on-failure
# keeps that final while restarting real failures (e.g. evtest dying).
Restart=on-failure
RestartSec=5
19 changes: 19 additions & 0 deletions system_files/usr/lib/systemd/user/armada-hud-bottom.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[Unit]
Description=Full-stats performance HUD on the bottom screen
# gamescope feeds mangoapp over a single-consumer message queue; Conflicts
# lets systemd guarantee only one HUD unit ever runs (hud-toggle swaps them).
Conflicts=armada-hud-top.service
# Only hud-toggle (the AYN button) starts this; it dies with gaming mode.
PartOf=armada-nested-gaming.service
BindsTo=armada-nested-gaming.service
After=graphical-session.target
After=armada-nested-gaming.service
# Conflicts= implies no ordering; this edge makes systemd stop one HUD
# before starting the other, in both toggle directions.
After=armada-hud-top.service
StartLimitIntervalSec=0

[Service]
ExecStart=/usr/libexec/armada/hud-bottom
Restart=always
RestartSec=1
17 changes: 17 additions & 0 deletions system_files/usr/lib/systemd/user/armada-hud-top.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[Unit]
Description=Performance overlay inside nested gamescope (top screen)
# gamescope feeds mangoapp over a single-consumer message queue; Conflicts
# lets systemd guarantee only one HUD unit ever runs (hud-toggle swaps them).
Conflicts=armada-hud-bottom.service
# Started by nested-gaming after its gamescope handshake; dies with it.
PartOf=armada-nested-gaming.service
BindsTo=armada-nested-gaming.service
After=armada-nested-gaming.service
# mangoapp exits immediately while gamescope is restarting (dead display);
# retry forever, as the old inline restart-loop did.
StartLimitIntervalSec=0

[Service]
ExecStart=/usr/libexec/armada/hud-top
Restart=always
RestartSec=1
18 changes: 18 additions & 0 deletions system_files/usr/lib/systemd/user/armada-nested-gaming.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[Unit]
Description=Nested gaming mode (gamescope + Steam on the desktop session)
# Steam exits to apply client updates and expects its wrapper to bring it
# back (the embedded session gets this from SDDM Relogin); Restart=always
# is that wrapper here. Stopping this unit is the one clean way to leave
# gaming mode (steamos-manager and os-session-select do that), so an
# explicit stop is never restarted. PartOf ties it to the desktop session:
# logout stops it instead of leaving it thrashing without a compositor.
PartOf=graphical-session.target
After=graphical-session.target
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
ExecStart=/usr/libexec/armada/nested-gaming
Restart=always
RestartSec=2
TimeoutStopSec=15
23 changes: 23 additions & 0 deletions system_files/usr/libexec/armada/desktop-bootstrap
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,29 @@ if [[ -n "${ARMADA_SECONDARY_TOUCHSCREEN:-}" ]]; then
/usr/libexec/armada/touchscreen-inhibit "$ARMADA_SECONDARY_TOUCHSCREEN" 0 2>/dev/null || true
fi

# KWin's rulebook only reads the per-user config. Merge Armada's packaged
# rules there before any managed windows are created, then reload the rules.
if [[ -x /usr/libexec/armada/install-kwin-rules ]] && \
/usr/libexec/armada/install-kwin-rules; then
for qdbus in qdbus-qt6 qdbus6 qdbus /usr/lib64/qt6/bin/qdbus; do
if command -v "${qdbus}" >/dev/null 2>&1; then
"${qdbus}" org.kde.KWin /KWin org.kde.KWin.reconfigure 2>/dev/null || true
break
fi
done
fi

# Nested-gaming devices boot into this session with gaming on the primary
# screen. reset-failed clears a start-limit lockout from a previous session
# (e.g. restart churn while the compositor was going down at logout).
if [[ ${ARMADA_GAMING_SESSION:-} == nested ]]; then
systemctl --user reset-failed armada-nested-gaming.service 2>/dev/null || true
systemctl --user start armada-nested-gaming.service 2>/dev/null || true
# The AYN-button HUD toggle only applies to nested gaming; the listener
# exits on its own unless the device conf names a button.
systemctl --user start armada-ayn-button.service 2>/dev/null || true
fi

display_connector="${ARMADA_PRIMARY_CONNECTOR:-}"
panel_orientation="${ARMADA_PANEL_ORIENTATION:-normal}"

Expand Down
4 changes: 4 additions & 0 deletions system_files/usr/libexec/armada/device-env
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ vars=(
ARMADA_PRIMARY_CONNECTOR
ARMADA_PRIMARY_BACKLIGHT
ARMADA_SECONDARY_CONNECTOR
ARMADA_SECONDARY_ORIENTATION
ARMADA_PRIMARY_TOUCHSCREEN
ARMADA_SECONDARY_TOUCHSCREEN
ARMADA_GAMING_SESSION
ARMADA_PANEL_TYPE
ARMADA_PANEL_ORIENTATION
ARMADA_PANEL_NATIVE_WIDTH
Expand All @@ -49,6 +51,8 @@ vars=(
ARMADA_GAMESCOPE_USE_ROTATION_SHADER
ARMADA_GAMESCOPE_FAKE_OUTPUT_MM
ARMADA_GAMEPAD_QUIRK
ARMADA_HUD_TOGGLE_BUTTON_DEV
ARMADA_HUD_TOGGLE_BUTTON_KEY
)

for var in "${vars[@]}"; do
Expand Down
18 changes: 18 additions & 0 deletions system_files/usr/libexec/armada/hud-bottom
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/bash
# Full-stats MangoApp on the desktop session (the Thor's bottom screen). It
# reads the same gamescope stats queue as the top overlay; the two units
# Conflict so only one consumer ever runs. Placement is the armada-hud-bottom
# KWin rule; supervision is armada-hud-bottom.service (Restart=always).
set -euo pipefail

# The desktop DISPLAY comes from the user manager environment (imported by
# desktop-bootstrap); without it there is nowhere to draw yet.
[[ -n ${DISPLAY:-} ]] || exit 1

# Land on the desktop Xwayland: GLFW would pick host Wayland otherwise, and
# mangoapp's window hints are X11-only.
export XDG_SESSION_TYPE=x11
unset WAYLAND_DISPLAY

export MANGOHUD_CONFIGFILE=/usr/share/armada/mangohud-bottom.conf
exec mangoapp
21 changes: 21 additions & 0 deletions system_files/usr/libexec/armada/hud-toggle
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/bash
# AYN-button action: swap the performance HUD between the nested-gamescope
# overlay (top screen, Steam's slider in control) and the bottom-screen
# full-stats HUD. State is simply which unit is active; Conflicts= in the
# units stops the other side, keeping gamescope's stats queue
# single-consumer.
set -euo pipefail

# The HUDs only mean something while gaming mode is up.
systemctl --user --quiet is-active armada-nested-gaming.service || exit 0

if systemctl --user --quiet is-active armada-hud-bottom.service; then
unit=armada-hud-top.service
else
# Covers top-active and the transient neither-active (mid Steam
# restart): both land on bottom, the state the press is asking for.
unit=armada-hud-bottom.service
fi

systemctl --user reset-failed "$unit" 2>/dev/null || true
exec systemctl --user start "$unit"
30 changes: 30 additions & 0 deletions system_files/usr/libexec/armada/hud-toggle-listener
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/bash
# Watch the device's HUD-toggle button (the AYN button on the Thor) and run
# hud-toggle on each press. Purely passive: no evdev grab, so other keys on
# the same device (volume-up shares gpio-keys) keep working. The keycode
# also still reaches the focused client; harmless for F24-class keys.
set -euo pipefail

eval "$(/usr/libexec/armada/device-env)"

[[ -n ${ARMADA_HUD_TOGGLE_BUTTON_DEV:-} && -n ${ARMADA_HUD_TOGGLE_BUTTON_KEY:-} ]] || exit 0

dev="/dev/input/by-path/${ARMADA_HUD_TOGGLE_BUTTON_DEV}"
# Tolerate udev still settling at session start.
for _ in $(seq 1 30); do
[[ -e $dev ]] && break
sleep 1
done
[[ -e $dev ]] || exit 1

# stdbuf: evtest block-buffers when piped, which would delay presses forever.
# Key-down lines look like:
# Event: time 1783912432.241849, type 1 (EV_KEY), code 194 (KEY_F24), value 1
stdbuf -oL evtest "$dev" \
| while read -r line; do
case "$line" in
*"(${ARMADA_HUD_TOGGLE_BUTTON_KEY}), value 1")
/usr/libexec/armada/hud-toggle || true
;;
esac
done
23 changes: 23 additions & 0 deletions system_files/usr/libexec/armada/hud-top
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/bash
# MangoApp overlay inside the nested gamescope (top screen); Steam's
# performance-overlay slider drives it by rewriting MANGOHUD_CONFIGFILE.
# nested-gaming writes the nested DISPLAY and that config path to the env
# file after its gamescope handshake. Refusing to run without the file
# matters: the user manager has the desktop DISPLAY in its environment, and
# mangoapp there would still steal gamescope's stats queue. Supervision is
# armada-hud-top.service (Restart=always).
set -euo pipefail

env_file="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/armada-nested-gaming.env"
[[ -r $env_file ]] || exit 1
set -a
# shellcheck disable=SC1090
source "$env_file"
set +a
[[ -n ${DISPLAY:-} && -n ${MANGOHUD_CONFIGFILE:-} ]] || exit 1

# Target the nested Xwayland, never the host compositor.
export XDG_SESSION_TYPE=x11
unset WAYLAND_DISPLAY

exec mangoapp
89 changes: 89 additions & 0 deletions system_files/usr/libexec/armada/install-kwin-rules
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/python3
"""Merge Armada's vendor KWin rules into the current user's rulebook."""

from __future__ import annotations

import configparser
import os
from pathlib import Path
import tempfile


def load_config(path: Path) -> configparser.ConfigParser:
config = configparser.ConfigParser(interpolation=None, strict=False)
config.optionxform = str
if path.is_file():
with path.open(encoding="utf-8") as config_file:
config.read_file(config_file)
return config


def rule_names(config: configparser.ConfigParser) -> list[str]:
if not config.has_section("General"):
return []
return [
name.strip()
for name in config.get("General", "rules", fallback="").split(",")
if name.strip()
]


def main() -> None:
vendor = Path(
os.environ.get("ARMADA_KWIN_RULES_VENDOR", "/etc/xdg/kwinrulesrc")
)
target = Path(
os.environ.get(
"ARMADA_KWIN_RULES_TARGET",
str(
Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
/ "kwinrulesrc"
),
)
)

if not vendor.is_file():
return

vendor_config = load_config(vendor)
vendor_rules = rule_names(vendor_config)
if not vendor_rules:
return

user_config = load_config(target)
existing_rules = [
name for name in rule_names(user_config) if name not in vendor_rules
]

for name in vendor_rules:
if not vendor_config.has_section(name):
continue
if user_config.has_section(name):
user_config.remove_section(name)
user_config.add_section(name)
for key, value in vendor_config.items(name, raw=True):
user_config.set(name, key, value)

merged_rules = existing_rules + [
name for name in vendor_rules if vendor_config.has_section(name)
]
if not user_config.has_section("General"):
user_config.add_section("General")
user_config.set("General", "rules", ",".join(merged_rules))
user_config.set("General", "count", str(len(merged_rules)))

target.parent.mkdir(parents=True, exist_ok=True)
old_mode = target.stat().st_mode & 0o777 if target.exists() else 0o600
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=target.parent, delete=False
) as config_file:
temporary = Path(config_file.name)
user_config.write(config_file, space_around_delimiters=False)
config_file.flush()
os.fsync(config_file.fileno())
temporary.chmod(old_mode)
temporary.replace(target)


if __name__ == "__main__":
main()
Loading
Loading