diff --git a/docs/display-session.md b/docs/display-session.md new file mode 100644 index 0000000..12ccb1d --- /dev/null +++ b/docs/display-session.md @@ -0,0 +1,53 @@ +# Game and streaming display profiles + +Use hyprmoncfg 1.25.1+ for monitor configuration. Two saved profiles and one streaming template define the contexts: + +| Profile | Mode | Color | VRR | +|---|---|---|---| +| desktop | preferred local mode | SDR | fullscreen | +| gaming | preferred local mode | HDR | fullscreen | +| streaming | client width, height and FPS | HDR | off | + +The supplied profiles match this LG panel. Edit Desktop and Gaming through hyprmoncfg and add desired changes back to chezmoi. Streaming defaults live in `~/.config/hyprmoncfg/streaming.json`, outside its saved profile library. Keep its generated `hyprmoncfg-monitors.lua` unmanaged; it loads last. Monitor HDR does not force an SDR game to render HDR. + +## Lifecycle hooks + +- keep Steam launch options as `scb -- %command%` +- apply Gaming before the native game or Gamescope starts; use ScopeBuddy's post hook for cleanup +- set Sunshine preparation Do to `$(HOME)/.local/bin/hypr-display-session stream-start` +- set Sunshine preparation Undo to `$(HOME)/.local/bin/hypr-display-session stream-stop` +- keep the Sunshine launcher detached when ending a stream should leave the game running + +`hypr-display-session` stores one game ID and the requested streaming dimensions/FPS in the compositor's runtime directory. A lock serializes hooks. Streaming wins over Gaming; Gaming wins over Desktop. Starting or ending a game during streaming updates only its marker. Every display change uses `hyprmoncfg apply PROFILE --confirm-timeout 0`. + +`stream-start` reads `SUNSHINE_CLIENT_WIDTH`, `SUNSHINE_CLIENT_HEIGHT` and `SUNSHINE_CLIENT_FPS`. Explicit arguments are available for testing: + +```sh +hypr-display-session stream-start --width 3840 --height 2160 --fps 60 +hypr-display-session stream-stop +``` + +The helper uses `hyprctl` and `jq` to select an advertised mode, including 59.94 Hz for a 60 FPS request. It generates a concrete profile under its runtime directory and supplies that directory to the hyprmoncfg CLI. No generated stream profile enters the saved hardware library. A new stream size reapplies even when the Streaming context is already active. `stream-info` reports requested dimensions/FPS for new ScopeBuddy launches. + +Failed starts restore the previous markers. Stops clear their marker even if applying the next profile fails. Sunshine's existing service has best-effort exit hooks for USB stop and display `stream-stop`, covering process failure before application Undo. It is ordered after the monitor daemon so cleanup precedes daemon shutdown. A monitor daemon restart does not clear activity. No subscriber, watcher or automatic retry runs. The reset service runs once per graphical session. Force-killing a game launcher can skip its post hook; use `hypr-display-session reset` to clear both contexts and restore Desktop. `hypr-display-session status` prints the intended profile, not verified monitor state. + +Systemd starts the native daemon with the graphical session. A separate `hypr-display-session.service` oneshot resets Desktop after the graphical environment is ready and the daemon starts. It waits up to five seconds for the daemon socket. UWSM imports the compositor identity before reaching the graphical session target. The oneshot remains active until logout, so daemon restarts and configuration reloads preserve activity. The generated monitor configuration persists, so a previous HDR profile may appear briefly during login before the reset runs. + +## Limits + +- accept advertised monitor resolutions with refresh within 0.5 Hz of client FPS; choose the closest refresh and reject unsupported modes before changing state +- follow Sunshine application lifetime, not each client connection; explicitly end the application before switching TV/phone modes or continuing locally +- retain an already-running game's resolution, frame cap and Gamescope arguments until changed in-game or relaunched +- rely on hyprmoncfg's native hardware matching; hotplug or daemon restart can supersede the selected profile without reapplying activity +- avoid editing monitor previews during a game or stream; failed hooks require manual recovery +- support one game and one Sunshine application at a time + +## Review and deploy + +Merge the reviewed changes into the configured chezmoi source before applying. Apply while no game or Sunshine application is running. Sunshine's `apps.json` and `sunshine.conf` are tracked directly, including the remaining adaptive Steam launcher. Add later UI changes back to chezmoi before applying. Pairing and credential state stays local. The apply hook reloads unit definitions and re-enables both units under `graphical-session.target`, replacing the daemon's packaged `default.target` enablement. Start a new Hyprland session after deployment. For an intentional reset in the current session, run `hypr-display-session reset`. + +The branch removes sticky-HDR initialization from monitors.lua; the old live Sunshine Lua adapter becomes unused. The old Sunshine helper and Lua adapter can be archived once no preparation commands reference them. + +Validate physical HDR, actual game presentation, Sunshine Quit App behavior and client modes after deployment. Automated checks exercise the installed daemon with a disposable compositor fixture and ScopeBuddy with fake launch commands; they do not prove physical HDR. + +To roll back, restore the previous monitor configuration, ScopeBuddy files and Sunshine helper together. Remove the generated include and stop native hyprmoncfg management before re-enabling sticky-HDR. diff --git a/docs/sunshine-usbip.md b/docs/sunshine-usbip.md new file mode 100644 index 0000000..9096b29 --- /dev/null +++ b/docs/sunshine-usbip.md @@ -0,0 +1,49 @@ +# Sunshine controller forwarding + +Use **Steam Big Picture (Adaptive + Shield USB)** from any client. USB forwarding is optional: clients absent from the controller map skip it, and a listed client without its controller still streams. Use Moonlight touch controls, keyboard/mouse or its regular gamepad forwarding for those clients. + +On the Shield, forward a Steam Controller puck through USB/IP. Runtime needs one user-owned Bash script and usbip-utils. No Python, privileged helper or background USB manager runs. + +Edit `~/.config/sunshine-usbip.conf`: + +```bash +declare -A controllers=( + [nvidia-shield-pro.host.tysmith.app]=28de:1304 +) +``` + +Name each paired client using its resolvable hostname in Sunshine. The start hook uses `SUNSHINE_CLIENT_NAME` as the USB/IP server and looks up its controller ID in this map. Keys must match the paired name exactly. Unlisted hosts skip USB discovery. Add another hostname/VID:PID entry for another setup. The Shield's paired name is `nvidia-shield-pro.host.tysmith.app`. Keep its USB/IP server running with permission to export the puck. Listed hosts without a reachable server or matching controller continue streaming without USB forwarding; discovery has a five-second timeout. + +## Installation and updates + +- install `usbip` through the Linux gaming package definitions +- apply the script, user config and service drop-in through chezmoi +- apply the tracked Sunshine `apps.json` and `sunshine.conf`; they preserve the local Desktop entries and the single adaptive Steam launcher with display and optional USB hooks +- keep pairing certificates, credentials and `sunshine_state.json` local; they are not tracked +- add later Sunshine UI changes back to chezmoi before applying, because the tracked application/config files are authoritative +- run setup after package installation to install `/etc/modules-load.d/sunshine-usbip.conf` and `/etc/sudoers.d/sunshine-usbip`, then load `vhci_hcd` immediately +- validate sudoers before installation; grant passwordless access only to `/usr/bin/usbip attach *` and `/usr/bin/usbip detach *` +- authenticate for initial privileged setup or changes to its definitions; ordinary config/script edits require no authentication + +The `run_onchange_52-configure-sunshine-usbip` script tracks the privileged definitions, not the user config or hook. It records their digest after successful installation so reexecution can skip sudo without reading the protected sudoers directory. This records the last installation, not a complete audit of later administrator changes. Remove `~/.local/state/sunshine-usbip/setup.sha256` and rerun setup to reinstall the policy intentionally. + +The sudoers rule delegates attach/detach with arbitrary arguments to your account. The script checks attachment ownership to avoid accidental cleanup of another import; this is not a restriction enforced by sudoers. Module loading requires no runtime sudo permission because systemd loads it at boot. A missing module reports an error without blocking streaming. + +Restart Sunshine while no application session is active after deploying its preparation commands or drop-in. Normal Quit App uses Sunshine's Undo command. The `app-dev.lizardbyte.app.Sunshine.service.d/cleanup.conf` drop-in adds `ExecStopPost` to the existing Sunshine service so systemd also runs USB stop and display `stream-stop` if Sunshine exits or crashes before Undo. Both are best effort. Ordering keeps the monitor daemon available until Sunshine cleanup finishes when both services stop. A running game retains Gaming; otherwise the display returns to Desktop. It creates no additional service. Repeated stops are harmless. Initial setup reloads the user service definitions; later config edits take effect at the next application launch. + +## Lifetime and cleanup + +- keep attachment active through network disconnects until Quit App +- end the Sunshine application before switching clients; reconnect does not rerun preparation hooks +- stop only the attachment created by the hook, checking its virtual port, local bus and device number +- retain the original identity for cleanup even when the paired client name or configured device changes +- preserve manually imported matching controllers and refuse ambiguous exports +- continue streaming after USB errors; do not retry automatically after a later network/device drop + +The hook uses a lock and ownership files under `$XDG_RUNTIME_DIR/sunshine-usbip`. An interrupted attachment is ambiguous: pending state blocks further operations until the port and state are inspected. It is never automatically adopted. Do not run manual USB/IP operations concurrently with the hook. + +Use `sunshine-usbip status` to inspect ownership; it needs no sudo. After a successful Shield stream, test controller inputs in-game and Quit App cleanup. USB enumeration alone does not prove controller behavior. + +## Remove + +End the application and detach the owned controller. Remove the preparation commands and Sunshine service drop-in. Remove the modules-load file and sudoers rule with sudo approval. Leave shared USB/IP metadata intact. diff --git a/home/.chezmoidata/packages.yaml b/home/.chezmoidata/packages.yaml index 3e6008b..40e7ce7 100644 --- a/home/.chezmoidata/packages.yaml +++ b/home/.chezmoidata/packages.yaml @@ -708,3 +708,10 @@ packages: brew_cask: true flatpak: com.valvesoftware.Steam rpm_ostree: true + + usbip: + tags: [gaming] + os: linux + desc: "USB/IP device forwarding" + brew: false + rpm_ostree: true diff --git a/home/.chezmoiignore.tmpl b/home/.chezmoiignore.tmpl index 9010e46..a09d509 100644 --- a/home/.chezmoiignore.tmpl +++ b/home/.chezmoiignore.tmpl @@ -35,6 +35,15 @@ {{ if ne .de "hyprland-omarchy" }} # Hyprland+omarchy ecosystem — skip on non-Hyprland systems .config/hypr/ +.config/hyprmoncfg/ +.config/systemd/user/hypr-display-session.service +.config/systemd/user/hyprmoncfgd.service.d/ +.local/bin/hypr-display-session +.local/bin/sunshine-usbip +.config/sunshine-usbip.conf +.config/sunshine/apps.json +.config/sunshine/sunshine.conf +.config/systemd/user/app-dev.lizardbyte.app.Sunshine.service.d/ .config/xkb/ .config/quickshell/ .config/hypr-dock/ diff --git a/home/dot_config/hypr/hyprland.lua b/home/dot_config/hypr/hyprland.lua index b783dc3..02b1c70 100644 --- a/home/dot_config/hypr/hyprland.lua +++ b/home/dot_config/hypr/hyprland.lua @@ -28,3 +28,6 @@ require("default.hypr.toggles") -- Add any other personal Hyprland configuration below. -- o.window("qemu", { workspace = "5" }) require("hypr.windows") + +-- Added by hyprmoncfg: its generated monitor rules load last, so nothing before this can override the applied layout. +do local path = (os.getenv("XDG_CONFIG_HOME") or os.getenv("HOME") .. "/.config") .. "/hypr/hyprmoncfg-monitors.lua"; local file = io.open(path, "r"); if file then file:close(); dofile(path) end end diff --git a/home/dot_config/hypr/monitors.lua b/home/dot_config/hypr/monitors.lua index 2468b79..6dfa887 100644 --- a/home/dot_config/hypr/monitors.lua +++ b/home/dot_config/hypr/monitors.lua @@ -1,9 +1,8 @@ -- See https://wiki.hypr.land/Configuring/Basics/Monitors/ -- List current monitors and supported resolutions with: hyprctl monitors all -- --- The panel rests in SDR and goes sticky-HDR while a tagged game (or gamescope) --- window lives; the mechanics come from the system-installed --- hypr.sticky_hdr Lua module. +-- hyprmoncfg owns the active layout. This fallback also covers first login +-- before its generated configuration exists. -- Kept on their own lines: `omarchy hyprland monitor scaling` rewrites these two -- by regex. GDK_SCALE must stay 1 on this scale-1 5120x2160 panel; stock's 2 @@ -13,8 +12,8 @@ local omarchy_monitor_scale = 1 hl.env("GDK_SCALE", tostring(omarchy_gdk_scale)) -require("hypr.sticky_hdr").setup({ - monitor = { +hl.config({ render = { cm_auto_hdr = 0 } }) +hl.monitor({ output = "", mode = "preferred", position = "auto", @@ -23,7 +22,7 @@ require("hypr.sticky_hdr").setup({ vrr = 2, sdrbrightness = 1.35, -- only acts in HDR mode sdrsaturation = 1.0, - } + cm = "srgb", }) -- NVIDIA VA-API. Not redundant with default/hypr/nvidia.lua: that module gates diff --git a/home/dot_config/hypr/windows.lua b/home/dot_config/hypr/windows.lua index 91dd6f6..713c3ce 100644 --- a/home/dot_config/hypr/windows.lua +++ b/home/dot_config/hypr/windows.lua @@ -28,6 +28,9 @@ app("com.vysp3r.ProtonPlus", 5) -- Titled Steam windows tile; untitled popup menus retain the default float. o.window({ class = "^steam$", title = "negative:^$" }, { tile = true }) +-- Big Picture defaults to fullscreen for local and streamed sessions. +o.window({ class = "^steam$", title = "^Steam Big Picture Mode$" }, { fullscreen = true }) + -- Undo tiling for notification overlays. o.window({ class = "^steam$", title = "^notificationtoasts_\\d+_desktop$" }, { float = true, no_initial_focus = true }) diff --git a/home/dot_config/hyprmoncfg/profiles/desktop.json b/home/dot_config/hyprmoncfg/profiles/desktop.json new file mode 100644 index 0000000..b9530db --- /dev/null +++ b/home/dot_config/hyprmoncfg/profiles/desktop.json @@ -0,0 +1,36 @@ +{ + "name": "desktop", + "outputs": [ + { + "key": "lg electronics|lg ultragear+|502ntpc9m944", + "match_key": "lg electronics|lg ultragear+|502ntpc9m944", + "name": "DP-1", + "description": "LG Electronics LG ULTRAGEAR+ 502NTPC9M944", + "make": "LG Electronics", + "model": "LG ULTRAGEAR+", + "serial": "502NTPC9M944", + "enabled": true, + "mode": "preferred", + "width": 5120, + "height": 2160, + "refresh": 165.058, + "x": 0, + "y": 0, + "scale": 1, + "transform": 0, + "vrr": 2, + "bitdepth": 10, + "cm": "srgb", + "sdr_brightness": 1.35, + "sdr_saturation": 1, + "sdr_min_luminance": 0.2, + "sdr_max_luminance": 80, + "min_luminance": 0 + } + ], + "workspaces": { + "enabled": false, + "persist": false + }, + "exec": "" +} diff --git a/home/dot_config/hyprmoncfg/profiles/gaming.json b/home/dot_config/hyprmoncfg/profiles/gaming.json new file mode 100644 index 0000000..adb20c4 --- /dev/null +++ b/home/dot_config/hyprmoncfg/profiles/gaming.json @@ -0,0 +1,36 @@ +{ + "name": "gaming", + "outputs": [ + { + "key": "lg electronics|lg ultragear+|502ntpc9m944", + "match_key": "lg electronics|lg ultragear+|502ntpc9m944", + "name": "DP-1", + "description": "LG Electronics LG ULTRAGEAR+ 502NTPC9M944", + "make": "LG Electronics", + "model": "LG ULTRAGEAR+", + "serial": "502NTPC9M944", + "enabled": true, + "mode": "preferred", + "width": 5120, + "height": 2160, + "refresh": 165.058, + "x": 0, + "y": 0, + "scale": 1, + "transform": 0, + "vrr": 2, + "bitdepth": 10, + "cm": "hdr", + "sdr_brightness": 1.35, + "sdr_saturation": 1, + "sdr_min_luminance": 0.2, + "sdr_max_luminance": 80, + "min_luminance": 0 + } + ], + "workspaces": { + "enabled": false, + "persist": false + }, + "exec": "" +} diff --git a/home/dot_config/hyprmoncfg/streaming.json b/home/dot_config/hyprmoncfg/streaming.json new file mode 100644 index 0000000..6f0e598 --- /dev/null +++ b/home/dot_config/hyprmoncfg/streaming.json @@ -0,0 +1,36 @@ +{ + "name": "streaming", + "outputs": [ + { + "key": "lg electronics|lg ultragear+|502ntpc9m944", + "match_key": "lg electronics|lg ultragear+|502ntpc9m944", + "name": "DP-1", + "description": "LG Electronics LG ULTRAGEAR+ 502NTPC9M944", + "make": "LG Electronics", + "model": "LG ULTRAGEAR+", + "serial": "502NTPC9M944", + "enabled": true, + "mode": "preferred", + "width": 5120, + "height": 2160, + "refresh": 165.058, + "x": 0, + "y": 0, + "scale": 1, + "transform": 0, + "vrr": 0, + "bitdepth": 10, + "cm": "hdr", + "sdr_brightness": 1.35, + "sdr_saturation": 1, + "sdr_min_luminance": 0.2, + "sdr_max_luminance": 80, + "min_luminance": 0 + } + ], + "workspaces": { + "enabled": false, + "persist": false + }, + "exec": "" +} diff --git a/home/dot_config/nvim/lua/config/remote_clipboard.lua b/home/dot_config/nvim/lua/config/remote_clipboard.lua index b54c54e..f287de6 100644 --- a/home/dot_config/nvim/lua/config/remote_clipboard.lua +++ b/home/dot_config/nvim/lua/config/remote_clipboard.lua @@ -1,9 +1,8 @@ --- Clipboard for sessions whose yanks may need to reach another machine: --- every copy is emitted as OSC 52 (inside tmux this becomes a tmux buffer, --- rebroadcast to every attached client, local or SSH). Paste prefers the --- local Wayland clipboard when one is available, so content copied in other --- apps remains pasteable; without a display, paste is an OSC 52 query that --- tmux (or the terminal) answers. +-- Copies reach the local Wayland clipboard and attached terminals. Reads use +-- Wayland or tmux's buffer, falling back to this Neovim's last copy. Never query +-- the terminal with OSC 52: support for writes does not imply permission to read, +-- and an unanswered query blocks ordinary paste for ten seconds. To insert new +-- text from the host when no readable clipboard exists, use terminal paste. local M = {} local function proc_lines(pid, file) @@ -54,10 +53,20 @@ function M.setup() and vim.fn.executable("wl-copy") == 1 and vim.fn.executable("wl-paste") == 1 + local has_tmux = in_tmux and vim.fn.executable("tmux") == 1 + local last_copy = {} + + local function tmux_export_enabled() + local setting = vim.fn.systemlist({ "tmux", "show-options", "-sv", "set-clipboard" }) + return vim.v.shell_error == 0 and (setting[1] == "on" or setting[1] == "external") + end + local function copy(register) local emit = osc52.copy(register) - return function(lines) + return function(lines, regtype) + last_copy[register] = { vim.deepcopy(lines), regtype } + if has_wayland then local cmd = { "wl-copy", "--sensitive", "--type", "text/plain" } if register == "*" then @@ -67,24 +76,68 @@ function M.setup() end if vim.g.omarchy_remote_clipboard_osc52 ~= false then + if has_tmux and register == "+" then + -- tmux ignores zero-byte loads. Record the current buffer's identity + -- instead: all Neovim instances see empty until a new buffer arrives, + -- without deleting the user's shared tmux clipboard history. + if #lines == 0 or (#lines == 1 and lines[1] == "") then + vim.fn.system({ "tmux", "set-option", "-sF", "@omarchy-nvim-cleared-buffer", "#{buffer_name}" }) + return + end + -- Let tmux emit OSC 52, avoiding its input parser's payload size limit. + -- Explicit -w bypasses set-clipboard, so honor the user's policy first. + local cmd = { "tmux", "load-buffer" } + if tmux_export_enabled() then + cmd[#cmd + 1] = "-w" + end + cmd[#cmd + 1] = "-" + vim.fn.system(cmd, lines) + if vim.v.shell_error == 0 then + return + end + end emit(lines) end end end local function paste(register) - if not has_wayland then - return osc52.paste(register) - end - return function() - local cmd = { "wl-paste", "--no-newline" } - if register == "*" then - cmd[#cmd + 1] = "--primary" + local cmd + -- tmux's buffer is shared between Neovim instances on the server. Over + -- SSH, prefer it to the remote machine's unrelated graphical clipboard. + if has_tmux and register == "+" and (in_ssh or not has_wayland) + and vim.g.omarchy_remote_clipboard_osc52 ~= false then + local buffers = vim.fn.systemlist({ "tmux", "display-message", "-p", + "#{buffer_name}\n#{@omarchy-nvim-cleared-buffer}" }) + if vim.v.shell_error ~= 0 then + return vim.deepcopy(last_copy[register] or { {}, "v" }) + end + if not buffers[1] or buffers[1] == "" or buffers[1] == buffers[2] then + return { {}, "v" } + end + -- Pin the buffer selected above: a concurrent copy must not change + -- which payload we read after checking its identity against the marker. + cmd = { "tmux", "save-buffer", "-b", buffers[1], "-" } + elseif has_wayland then + cmd = { "wl-paste", "--no-newline" } + if register == "*" then + cmd[#cmd + 1] = "--primary" + end + end + + if cmd then + local lines = vim.fn.systemlist(cmd, "", 1) + if vim.v.shell_error == 0 then + -- An empty clipboard is valid; only failed reads use the fallback. + if last_copy[register] and vim.deep_equal(lines, last_copy[register][1]) then + return vim.deepcopy(last_copy[register]) + end + return lines + end end - local lines = vim.fn.systemlist(cmd, "", 1) - return vim.v.shell_error == 0 and lines or {} + return vim.deepcopy(last_copy[register] or { {}, "v" }) end end @@ -94,6 +147,12 @@ function M.setup() paste = { ["+"] = paste("+"), ["*"] = paste("*") }, cache_enabled = 0, } + + -- LazyVim clears this over SSH. Users can opt out before setup or override + -- the option afterward in config/options.lua. + if vim.g.omarchy_remote_clipboard_sync ~= false then + vim.opt.clipboard:append("unnamedplus") + end end return M diff --git a/home/dot_config/omarchy/hooks/pre-refresh-pacman.d/executable_10-custom-repos.sh b/home/dot_config/omarchy/hooks/pre-refresh-pacman.d/executable_10-custom-repos.sh index 527a5f6..3a99dd8 100755 --- a/home/dot_config/omarchy/hooks/pre-refresh-pacman.d/executable_10-custom-repos.sh +++ b/home/dot_config/omarchy/hooks/pre-refresh-pacman.d/executable_10-custom-repos.sh @@ -3,13 +3,13 @@ # Include'd because cachyos-kernel-manager parses pacman.conf with a plain INI # reader and never follows Include lines. # -# ~/.config/pacman/custom-repos-head.conf -> above [core] repos that should win over Arch +# ~/.config/pacman/custom-repos-head.conf -> before all repos repos that should win over Omarchy and Arch # ~/.config/pacman/custom-repos-tail.conf -> end of file repos that must never shadow Arch # # Safe to re-run any time: existing marker blocks are removed and re-spliced # from the current snippets, so editing a snippet + running this applies it. # `--check` only reports: exit 0 if pacman.conf is current, 2 if it would -# change. Never escalates. +# change, 1 if marker blocks need repair. Never escalates. # # `omarchy refresh pacman` overwrites /etc/pacman.conf from the channel # template, runs this hook, then `pacman -Syyuu`. @@ -28,6 +28,11 @@ TAIL=$HOME/.config/pacman/custom-repos-tail.conf tmp=$(mktemp) trap 'rm -f "$tmp"' EXIT awk -v head="$HEAD" -v tail="$TAIL" ' + function reject() { + print "pre-refresh-pacman: malformed repo blocks; repair pacman.conf before retrying" > "/dev/stderr" + failed = 1 + exit 1 + } function emit(f) { out[++n] = "# >>> " f " (inlined by pre-refresh-pacman hook) >>>" while ((getline l < f) > 0) out[++n] = l @@ -35,14 +40,30 @@ awk -v head="$HEAD" -v tail="$TAIL" ' out[++n] = "# <<< " f " <<<" } # drop any previously spliced block (and the blank line we added after/before it) - /^# >>> .* >>>$/ { skip = 1; next } - /^# <<< .* <<<$/ { skip = 0; eat_blank = 1; next } + /^# >>> .* >>>$/ { + if (skip) reject() + block = $0 + sub(/^# >>> /, "", block) + sub(/ \(inlined by pre-refresh-pacman hook\) >>>$/, "", block) + skip = 1 + next + } + /^# <<< .* <<<$/ { + if (!skip || $0 != "# <<< " block " <<<") reject() + skip = 0; eat_blank = 1; next + } + # Omarchy migrations can move the distro section inside our comment markers. + skip && /^[[:space:]]*\[omarchy\][[:space:]]*$/ { reject() } skip { next } eat_blank && /^$/ { eat_blank = 0; next } { eat_blank = 0 } - head && /^\[core\]/ && !done { emit(head); out[++n] = ""; done = 1 } + head && /^[[:space:]]*\[[^]]+\]/ && !/^[[:space:]]*\[options\]/ && !done { + emit(head); out[++n] = ""; done = 1 + } { out[++n] = $0 } END { + if (failed) exit 1 + if (skip) reject() while (n && out[n] == "") n-- # trim trailing blanks so tail block never drifts for (i = 1; i <= n; i++) print out[i] if (tail) { n0 = n; out[++n] = ""; emit(tail); for (i = n0 + 1; i <= n; i++) print out[i] } @@ -51,5 +72,5 @@ awk -v head="$HEAD" -v tail="$TAIL" ' cmp -s "$tmp" "$CONF" && exit 0 (( check )) && exit 2 -echo "pre-refresh-pacman: splicing${HEAD:+ $HEAD above [core]}${TAIL:+ $TAIL at end}" +echo "pre-refresh-pacman: splicing${HEAD:+ $HEAD before all repositories}${TAIL:+ $TAIL at end}" sudo install -m 644 "$tmp" "$CONF" diff --git a/home/dot_config/pacman/custom-repos-head.conf b/home/dot_config/pacman/custom-repos-head.conf index 8b79143..c99e9bd 100644 --- a/home/dot_config/pacman/custom-repos-head.conf +++ b/home/dot_config/pacman/custom-repos-head.conf @@ -1,5 +1,5 @@ -# Third-party repos spliced ABOVE [core] by the omarchy pre-refresh-pacman hook -# so these win over stock Arch. Only put repos here that should shadow Arch. +# Third-party repos spliced before all repositories by the pre-refresh-pacman +# hook so CPU-optimized CachyOS builds win over both Omarchy and stock Arch. # Mirrorlists are system-owned (cachyos-mirrorlist package + rate-mirrors). [cachyos-znver4] diff --git a/home/dot_config/scopebuddy/modes/gamescope.conf b/home/dot_config/scopebuddy/modes/gamescope.conf index db937ff..af96240 100644 --- a/home/dot_config/scopebuddy/modes/gamescope.conf +++ b/home/dot_config/scopebuddy/modes/gamescope.conf @@ -11,22 +11,18 @@ SCB_NOSCOPE=0 # crisp under any upscaling, and is color-managed for HDR. It reads the outer # MANGOHUD_CONFIG from ../scb.conf. SCB_GAMESCOPE_ARGS="-w 5120 -h 2160 -W 5120 -H 2160 -r 165 -f --hdr-enabled --hdr-itm-enable --fullscreen --adaptive-sync --mangoapp" +if (( scb_stream_width > 0 )); then + SCB_GAMESCOPE_ARGS="-w $scb_stream_width -h $scb_stream_height -W $scb_stream_width -H $scb_stream_height -r $scb_stream_fps -f --hdr-enabled --mangoapp" +fi # Gamescope supplies the game's Wayland display and HDR itself (WSI layer); # the direct-to-compositor switches from native mode must not leak through. unset PROTON_ENABLE_WAYLAND unset DXVK_HDR -# HDR prewarm rides SCB_PRE_COMMAND: scb evals it right before exec'ing -# gamescope, so every title here -- all launched with --hdr-enabled above -- -# finds the monitor already in HDR when gamescope probes the compositor's -# color state at startup. A command= prefix would run INSIDE gamescope, after -# the probe. -# -# No SDR recipe exists for this mode: even with SCB_PRE_COMMAND unset and -# --hdr-enabled stripped, sticky_hdr's class match latches onto any gamescope -# window and holds HDR for the session. Run SDR titles in native mode. -SCB_PRE_COMMAND="$hdr_prewarm" +# Configure the physical output before Gamescope probes HDR capabilities. +# ScopeBuddy otherwise logs a pre-command failure and launches anyway. +SCB_PRE_COMMAND="$display_start || exit 1" # The in-game layer stays loaded purely as the fps limiter: mangoapp cannot # cap (out of process) and gamescope's --framerate-limit only does diff --git a/home/dot_config/scopebuddy/modes/native.conf b/home/dot_config/scopebuddy/modes/native.conf index 728ff26..f644f23 100644 --- a/home/dot_config/scopebuddy/modes/native.conf +++ b/home/dot_config/scopebuddy/modes/native.conf @@ -13,9 +13,6 @@ SCB_GAMESCOPE_ARGS="" export PROTON_ENABLE_WAYLAND=1 export DXVK_HDR=1 -# HDR prewarm rides the wrapper chain: scb only evals SCB_PRE_COMMAND on the -# gamescope path (verified scopebuddy 1.5.0 -- the SCB_NOSCOPE=1 branch never -# calls it). The noscope path is a plain `eval "$command"`, so the `;` -# compound is safe here. +# Native mode has no pre-command hook; configure the output before launching. unset SCB_PRE_COMMAND -command="$hdr_prewarm; $wrapped_command" +command="$display_start && $wrapped_command" diff --git a/home/dot_config/scopebuddy/scb.conf b/home/dot_config/scopebuddy/scb.conf index 8cc51bf..abd6f62 100644 --- a/home/dot_config/scopebuddy/scb.conf +++ b/home/dot_config/scopebuddy/scb.conf @@ -24,6 +24,15 @@ export HYPR_WORKSPACE=6 # in-process layer can enforce it, so gamescope mode re-issues it to the game # in a private MANGOHUD_CONFIG (see modes/gamescope.conf). fps_cap=161 +scb_stream_width=0 scb_stream_height=0 scb_stream_fps=0 +display_start=: +if [[ -n ${HYPRLAND_INSTANCE_SIGNATURE:-} ]] && command -v hypr-display-session >/dev/null; then + read -r scb_stream_width scb_stream_height scb_stream_fps < <(hypr-display-session stream-info) + printf -v display_game_id '%q' "${SCB_APPID:-null}" + display_start="hypr-display-session game-start --game $display_game_id" + SCB_POST_COMMAND="hypr-display-session game-stop --game $display_game_id; ${SCB_POST_COMMAND:-:}" +fi +if (( scb_stream_width > 0 )); then fps_cap=$scb_stream_fps; fi export MANGOHUD_CONFIG=preset=2,fps_limit=$fps_cap export PROTON_LOG_DIR="$HOME/.cache/proton-logs" @@ -50,11 +59,6 @@ export PROTON_LOG_DIR="$HOME/.cache/proton-logs" game_command="$command" wrapped_command="game-performance gamemoderun $game_command" -# The one HDR prewarm both modes invoke; each mode file decides the delivery -# (SCB_PRE_COMMAND vs a command= prefix). Hold/revert mechanics: -# hypr/sticky_hdr.lua. -hdr_prewarm="hyprctl eval \"require('hypr.sticky_hdr').prewarm()\"" - # --------------------------------------------------------------------------- # Modes. Each modes/.conf writes the COMPLETE conflicting set -- sets # its own side, unsets the other's -- so `mode X` is a full state transition: @@ -69,15 +73,13 @@ mode native # # # ~/.config/scopebuddy/AppID/.conf # unset PROTON_ENABLE_WAYLAND # this title breaks on Wine-Wayland -# unset DXVK_HDR # SDR title -- and ALSO rebuild: -# command="$wrapped_command" # native mode baked the HDR prewarm -# # into $command; drop it too +# unset DXVK_HDR # keep game rendering SDR; monitor stays HDR # unset PROTON_DLSS_UPGRADE # keep the shipped DLSS DLL # export MANGOHUD=0 # or DISABLE_MANGOHUD=1; no overlay # # The wrapper chain is rebuilt from the pristine pieces, not pattern-stripped. -# After the mode line, e.g. drop the wrappers but keep the prewarm: -# command="$hdr_prewarm; $game_command" # native titles +# After the mode line, e.g. drop performance wrappers but keep display setup: +# command="$display_start && $game_command" # native titles # command="$game_command" # gamescope titles (no `;` allowed) # # Never set anywhere. Verified zero occurrences in the installed runner diff --git a/home/dot_config/sunshine-usbip.conf b/home/dot_config/sunshine-usbip.conf new file mode 100644 index 0000000..bb401fc --- /dev/null +++ b/home/dot_config/sunshine-usbip.conf @@ -0,0 +1,4 @@ +# Match Sunshine's paired hostname exactly; unlisted clients skip USB. +declare -A controllers=( + [nvidia-shield-pro.host.tysmith.app]=28de:1304 +) diff --git a/home/dot_config/sunshine/apps.json b/home/dot_config/sunshine/apps.json new file mode 100644 index 0000000..55c42e1 --- /dev/null +++ b/home/dot_config/sunshine/apps.json @@ -0,0 +1,42 @@ +{ + "apps": [ + { + "image-path": "desktop.png", + "name": "Desktop" + }, + { + "image-path": "desktop.png", + "name": "Low Res Desktop", + "prep-cmd": [ + { + "do": "xrandr --output HDMI-1 --mode 1920x1080", + "undo": "xrandr --output HDMI-1 --mode 1920x1200" + } + ] + }, + { + "detached": [ + "setsid steam steam://open/bigpicture" + ], + "image-path": "steam.png", + "name": "Steam Big Picture (Adaptive + Shield USB)", + "prep-cmd": [ + { + "do": "$(HOME)/.local/bin/hypr-display-session stream-start", + "undo": "$(HOME)/.local/bin/hypr-display-session stream-stop" + }, + { + "do": "$(HOME)/.local/bin/sunshine-usbip start", + "undo": "$(HOME)/.local/bin/sunshine-usbip stop" + }, + { + "do": "", + "undo": "setsid steam steam://close/bigpicture" + } + ] + } + ], + "env": { + "PATH": "$(PATH):$(HOME)/.local/bin" + } +} \ No newline at end of file diff --git a/home/dot_config/sunshine/sunshine.conf b/home/dot_config/sunshine/sunshine.conf new file mode 100644 index 0000000..5820f54 --- /dev/null +++ b/home/dot_config/sunshine/sunshine.conf @@ -0,0 +1,3 @@ +av1_mode = 1 +capture = kms +encoder = nvenc diff --git a/home/dot_config/systemd/user/app-dev.lizardbyte.app.Sunshine.service.d/cleanup.conf b/home/dot_config/systemd/user/app-dev.lizardbyte.app.Sunshine.service.d/cleanup.conf new file mode 100644 index 0000000..e925541 --- /dev/null +++ b/home/dot_config/systemd/user/app-dev.lizardbyte.app.Sunshine.service.d/cleanup.conf @@ -0,0 +1,6 @@ +[Unit] +After=hyprmoncfgd.service + +[Service] +ExecStopPost=-%h/.local/bin/sunshine-usbip stop +ExecStopPost=-%h/.local/bin/hypr-display-session stream-stop diff --git a/home/dot_config/systemd/user/hypr-display-session.service b/home/dot_config/systemd/user/hypr-display-session.service new file mode 100644 index 0000000..4927bf1 --- /dev/null +++ b/home/dot_config/systemd/user/hypr-display-session.service @@ -0,0 +1,15 @@ +[Unit] +Description=Reset Hyprland display activity for a new graphical session +Wants=hyprmoncfgd.service +After=graphical-session.target hyprmoncfgd.service +PartOf=graphical-session.target +ConditionEnvironment=HYPRLAND_INSTANCE_SIGNATURE + +[Service] +Type=oneshot +ExecStart=%h/.local/bin/hypr-display-session reset +RemainAfterExit=yes +Slice=session.slice + +[Install] +WantedBy=graphical-session.target diff --git a/home/dot_config/systemd/user/hyprmoncfgd.service.d/session.conf b/home/dot_config/systemd/user/hyprmoncfgd.service.d/session.conf new file mode 100644 index 0000000..85d608c --- /dev/null +++ b/home/dot_config/systemd/user/hyprmoncfgd.service.d/session.conf @@ -0,0 +1,10 @@ +[Unit] +PartOf=graphical-session.target +ConditionEnvironment=HYPRLAND_INSTANCE_SIGNATURE + +[Service] +Slice=session.slice + +[Install] +WantedBy= +WantedBy=graphical-session.target diff --git a/home/dot_local/bin/executable_hypr-display-session b/home/dot_local/bin/executable_hypr-display-session new file mode 100755 index 0000000..0e7da7f --- /dev/null +++ b/home/dot_local/bin/executable_hypr-display-session @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Select saved profiles; streaming takes precedence over a running game. +set -euo pipefail +umask 077 +positive_integer='^[1-9][0-9]*$' +positive_number='^[1-9][0-9]*([.][0-9]+)?$' +fail() { printf 'hypr-display-session: %s\n' "$*" >&2; exit 1; } +[[ ${XDG_RUNTIME_DIR:-} == /* && -d $XDG_RUNTIME_DIR && -O $XDG_RUNTIME_DIR ]] || fail 'missing owned runtime directory' +[[ ${HYPRLAND_INSTANCE_SIGNATURE:-} =~ ^[a-zA-Z0-9_-]+$ ]] || fail 'missing compositor identity' +state=$XDG_RUNTIME_DIR/hypr-display-session-$HYPRLAND_INSTANCE_SIGNATURE +[[ ! -L $state ]] || fail 'invalid state directory' +mkdir -p "$state" +chmod 700 "$state" +exec 9>"$state/lock" +flock -w 30 9 || fail 'another profile change is busy' +game=$(cat "$state/game" 2>/dev/null || :) +stream=$(cat "$state/stream" 2>/dev/null || :) +profile() { + if [[ -n $stream ]]; then printf streaming + elif [[ -n $game ]]; then printf gaming + else printf desktop; fi +} +prepare_stream_profile() { + local template=${XDG_CONFIG_HOME:-$HOME/.config}/hyprmoncfg/streaming.json + local stream_profile=$state/profiles/streaming.json + mkdir -p "${stream_profile%/*}" + # Generate outside the saved profile library so hardware matching stays independent. + if ! hyprctl -j monitors all | jq -e --slurpfile template "$template" \ + --argjson width "$width" --argjson height "$height" --argjson fps "$fps" ' + $template[0] as $p | + ($p.outputs[0]) as $output | + [.[] | select(.description == $output.description)] | + if length != 1 then error("streaming output is missing or ambiguous") else .[0] end as $monitor | + [$monitor.availableModes[] | + . as $mode | + capture("^(?[0-9]+)x(?[0-9]+)@(?[0-9.]+)Hz$") | + select((.w|tonumber) == $width and (.h|tonumber) == $height) | + (.hz|tonumber) as $hz | + (($hz - $fps)|fabs) as $difference | + select($difference <= 0.5) | + {mode:$mode,hz:$hz,difference:$difference}] | + sort_by(.difference) | + if length == 0 then error("requested stream mode is not advertised by the monitor") else .[0] end as $selected | + $p | .name = "streaming" | + .outputs[0] += {name:$monitor.name, mode:$selected.mode, width:$width, height:$height, refresh:$selected.hz} + ' > "$stream_profile.tmp"; then + rm -f "$stream_profile.tmp" + fail 'could not prepare streaming profile' + fi + mv "$stream_profile.tmp" "$stream_profile" +} +apply_profile() { + # Starting the user service does not guarantee its socket is ready yet. + local attempt + for ((attempt=0; attempt<50; attempt++)); do + [[ -S $XDG_RUNTIME_DIR/hyprmoncfgd.sock ]] && break + sleep 0.1 + done + local arguments=(apply "$1" --confirm-timeout 0) + if [[ $1 == streaming ]]; then arguments+=(--config-dir "$state"); fi + [[ -S $XDG_RUNTIME_DIR/hyprmoncfgd.sock ]] && hyprmoncfg "${arguments[@]}" +} +before=$(profile) +old_game=$game +old_stream=$stream +command=${1:-status} +shift "$(( $# > 0 ? 1 : 0 ))" +case $command in + game-start|game-stop) + [[ $# == 2 && $1 == --game && -n $2 && $2 != *$'\n'* ]] || fail 'expected --game APPID' + if [[ $command == game-start ]]; then + [[ -z $game || $game == "$2" ]] || fail 'another game is already active' + game=$2 + elif [[ $game == "$2" ]]; then game=; fi + ;; + stream-start) + if [[ $# == 0 ]]; then + width=${SUNSHINE_CLIENT_WIDTH:-} + height=${SUNSHINE_CLIENT_HEIGHT:-} + fps=${SUNSHINE_CLIENT_FPS:-} + else + [[ $# == 6 && $1 == --width && $3 == --height && $5 == --fps ]] || fail 'expected --width WIDTH --height HEIGHT --fps FPS' + width=$2 height=$4 fps=$6 + fi + [[ $width =~ $positive_integer && $height =~ $positive_integer && $fps =~ $positive_number ]] || fail 'missing or invalid stream dimensions/FPS' + prepare_stream_profile + stream="$width $height $fps" + ;; + stream-stop) [[ $# == 0 ]] || fail 'unexpected arguments'; stream= ;; + reset) [[ $# == 0 ]] || fail 'unexpected arguments'; game=; stream= ;; + stream-info) + printf '%s\n' "${stream:-0 0 0}" + exit ;; + status) profile; printf '\n'; exit ;; + *) fail 'expected game-start, game-stop, stream-start, stream-stop, reset, status, or stream-info' ;; +esac +save() { + printf '%s' "$game" > "$state/game" + printf '%s' "$stream" > "$state/stream" +} +save +after=$(profile) +if [[ $before != "$after" || $stream != "$old_stream" || $command == reset ]]; then + if ! apply_profile "$after"; then + case $command in + game-start|stream-start) game=$old_game; stream=$old_stream; save ;; + esac + fail 'profile application failed; run hypr-display-session reset to recover' + fi +fi diff --git a/home/dot_local/bin/executable_sunshine-usbip b/home/dot_local/bin/executable_sunshine-usbip new file mode 100755 index 0000000..7c7471a --- /dev/null +++ b/home/dot_local/bin/executable_sunshine-usbip @@ -0,0 +1,109 @@ +#!/usr/bin/bash +# Sunshine application hooks; only USB/IP attach and detach run as root. +set -euo pipefail +export LC_ALL=C +umask 077 +fail() { printf 'sunshine-usbip: %s\n' "$*" >&2; exit 1; } +[[ $# == 1 && $1 =~ ^(start|stop|status)$ ]] || { echo 'usage: sunshine-usbip start|stop|status' >&2; exit 2; } +action=$1 +best_effort() { + local result=$? + if (( result != 0 )) && [[ $action != status ]]; then + echo 'sunshine-usbip: USB unavailable; streaming can continue' >&2 + exit 0 + fi +} +trap best_effort EXIT +[[ ${XDG_RUNTIME_DIR:-} == /* && -d $XDG_RUNTIME_DIR && -O $XDG_RUNTIME_DIR ]] || fail 'missing owned runtime directory' +state=$XDG_RUNTIME_DIR/sunshine-usbip +[[ ! -L $state ]] || fail 'invalid state directory' +mkdir -p "$state" +chmod 700 "$state" +exec 9>"$state/lock" +flock -w 15 9 || fail 'another USB operation is busy' +shopt -s nullglob + +occupied_ports() { + local file + for file in /sys/devices/platform/vhci_hcd.*/status*; do + awk 'NR>1 && $2 ~ /^[0-9]+$/ && $3 != 4 {print $2+0}' "$file" + done +} +attachments() { + local file port status local_bus vendor product devnum + for file in /sys/devices/platform/vhci_hcd.*/status*; do + while read -r _ port status _ _ _ local_bus _; do + [[ $port =~ ^[0-9]+$ && $status != 4 && $local_bus != 0-0 ]] || continue + vendor=$(cat "/sys/bus/usb/devices/$local_bus/idVendor" 2>/dev/null) || continue + product=$(cat "/sys/bus/usb/devices/$local_bus/idProduct" 2>/dev/null) || continue + [[ $vendor:$product == "$1" ]] || continue + devnum=$(cat "/sys/bus/usb/devices/$local_bus/devnum" 2>/dev/null) || continue + printf '%s %s %s\n' "$((10#$port))" "$local_bus" "$devnum" + done < "$file" + done +} +finish_attach() { + local row port + local matches=() + while read -r row; do + [[ -n $row ]] || continue + port=${row%% *} + grep -Fxq -- "$port" "$state/before" || matches+=("$row") + done < <(attachments "$device") + if (( ${#matches[@]} == 1 )); then + printf '%s %s %s\n' "$remote" "$device" "${matches[0]}" > "$state/owned" + rm -f "$state/pending" "$state/before" + elif [[ -n $(comm -13 <(sort -u "$state/before") <(occupied_ports | sort -u)) ]]; then + fail 'attach has not enumerated; inspect usbip port and pending state' + else + rm -f "$state/pending" "$state/before" + fi +} + +if [[ -f $state/owned ]]; then + read -r owned_remote owned_device port local_bus devnum < "$state/owned" + if ! grep -Fxq -- "$port $local_bus $devnum" < <(attachments "$owned_device"); then + rm -f "$state/owned" + fi +fi +[[ ! -f $state/pending ]] || fail 'interrupted attach; inspect usbip port and pending state before retrying' +case $action in + start) + # shellcheck source=home/dot_config/sunshine-usbip.conf + source "${XDG_CONFIG_HOME:-$HOME/.config}/sunshine-usbip.conf" + remote=${SUNSHINE_CLIENT_NAME:-} + [[ $remote =~ ^[a-zA-Z0-9][a-zA-Z0-9._:-]*$ ]] || exit 0 + device=${controllers[$remote]:-} + [[ -n $device ]] || exit 0 + [[ $device =~ ^[0-9a-f]{4}:[0-9a-f]{4}$ ]] || fail 'invalid configured USB device ID' + if [[ -f $state/owned ]]; then + [[ $remote == "$owned_remote" && $device == "$owned_device" ]] || fail 'end the existing USB session before changing its target' + exit 0 + fi + [[ -n $(compgen -G '/sys/devices/platform/vhci_hcd.*/status*') ]] || fail 'vhci_hcd is not loaded; run chezmoi USB setup' + [[ -z $(attachments "$device") ]] || fail 'matching device is attached manually; leaving it alone' + exports=$(timeout 5s /usr/bin/usbip list -r "$remote") || fail 'USB server unavailable' + mapfile -t buses < <(awk -v device="$device" '$0 ~ "\\(" device "\\)[[:space:]]*$" {bus=$1; sub(/:$/,"",bus); if (bus ~ /^[0-9]+-[0-9]+([.][0-9]+)*$/) print bus}' <<< "$exports") + (( ${#buses[@]} == 1 )) || fail 'expected exactly one matching exported controller' + occupied_ports > "$state/before" + printf '%s %s %s\n' "$remote" "$device" "${buses[0]}" > "$state/pending" + # A failed command can still have attached the device in the kernel. + timeout 8s sudo /usr/bin/usbip attach -r "$remote" -b "${buses[0]}" || : + for ((attempt=0; attempt<20; attempt++)); do + [[ -n $(attachments "$device") ]] && break + sleep 0.1 + done + finish_attach + [[ -f $state/owned ]] || fail 'controller did not attach' + printf 'sunshine-usbip: attached %s at %s\n' "$device" "$remote" + ;; + stop) + [[ -f $state/owned ]] || exit 0 + timeout 8s sudo /usr/bin/usbip detach -p "$port" + rm -f "$state/owned" + printf 'sunshine-usbip: detached port %s\n' "$port" + ;; + status) + if [[ -f $state/owned ]]; then cat "$state/owned"; else printf 'detached\n'; fi + ;; +esac diff --git a/home/run_onchange_20-configure-pacman-repos.sh.tmpl b/home/run_onchange_20-configure-pacman-repos.sh.tmpl index 8a79f06..50463df 100644 --- a/home/run_onchange_20-configure-pacman-repos.sh.tmpl +++ b/home/run_onchange_20-configure-pacman-repos.sh.tmpl @@ -15,6 +15,10 @@ HOOK="${HOME}/.config/omarchy/hooks/pre-refresh-pacman.d/10-custom-repos.sh" # Nothing to do if pacman.conf already matches the snippets (no sudo needed). if "$HOOK" --check; then exit 0 +else + status=$? + # Only 2 means a safe update is available; malformed blocks need repair. + [[ $status == 2 ]] || exit "$status" fi # The hook needs one sudo (install over /etc/pacman.conf). Escalate when diff --git a/home/run_onchange_51-configure-hypr-display-session.sh.tmpl b/home/run_onchange_51-configure-hypr-display-session.sh.tmpl new file mode 100644 index 0000000..380f671 --- /dev/null +++ b/home/run_onchange_51-configure-hypr-display-session.sh.tmpl @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +{{ if ne .de "hyprland-omarchy" -}} +exit 0 +{{ else -}} +set -euo pipefail +# {{ include "dot_config/systemd/user/hypr-display-session.service" | sha256sum }} +# {{ include "dot_config/systemd/user/hyprmoncfgd.service.d/session.conf" | sha256sum }} +command -v hyprmoncfgd >/dev/null +systemctl --user daemon-reload +# Replace the packaged default.target enablement with graphical-session.target. +systemctl --user reenable hyprmoncfgd.service hypr-display-session.service +{{ end -}} diff --git a/home/run_onchange_52-configure-sunshine-usbip.sh.tmpl b/home/run_onchange_52-configure-sunshine-usbip.sh.tmpl new file mode 100644 index 0000000..8ad3b63 --- /dev/null +++ b/home/run_onchange_52-configure-sunshine-usbip.sh.tmpl @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +{{ if ne .de "hyprland-omarchy" -}} +exit 0 +{{ else -}} +set -euo pipefail +# {{ include "dot_config/systemd/user/app-dev.lizardbyte.app.Sunshine.service.d/cleanup.conf" | sha256sum }} +command -v usbip >/dev/null +staging=$(mktemp -d) +trap 'rm -rf "$staging"' EXIT +printf 'vhci_hcd\n' > "$staging/modules.conf" +printf '%s ALL=(root) NOPASSWD: /usr/bin/usbip attach *, /usr/bin/usbip detach *\n' \ + '{{ .chezmoi.username }}' > "$staging/sudoers" +# Cache the last successful definitions: sudoers.d may not be readable as a user. +digest=$(cat "$staging/modules.conf" "$staging/sudoers" | sha256sum | cut -d ' ' -f 1) +record=${XDG_STATE_HOME:-$HOME/.local/state}/sunshine-usbip/setup.sha256 +if [[ $(cat "$record" 2>/dev/null || :) != "$digest" ]] || \ + ! cmp -s "$staging/modules.conf" /etc/modules-load.d/sunshine-usbip.conf; then + sudo /usr/bin/bash -s -- "$staging" <<'ROOT' +set -euo pipefail +/usr/bin/visudo -c -f "$1/sudoers" +/usr/bin/install -d -o root -g root -m 755 /etc/modules-load.d +/usr/bin/install -d -o root -g root -m 750 /etc/sudoers.d +/usr/bin/install -o root -g root -m 440 "$1/sudoers" /etc/sudoers.d/sunshine-usbip +/usr/bin/install -o root -g root -m 644 "$1/modules.conf" /etc/modules-load.d/sunshine-usbip.conf +/usr/bin/modprobe vhci_hcd +ROOT + mkdir -p "${record%/*}" + printf '%s\n' "$digest" > "$record" +fi +systemctl --user daemon-reload +{{ end -}} diff --git a/tests/chezmoi/test_source_root.py b/tests/chezmoi/test_source_root.py index db5c4eb..e689c08 100644 --- a/tests/chezmoi/test_source_root.py +++ b/tests/chezmoi/test_source_root.py @@ -92,6 +92,20 @@ def test_all_profiles_render_and_scripts_find_libraries(self): self.assertIn('.agents/AGENTS.md', targets) self.assertIn('.claude/CLAUDE.md', targets) self.assertIn('.codex/AGENTS.md', targets) + for name in ('.local/bin/hypr-display-session', + '.local/bin/sunshine-usbip', + '.config/sunshine-usbip.conf', + '.config/sunshine/apps.json', + '.config/sunshine/sunshine.conf', + '.config/systemd/user/app-dev.lizardbyte.app.Sunshine.service.d/cleanup.conf', + '.config/systemd/user/hypr-display-session.service', + '.config/systemd/user/hyprmoncfgd.service.d/session.conf', + '.config/hyprmoncfg/profiles/desktop.json', + '.config/hyprmoncfg/profiles/gaming.json', + '.config/hyprmoncfg/streaming.json'): + self.assertEqual(name in targets, profile == 'arch-desktop') + self.assertNotIn('.config/hypr/hyprmoncfg-monitors.lua', targets) + self.assertNotIn('.config/sunshine/sunshine_state.json', targets) self.assertFalse({'scripts', 'tests', 'README.md', 'home'} & targets.keys()) for entry in targets.values(): if entry['type'] != 'script': diff --git a/tests/display-session/test_daemon.py b/tests/display-session/test_daemon.py new file mode 100644 index 0000000..e76ed7c --- /dev/null +++ b/tests/display-session/test_daemon.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Run the installed daemon against a disposable compositor, never the desktop.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import time +import unittest + +from test_session import MODES, ROOT + + +HYPRCTL = r'''#!/usr/bin/env python3 +import json, os, re, sys +from pathlib import Path +root = Path(os.environ['FIXTURE_ROOT']) +args = sys.argv[1:] +if args[:1] == ['--instance']: args = args[2:] +with (root/'calls.log').open('a') as f: f.write(json.dumps(args)+'\n') +state = root/'monitors.json' +if args[:2] == ['-j', 'version']: + print('{"version":"0.56.2"}') +elif args[:2] == ['-j', 'monitors']: + print(state.read_text()) +elif args[:2] in [['-j', 'workspacerules'], ['-j', 'workspaces']]: + print('[]') +elif args[:1] == ['reload']: + target = root/'home/.config/hypr/hyprmoncfg-monitors.lua' + if target.exists(): + text = target.read_text() + monitors = json.loads(state.read_text()) + for block in re.findall(r'hl.monitor\(\{(.*?)\}\)', text, re.S): + values = {} + for k, v in re.findall(r'^\s*(\w+) = (.*?),?$', block, re.M): + v = v.rstrip(',') + try: values[k] = json.loads(v) + except ValueError: values[k] = v + for m in monitors: + if values.get('output') not in [m['name'], 'desc:'+m['description']]: continue + mode = values.get('mode', 'preferred') + if mode == 'preferred': mode = m['availableModes'][0] + match = re.fullmatch(r'(\d+)x(\d+)@([\d.]+)(?:Hz)?', mode) + if match: + m['width'],m['height'],m['refreshRate']=int(match[1]),int(match[2]),float(match[3]) + pos = re.fullmatch(r'(-?\d+)x(-?\d+)', values.get('position', '0x0')) + m.update(scale=values.get('scale',1),transform=values.get('transform',0), + x=int(pos[1]),y=int(pos[2]),vrr=bool(values.get('vrr',0)), + colorManagementPreset=values.get('cm','srgb'), + currentFormat='XBGR2101010' if values.get('bitdepth',8)==10 else 'XRGB8888', + sdrBrightness=values.get('sdrbrightness',1),sdrSaturation=values.get('sdrsaturation',1)) + state.write_text(json.dumps(monitors)) + (root/'loaded.lua').write_text(text) + print('ok') +elif args[:1] == ['eval']: + probe = re.search(r'_G\.(hyprmoncfg_\w+)', ' '.join(args)) + loaded = (root/'loaded.lua').read_text() if (root/'loaded.lua').exists() else '' + if probe and probe[1] not in loaded: + print('error: generated configuration did not run');sys.exit(1) + print('ok') +else: + print('unsupported fake compositor call: '+repr(args),file=sys.stderr);sys.exit(1) +''' + + +@unittest.skipUnless(shutil.which('hyprmoncfgd'), 'hyprmoncfgd is not installed') +class InstalledDaemonTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='display-daemon-') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.home = self.root/'home' + self.config = self.home/'.config' + runtime = self.root/'runtime' + binary = self.root/'bin' + for p in [runtime,binary,self.config/'hypr',self.config/'hyprmoncfg/profiles']: + p.mkdir(parents=True, exist_ok=True) + (self.config/'hypr/hyprland.lua').write_text('-- isolated compositor fixture\n') + for source in (ROOT/'home/dot_config/hyprmoncfg/profiles').glob('*.json'): + shutil.copy2(source,self.config/'hyprmoncfg/profiles'/source.name) + shutil.copy2(ROOT/'home/dot_config/hyprmoncfg/streaming.json',self.config/'hyprmoncfg/streaming.json') + self.monitor = dict(id=0,name='DP-1',description='LG Electronics LG ULTRAGEAR+ 502NTPC9M944', + make='LG Electronics',model='LG ULTRAGEAR+',serial='502NTPC9M944',width=5120, + height=2160,refreshRate=165.06,x=0,y=0,scale=1,transform=0,dpmsStatus=True, + focused=True,disabled=False,vrr=False,currentFormat='XBGR2101010', + colorManagementPreset='srgb',sdrBrightness=1.35,sdrSaturation=1, + availableModes=MODES,mirrorOf='none') + (self.root/'monitors.json').write_text(json.dumps([self.monitor])) + (binary/'hyprctl').write_text(HYPRCTL) + (binary/'hyprctl').chmod(0o755) + # Intercept service integration too; never contact the real user manager. + for command in ['systemctl','busctl','gdbus']: + (binary/command).write_text('#!/bin/sh\nexit 1\n') + (binary/command).chmod(0o755) + self.env = os.environ | dict(HOME=str(self.home),XDG_CONFIG_HOME=str(self.config), + XDG_RUNTIME_DIR=str(runtime),HYPRLAND_INSTANCE_SIGNATURE='fixture', + FIXTURE_ROOT=str(self.root),DBUS_SESSION_BUS_ADDRESS='unix:path='+str(runtime/'missing-bus'), + OMARCHY_PATH=str(self.root/'no-omarchy'),PATH=str(binary)+':/usr/bin:/bin') + self.log = (self.root/'daemon.log').open('w') + self.addCleanup(self.log.close) + self.process = subprocess.Popen(['hyprmoncfgd','--config-dir',str(self.config/'hyprmoncfg'), + '--hypr-config',str(self.config/'hypr/hyprland.lua'), + '--monitors-conf',str(self.config/'hypr/hyprmoncfg-monitors.lua'), + '--poll-interval','1h','--lid-poll-interval','1h'],env=self.env, + stdout=self.log,stderr=subprocess.STDOUT) + self.addCleanup(self.stop_daemon) + path=runtime/'hyprmoncfgd.sock' + for _ in range(100): + if path.exists():break + if self.process.poll() is not None:break + time.sleep(0.05) + if not path.exists(): + self.fail('daemon did not start: '+(self.root/'daemon.log').read_text()) + + def stop_daemon(self): + self.process.terminate() + try:self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill();self.process.wait() + + def run_command(self,*args,instance='fixture',ok=True): + env=self.env|dict(HYPRLAND_INSTANCE_SIGNATURE=instance) + result=subprocess.run(['bash',str(ROOT/'home/dot_local/bin/executable_hypr-display-session'),*args], + env=env,capture_output=True,text=True,timeout=35) + if ok and result.returncode: + self.fail(result.stderr+'\n'+(self.root/'daemon.log').read_text()[-5000:]) + return result + + def current(self): + return json.loads((self.root/'monitors.json').read_text())[0] + + def test_installed_daemon_full_lifecycle_and_fresh_session(self): + self.run_command('reset') + self.run_command('stream-start','--width','3840','--height','2160','--fps','60') + self.assertEqual(self.current()['width'],3840) + self.assertEqual(self.current()['colorManagementPreset'],'hdr') + self.run_command('game-start','--game','553850') + self.run_command('stream-stop') + self.assertEqual(self.current()['width'],5120) + self.assertEqual(self.current()['colorManagementPreset'],'hdr') + self.run_command('stream-start','--width','1920','--height','1080','--fps','60') + self.run_command('game-stop','--game','553850') + self.assertEqual(self.current()['width'],1920) + self.run_command('stream-stop') + self.assertEqual(self.current()['colorManagementPreset'],'srgb') + self.run_command('game-start','--game','3010850') + self.run_command('reset',instance='new-compositor') + self.assertEqual(self.current()['colorManagementPreset'],'srgb') + + +if __name__=='__main__':unittest.main() diff --git a/tests/display-session/test_scopebuddy.py b/tests/display-session/test_scopebuddy.py new file mode 100644 index 0000000..92c5a14 --- /dev/null +++ b/tests/display-session/test_scopebuddy.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Exercise the installed ScopeBuddy without starting a game or compositor.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from test_session import ROOT + + +@unittest.skipUnless(shutil.which('scb'), 'ScopeBuddy is not installed') +class ScopeBuddyTest(unittest.TestCase): + def run_game(self, mode='native', stream=False, fail_start=False, exit_code=0, hyprland=True): + with tempfile.TemporaryDirectory(prefix='scopebuddy-hooks-') as temporary: + root=Path(temporary) + config=root/'.config/scopebuddy' + shutil.copytree(ROOT/'home/dot_config/scopebuddy',config) + (config/'AppID/1234.conf').write_text('mode '+mode+'\n') + binary=root/'bin';binary.mkdir() + (binary/'hypr-display-session').write_text('''#!/usr/bin/env python3 +import json,os,sys +from pathlib import Path +if sys.argv[1]=='stream-info': + print('1920 1080 60' if os.environ['STREAM']=='1' else '0 0 0') +else: + with Path(os.environ['EVENT_LOG']).open('a') as f:f.write(sys.argv[1]+'\\n') + if sys.argv[1]=='game-start' and os.environ['FAIL_START']=='1':sys.exit(1) +''') + for name in ['game-performance','gamemoderun']: + (binary/name).write_text('#!/bin/sh\nexec "$@"\n') + (binary/'gamescope').write_text('''#!/bin/bash +printf 'gamescope %s\\n' "$*" >> "$EVENT_LOG" +while [[ $1 != -- ]]; do shift; done +shift +exec "$@" +''') + (binary/'fake-game').write_text('''#!/bin/sh +printf 'game %s %s\\n' "$HYPR_WORKSPACE" "$MANGOHUD_CONFIG" >> "$EVENT_LOG" +exit "$GAME_EXIT" +''') + for p in binary.iterdir():p.chmod(0o755) + log=root/'events' + env=os.environ|dict(HOME=str(root),XDG_CONFIG_HOME=str(root/'.config'), + PATH=str(binary)+':/usr/bin:/bin',SCB_APPID='1234', + STREAM=str(int(stream)),FAIL_START=str(int(fail_start)),GAME_EXIT=str(exit_code), + EVENT_LOG=str(log),LD_PRELOAD='',SCB_NESTEDFIX='0', + HYPRLAND_INSTANCE_SIGNATURE='test' if hyprland else '') + result=subprocess.run([shutil.which('scb'),'--',str(binary/'fake-game')], + env=env,capture_output=True,text=True,timeout=10) + return result,log.read_text().splitlines() if log.exists() else [] + + def test_native_hooks_wrap_game_and_preserve_exit_status(self): + result,events=self.run_game(exit_code=7) + self.assertEqual(result.returncode,7,result.stdout+result.stderr) + self.assertEqual(events,['game-start','game 6 preset=2,fps_limit=161','game-stop']) + + def test_gamescope_hook_precedes_compositor(self): + result,events=self.run_game(mode='gamescope') + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + self.assertEqual(events[0],'game-start') + self.assertTrue(events[1].startswith('gamescope ')) + self.assertEqual(events[-1],'game-stop') + + def test_stream_dimensions_and_frame_cap_reach_gamescope(self): + result,events=self.run_game(mode='gamescope',stream=True) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + self.assertIn('-w 1920 -h 1080 -W 1920 -H 1080 -r 60',events[1]) + self.assertIn('game 6 no_display,fps_limit=60',events) + + def test_other_desktops_launch_without_display_hooks(self): + for mode in ['native','gamescope']: + with self.subTest(mode=mode): + result,events=self.run_game(mode=mode,hyprland=False) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + self.assertTrue(any(x.startswith('game 6 ') for x in events)) + self.assertNotIn('game-start',events) + self.assertNotIn('game-stop',events) + + def test_failed_display_start_prevents_both_launch_paths(self): + for mode in ['native','gamescope']: + with self.subTest(mode=mode): + result,events=self.run_game(mode=mode,fail_start=True) + self.assertNotEqual(result.returncode,0) + self.assertEqual(events,['game-start','game-stop']) + + +if __name__=='__main__':unittest.main() diff --git a/tests/display-session/test_session.py b/tests/display-session/test_session.py new file mode 100644 index 0000000..bd7a295 --- /dev/null +++ b/tests/display-session/test_session.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Check lifecycle markers and CLI selection without touching the desktop.""" +import json +import shutil +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / 'home/dot_local/bin/executable_hypr-display-session' +MODES = ['5120x2160@165.06Hz', '3840x2160@59.94Hz', '1920x1080@60.00Hz'] + + +class SessionTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + binary = self.root/'bin' + binary.mkdir() + command = binary/'hyprmoncfg' + command.write_text('''#!/bin/sh +if [ -f "$XDG_RUNTIME_DIR/fail" ]; then exit 1; fi +printf '%s\\n' "$*" >> "$XDG_RUNTIME_DIR/calls" +''') + command.chmod(0o755) + self.sock = socket.socket(socket.AF_UNIX) + self.sock.bind(str(self.root/'hyprmoncfgd.sock')) + self.addCleanup(self.sock.close) + config = self.root/'config' + (config/'hyprmoncfg').mkdir(parents=True) + shutil.copy2(ROOT/'home/dot_config/hyprmoncfg/streaming.json',config/'hyprmoncfg/streaming.json') + monitor = dict(name='DP-1',description='LG Electronics LG ULTRAGEAR+ 502NTPC9M944',availableModes=MODES) + (self.root/'monitors.json').write_text(json.dumps([monitor])) + (binary/'hyprctl').write_text('#!/bin/sh\ncat "$XDG_RUNTIME_DIR/monitors.json"\n') + (binary/'hyprctl').chmod(0o755) + self.env = os.environ | dict(XDG_CONFIG_HOME=str(config), XDG_RUNTIME_DIR=str(self.root), + HYPRLAND_INSTANCE_SIGNATURE='test', PATH=str(binary)+':/usr/bin:/bin') + + def run_command(self, *args, ok=True, instance='test'): + result = subprocess.run(['bash', str(SCRIPT), *args], capture_output=True, + text=True, env=self.env|dict(HYPRLAND_INSTANCE_SIGNATURE=instance), timeout=10) + if ok: + self.assertEqual(result.returncode, 0, result.stderr) + return result + + def applied(self): + path = self.root/'calls' + return [line.split()[1] for line in path.read_text().splitlines()] if path.exists() else [] + + def test_non_steam_game_identifiers(self): + for identifier in ['ubisoft/123','heroic/a-game','null']: + with self.subTest(identifier=identifier): + self.run_command('game-start','--game',identifier) + self.assertEqual(self.run_command('status').stdout.strip(),'gaming') + self.run_command('game-stop','--game',identifier) + self.assertEqual(self.run_command('status').stdout.strip(),'desktop') + + def test_stream_game_exit_stream_exit(self): + self.run_command('stream-start','--width','3840','--height','2160','--fps','60') + self.run_command('game-start','--game','553850') + self.run_command('game-stop','--game','553850') + self.run_command('stream-stop') + self.assertEqual(self.applied(), ['streaming','desktop']) + + def test_stream_ends_then_phone_reconnects_while_game_runs(self): + self.run_command('stream-start','--width','3840','--height','2160','--fps','60') + self.run_command('game-start','--game','3010850') + self.run_command('stream-stop') + self.run_command('stream-start','--width','1920','--height','1080','--fps','60') + self.run_command('game-stop','--game','3010850') + self.run_command('stream-stop') + self.assertEqual(self.applied(), ['streaming','gaming','streaming','desktop']) + + def test_new_compositor_reset_ignores_old_activity(self): + self.run_command('game-start','--game','553850') + self.run_command('stream-start','--width','1920','--height','1080','--fps','60') + self.run_command('reset',instance='new') + self.assertEqual(self.applied()[-1], 'desktop') + self.assertEqual(self.run_command('stream-info',instance='new').stdout.strip(), '0 0 0') + + def test_duplicate_start_and_unrelated_stop_do_not_reapply(self): + self.run_command('game-start','--game','553850') + self.run_command('game-start','--game','553850') + self.run_command('game-stop','--game','3010850') + self.assertEqual(self.applied(), ['gaming']) + + def test_unsupported_stream_mode_leaves_game_active(self): + self.run_command('game-start','--game','553850') + result=self.run_command('stream-start','--width','3840','--height','2160','--fps','120',ok=False) + self.assertNotEqual(result.returncode,0) + self.assertEqual(self.applied(), ['gaming']) + self.run_command('game-stop','--game','553850') + self.assertEqual(self.applied()[-1], 'desktop') + + def test_sunshine_environment_and_fractional_refresh(self): + self.env.update(SUNSHINE_CLIENT_WIDTH='3840',SUNSHINE_CLIENT_HEIGHT='2160',SUNSHINE_CLIENT_FPS='60') + self.run_command('stream-start') + profile=json.loads((self.root/'hypr-display-session-test/profiles/streaming.json').read_text()) + self.assertEqual(profile['outputs'][0]['mode'],'3840x2160@59.94Hz') + self.assertEqual(self.run_command('stream-info').stdout.strip(),'3840 2160 60') + + def test_arbitrary_advertised_resolution_and_active_stream_change(self): + monitors=json.loads((self.root/'monitors.json').read_text()) + monitors[0]['availableModes'].append('2560x1440@120.00Hz') + (self.root/'monitors.json').write_text(json.dumps(monitors)) + self.run_command('stream-start','--width','1920','--height','1080','--fps','60') + self.run_command('stream-start','--width','2560','--height','1440','--fps','120') + self.assertEqual(self.applied(),['streaming','streaming']) + self.assertEqual(self.run_command('stream-info').stdout.strip(),'2560 1440 120') + + def test_failed_stream_change_preserves_previous_dimensions(self): + self.run_command('stream-start','--width','1920','--height','1080','--fps','60') + (self.root/'fail').touch() + self.assertNotEqual(self.run_command('stream-start','--width','3840','--height','2160','--fps','60',ok=False).returncode,0) + self.assertEqual(self.run_command('stream-info').stdout.strip(),'1920 1080 60') + + def test_missing_daemon_socket_does_not_apply_directly(self): + (self.root/'hyprmoncfgd.sock').unlink() + result=self.run_command('game-start','--game','553850',ok=False) + self.assertNotEqual(result.returncode,0) + self.assertEqual(self.applied(),[]) + self.assertEqual(self.run_command('status').stdout.strip(),'desktop') + + def test_failed_start_restores_previous_markers(self): + (self.root/'fail').touch() + self.assertNotEqual(self.run_command('game-start','--game','553850',ok=False).returncode,0) + (self.root/'fail').unlink() + self.run_command('game-start','--game','3010850') + self.assertEqual(self.applied(), ['gaming']) + + def test_stop_records_cleanup_even_when_apply_fails(self): + self.run_command('game-start','--game','553850') + (self.root/'fail').touch() + self.assertNotEqual(self.run_command('game-stop','--game','553850',ok=False).returncode,0) + (self.root/'fail').unlink() + self.run_command('reset') + self.assertEqual(self.applied()[-1], 'desktop') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/display-session/test_units.py b/tests/display-session/test_units.py new file mode 100644 index 0000000..868baca --- /dev/null +++ b/tests/display-session/test_units.py @@ -0,0 +1,40 @@ +"""Check the real systemd parser and dependency graph without starting units.""" +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from test_session import ROOT, SCRIPT + + +@unittest.skipUnless(shutil.which('systemd-analyze') and Path('/usr/lib/systemd/user/hyprmoncfgd.service').exists(), + 'installed systemd and hyprmoncfg unit required') +class UnitsTest(unittest.TestCase): + def test_graphical_session_units_verify(self): + with tempfile.TemporaryDirectory(prefix='hypr-session-units-') as tmp: + root = Path(tmp) + runtime = root/'runtime' + runtime.mkdir(mode=0o700) + source = ROOT/'home/dot_config/systemd/user' + unit = root/'hypr-display-session.service' + unit.write_text((source/unit.name).read_text().replace( + '%h/.local/bin/hypr-display-session', str(SCRIPT))) + daemon = root/'hyprmoncfgd.service' + shutil.copy2('/usr/lib/systemd/user/hyprmoncfgd.service', daemon) + shutil.copytree(source/'hyprmoncfgd.service.d', root/'hyprmoncfgd.service.d') + sunshine = root/'app-dev.lizardbyte.app.Sunshine.service' + shutil.copy2(Path('/usr/lib/systemd/user')/sunshine.name,sunshine) + dropin = root/(sunshine.name+'.d') + dropin.mkdir() + (dropin/'cleanup.conf').write_text((source/(sunshine.name+'.d/cleanup.conf')).read_text().replace( + '%h/.local/bin/sunshine-usbip',str(ROOT/'home/dot_local/bin/executable_sunshine-usbip')).replace( + '%h/.local/bin/hypr-display-session',str(SCRIPT))) + result = subprocess.run(['systemd-analyze','--user','verify','--man=no',str(unit),str(daemon),str(sunshine)], + env=os.environ | {'XDG_RUNTIME_DIR':str(runtime)},capture_output=True,text=True,timeout=15) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/hypr/config.lua b/tests/hypr/config.lua index e8aa165..a090fa3 100644 --- a/tests/hypr/config.lua +++ b/tests/hypr/config.lua @@ -14,16 +14,18 @@ dofile('home/dot_config/hypr/autostart.lua') dofile('home/dot_config/hypr/windows.lua') assert(#commands == 0, 'config reload must not launch apps') assert(package.loaded['hypr.apps'] == nil, 'removed registry must not be required') -assert(#rules == 14, 'preserve all live rules and exceptions') +assert(#rules == 15, 'preserve all live rules and exceptions') for _, rule in ipairs(rules) do assert(rule[2].workspace ~= '6 silent', 'games must use ScopeBuddy placement only') end assert(rules[1][1] == 'com\\.slack\\.Slack' and rules[1][2].workspace == '4 silent') assert(rules[4][1].title == 'negative:^Steam Input On-screen Keyboard$') -assert(rules[9][2].no_initial_focus and rules[9][2].float) -assert(rules[14][2].workspace == 'special:hidden silent') +assert(rules[8][1].class == '^steam$' and rules[8][1].title == '^Steam Big Picture Mode$') +assert(rules[8][2].fullscreen == true) +assert(rules[10][2].no_initial_focus and rules[10][2].float) +assert(rules[15][2].workspace == 'special:hidden silent') for _, start in ipairs(starts) do start() end -assert(#commands == 8, 'two services and six tray apps; Steam stays disabled') +assert(#commands == 8, 'two service commands and six tray apps; Steam stays disabled') local units = {} for i = 3, #commands do local unit = commands[i]:match('%-u (app%-hypr%-autostart%-%d+%.scope)') diff --git a/tests/pacman/test_custom_repos.py b/tests/pacman/test_custom_repos.py new file mode 100644 index 0000000..95e9f2b --- /dev/null +++ b/tests/pacman/test_custom_repos.py @@ -0,0 +1,80 @@ +"""Run the repository hook against disposable pacman configurations.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +HOOK = Path(__file__).resolve().parents[2] / 'home/dot_config/omarchy/hooks/pre-refresh-pacman.d/executable_10-custom-repos.sh' + + +class CustomReposTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + config = self.root / '.config/pacman' + config.mkdir(parents=True) + self.head = config / 'custom-repos-head.conf' + self.tail = config / 'custom-repos-tail.conf' + self.head.write_text('[cachyos]\nServer = custom\n') + self.tail.write_text('[warpdotdev]\nServer = warp\n') + self.conf = self.root / 'pacman.conf' + self.script = self.root / 'hook' + self.script.write_text(HOOK.read_text().replace('CONF=/etc/pacman.conf', f'CONF={self.conf}')) + # Keep real install writes inside the fixture, without privilege elevation. + sudo = self.root / 'sudo' + sudo.write_text('#!/bin/bash\nexec "$@"\n') + sudo.chmod(0o755) + self.env = dict(os.environ, HOME=str(self.root), PATH=f'{self.root}:/usr/bin:/bin') + + def run_hook(self, *args): + return subprocess.run(['bash', str(self.script), *args], env=self.env, capture_output=True, text=True) + + def start(self, path): + return f'# >>> {path} (inlined by pre-refresh-pacman hook) >>>\n' + + def end(self, path): + return f'# <<< {path} <<<\n' + + def test_repositories_survive_and_second_run_is_unchanged(self): + self.conf.write_text('[options]\nColor\n[omarchy]\nServer = distro\n[core]\nServer = arch\n') + self.assertEqual(self.run_hook('--check').returncode, 2) + self.assertEqual(self.run_hook().returncode, 0) + first = self.conf.read_text() + self.assertEqual([x for x in first.splitlines() if x.startswith('[')], + ['[options]', '[cachyos]', '[omarchy]', '[core]', '[warpdotdev]']) + self.assertEqual(self.run_hook('--check').returncode, 0) + self.assertEqual(self.run_hook().returncode, 0) + self.assertEqual(self.conf.read_text(), first) + + def test_head_precedes_repositories_with_or_without_omarchy(self): + for repositories in ['[core]\n[omarchy]\n', '[core]\n', ' [omarchy] \n[core]\n']: + with self.subTest(repositories=repositories): + self.conf.write_text('[options]\nColor\n' + repositories) + self.assertEqual(self.run_hook().returncode, 0) + sections = [line.strip() for line in self.conf.read_text().splitlines() + if line.strip().startswith('[')] + self.assertEqual(sections[0:2], ['[options]', '[cachyos]']) + self.assertEqual(self.run_hook('--check').returncode, 0) + + def test_rejects_damaged_blocks_without_writing(self): + cases = { + 'migration_reordered': self.start(self.head) + '[omarchy]\nServer = distro\n' + self.start(self.tail) + self.head.read_text() + self.end(self.head) + '[core]\n' + self.tail.read_text() + self.end(self.tail), + 'nested': self.start(self.head) + self.start(self.tail) + self.end(self.head) + self.end(self.tail), + 'unclosed': self.start(self.head) + self.head.read_text(), + 'unopened': self.end(self.head) + '[core]\n', + 'mismatched': self.start(self.head) + self.head.read_text() + self.end(self.tail), + 'distro_inside': self.start(self.head) + '[omarchy]\nServer = distro\n' + self.end(self.head), + } + for name, content in cases.items(): + for args in [(), ('--check',)]: + with self.subTest(case=name, args=args): + self.conf.write_text(content) + result = self.run_hook(*args) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(self.conf.read_text(), content) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/sunshine-usbip/test_hooks.py b/tests/sunshine-usbip/test_hooks.py new file mode 100644 index 0000000..c3cbe41 --- /dev/null +++ b/tests/sunshine-usbip/test_hooks.py @@ -0,0 +1,142 @@ +"""Exercise USB ownership with a fake kernel and USB/IP backend.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT=Path(__file__).resolve().parents[2] + +class USBTest(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup) + self.root=Path(self.temp.name) + for name in ['bin','platform/vhci_hcd.0','usb']:(self.root/name).mkdir(parents=True) + (self.root/'config').write_text("declare -A controllers=([nvidia-shield-pro.host.tysmith.app]=28de:1304)\n") + self.status=self.root/'platform/vhci_hcd.0/status' + self.status.write_text('hub port sta spd dev sock local_busid\n') + (self.root/'bin/sudo').write_text('#!/bin/sh\nexec "$@"\n') + (self.root/'bin/usbip').write_text('''#!/usr/bin/env python3 +import os,sys +from pathlib import Path +r=Path(os.environ['FIXTURE']);a=sys.argv[1:] +device=os.environ.get('DEVICE_ID','28de:1304');vendor,product=device.split(':') +with (r/'calls').open('a') as f:f.write(' '.join(a)+'\\n') +if a[0]=='list': + if (r/'unavailable').exists():sys.exit(1) + if (r/'no-controller').exists():sys.exit(0) + if a[2]!=os.environ.get('SERVER_HOST','nvidia-shield-pro.host.tysmith.app'):sys.exit(1) + print(f' 1-2: Controller ({device})') + if (r/'ambiguous').exists():print(' 1-3: Valve : Steam Controller (28de:1304)') +elif a[0]=='attach': + p=r/'usb/12-1';p.mkdir(exist_ok=True) + for name,value in [('idVendor',vendor),('idProduct',product),('devnum','9')]: (p/name).write_text(value) + with (r/'platform/vhci_hcd.0/status').open('a') as f:f.write('hs 0 6 3 0 0 12-1\\n') + if (r/'attach-error').exists():sys.exit(1) +elif a[0]=='detach': + (r/'platform/vhci_hcd.0/status').write_text('hub port sta spd dev sock local_busid\\n') +else:sys.exit(1) +''') + for p in (self.root/'bin').iterdir():p.chmod(0o755) + source=(ROOT/'home/dot_local/bin/executable_sunshine-usbip').read_text() + for old,new in [('/sys/devices/platform',str(self.root/'platform')),('/sys/bus/usb/devices',str(self.root/'usb')),('/usr/bin/usbip',str(self.root/'bin/usbip'))]:source=source.replace(old,new) + self.script=self.root/'helper';self.script.write_text(source) + self.env=os.environ|{'FIXTURE':str(self.root),'XDG_RUNTIME_DIR':str(self.root), + 'XDG_CONFIG_HOME':str(self.root),'SUNSHINE_CLIENT_NAME':'nvidia-shield-pro.host.tysmith.app', + 'PATH':str(self.root/'bin')+':/usr/bin:/bin'} + (self.root/'config').rename(self.root/'sunshine-usbip.conf') + + def run_hook(self,*args,ok=True): + result=subprocess.run(['bash',str(self.script),*args],env=self.env,capture_output=True,text=True,timeout=15) + if ok:self.assertEqual(result.returncode,0,result.stderr) + return result + + def calls(self): + p=self.root/'calls' + return p.read_text().splitlines() if p.exists() else [] + + def test_attach_duplicate_and_detach(self): + self.run_hook('start');self.run_hook('start');self.run_hook('stop');self.run_hook('stop') + self.assertEqual(sum(x.startswith('attach ') for x in self.calls()),1) + self.assertEqual(sum(x.startswith('detach ') for x in self.calls()),1) + + def test_ambiguous_export_does_not_attach(self): + (self.root/'ambiguous').touch() + self.assertIn('sunshine-usbip:',self.run_hook('start').stderr) + self.assertFalse(any(x.startswith('attach ') for x in self.calls())) + + def test_manual_attachment_is_not_claimed(self): + self.run_hook('start');(self.root/'sunshine-usbip/owned').unlink() + self.assertIn('sunshine-usbip:',self.run_hook('start').stderr) + self.run_hook('stop') + self.assertFalse(any(x.startswith('detach ') for x in self.calls())) + + def test_reused_port_is_not_detached(self): + self.run_hook('start');(self.root/'usb/12-1/devnum').write_text('10') + self.run_hook('stop') + self.assertFalse(any(x.startswith('detach ') for x in self.calls())) + + def test_attach_error_after_kernel_success_is_owned(self): + (self.root/'attach-error').touch() + self.run_hook('start');self.run_hook('stop') + self.assertTrue(any(x.startswith('detach ') for x in self.calls())) + + def test_interrupted_attachment_is_not_adopted_on_stop(self): + self.run_hook('start') + (self.root/'sunshine-usbip/owned').unlink() + (self.root/'sunshine-usbip/before').write_text('') + (self.root/'sunshine-usbip/pending').write_text('1-2\n') + self.assertIn('interrupted',self.run_hook('stop').stderr) + self.assertFalse(any(x.startswith('detach ') for x in self.calls())) + + def test_arguments_cannot_select_arbitrary_devices(self): + self.assertNotEqual(self.run_hook('start','other-host',ok=False).returncode,0) + + + def test_unlisted_phone_skips_discovery(self): + self.env['SUNSHINE_CLIENT_NAME']='Pixel-10-Pro-Fold.host.tysmith.app' + self.run_hook('start') + self.assertEqual(self.calls(),[]) + + def test_listed_client_without_controller_can_stream(self): + (self.root/'no-controller').touch() + self.assertIn('expected exactly one',self.run_hook('start').stderr) + self.run_hook('stop') + self.assertFalse(any(x.startswith(('attach ', 'detach ')) for x in self.calls())) + self.assertEqual(self.run_hook('status').stdout.strip(),'detached') + + def test_listed_client_without_server_can_stream(self): + (self.root/'unavailable').touch() + self.assertIn('USB server unavailable',self.run_hook('start').stderr) + self.run_hook('stop') + self.assertFalse(any(x.startswith(('attach ', 'detach ')) for x in self.calls())) + + def test_client_hostname_is_used_directly(self): + self.env['SUNSHINE_CLIENT_NAME']='another-device.example.test' + self.env['SERVER_HOST']='another-device.example.test' + self.env['DEVICE_ID']='054c:0ce6' + with (self.root/'sunshine-usbip.conf').open('a') as f: + f.write('controllers[another-device.example.test]=054c:0ce6\n') + self.run_hook('start') + self.assertIn('list -r another-device.example.test',self.calls()) + self.assertIn('attach -r another-device.example.test -b 1-2',self.calls()) + self.assertIn('054c:0ce6',(self.root/'sunshine-usbip/owned').read_text()) + + def test_empty_client_skips_discovery(self): + self.env.pop('SUNSHINE_CLIENT_NAME') + self.run_hook('start') + self.assertEqual(self.calls(),[]) + + def test_friendly_label_without_hostname_does_not_attach(self): + self.env['SUNSHINE_CLIENT_NAME']='My Shield' + self.run_hook('start') + self.assertEqual(self.calls(),[]) + + def test_stop_uses_original_identity_after_config_change(self): + self.run_hook('start') + (self.root/'sunshine-usbip.conf').write_text("declare -A controllers=([different-device.example.test]=1234:5678)\n") + self.env['SUNSHINE_CLIENT_NAME']='different-device.example.test' + self.run_hook('stop') + self.assertTrue(any(x.startswith('detach ') for x in self.calls())) + +if __name__=='__main__':unittest.main() diff --git a/tests/sunshine-usbip/test_setup.py b/tests/sunshine-usbip/test_setup.py new file mode 100644 index 0000000..0764be0 --- /dev/null +++ b/tests/sunshine-usbip/test_setup.py @@ -0,0 +1,78 @@ +"""Check setup authentication decisions without running privileged commands.""" +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT=Path(__file__).resolve().parents[2] + +@unittest.skipUnless(shutil.which('chezmoi'),'chezmoi required') +class SetupTest(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup) + self.root=Path(self.temp.name) + binary=self.root/'bin';binary.mkdir() + (self.root/'config.toml').touch() + (binary/'usbip').write_text('#!/bin/sh\nexit 0\n') + (binary/'systemctl').write_text('#!/bin/sh\nexit 0\n') + (binary/'sudo').write_text('''#!/usr/bin/env python3 +import os,sys,shutil +from pathlib import Path +r=Path(os.environ['FIXTURE']) +with (r/'calls').open('a') as f:f.write('sudo\\n') +sys.stdin.read() +if (r/'fail').exists():sys.exit(1) +staging=Path(sys.argv[-1]);target=r/'modules' +target.parent.mkdir(exist_ok=True) +shutil.copy2(staging/'modules.conf',target) +shutil.copy2(staging/'sudoers',r/'installed-sudoers') +''') + for p in binary.iterdir():p.chmod(0o755) + self.env=os.environ|{'HOME':str(self.root),'XDG_STATE_HOME':str(self.root/'state'), + 'FIXTURE':str(self.root),'PATH':str(binary)+':/usr/bin:/bin'} + + def run_setup(self,username='testuser'): + template=(ROOT/'home/run_onchange_52-configure-sunshine-usbip.sh.tmpl').read_text() + rendered=subprocess.run(['chezmoi','--source',str(ROOT),'--config',str(self.root/'config.toml'), + '--override-data','{"de":"hyprland-omarchy","chezmoi":{"username":"'+username+'"}}', + 'execute-template'],input=template,capture_output=True,text=True,check=True).stdout + rendered=rendered.replace('/etc/modules-load.d/sunshine-usbip.conf',str(self.root/'modules')) + return subprocess.run(['bash'],input=rendered,env=self.env,capture_output=True,text=True) + + def count(self): + p=self.root/'calls' + return len(p.read_text().splitlines()) if p.exists() else 0 + + def test_unchanged_setup_and_user_config_changes_do_not_authenticate(self): + first=self.run_setup();self.assertEqual(first.returncode,0,first.stderr) + self.assertEqual(self.count(),1) + (self.root/'sunshine-usbip.conf').write_text('declare -A controllers=([other.example]=1234:5678)\n') + again=self.run_setup();self.assertEqual(again.returncode,0,again.stderr) + self.assertEqual(self.count(),1) + policy=(self.root/'installed-sudoers').read_text() + self.assertIn('/usr/bin/usbip attach *, /usr/bin/usbip detach *',policy) + self.assertNotIn('modprobe',policy) + self.assertEqual((self.root/'modules').read_text(),'vhci_hcd\n') + + def test_changed_privileged_definitions_authenticate(self): + self.assertEqual(self.run_setup().returncode,0) + self.assertEqual(self.run_setup(username='otheruser').returncode,0) + self.assertEqual(self.count(),2) + + def test_failed_installation_is_not_cached(self): + (self.root/'fail').touch() + self.assertNotEqual(self.run_setup().returncode,0) + self.assertFalse((self.root/'state/sunshine-usbip/setup.sha256').exists()) + (self.root/'fail').unlink() + self.assertEqual(self.run_setup().returncode,0) + self.assertEqual(self.count(),2) + + def test_removed_module_file_requires_repair(self): + self.assertEqual(self.run_setup().returncode,0) + (self.root/'modules').unlink() + self.assertEqual(self.run_setup().returncode,0) + self.assertEqual(self.count(),2) + +if __name__=='__main__':unittest.main()