commit a4aa721a590ea73929a97d2e5c232764ded39244 Author: Oscar Grey Date: Sat Aug 15 16:46:58 2026 +1200 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fdf96e --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +__pycache__/ +*.py[cod] +*$py.class + +venv/ +.venv/ +env/ +.env/ +ENV/ +env.bak/ +venv.bak/ + +.vscode/ +.idea/ +*.swp +*.swo +*~ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2278711 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Sunshine Snake Glue +(Yes it's a silly name) + +## What's This? +A tool that changes the resolution of a Sunshine host to an approximation of the client Moonlight resolution. + +## What is Sunshine and Moonlight? +[Sunshine](https://github.com/lizardbyte/sunshine) + +[Moonlight](https://moonlight-stream.org/) + +**TLDR**: Sunshine is an ultra low latency display streaming server, and Moonlight is a client for it. I get around 1ms~ when using it for my tablet as a second display. + +## What Does This Support? +Linux, specifically only GNOME's Wayland session at the moment ([and in a very hacky state...](https://git.coolbea.nz/oscarg/sunshine-snake-glue/src/branch/main/pkg/wayland_display_manager/gnome/__init__.py)). + +### Support list: +| Compositor | Support | +| :--- | ---: | +| GNOME | ⚠️ (Work in progress / Hacky) | +| KDE Plasma | ❌ | +| Sway | ❌ | +| Hyprland | ❌ | + +### X11? +I don't think I'll ever extend this to support X11. Sunshine has options to interact with an X server, Wayland compositors tend to be much stricter, and this is just a middle layer to make that communication pleasant. Sunshine's documentation [even has instructions for X11](https://docs.lizardbyte.dev/projects/sunshine/master/md_docs_2app__examples.html#x11). Yes, it does mention GNOME's Wayland session, but it makes the bold assumption that the client has an exact match with a resolution/refresh rate the compositor offers. + +## Dependencies +A version of GNOME that supports `gdctl`. That's all! Presumably you have `python3`, right? + +## Usage +### Arguments +- `set` changes the resolution of the given display adapter +- `create` generates a script, filling placeholders documented below. (It aligns the application with the matched resolution, not Sunshine's internal resolution info) +- `reset` sets the resolution of the provided adapter back to what it was before connecting. +### Common Usage +`[path]` represents the full path of sunshine snake glue for brevity. +`[display adapter]` represents the adapter identifier that Sunshine internally uses. +`[string with placeholders]` is self explanatory. +#### Do Instructions +Do: `python3 [path] set [display adapter]` + +Do: `python3 [path] create [display adapter] [string with placeholders]` +(This saves a file in `~/.sunshine-snake-glue` called `script.sh`, and sets its permissions accordingly.) + +#### Undo Instructions +Undo: `python3 [path] reset [display adapter]` + +#### Possible Placeholders +- `{res_x}` (The horizontal resolution.) +- `{res_y}` (The vertical resolution.) +- `{rate}` (The refresh rate.) + +### Example Usage +#### Do: +``` +python3 /home/agrey/Development/sunshine-snake-glue set HDMI-2 +python3 /home/agrey/Development/sunshine-snake-glue create HDMI-2 "gamescope -f -W {res_x} -H {res_y} -r {rate} -e -- steam -bigpicture -steamos3" +``` +#### Command: +``` +/home/agrey/.sunshine-snake-glue/script.sh +``` +#### Undo: +``` +python3 /home/agrey/Development/sunshine-snake-glue reset HDMI-2 +``` \ No newline at end of file diff --git a/__main__.py b/__main__.py new file mode 100755 index 0000000..23f279a --- /dev/null +++ b/__main__.py @@ -0,0 +1,87 @@ +from pkg import sunshine +from pkg.sunshine.structs import SunshineEnvironmentVariables + +from pkg import wayland_display_manager +from pkg.wayland_display_manager.structs import Modeline + +from app.vars import Arguments, Instructions, CONFIG_DIR, GEN_SCRIPT_PATH, PREVIOUS_MODE_PATH +from app.errors import Errors + +import sys +import os +import stat + +def calculate_closest_mode(sunshine_env: SunshineEnvironmentVariables, supported_modes: list[Modeline]) -> Modeline: + def _distance(mode: Modeline): + return( + abs(mode.width - sunshine_env.specs.width) *2 + + abs(mode.height - sunshine_env.specs.height) *2 + + abs(mode.refresh_rate - sunshine_env.specs.fps) + ) + + return min(supported_modes, key = _distance) + +def main(): + try: + os.mkdir(CONFIG_DIR) + except FileExistsError: + pass + + sunshine_env = sunshine.load_env_variables() + display_manager = wayland_display_manager.get_display() + + if len(sys.argv) -1 < Arguments.CONNECTOR: + raise SyntaxError(Errors.INSUFFICIENT_INSTRUCTIONS) + + match sys.argv[Arguments.INSTRUCTION]: + case Instructions.SET: + display_manager.save_current_mode(PREVIOUS_MODE_PATH) + + for i in display_manager.get_modes(): + if i.connector == sys.argv[Arguments.CONNECTOR]: + display_modes = i.modes + + if display_modes: + modeline = calculate_closest_mode(sunshine_env, display_modes) + + display_manager.set_mode( + modeline = modeline, + requested_connector = sys.argv[Arguments.CONNECTOR] + ) + else: + raise ValueError(Errors.CONNECTOR_INVALID) + + case Instructions.RESET: + modeline = display_manager.read_saved_mode(PREVIOUS_MODE_PATH) + + display_manager.set_mode(modeline, sys.argv[Arguments.CONNECTOR]) + + case Instructions.CREATE: + if len(sys.argv) -1 < Arguments.TEMPLATE: + raise SyntaxError(Errors.INSUFFICIENT_INSTRUCTIONS) + + for i in display_manager.get_modes(): + if i.connector == sys.argv[Arguments.CONNECTOR]: + display_modes = i.modes + + modeline = calculate_closest_mode(sunshine_env, display_modes) + + with open(GEN_SCRIPT_PATH, "w") as file: + file.write("#!/bin/bash\n" + sys.argv[Arguments.TEMPLATE].format( + res_x = modeline.width, + res_y = modeline.height, + rate = modeline.refresh_rate + )) + + os.chmod( + GEN_SCRIPT_PATH, + stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | + stat.S_IRGRP | stat.S_IXGRP | + stat.S_IROTH | stat.S_IXOTH + ) + + case _: + raise ValueError(Errors.CONNECTOR_UNSPECIFIED) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..b1f47be --- /dev/null +++ b/app/errors.py @@ -0,0 +1,6 @@ +from enum import StrEnum + +class Errors(StrEnum): + INSUFFICIENT_INSTRUCTIONS = "Not enough supplied instructions! Usage: {instruction} {connector}" + CONNECTOR_INVALID = "The requested connector is invalid!" + CONNECTOR_UNSPECIFIED = "No requested connector!" \ No newline at end of file diff --git a/app/vars.py b/app/vars.py new file mode 100644 index 0000000..5d5207c --- /dev/null +++ b/app/vars.py @@ -0,0 +1,23 @@ +from enum import IntEnum, StrEnum +import pathlib + +class Config(StrEnum): + CONFIG_DIR_NAME = ".sunshine-snake-glue" + PREVIOUS_MODE_FILE = "previous_mode" + GEN_SCRIPT_FILE = "script.sh" + +CONFIG_DIR = pathlib.Path.home() / Config.CONFIG_DIR_NAME + +PREVIOUS_MODE_PATH = CONFIG_DIR / Config.PREVIOUS_MODE_FILE +GEN_SCRIPT_PATH = CONFIG_DIR / Config.GEN_SCRIPT_FILE + +class Arguments(IntEnum): + SCRIPT_NAME = 0 + INSTRUCTION = 1 + CONNECTOR = 2 + TEMPLATE = 3 + +class Instructions(StrEnum): + SET = "set" + RESET = "reset" + CREATE = "create" \ No newline at end of file diff --git a/pkg/sunshine/__init__.py b/pkg/sunshine/__init__.py new file mode 100644 index 0000000..d38bb9c --- /dev/null +++ b/pkg/sunshine/__init__.py @@ -0,0 +1,29 @@ +from .env_key import SunshineEnvKey as EnvKey +from .enums import SunshineAudioConfig +from .structs import SunshineAudioConfig, SunshineEnvironmentVariables, SunshineClientSpecs, SunshineClientInfo, SunshineClientOptions + +from os import environ + +def load_env_variables() -> SunshineEnvironmentVariables: + def ld_str(var: EnvKey): return environ.get(var.requested_key, var.default) + def ld_int(var: EnvKey): return int(ld_str(var)) + def ld_bool(var: EnvKey): return ld_str(var).lower() == "true" + + return SunshineEnvironmentVariables( + SunshineClientInfo( + app_id = ld_str(EnvKey.APP_ID), + app_name = ld_str(EnvKey.APP_NAME) + ), + SunshineClientSpecs( + width = ld_int(EnvKey.WIDTH), + height = ld_int(EnvKey.HEIGHT), + fps = ld_int(EnvKey.FPS), + hdr = ld_bool(EnvKey.HDR), + gcmap = ld_int(EnvKey.GCMAP) + ), + SunshineClientOptions( + host_audio = ld_bool(EnvKey.HOST_AUDIO), + enable_sops = ld_bool(EnvKey.ENABLE_SOPS), + audio_config = SunshineAudioConfig(ld_str(EnvKey.AUDIO_CONFIG)) + ) + ) \ No newline at end of file diff --git a/pkg/sunshine/enums.py b/pkg/sunshine/enums.py new file mode 100644 index 0000000..739d2f0 --- /dev/null +++ b/pkg/sunshine/enums.py @@ -0,0 +1,7 @@ +from enum import StrEnum + +class SunshineAudioConfig(StrEnum): + STEREO = "2.0" + SURROUND_5_1 = "5.1" + SURROUND_7_1 = "7.1" + diff --git a/pkg/sunshine/env_key.py b/pkg/sunshine/env_key.py new file mode 100644 index 0000000..e671199 --- /dev/null +++ b/pkg/sunshine/env_key.py @@ -0,0 +1,20 @@ +from enum import Enum + +class KeyDefaultMixin(str): + def __new__(cls, requested_key: str, default: str): + obj = str.__new__(cls, requested_key) + obj.requested_key = requested_key + obj.default = default + return obj + +class SunshineEnvKey(KeyDefaultMixin, Enum): + APP_ID = ("SUNSHINE_APP_ID", "Unknown ID") + APP_NAME = ("SUNSHINE_APP_NAME", "Unknown App Name") + WIDTH = ("SUNSHINE_CLIENT_WIDTH", "1920") + HEIGHT = ("SUNSHINE_CLIENT_HEIGHT", "1080") + FPS = ("SUNSHINE_CLIENT_FPS", "60") + HDR = ("SUNSHINE_CLIENT_HDR", "0") + GCMAP = ("SUNSHINE_CLIENT_GCMAP", "0") + HOST_AUDIO = ("SUNSHINE_CLIENT_HOST_AUDIO", "0") + ENABLE_SOPS = ("SUNSHINE_CLIENT_ENABLE_SOPS", "0") + AUDIO_CONFIG = ("SUNSHINE_CLIENT_AUDIO_CONFIGURATION", "2.0") \ No newline at end of file diff --git a/pkg/sunshine/structs.py b/pkg/sunshine/structs.py new file mode 100644 index 0000000..32fe333 --- /dev/null +++ b/pkg/sunshine/structs.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass + +from .enums import SunshineAudioConfig + +@dataclass +class SunshineClientInfo: + app_id: str + app_name: str + +@dataclass +class SunshineClientSpecs: + width: int + height: int + fps: int + hdr: bool + gcmap: int + +@dataclass +class SunshineClientOptions: + host_audio: bool + enable_sops: bool + audio_config: SunshineAudioConfig + +@dataclass +class SunshineEnvironmentVariables: + info: SunshineClientInfo + specs: SunshineClientSpecs + options: SunshineClientOptions \ No newline at end of file diff --git a/pkg/wayland_display_manager/__init__.py b/pkg/wayland_display_manager/__init__.py new file mode 100644 index 0000000..f88cd29 --- /dev/null +++ b/pkg/wayland_display_manager/__init__.py @@ -0,0 +1,16 @@ +from .abstracts import AbstractDisplay +from .enums import DisplayManagerDriver, Error + +def get_display(requested_driver: DisplayManagerDriver = DisplayManagerDriver.INFER) -> AbstractDisplay: + match requested_driver: + case DisplayManagerDriver.INFER: + for display in DisplayManagerDriver.get_display_list(): + if display.meta.detect(): return display.meta() + + raise RuntimeError(Error.DISPLAY_INFER_TYPE_FAILURE) + + case _ if requested_driver in DisplayManagerDriver.get_display_list(): + return requested_driver.meta() + + case _: + raise ValueError(Error.DISPLAY_TYPE_UNKNOWN) \ No newline at end of file diff --git a/pkg/wayland_display_manager/abstracts.py b/pkg/wayland_display_manager/abstracts.py new file mode 100644 index 0000000..46cb06c --- /dev/null +++ b/pkg/wayland_display_manager/abstracts.py @@ -0,0 +1,26 @@ +from .structs import MonitorInfo, Modeline + +from abc import ABC, abstractmethod + +class AbstractDisplay(ABC): + @abstractmethod + def get_modes(self) -> list[MonitorInfo]: + pass + + @abstractmethod + def set_mode(self, modeline: Modeline, requested_connector: str) -> None: + pass + + @abstractmethod + def save_current_mode(self, location: str) -> None: + pass + + @abstractmethod + def read_saved_mode(self, location: str) -> Modeline: + pass + + @staticmethod + @abstractmethod + def detect(self) -> bool: + pass + diff --git a/pkg/wayland_display_manager/enums.py b/pkg/wayland_display_manager/enums.py new file mode 100644 index 0000000..5911ff9 --- /dev/null +++ b/pkg/wayland_display_manager/enums.py @@ -0,0 +1,28 @@ +from .abstracts import AbstractDisplay + +from . import gnome, plasma, wlroots + +from enum import Enum, StrEnum +from typing import Optional, Type + +class MetaMixin(str): + def __new__(cls, text: str, meta: AbstractDisplay): + obj = str.__new__(cls, text) + obj.text = text + obj.meta = meta + return obj + +class DisplayManagerDriver(MetaMixin, Enum): + GNOME = ("GNOME (Mutter)", gnome.Display) + PLASMA = ("KDE Plasma 5/6 (KWin)", plasma.Display) + SWAY = ("Sway", wlroots.Display) + INFER = ("Inferring Wayland Compositor", None) + + @classmethod + def get_display_list(cls): + return [display for display in cls if display.meta is not None] + +class Error(StrEnum): + DISPLAY_TYPE_NOT_IMPL = "Requested manager type has not been implemented!" + DISPLAY_TYPE_UNKNOWN = "Requested manager type does not exist." + DISPLAY_INFER_TYPE_FAILURE = "Could not infer a compatible display manager from supported list." \ No newline at end of file diff --git a/pkg/wayland_display_manager/gnome/__init__.py b/pkg/wayland_display_manager/gnome/__init__.py new file mode 100644 index 0000000..b862ecf --- /dev/null +++ b/pkg/wayland_display_manager/gnome/__init__.py @@ -0,0 +1,64 @@ +from ..structs import MonitorInfo, Modeline +from ..abstracts import AbstractDisplay + +import os +import re +import subprocess + +### Dirty Implementation ### +# Needs to be replaced with a proper dbus solution. Preferably by taking reference from gdctl. `cat /usr/bin/gdctl` # +# Issues: +# - Only works with one monitor (will lead to strange results if multiple are present). +# - Possible has 1 quadrillion issues that aren't known. +# - Sucks dog eggs and unironically uses the unholy trinity `os`, `re`, and `subprocess`. + +MODE_REGEX_PATTERN = re.compile(r'(\d+)x(\d+)@(\d+\.\d+)') + +class Display(AbstractDisplay): + def get_modes(self): + gdctl_output = subprocess.run(["gdctl", "show", "--modes"], capture_output=True, text=True, check=True) + modes = MODE_REGEX_PATTERN.findall(gdctl_output.stdout) + + # Hard coding my beloved + return [ + MonitorInfo( + connector = "HDMI-2", + primary = True, + modes = [ + Modeline( + width = int(w), + height = int(h), + refresh_rate = float(r) + ) + for _, (w, h, r) in enumerate(modes) + ] + ) + ] + + def set_mode(self, modeline, requested_connector = None) -> None: + subprocess.run(["gdctl", "set", "--logical-monitor", "--primary", "--monitor", requested_connector, "--mode", str(modeline)]) + + def save_current_mode(self, location): + gdctl_output = subprocess.run(["gdctl", "show"], capture_output=True, text=True, check=True) + match = MODE_REGEX_PATTERN.search(gdctl_output.stdout) + w, h, r = match.groups() + with open(location, "w") as file: + file.write(f"{w}x{h}@{r}") + + def read_saved_mode(self, location): + with open(location, "r") as file: + config_file_content = file.read() + + + match = MODE_REGEX_PATTERN.search(config_file_content) + + w, h, r = match.groups() + return Modeline( + width = int(w), + height = int(h), + refresh_rate = float(r) + ) + + @staticmethod + def detect(): + return "gnome" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() \ No newline at end of file diff --git a/pkg/wayland_display_manager/plasma/__init__.py b/pkg/wayland_display_manager/plasma/__init__.py new file mode 100644 index 0000000..2d2df51 --- /dev/null +++ b/pkg/wayland_display_manager/plasma/__init__.py @@ -0,0 +1,14 @@ +from ..abstracts import AbstractDisplay + +import os + +class Display(AbstractDisplay): + def get_modes(self): + pass + + def set_mode(self, modeline: str): + pass + + @staticmethod + def detect(): + return "plasma" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() \ No newline at end of file diff --git a/pkg/wayland_display_manager/structs.py b/pkg/wayland_display_manager/structs.py new file mode 100644 index 0000000..c7b648f --- /dev/null +++ b/pkg/wayland_display_manager/structs.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +@dataclass +class Modeline: + width: int + height: int + refresh_rate: float + + def __str__(self): + return f"{self.width}x{self.height}@{self.refresh_rate:.3f}" + + def __repr__(self): + return self.__str__() + +@dataclass +class MonitorInfo: + connector: str + primary: bool + modes: list[Modeline] + diff --git a/pkg/wayland_display_manager/wlroots/__init__.py b/pkg/wayland_display_manager/wlroots/__init__.py new file mode 100644 index 0000000..efc30b5 --- /dev/null +++ b/pkg/wayland_display_manager/wlroots/__init__.py @@ -0,0 +1,56 @@ +from ..structs import MonitorInfo, Modeline +from ..abstracts import AbstractDisplay + +import os +import re +import subprocess +import json + +# Matches "1920x1080 @ 60.000 Hz" or "1920x1080@60.000" +MODE_REGEX = re.compile(r'(\d+)x(\d+)@(\d+\.\d+)') + +class Display(AbstractDisplay): + def get_modes(self): + # wlr-randr --json is the 'civilized' way to do this on Trixie + result = subprocess.run(["wlr-randr", "--json"], capture_output=True, text=True, check=True) + data = json.loads(result.stdout) + + monitors = [] + for output in data: + modes = [] + for m in output.get("modes", []): + modes.append(Modeline( + width=int(m["width"]), + height=int(m["height"]), + refresh_rate=float(m["refresh"]) + )) + + monitors.append(MonitorInfo( + connector=output["name"], # Usually 'HEADLESS-1' in Cage + primary=output.get("focused", False), + modes=modes + )) + return monitors + + def set_mode(self, modeline, requested_connector="HEADLESS-1") -> None: + # Format: 1920x1080@60Hz + mode_str = f"{modeline.width}x{modeline.height}@{modeline.refresh_rate}Hz" + subprocess.run(["wlr-randr", "--output", requested_connector, "--mode", mode_str], check=True) + + def save_current_mode(self, location): + # Implementation similar to your GNOME one but using wlr-randr + result = subprocess.run(["wlr-randr", "--json"], capture_output=True, text=True, check=True) + data = json.loads(result.stdout)[0] # Grab first monitor + curr = data["current_mode"] + with open(location, "w") as f: + f.write(f"{curr['width']}x{curr['height']}@{curr['refresh']}") + + def read_saved_mode(self, location): + with open(location, "r") as f: + w, h, r = MODE_REGEX.search(f.read()).groups() + return Modeline(width=int(w), height=int(h), refresh_rate=float(r)) + + @staticmethod + def detect(): + # Cage doesn't always set XDG_CURRENT_DESKTOP, but it's a wlroots compositor + return os.environ.get("WLR_BACKENDS") == "headless" or "cage" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() \ No newline at end of file