feat(sunshine): add clipboard bridge for streamed sessions

This commit is contained in:
Ryan Yin
2026-09-04 20:21:52 +08:00
parent 04ceef6ee7
commit 74f009e734
3 changed files with 199 additions and 13 deletions
@@ -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 <hex> <Enter>` 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.
+111
View File
@@ -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"
}
}
@@ -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 <ssh-target>, 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 <target> true`) and the remote script path is correct'
}
}
}