From 74f009e73429e1679056f45be66fda48de9b4400 Mon Sep 17 00:00:00 2001 From: Ryan Yin Date: Wed, 2 Sep 2026 13:56:35 +0800 Subject: [PATCH] feat(sunshine): add clipboard bridge for streamed sessions --- .../desktop/networking/sunshine/README.md | 55 +++++++-- .../desktop/networking/sunshine/clipboard.nu | 111 ++++++++++++++++++ .../networking/sunshine/send-clipboard.nu | 46 ++++++++ 3 files changed, 199 insertions(+), 13 deletions(-) create mode 100755 modules/nixos/desktop/networking/sunshine/clipboard.nu create mode 100755 modules/nixos/desktop/networking/sunshine/send-clipboard.nu diff --git a/modules/nixos/desktop/networking/sunshine/README.md b/modules/nixos/desktop/networking/sunshine/README.md index 2d4f0730..da04e593 100644 --- a/modules/nixos/desktop/networking/sunshine/README.md +++ b/modules/nixos/desktop/networking/sunshine/README.md @@ -1,23 +1,21 @@ # Temporary remote session recovery -When a remote-only machine is stuck at the `greetd` TUI, run the Nushell helper from an -SSH session to start one complete PAM/logind Wayland session without changing -the persistent NixOS configuration: +When a remote-only machine is stuck at the `greetd` TUI, run the Nushell helper from an SSH session +to start one complete PAM/logind Wayland session without changing the persistent NixOS +configuration: ```bash sudo nu ./remote-session.nu ``` -The helper validates its dependencies before changing the current session. It -then stops the normal `greetd` service, starts a transient systemd-managed -`greetd` configuration on `/dev/tty1`, and runs Niri directly. It returns after -greetd, Niri, and Sunshine are ready. Tailscale is not affected. +The helper validates its dependencies before changing the current session. It then stops the normal +`greetd` service, starts a transient systemd-managed `greetd` configuration on `/dev/tty1`, and runs +Niri directly. It returns after greetd, Niri, and Sunshine are ready. Tailscale is not affected. The `Virtual-1` output is created persistently by the `vkms` configuration in -`hosts/idols-ai/hardware-intel.nix` and configured by -`hosts/idols-ai/niri-hardware.kdl`. It gives Niri and Sunshine a capture target -when both physical monitors attached to the NVIDIA GPU are off. The output also -exists during normal physical sessions; keeping an idle virtual framebuffer has +`hosts/idols-ai/hardware-intel.nix` and configured by `hosts/idols-ai/niri-hardware.kdl`. It gives +Niri and Sunshine a capture target when both physical monitors attached to the NVIDIA GPU are off. +The output also exists during normal physical sessions; keeping an idle virtual framebuffer has minor GPU and memory overhead. Options: @@ -30,5 +28,36 @@ Options: --check validate prerequisites without changing service state ``` -A reboot or a manual restart of `greetd.service` restores the normal TUI -configuration. +A reboot or a manual restart of `greetd.service` restores the normal TUI configuration. + +## Clipboard bridge (client -> host paste) + +Moonlight's `Ctrl+Alt+Shift+V` pastes by asking Sunshine to _type_ the text back as +`Ctrl+Shift+U ` key events per character. Only GTK widgets interpret that sequence, so +pasting into a browser address bar or a terminal garbles the content (a URL turns into its literal +hex form). To paste reliably, set the _real_ clipboard of the streamed session instead and then use +a normal `Ctrl+V`. + +- `clipboard.nu` (host side): reads text from stdin and writes it to the clipboard of the compositor + Sunshine is currently streaming to. The streamed session is found via the running `sunshine` + process environment (`WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR`), so this works for both the + `greetd-sunshine` remote session and a regular desktop session. +- `send-clipboard.nu` (client side, Linux): pipes the client clipboard to the host over SSH. + +Host usage (input must be piped, not typed): + +```bash +printf 'https://example.com/path?q=1' | nu ./clipboard.nu +``` + +Client usage, from the Moonlight client (requires key-based SSH to the host and `wl-clipboard` on +the client): + +```bash +wl-paste -n | ssh ryan@ai "nu ~/nix-config/modules/nixos/desktop/networking/sunshine/clipboard.nu" +# or, from this repo: +nu ./send-clipboard.nu ryan@ai +``` + +`--verify` makes the host script read the clipboard back and fail on mismatch. An empty input, or a +host with no active `sunshine` process, exits with an error. diff --git a/modules/nixos/desktop/networking/sunshine/clipboard.nu b/modules/nixos/desktop/networking/sunshine/clipboard.nu new file mode 100755 index 00000000..608b0d70 --- /dev/null +++ b/modules/nixos/desktop/networking/sunshine/clipboard.nu @@ -0,0 +1,111 @@ +#!/usr/bin/env nu + +# Sunshine clipboard bridge (host side) +# +# Reads raw text from stdin and sets it as the clipboard of the compositor that +# is currently streaming via Sunshine, so pasting inside the stream is a plain +# Ctrl+V. Moonlight's built-in "type clipboard text" (Ctrl+Alt+Shift+V) is not +# usable here: Sunshine re-types the payload as Ctrl+Shift+U hex key events, +# which only GTK widgets understand, so browsers and terminals garble it. +# +# The streamed compositor is discovered through the running `sunshine` process +# (its WAYLAND_DISPLAY / XDG_RUNTIME_DIR). This works for both a headless +# greetd-sunshine remote session and a regular desktop session. +# +# Usage (input must be a pipe, not a tty): +# +# printf 'https://example.com/path?q=1' | nu clipboard.nu +# wl-paste -n | nu clipboard.nu +# +# Usage from a Moonlight client over SSH (see send-clipboard.nu): +# +# wl-paste -n | ssh ryan@ai "nu ~/nix-config/modules/nixos/desktop/networking/sunshine/clipboard.nu" + +def main [ + --user (-u): string = "" # Sunshine session user (default: SUDO_USER or current user) + --display (-d): string = "" # Override WAYLAND_DISPLAY (auto-detected from the sunshine process) + --verify (-v) # Read the clipboard back and fail if it differs from the input +] { + if (is-terminal --stdin) { + error make { + msg: 'no input: pipe text on stdin, e.g. `printf "text" | nu clipboard.nu`' + } + } + + let raw = (open --raw /dev/stdin) + if ($raw | str trim) == '' { + error make { msg: 'empty input: nothing to copy' } + } + + let user = (effective-user $user) + let pid = (streaming-sunshine-pid $user) + if $pid < 0 { + error make { + msg: $"no running `sunshine` process for user `($user)`" + help: 'start the Sunshine session first (headless: `sudo nu ./remote-session.nu`)' + } + } + + let runtime = (proc-env $pid 'XDG_RUNTIME_DIR' $"/run/user/(^id -u $user | str trim)") + let display = if $display != '' { + $display + } else { + let detected = (proc-env $pid 'WAYLAND_DISPLAY' '') + if $detected != '' { $detected } else { (single-wayland-display $runtime) } + } + + let target_env = { + WAYLAND_DISPLAY: $display + XDG_RUNTIME_DIR: $runtime + } + + # wl-copy forks a server that must outlive this process and serve the + # selection; redirect its stdio so the daemon does not hold our pipes open. + with-env $target_env { + $raw | ^wl-copy out> /dev/null err> /dev/null + } + if $env.LAST_EXIT_CODE != 0 { + error make { msg: 'wl-copy failed to set the clipboard' } + } + + if $verify { + let pasted = (with-env $target_env { ^wl-paste -n | complete }) + if $pasted.exit_code != 0 or ($pasted.stdout | str trim) != ($raw | str trim) { + error make { msg: 'verification failed: clipboard content differs from the input' } + } + } +} + +def effective-user [user: string] { + if $user != '' { return $user } + $env.SUDO_USER? | default (whoami) +} + +def streaming-sunshine-pid [user: string] { + let found = (^pgrep -o -u $user -x sunshine | complete) + if $found.exit_code != 0 { return (-1) } + ($found.stdout | str trim | into int) +} + +def proc-env [pid: int, key: string, fallback: string] { + let environ = (open --raw $"/proc/($pid)/environ") + for kv in ($environ | split row "\u{0}") { + if ($kv | str starts-with $"($key)=") { + return ($kv | str replace $"($key)=" '') + } + } + $fallback +} + +def single-wayland-display [runtime: string] { + let sockets = (ls $runtime + | where { |it| ($it.name | path basename) =~ '^wayland-[0-9]+$' } + | get name) + if ($sockets | length) == 1 { + return ($sockets.0 | path basename) + } + error make { + msg: $"cannot determine WAYLAND_DISPLAY under ($runtime)" + help: $"candidates: (($sockets | path basename | str join ', ')) -- pass --display explicitly" + } +} diff --git a/modules/nixos/desktop/networking/sunshine/send-clipboard.nu b/modules/nixos/desktop/networking/sunshine/send-clipboard.nu new file mode 100755 index 00000000..91add938 --- /dev/null +++ b/modules/nixos/desktop/networking/sunshine/send-clipboard.nu @@ -0,0 +1,46 @@ +#!/usr/bin/env nu + +# Sunshine clipboard bridge (client side) +# +# Sends the local client clipboard to the Sunshine host and writes it into the +# clipboard of the streamed session, so pasting inside the stream is a plain +# Ctrl+V. Prefer this over Moonlight's Ctrl+Alt+Shift+V, which "types" the text +# via unicode-hex key events and garbles it in non-GTK apps (e.g. browsers). +# +# Requires: +# - wl-clipboard (wl-paste) on this machine, inside a Wayland session +# - key-based SSH access to the host (the pipe needs a non-interactive session) +# - the host-side `clipboard.nu` reachable at --remote (default assumes the +# repo is cloned at ~/nix-config on the host) +# +# Usage: +# nu send-clipboard.nu ryan@ai +# nu send-clipboard.nu ryan@ai --remote /abs/path/to/clipboard.nu + +def main [ + host: string = "" # SSH target of the Sunshine host, e.g. ryan@ai + --remote: string = "~/nix-config/modules/nixos/desktop/networking/sunshine/clipboard.nu" +] { + if $host == '' { + error make { + msg: 'missing SSH target' + help: 'usage: nu send-clipboard.nu , e.g. `nu send-clipboard.nu ryan@ai`' + } + } + + let picked = (^wl-paste -n | complete) + if $picked.exit_code != 0 { + error make { msg: $"wl-paste failed: ($picked.stderr | str trim)" } + } + if ($picked.stdout | str trim) == '' { + error make { msg: 'client clipboard is empty: copy some text first' } + } + + $picked.stdout | ^ssh -o BatchMode=yes $host $"nu ($remote)" + if $env.LAST_EXIT_CODE != 0 { + error make { + msg: $"could not write clipboard to ($host)" + help: 'ensure key-based SSH works (`ssh -o BatchMode=yes true`) and the remote script path is correct' + } + } +}