Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
720eaeb
Add AYN Thor second screen support (userspace)
brycesub Jul 7, 2026
5a29a93
Inhibit secondary touchscreen in gaming mode
brycesub Jul 7, 2026
083d854
setup-dual-screen: tolerate trailing text in kscreen-doctor JSON
brycesub Jul 8, 2026
a11ae55
Withhold direct backlight access on dual-screen devices
brycesub Jul 11, 2026
421c1e8
Add nested gaming session for dual-screen devices
brycesub Jul 8, 2026
8cf5028
Boot nested-gaming devices into the persistent desktop session
brycesub Jul 8, 2026
249300a
Bridge Steam refresh-rate requests to KWin in nested gaming
brycesub Jul 8, 2026
09f7e03
nested-gaming: advertise panel refresh rates to nested gamescope
brycesub Jul 8, 2026
e8445c0
nested-gaming: pin the gamescope window to the primary screen
brycesub Jul 8, 2026
e3e121e
Disable PowerDevil idle suspend
brycesub Jul 8, 2026
e963017
nested-gaming: supervise Steam and fast-path all mode switches
brycesub Jul 11, 2026
0a4b5ef
nested-gaming: kill stray gamescopes when switching to desktop
brycesub Jul 11, 2026
ba27537
Silence the kscreen screen-layout picker OSD
brycesub Jul 12, 2026
35c6b34
setup-dual-screen: make the bottom screen the desktop primary
brycesub Jul 12, 2026
22f4b39
docs: design nested gaming MangoApp integration
brycesub Jul 12, 2026
803ef3c
docs: add nested gaming MangoApp implementation plan
brycesub Jul 12, 2026
33972a4
fix: enable MangoApp in nested gaming
brycesub Jul 12, 2026
684caa2
fix: wait for nested Xwayland before MangoApp
brycesub Jul 12, 2026
b8b06a9
fix: force X11 for nested gamescope clients
brycesub Jul 12, 2026
ba7cb43
Delete docs/superpowers/plans/2026-07-12-nested-gaming-mangoapp.md
brycesub Jul 12, 2026
c571793
Delete docs/superpowers/specs/2026-07-12-nested-gaming-mangoapp-desig…
brycesub Jul 12, 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 @@ -52,3 +52,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
6 changes: 6 additions & 0 deletions system_files/etc/gamescope-session-plus/sessions.d/steam
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ if [[ -n "${ARMADA_PANEL_REFRESH_RATES:-}" ]]; then
unset STEAM_DISPLAY_REFRESH_LIMITS
fi
fi
# Gamescope only lights up the primary panel; silence the secondary
# touchscreen so fingers on the dark glass don't move Steam's cursor.
# The desktop session re-enables it (see desktop-bootstrap).
if [[ -n "${ARMADA_SECONDARY_TOUCHSCREEN:-}" ]]; then
/usr/libexec/armada/touchscreen-inhibit "$ARMADA_SECONDARY_TOUCHSCREEN" 1 2>/dev/null || true
fi
USE_ROTATION_SHADER="${ARMADA_GAMESCOPE_USE_ROTATION_SHADER:-0}"
if [[ -n "${ARMADA_GAMESCOPE_FAKE_OUTPUT_MM:-}" ]]; then
export GAMESCOPE_FAKE_OUTPUT_MM="$ARMADA_GAMESCOPE_FAKE_OUTPUT_MM"
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
25 changes: 25 additions & 0 deletions system_files/usr/bin/steamos-polkit-helpers/steamos-priv-write
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,30 @@ if [[ "$WRITE_PATH" == /sys/class/hwmon/hwmon*/power*_cap ]]; then
exit 1
fi

if [[ "$WRITE_PATH" == /sys/class/backlight/*/brightness ]]; then
[[ "$WRITE_VALUE" =~ ^[0-9]+$ ]] || exit 1
# Steam adjusts the first backlight it finds; on dual-screen devices
# that can be the secondary panel. Steer writes to the primary
# backlight, rescaling between the two devices' brightness ranges.
eval "$(/usr/libexec/armada/device-env)"
src_dir="$(dirname "$WRITE_PATH")"
tgt_dir="$src_dir"
if [[ -n "${ARMADA_PRIMARY_BACKLIGHT:-}" ]]; then
tgt_dir="/sys/class/backlight/${ARMADA_PRIMARY_BACKLIGHT}"
fi
[[ -e "$tgt_dir/brightness" ]] || exit 1
val="$WRITE_VALUE"
if [[ "$tgt_dir" != "$src_dir" && -e "$src_dir/max_brightness" ]]; then
src_max="$(cat "$src_dir/max_brightness")"
tgt_max="$(cat "$tgt_dir/max_brightness")"
if [[ "$src_max" -gt 0 && "$src_max" -ne "$tgt_max" ]]; then
val=$(( val * tgt_max / src_max ))
fi
fi
echo "$val" > "$tgt_dir/brightness"
log "backlight: $WRITE_VALUE -> $tgt_dir/brightness ($val)"
exit 0
fi

echo "refusing unsupported write: $WRITE_VALUE -> $WRITE_PATH" >&2
exit 1
11 changes: 10 additions & 1 deletion system_files/usr/lib/armada/devices/ayn-thor.conf
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ ARMADA_DEVICE_ID=ayn-thor
ARMADA_DEVICE_NAME='AYN Thor'
ARMADA_SOC_CLASS=SM8550

ARMADA_PRIMARY_CONNECTOR=DSI-1
# Dual screen: top panel is DSI-2 (mdss_dsi1), bottom panel is DSI-1
# (mdss_dsi0). Gamescope drives only the top panel; the desktop session
# uses both (see desktop-bootstrap).
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
Expand Down
16 changes: 16 additions & 0 deletions system_files/usr/lib/armada/devices/defaults.conf
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@ ARMADA_DEVICE_NAME=unknown
ARMADA_SOC_CLASS=

ARMADA_PRIMARY_CONNECTOR=
# Backlight Steam brightness writes are steered to. Only needed on devices
# with more than one backlight (Steam grabs the first one it finds).
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 Down
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
6 changes: 4 additions & 2 deletions system_files/usr/lib/udev/rules.d/60-armada-perf-acls.rules
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ SUBSYSTEM=="cpu", KERNEL=="cpu[0-9]*", \
SUBSYSTEM=="devfreq", KERNEL=="*.gpu", \
RUN+="/bin/sh -c 'chgrp wheel /sys/class/devfreq/%k/governor /sys/class/devfreq/%k/max_freq /sys/class/devfreq/%k/min_freq 2>/dev/null; chmod g+w /sys/class/devfreq/%k/governor /sys/class/devfreq/%k/max_freq /sys/class/devfreq/%k/min_freq 2>/dev/null'"

SUBSYSTEM=="backlight", \
RUN+="/bin/sh -c 'chgrp wheel /sys/class/backlight/%k/brightness 2>/dev/null; chmod g+w /sys/class/backlight/%k/brightness 2>/dev/null'"
SUBSYSTEM=="backlight", RUN+="/usr/libexec/armada/backlight-acl %k"

SUBSYSTEM=="input", KERNEL=="input[0-9]*", \
RUN+="/bin/sh -c 'chgrp wheel /sys/class/input/%k/inhibited 2>/dev/null; chmod g+w /sys/class/input/%k/inhibited 2>/dev/null'"
16 changes: 16 additions & 0 deletions system_files/usr/libexec/armada/backlight-acl
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/bash
# udev helper: grant Steam (wheel) direct write access to a backlight.
#
# On devices with more than one panel (ARMADA_PRIMARY_BACKLIGHT set) the
# grant is withheld from every backlight: Steam writes the first backlight
# it finds directly whenever it can, which bypasses the steamos-priv-write
# steering to the primary panel. Without write access Steam falls back to
# the polkit helper (its standard path on SteamOS), which steers and
# rescales the write.
set -euo pipefail

eval "$(/usr/libexec/armada/device-env)"
[[ -n "${ARMADA_PRIMARY_BACKLIGHT:-}" ]] && exit 0

chgrp wheel "/sys/class/backlight/$1/brightness" 2>/dev/null || true
chmod g+w "/sys/class/backlight/$1/brightness" 2>/dev/null || true
33 changes: 28 additions & 5 deletions system_files/usr/libexec/armada/desktop-bootstrap
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,32 @@ else
systemctl --user import-environment "${activation_env[@]}" 2>/dev/null || true
fi

if command -v /usr/libexec/armada/device-env >/dev/null 2>&1; then
eval "$(/usr/libexec/armada/device-env)"
fi

# The gaming session inhibits the secondary touchscreen (the desktop is the
# only session that lights up the secondary panel); re-enable it every start.
if [[ -n "${ARMADA_SECONDARY_TOUCHSCREEN:-}" ]]; then
/usr/libexec/armada/touchscreen-inhibit "$ARMADA_SECONDARY_TOUCHSCREEN" 0 2>/dev/null || true
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
fi

config_dir="${XDG_CONFIG_HOME:-${HOME}/.config}/armada"
rotation_done="${config_dir}/desktop-rotation.done"
scale_done="${config_dir}/desktop-scale.done"
dual_done="${config_dir}/desktop-dualscreen.done"

[[ ! -e "${rotation_done}" || ! -e "${scale_done}" ]] || exit 0
[[ ! -e "${rotation_done}" || ! -e "${scale_done}" || ! -e "${dual_done}" ]] || exit 0
mkdir -p "${config_dir}"

if command -v /usr/libexec/armada/device-env >/dev/null 2>&1; then
eval "$(/usr/libexec/armada/device-env)"
fi

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

Expand All @@ -57,3 +72,11 @@ fi
if [[ ! -e "${scale_done}" ]] && kscreen-doctor "output.${display_connector}.scale.1.5" >/dev/null 2>&1; then
touch "${scale_done}"
fi

if [[ ! -e "${dual_done}" ]]; then
if [[ -z "${ARMADA_SECONDARY_CONNECTOR:-}" ]]; then
touch "${dual_done}"
elif /usr/libexec/armada/setup-dual-screen; then
touch "${dual_done}"
fi
fi
6 changes: 6 additions & 0 deletions system_files/usr/libexec/armada/device-env
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ vars=(
ARMADA_SOC_CLASS
ARMADA_SUSPEND_MODE
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 Down
101 changes: 101 additions & 0 deletions system_files/usr/libexec/armada/nested-gaming
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/bash
# Gaming mode on dual-screen devices (ARMADA_GAMING_SESSION=nested): run
# gamescope+Steam as a nested fullscreen window on the primary screen of the
# already-running desktop session. Runs as armada-nested-gaming.service (user
# unit); steamos-manager and os-session-select start/stop that unit; see
# docs/superpowers/specs/2026-07-07-thor-second-screen-phase2-design.md.
#
# Steam runs as our child rather than gamescope's so that its exit ends the
# unit and Restart= brings the whole stack back up. Steam applies client
# updates by exiting and relying on its wrapper to relaunch it, sometimes
# twice in a row (the embedded session gets the same effect from SDDM
# Relogin restarting the whole gamescope session).
set -euo pipefail

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

# The host desktop session is Wayland, but clients inside nested Gamescope use
# Xwayland. This also prevents GLFW applications from selecting host Wayland.
export XDG_SESSION_TYPE=x11

# Advertise the external MangoApp overlay path and the layouts supported by
# the installed MangoHud package. These mirror gamescope-session-steam.
export STEAM_USE_MANGOAPP=1
export STEAM_MANGOAPP_PRESETS_SUPPORTED=1
export STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1
export STEAM_DISABLE_MANGOAPP_ATOM_WORKAROUND=1

# Steam rewrites this file when the performance-overlay level changes. Keep
# MangoApp hidden until Steam has restored the selected level.
MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX)
export MANGOHUD_CONFIGFILE
echo "no_display" >"$MANGOHUD_CONFIGFILE"

# The desktop shows rotated panels in landscape; gamescope renders landscape.
width="${ARMADA_PANEL_NATIVE_WIDTH:-1080}"
height="${ARMADA_PANEL_NATIVE_HEIGHT:-1920}"
case "${ARMADA_PANEL_ORIENTATION:-normal}" in
left|right) gs_w="$height" gs_h="$width" ;;
*) gs_w="$width" gs_h="$height" ;;
esac

# Expose the refresh range so Steam offers per-game refresh switching;
# nested-refresh-bridge applies requests to the host compositor.
if [[ -n "${ARMADA_PANEL_REFRESH_RATES:-}" ]]; then
IFS=',' read -r -a _rates <<< "$ARMADA_PANEL_REFRESH_RATES"
if (( ${#_rates[@]} > 1 )); then
export STEAM_DISPLAY_REFRESH_LIMITS="${_rates[0]},${_rates[-1]}"
fi
# Advertised to Steam via gamescope_control (armada gamescope patch);
# the Wayland backend cannot enumerate the host output's modes itself.
export GAMESCOPE_NESTED_REFRESH_RATES="$ARMADA_PANEL_REFRESH_RATES"
/usr/libexec/armada/nested-refresh-bridge &
fi

# --prefer-output pins the fullscreen window to the primary screen (armada
# gamescope patch: the Wayland backend passes it to the fullscreen request;
# otherwise the host compositor picks whichever output was last active).
prefer_output=()
[[ -n "${ARMADA_PRIMARY_CONNECTOR:-}" ]] && prefer_output=(--prefer-output "$ARMADA_PRIMARY_CONNECTOR")

# Readiness handshake: gamescope writes its X and Wayland display names to
# the -R pipe once it is up (same mechanism gamescope-session-plus uses).
socket=$(mktemp --tmpdir -u armada-nested-gaming.XXXXXX.socket)
mkfifo -- "$socket"
trap 'rm -f -- "$socket" "$MANGOHUD_CONFIGFILE"' EXIT

gamescope -e --fullscreen -W "$gs_w" -H "$gs_h" --steam "${prefer_output[@]}" \
-R "$socket" &
gamescope_pid=$!

if ! read -r -t 15 x_display wl_display <>"$socket"; then
echo "nested-gaming: gamescope not ready after 15s, giving up" >&2
kill -9 "$gamescope_pid" 2>/dev/null || true
exit 1
fi

# Match the environment gamescope gives a child it launches itself: X11
# clients target the nested Xwayland, and WAYLAND_DISPLAY must not leak the
# host compositor to games.
export DISPLAY="$x_display"
export GAMESCOPE_WAYLAND_DISPLAY="$wl_display"
unset WAYLAND_DISPLAY

# MangoApp must target Gamescope's nested Xwayland. Restart it if it exits so
# one overlay failure does not require restarting Steam or Gamescope.
if command -v mangoapp >/dev/null; then
(
while true; do
mangoapp || true
sleep 1
done
) &
fi

status=0
/usr/libexec/armada/launch-steam -gamepadui -steamos3 -steampal -steamdeck -noverifyfiles || status=$?

# Steam is gone (client-update restart or crash); take gamescope down with
# us so the unit restart brings up a fresh pair.
kill "$gamescope_pid" 2>/dev/null || true
exit "$status"
71 changes: 71 additions & 0 deletions system_files/usr/libexec/armada/nested-refresh-bridge
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/bash
# Bridge Steam's refresh-rate requests to KWin for the nested gaming session.
#
# In the embedded session gamescope owns the display and applies
# GAMESCOPE_DYNAMIC_REFRESH (set by Steam on gamescope's Xwayland root)
# itself; nested gamescope cannot modeset the host output. Watch the atom on
# the nested Xwayland and apply the nearest supported panel rate through
# kscreen-doctor. Spawned by nested-gaming; exits with the session.
set -euo pipefail

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

[[ ${ARMADA_GAMING_SESSION:-} == nested ]] || exit 0
[[ -n ${ARMADA_PRIMARY_CONNECTOR:-} && -n ${ARMADA_PANEL_REFRESH_RATES:-} ]] || exit 0
[[ -n ${ARMADA_PANEL_NATIVE_WIDTH:-} && -n ${ARMADA_PANEL_NATIVE_HEIGHT:-} ]] || exit 0

IFS=',' read -r -a rates <<< "$ARMADA_PANEL_REFRESH_RATES"
max_rate="${rates[-1]}"
mode_res="${ARMADA_PANEL_NATIVE_WIDTH}x${ARMADA_PANEL_NATIVE_HEIGHT}"

# The nested gamescope's Xwayland is where Steam sets the atom; find its
# display number from Steam's environment.
nested_display=""
for _ in $(seq 1 60); do
steam_pid=$(pgrep -x steam | head -1 || true)
if [[ -n "$steam_pid" ]]; then
nested_display=$(tr '\0' '\n' < "/proc/${steam_pid}/environ" 2>/dev/null \
| sed -n 's/^DISPLAY=//p' | head -1)
[[ -n "$nested_display" ]] && break
fi
sleep 2
done
[[ -n "$nested_display" ]] || exit 0

snap_rate() {
local want=$1 best="${rates[0]}" best_delta=-1 rate delta
for rate in "${rates[@]}"; do
delta=$(( want > rate ? want - rate : rate - want ))
# Ties go to the higher rate: rates are listed ascending.
if (( best_delta < 0 || delta <= best_delta )); then
best=$rate best_delta=$delta
fi
done
printf '%s' "$best"
}

current=""
apply() {
local hz=$1
[[ "$hz" =~ ^[0-9]+$ ]] || hz=0
(( hz == 0 )) && hz=$max_rate
hz=$(snap_rate "$hz")
[[ "$hz" == "$current" ]] && return 0
if kscreen-doctor "output.${ARMADA_PRIMARY_CONNECTOR}.mode.${mode_res}@${hz}" >/dev/null 2>&1; then
current=$hz
fi
}

# Seed the atom so xprop -spy has a property to watch from the start.
xprop -display "$nested_display" -root -f GAMESCOPE_DYNAMIC_REFRESH 32c \
-set GAMESCOPE_DYNAMIC_REFRESH 0 2>/dev/null || exit 0

# stdbuf: xprop block-buffers when piped, which would delay events forever.
stdbuf -oL xprop -display "$nested_display" -root -spy GAMESCOPE_DYNAMIC_REFRESH 2>/dev/null \
| while read -r line; do
apply "${line##*= }"
done

# Nested Xwayland is gone (gaming session ended): restore the default rate.
current=""
apply "$max_rate"
Loading