Launch remote Wayland apps through waypipe over ssh, with per-host options for GPU buffers, audio forwarding and ssh connection sharing.
saypipe myvm foot # run foot on myvm, shown here
saypipe --host-add myvm # enable myvm (checks it has waypipe)
saypipe --probe --apply myvm # test what it supports and turn that on
saypipe --link-add myvm # now `myvm foot` works too
Everything after the host is the command to run there. Launches run
detached, so closing the terminal doesn't close the app. A launch that fails
right away (ssh error, command not found) is reported in the terminal; all
such failures are also appended to ~/.local/state/saypipe/errors.log.
saypipe needs bash (4.4 or newer), an OpenSSH client and waypipe locally, and waypipe on each remote host. It otherwise uses only POSIX tools, so it runs on Linux distributions and the BSDs.
./install.sh # system-wide: packages + /usr/local/bin/saypipe
./install.sh --user # per-user: packages + ~/.local/bin/saypipe,
# a link to this checkout
./install.sh [--user] --uninstall # remove it, its completions and your host links
./install.sh [--user] --uninstall --purge
# ... and your saypipe config and state
install.sh is POSIX sh (it runs before bash exists) and installs only
what's missing from your PATH, using apt, dnf, yum, zypper, pacman, apk, xbps,
emerge or FreeBSD pkg through sudo or doas. Uninstalling keeps packages.
PREFIX changes /usr/local; SAYPIPE_BIN_DIR changes ~/.local/bin.
install.sh also installs completions for bash (with bash-completion), zsh
and fish. They complete options, hosts (enabled hosts and aliases first, then
the Host names in your ssh config, following Include) and, after options
like --gpu-remove or --alias-remove, only what applies there.
saypipe --completion-hosts enabled limits host completion to enabled hosts.
System-wide installs use /usr/local/share (fish falls back to
/etc/fish/completions where it doesn't search there); --user installs link
them into your own completion directories. For zsh with --user, add
fpath=(~/.local/share/zsh/site-functions $fpath) to ~/.zshrc before
compinit if completion doesn't load.
saypipe works with your distribution's waypipe. If that one is too old (common
on LTS releases), scripts/build-waypipe.sh builds the latest waypipe release
with all features on apt-based systems and installs it to /usr/local. It
also builds a private ffmpeg in /opt/ffmpeg-waypipe if the system's is too
old for waypipe's video support. Rerun it to update. It needs sudo and takes
a while.
saypipe keeps a list of enabled hosts, the hosts it works with. A host is enabled once saypipe has confirmed over ssh that it has waypipe:
saypipe --host-add myvm # checks for waypipe, then enables it
saypipe --host-add --force myvm # enable without the check (e.g. host offline)
saypipe --list # enabled hosts with their options
saypipe --host-remove myvm # remove it with all its options, aliases and link
Options belong to enabled hosts: adding one to a host that isn't enabled yet
enables it first, with the same check (--force works there too). Launching
doesn't need an enabled host: saypipe <host> <command> works for any host
ssh can reach.
| Host | waypipe options | Why |
|---|---|---|
| default | -n -c zstd |
-n (--no-gpu) sends shared-memory buffers. Hosts without a GPU need it. |
saypipe --gpu-add <host> |
-c zstd |
Lets apps share GPU buffers (dmabufs). |
-c zstd uses about 30% less bandwidth than waypipe's default lz4 at the same
frame rate. SAYPIPE_OPTS="..." replaces the options for every host.
waypipe doesn't carry audio. For hosts with saypipe --audio-add <host>,
saypipe forwards the local PulseAudio socket over the same ssh connection and
starts the app with PULSE_SERVER pointing at it, so its sound plays here:
waypipe ... ssh -R /tmp/saypipe-pulse-<random>.sock:$XDG_RUNTIME_DIR/pulse/native <host> \
env PULSE_SERVER=unix:/tmp/saypipe-pulse-<random>.sock <command...>
- Encrypted with the ssh session and needs no open ports.
- Each session gets its own random socket name, and saypipe removes it on the remote host afterwards (sshd leaves forwarded sockets behind; with a shared connection it also cancels the forward there).
- If there's no local PulseAudio socket, the app starts without audio.
- Needs a PulseAudio server locally (PipeWire's
pipewire-pulsecounts) and, on the remote host, apps that use libpulse (most do) and sshd withAllowStreamLocalForwarding(the default). Apps talking to PipeWire or ALSA directly still play on the remote host.
Every launch opens an ssh session, and audio launches a second, short one for
cleanup. On hosts whose firewall rate-limits ssh (e.g. ufw limit, 6 new
connections per 30 s) quick launches can get refused. For hosts with
saypipe --share-add <host>, saypipe runs ssh with:
-o ControlMaster=auto -o ControlPath=$XDG_RUNTIME_DIR/saypipe/cm/%C
-o ControlPersist=10m -o ServerAliveInterval=15 -o ServerAliveCountMax=3
so all sessions to the host reuse one connection and start faster. The master
stays for SAYPIPE_SHARE_PERSIST (default 10m) after the last session, and
the keepalives notice a dropped connection within about 45 s. The trade-off:
a dropped connection closes every app on that host at once.
saypipe --share-exit <host> closes a stuck master (and its apps). Hosts
without the option use whatever your ssh config says.
A host can have a command that runs when none is given, and a global default covers hosts without their own:
saypipe --default-set myvm ghostty -e htop # `saypipe myvm` runs it
saypipe --default-set --global ghostty # for every other host
saypipe --default-remove myvm
saypipe --default-remove --global
saypipe --list defaults
An explicit command always wins. Options go before the host; everything after it is the command, stored as typed.
saypipe --link-add myvm creates a myvm command in ~/.local/bin (or
$SAYPIPE_BIN_DIR), so myvm foot works like saypipe myvm foot. saypipe
won't create a link that shadows an existing command; give such a host an
alias instead:
saypipe --alias-add vm-dev dev # `vm-dev foot` connects to host "dev"
saypipe vm-dev foot # aliases work on the command line too
saypipe --alias-remove vm-dev # remove the alias and its link
Options and defaults belong to the real host, and the management options
accept an alias in its place. An alias can't take the name of an enabled host,
and --host-remove refuses alias names.
saypipe --probe <host>... tests what a host supports and shows what's
enabled:
$ saypipe --probe myvm
myvm
ssh ok connected
waypipe ok waypipe 0.11.2 (dmabuf: yes)
shared yes extra sessions reuse one connection
gpu yes Virtio-GPU Venus (Quadro RTX 4000) on /dev/dri/renderD128
audio yes reached local sound server: PulseAudio (on PipeWire 1.6.0)
host enabled
options shared
-> saypipe --gpu-add myvm
-> saypipe --audio-add myvm
- shared: the probe runs over one temporary shared connection; extra sessions over it must work. It opens a single TCP connection, so it won't trip ssh rate limits.
- gpu: waypipe built with dmabuf, an accessible
/dev/dri/renderD*and a hardware Vulkan device (software Vulkan doesn't count). Withoutvulkaninfoon the host the result islikely. - audio: forwards your PulseAudio socket for real and asks the sound server for its name through it.
--apply enables the host (if it has waypipe) and every supported option
that's off. Options that are on but unsupported get a warning with the command
to turn them off; --apply doesn't remove anything.
In short (saypipe --help has the details):
saypipe [--] <host> [<command...>] run a command on a host
<host-link> [<command...>] the same, through a link
Hosts:
--host-add [--force] <host>... enable hosts after checking for waypipe
--host-remove <host>... remove hosts with their options, aliases, links
--list [<list>] enabled hosts with their options, or one list:
hosts gpu audio share aliases links defaults
--probe [--apply] <host>... test what hosts support; --apply enables it
Host options (adding one enables the host; --force works here too):
--gpu-add / --gpu-remove <host>...
--audio-add / --audio-remove <host>...
--share-add / --share-remove <host>...
--share-exit <host>... close a shared connection (closes its apps)
--default-set <host>|--global <command...>
--default-remove <host>...|--global
--alias-add <alias> <host>, --alias-remove <alias>...
--link-add / --link-remove <host>...
Settings:
--completion-hosts [all|enabled]
-h, --help
Management commands report per host, print errors as saypipe: ... on
stderr, and exit 1 when something couldn't be done (2 for wrong arguments).
Environment: SAYPIPE_OPTS (waypipe options for every host),
SAYPIPE_BIN_DIR (links), SAYPIPE_SHARE_PERSIST (shared connection
lifetime, default 10m), SAYPIPE_WAIT (seconds to watch a launch for
failure, default 2).
~/.config/saypipe/hosts: enabled hosts, one per line (hosts with options count as enabled too).~/.config/saypipe/gpu-hosts,audio-hosts,share-hosts: options, one host per line,#comments.~/.config/saypipe/aliases:<alias> <host>per line.~/.config/saypipe/commands:<host> <command...>per line (*is the global default).~/.config/saypipe/settings:<key> <value>per line (completion-hosts).$XDG_RUNTIME_DIR/saypipe/cm/: control sockets of shared connections.~/.local/state/saypipe/errors.log: failed launches.
MIT. See LICENSE.