Files
nix-config/modules/nixos/desktop/networking/sunshine/clipboard.nu
T

112 lines
3.7 KiB
Nu
Executable File

#!/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"
}
}