Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 53 additions & 0 deletions docs/display-session.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 49 additions & 0 deletions docs/sunshine-usbip.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions home/.chezmoidata/packages.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions home/.chezmoiignore.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
3 changes: 3 additions & 0 deletions home/dot_config/hypr/hyprland.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 5 additions & 6 deletions home/dot_config/hypr/monitors.lua
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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",
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions home/dot_config/hypr/windows.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down
36 changes: 36 additions & 0 deletions home/dot_config/hyprmoncfg/profiles/desktop.json
Original file line number Diff line number Diff line change
@@ -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": ""
}
36 changes: 36 additions & 0 deletions home/dot_config/hyprmoncfg/profiles/gaming.json
Original file line number Diff line number Diff line change
@@ -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": ""
}
36 changes: 36 additions & 0 deletions home/dot_config/hyprmoncfg/streaming.json
Original file line number Diff line number Diff line change
@@ -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": ""
}
Loading
Loading