commit 5c3d47081edd27cd751f6c119c1293ec5ecbf539 Author: oscarg Date: Mon Aug 24 11:37:33 2026 +1200 History wipe because Zed is very annoying... 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..9f977be --- /dev/null +++ b/__main__.py @@ -0,0 +1,136 @@ +import os +import stat +import sys + +from app.errors import Errors +from app.vars import ( + CONFIG_DIR, + GEN_SCRIPT_PATH, + PREVIOUS_MODE_PATH, + Arguments, + Instructions, +) +from pkg import sunshine, wayland_display_manager +from pkg.sunshine.structs import SunshineEnvironmentVariables +from pkg.wayland_display_manager.structs import Modeline + + +def calculate_closest_mode( + sunshine_env: SunshineEnvironmentVariables, supported_modes: list[Modeline] +) -> Modeline: + target_w = sunshine_env.specs.width + target_h = sunshine_env.specs.height + target_fps = sunshine_env.specs.fps + + target_ratio = target_w / target_h + target_pixels = target_w * target_h + + def _sorting_rules(mode: Modeline): + # Shape difference + mode_ratio = mode.width / mode.height + shape_difference = abs(mode_ratio - target_ratio) + + # Is refresh rate too slow? (rules out very low refresh rates) + is_too_slow = mode.refresh_rate < target_fps + + # How off is the refresh rate? + fps_difference = abs(mode.refresh_rate - target_fps) + + # For comparing total pixel quantities + mode_pixels = mode.width * mode.height + pixel_difference = abs(mode_pixels - target_pixels) + + return (shape_difference, is_too_slow, fps_difference, pixel_difference) + + return min(supported_modes, key=_sorting_rules) + + +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) + + display_modes = next( + ( + i.modes + for i in display_manager.get_modes() + if i.connector == sys.argv[Arguments.CONNECTOR] + ), + None, + ) + + if display_modes: + modeline = calculate_closest_mode(sunshine_env, display_modes) + + display_manager.set_mode( + modeline=modeline, requested_connector=sys.argv[Arguments.CONNECTOR] + ) + + print(modeline) + + 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]) + + print(modeline) + + case Instructions.CREATE: + if len(sys.argv) - 1 < Arguments.TEMPLATE: + raise SyntaxError(Errors.INSUFFICIENT_INSTRUCTIONS) + + display_modes = next( + ( + i.modes + for i in display_manager.get_modes() + if i.connector == sys.argv[Arguments.CONNECTOR] + ), + None, + ) + + if not display_modes: + raise ValueError(Errors.CONNECTOR_INVALID) + + 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() diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..7900487 --- /dev/null +++ b/app/errors.py @@ -0,0 +1,9 @@ +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!" diff --git a/app/vars.py b/app/vars.py new file mode 100644 index 0000000..5901bbb --- /dev/null +++ b/app/vars.py @@ -0,0 +1,27 @@ +import pathlib +from enum import IntEnum, StrEnum + + +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" diff --git a/pkg/sunshine/__init__.py b/pkg/sunshine/__init__.py new file mode 100644 index 0000000..997e088 --- /dev/null +++ b/pkg/sunshine/__init__.py @@ -0,0 +1,40 @@ +from os import environ + +from .enums import SunshineAudioConfig +from .env_key import SunshineEnvKey as EnvKey +from .structs import ( + SunshineAudioConfig, + SunshineClientInfo, + SunshineClientOptions, + SunshineClientSpecs, + SunshineEnvironmentVariables, +) + + +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)), + ), + ) diff --git a/pkg/sunshine/enums.py b/pkg/sunshine/enums.py new file mode 100644 index 0000000..7b3c859 --- /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..c8424d4 --- /dev/null +++ b/pkg/sunshine/env_key.py @@ -0,0 +1,22 @@ +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") diff --git a/pkg/sunshine/structs.py b/pkg/sunshine/structs.py new file mode 100644 index 0000000..0d95181 --- /dev/null +++ b/pkg/sunshine/structs.py @@ -0,0 +1,32 @@ +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 diff --git a/pkg/wayland_display_manager/__init__.py b/pkg/wayland_display_manager/__init__.py new file mode 100644 index 0000000..7e2b9c6 --- /dev/null +++ b/pkg/wayland_display_manager/__init__.py @@ -0,0 +1,20 @@ +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) diff --git a/pkg/wayland_display_manager/abstracts.py b/pkg/wayland_display_manager/abstracts.py new file mode 100644 index 0000000..862c171 --- /dev/null +++ b/pkg/wayland_display_manager/abstracts.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from pathlib import Path + +from .structs import Modeline, MonitorInfo + + +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 | Path) -> None: + pass + + @abstractmethod + def read_saved_mode(self, location: str | Path) -> Modeline: + pass + + @staticmethod + @abstractmethod + def detect() -> bool: + pass diff --git a/pkg/wayland_display_manager/enums.py b/pkg/wayland_display_manager/enums.py new file mode 100644 index 0000000..c805ee6 --- /dev/null +++ b/pkg/wayland_display_manager/enums.py @@ -0,0 +1,32 @@ +from enum import Enum, StrEnum +from typing import Optional, Type + +from . import gnome, plasma, wlroots +from .abstracts import AbstractDisplay + + +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." + ) diff --git a/pkg/wayland_display_manager/gnome/__init__.py b/pkg/wayland_display_manager/gnome/__init__.py new file mode 100644 index 0000000..79c0df8 --- /dev/null +++ b/pkg/wayland_display_manager/gnome/__init__.py @@ -0,0 +1,87 @@ +import os +import re +import subprocess +from typing import override + +from ..abstracts import AbstractDisplay +from ..structs import Modeline, MonitorInfo +from ._gnome_types import GNOMEDisplayConfig + +# Half replaced implementation. More refactoring needs to be done in regards to setting the display. +# Everything will be replaced with dorkbus Mutter stuff. + +MODE_REGEX_PATTERN = re.compile(r"(\d+)x(\d+)@(\d+\.\d+)") + +class Display(AbstractDisplay): + @override + def get_modes(self): + mutter_display_state = GNOMEDisplayConfig() + mutter_display_state.get_dbus_info() + + primary_connectors = { + m[0] + for lm in mutter_display_state.logical_monitors + if lm.is_primary + for m in lm.monitors + } + + result = [] + for monitor in mutter_display_state.monitors: + modes = [ + Modeline( + mode.width, + mode.height, + mode.refresh_rate, + ) + for mode in monitor.modes + ] + + result.append( + MonitorInfo( + monitor.connector, + monitor.connector in primary_connectors, + modes + ) + ) + + return result + + @override + def set_mode(self, modeline, requested_connector=None) -> None: + subprocess.run( + [ + "gdctl", + "set", + "--logical-monitor", + "--primary", + "--monitor", + requested_connector, + "--mode", + str(modeline), + ] + ) + + @override + 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}") + + @override + 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 + @override + def detect(): + return "gnome" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() diff --git a/pkg/wayland_display_manager/gnome/_gnome_types.py b/pkg/wayland_display_manager/gnome/_gnome_types.py new file mode 100644 index 0000000..cee6810 --- /dev/null +++ b/pkg/wayland_display_manager/gnome/_gnome_types.py @@ -0,0 +1,104 @@ +from dataclasses import dataclass, field +from typing import Any + +from gi.repository import Gio, GLib + +# This is not going to be nice + +@dataclass +class _GNOMEDisplayMode: + id: str + width: int + height: int + refresh_rate: float + preferred_scale: float + supported_scales: list[float] + +@dataclass +class _GNOMEMonitor: + connector: str + vendor: str + product: str + serial: str + modes: list[_GNOMEDisplayMode] + properties: dict[str, Any] + +@dataclass +class _GNOMELogicalMonitor: + x: int + y: int + scale: float + transform: int + is_primary: bool + monitors: list[tuple[str, str, str, str]] + properties: dict[str, Any] + +@dataclass +class GNOMEDisplayConfig: + serial: int = 0 + monitors: list[_GNOMEMonitor] = field(default_factory=list) + logical_monitors: list[_GNOMELogicalMonitor] = field(default_factory=list) + global_properties: dict[str, Any] = field(default_factory=dict) + + def get_dbus_info(self): + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + + # Some beautiful dorkbus + res = bus.call_sync( + "org.gnome.Mutter.DisplayConfig", + "/org/gnome/Mutter/DisplayConfig", + "org.gnome.Mutter.DisplayConfig", + "GetCurrentState", + None, + None, + Gio.DBusCallFlags.NONE, + -1, + None + ) + + # Serial, Monitors, Logical Monitors, Properties + dbus_serial, monitors, logical_monitors, global_props = res.unpack() + + self.serial = dbus_serial + self.global_properties = global_props + + self.logical_monitors = [ + _GNOMELogicalMonitor( + x=lm[0], + y=lm[1], + scale=lm[2], + transform=lm[3], + is_primary=lm[4], + monitors=lm[5], + properties=lm[6], + ) + for lm in logical_monitors + ] + + self.monitors = [] + for info, modes_data, monitor_props in monitors: + connector, vendor, product, monitor_serial = info + + modes = [ + _GNOMEDisplayMode( + id=m[0], + width=m[1], + height=m[2], + refresh_rate=m[3], + preferred_scale=m[4], + supported_scales=m[5], + ) + for m in modes_data + ] + + monitor = _GNOMEMonitor( + connector=connector, + vendor=vendor, + product=product, + serial=monitor_serial, + modes=modes, + properties=monitor_props, + ) + self.monitors.append(monitor) + + diff --git a/pkg/wayland_display_manager/plasma/__init__.py b/pkg/wayland_display_manager/plasma/__init__.py new file mode 100644 index 0000000..aaf48de --- /dev/null +++ b/pkg/wayland_display_manager/plasma/__init__.py @@ -0,0 +1,28 @@ +import os +from typing import override + +from ..abstracts import AbstractDisplay +from ..structs import MonitorInfo, Modeline + + +class Display(AbstractDisplay): + @override + def get_modes(self) -> list[MonitorInfo]: + pass + + @override + def set_mode(self, modeline: Modeline, requested_connector: str) -> None: + pass + + @override + def read_saved_mode(self, location: str | Path) -> Modeline: + pass + + @override + def save_current_mode(self, location: str | Path) -> None: + pass + + @staticmethod + @override + def detect(): + return "plasma" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() diff --git a/pkg/wayland_display_manager/structs.py b/pkg/wayland_display_manager/structs.py new file mode 100644 index 0000000..ac725d3 --- /dev/null +++ b/pkg/wayland_display_manager/structs.py @@ -0,0 +1,21 @@ +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..53a2b3f --- /dev/null +++ b/pkg/wayland_display_manager/wlroots/__init__.py @@ -0,0 +1,26 @@ +import os +from typing import override + +from ..abstracts import AbstractDisplay +from ..structs import MonitorInfo, Modeline + + +class Display(AbstractDisplay): + def get_modes(self) -> list[MonitorInfo]: + pass + + def set_mode(self, modeline: Modeline, requested_connector: str) -> None: + pass + + def read_saved_mode(self, location: str | Path) -> Modeline: + pass + + def save_current_mode(self, location: str | Path) -> None: + pass + + @staticmethod + def detect(): + desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() + known = {"sway", "cage", "labwc", "wayfire", "hyprland"} + + return bool(set(desktop) & known) or "WAYLAND_DISPLAY" in os.environ