mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-09-11 12:31:46 +02:00
feat(sunshine): add clipboard bridge for streamed sessions
This commit is contained in:
@@ -1,23 +1,21 @@
|
|||||||
# Temporary remote session recovery
|
# Temporary remote session recovery
|
||||||
|
|
||||||
When a remote-only machine is stuck at the `greetd` TUI, run the Nushell helper from an
|
When a remote-only machine is stuck at the `greetd` TUI, run the Nushell helper from an SSH session
|
||||||
SSH session to start one complete PAM/logind Wayland session without changing
|
to start one complete PAM/logind Wayland session without changing the persistent NixOS
|
||||||
the persistent NixOS configuration:
|
configuration:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo nu ./remote-session.nu
|
sudo nu ./remote-session.nu
|
||||||
```
|
```
|
||||||
|
|
||||||
The helper validates its dependencies before changing the current session. It
|
The helper validates its dependencies before changing the current session. It then stops the normal
|
||||||
then stops the normal `greetd` service, starts a transient systemd-managed
|
`greetd` service, starts a transient systemd-managed `greetd` configuration on `/dev/tty1`, and runs
|
||||||
`greetd` configuration on `/dev/tty1`, and runs Niri directly. It returns after
|
Niri directly. It returns after greetd, Niri, and Sunshine are ready. Tailscale is not affected.
|
||||||
greetd, Niri, and Sunshine are ready. Tailscale is not affected.
|
|
||||||
|
|
||||||
The `Virtual-1` output is created persistently by the `vkms` configuration in
|
The `Virtual-1` output is created persistently by the `vkms` configuration in
|
||||||
`hosts/idols-ai/hardware-intel.nix` and configured by
|
`hosts/idols-ai/hardware-intel.nix` and configured by `hosts/idols-ai/niri-hardware.kdl`. It gives
|
||||||
`hosts/idols-ai/niri-hardware.kdl`. It gives Niri and Sunshine a capture target
|
Niri and Sunshine a capture target when both physical monitors attached to the NVIDIA GPU are off.
|
||||||
when both physical monitors attached to the NVIDIA GPU are off. The output also
|
The output also exists during normal physical sessions; keeping an idle virtual framebuffer has
|
||||||
exists during normal physical sessions; keeping an idle virtual framebuffer has
|
|
||||||
minor GPU and memory overhead.
|
minor GPU and memory overhead.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -30,5 +28,36 @@ Options:
|
|||||||
--check validate prerequisites without changing service state
|
--check validate prerequisites without changing service state
|
||||||
```
|
```
|
||||||
|
|
||||||
A reboot or a manual restart of `greetd.service` restores the normal TUI
|
A reboot or a manual restart of `greetd.service` restores the normal TUI configuration.
|
||||||
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
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user