Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
921e6d65df | ||
|
|
0c97390fe2 | ||
|
|
5f5a2d8650 | ||
|
|
a8e2a860b4 | ||
|
|
5663fb6b36 | ||
|
|
50955fd606 | ||
|
|
c1ad3416a1 | ||
|
|
3084c438ee | ||
|
|
973c75f3b4 | ||
|
|
34efbaf26d |
@@ -6,3 +6,9 @@
|
||||
# nix
|
||||
result
|
||||
result-*
|
||||
|
||||
# Packaging build output. Keep the release artifacts (AppImage/.deb) tracked
|
||||
# so they are downloadable from the remote, but ignore intermediates.
|
||||
dist/*
|
||||
!dist/*.AppImage
|
||||
!dist/*.deb
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# TeleportFling
|
||||
|
||||
Standalone Linux screen + audio sender for the
|
||||
[OBS Teleport protocol](https://github.com/fzwoch/obs-teleport).
|
||||
|
||||
TeleportFling captures a Wayland screen (via `xdg-desktop-portal` + PipeWire)
|
||||
and the system's default audio, encodes the video to JPEG, packetizes both
|
||||
into the Teleport protocol, and streams them over TCP so any OBS instance
|
||||
with the `obs-teleport` plugin can discover and display the stream.
|
||||
|
||||
## Features
|
||||
|
||||
- **Real screen + audio capture** on Wayland (PipeWire / xdg-desktop-portal).
|
||||
- **LAN discovery** via UDP multicast, matching obs-teleport's `AnnouncePayload`.
|
||||
- **Multiple receivers** — every connected OBS streams from one sender.
|
||||
- **Headless CLI** (`teleportfling`) and **desktop GUI + system tray**
|
||||
(`teleportfling-gui`).
|
||||
- **Config persistence** at `~/.config/teleportfling/config.json`, customisable
|
||||
with `--config`.
|
||||
- **Daemon mode** via a per-user systemd service (see `contrib/`).
|
||||
|
||||
## Build & run
|
||||
|
||||
Requires a Nix dev shell (or Go 1.26+ with libjpeg-turbo and the Fyne/GLFW
|
||||
native deps for the GUI).
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
go build ./cmd/teleportfling # headless CLI
|
||||
go build ./cmd/teleportfling-gui # desktop GUI + tray
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
./teleportfling --name "Studio" --port 9756
|
||||
```
|
||||
|
||||
Flags: `--name`, `--port`, `--quality`, `--fps`, `--source screen|pattern`,
|
||||
`--audio`, `--no-announce`, `--stream-index`, `--duration`, `--config`.
|
||||
|
||||
## Network requirements
|
||||
|
||||
TeleportFling uses two kinds of traffic:
|
||||
|
||||
| Port | Proto | Direction | Purpose |
|
||||
|-------|-------|----------------|----------------------------------|
|
||||
| 9756 | TCP | Sender → listen | Video + audio stream to receivers |
|
||||
| 9999 | UDP | Sender → listen | multicast discovery (peerdiscovery) |
|
||||
|
||||
For a **sender** to be discoverable and reachable by OBS receivers on another
|
||||
machine, **both** machines need these ports open:
|
||||
|
||||
### NixOS
|
||||
|
||||
```nix
|
||||
networking.firewall = {
|
||||
enable = true;
|
||||
allowedTCPPorts = [ 9756 ]; # TeleportFling screen/audio streaming
|
||||
allowedUDPPorts = [ 9999 ]; # TeleportFling multicast discovery
|
||||
};
|
||||
```
|
||||
|
||||
Apply with `sudo nixos-rebuild switch --flake .#<hostname>`. If the machines
|
||||
are on separate/isolation LANs, Tailscale works as an alternative transport
|
||||
(point OBS at the receiver's Tailscale IP:port), but the multicast
|
||||
discovery list will only show senders reachable via LAN multicast.
|
||||
|
||||
### Other distros / firewalls
|
||||
|
||||
Open TCP **9756** inbound and UDP **9999** inbound on the sender machine;
|
||||
receivers just need outbound access (or the same rules if a firewall restricts
|
||||
it). mDNS (UDP 5353) is only needed for zero-config name resolution, not for
|
||||
TeleportFling itself.
|
||||
|
||||
## Daemon mode
|
||||
|
||||
See `contrib/README.md` — `contrib/install-daemon.sh` installs a per-user
|
||||
systemd unit that runs the headless CLI with its own config.
|
||||
|
||||
## Packaging
|
||||
|
||||
Three distribution formats are provided. Prebuilt artifacts are tracked in
|
||||
the repo under `dist/` (click to view on the remote; raw downloads require
|
||||
access to the Gitea server):
|
||||
|
||||
- [AppImage](http://homeserver:3050/pedley/TeleportFling/src/branch/main/dist/TeleportFling-0.1.0-x86_64.AppImage)
|
||||
- [Debian/Ubuntu .deb](http://homeserver:3050/pedley/TeleportFling/src/branch/main/dist/teleportfling_0.1.0_amd64.deb)
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
The flake builds both binaries as packages:
|
||||
|
||||
```sh
|
||||
nix build .#teleportfling # headless CLI
|
||||
nix build .#teleportfling-gui # desktop GUI + tray (default)
|
||||
nix run .#teleportfling-gui
|
||||
```
|
||||
|
||||
The GUI package also installs a `.desktop` entry and icon.
|
||||
|
||||
### AppImage
|
||||
|
||||
A self-contained AppImage of the GUI (bundles the runtime libraries):
|
||||
|
||||
```sh
|
||||
./packaging/appimage.sh
|
||||
# → dist/TeleportFling-0.1.0-x86_64.AppImage
|
||||
```
|
||||
|
||||
Requires `appimagetool` in `dist/` (see the script header) and the flake dev
|
||||
shell for building.
|
||||
|
||||
### Debian / Ubuntu
|
||||
|
||||
A `.deb` for Debian/Ubuntu (both binaries + desktop entry + icon):
|
||||
|
||||
```sh
|
||||
./packaging/deb.sh
|
||||
# → dist/teleportfling_0.1.0_amd64.deb
|
||||
```
|
||||
|
||||
Install with `sudo dpkg -i dist/teleportfling_0.1.0_amd64.deb` (then
|
||||
`sudo apt-get install -f` to pull dependencies if needed). The binaries
|
||||
require glibc ≥ 2.34, so Ubuntu 22.04+ / Debian 12+ are supported.
|
||||
|
||||
## Development
|
||||
|
||||
- `go build ./...` — build
|
||||
- `go test ./...` — tests
|
||||
- `golangci-lint run ./...` — lint
|
||||
- See `AGENTS.md` and `project.md` for workflow and milestone history.
|
||||
|
||||
## License
|
||||
|
||||
GPL-2.0, matching the obs-teleport project whose protocol we implement.
|
||||
@@ -3,12 +3,21 @@
|
||||
//
|
||||
// The streaming engine lives in internal/flinger; this command only wires the
|
||||
// GUI and tray around it. Headless use is handled by cmd/teleportfling.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// teleportfling-gui [--config PATH]
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
|
||||
"teleportfling/internal/gui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
gui.Run()
|
||||
configPath := flag.String("config", "", "config file path (default: ~/.config/teleportfling/config.json)")
|
||||
flag.Parse()
|
||||
|
||||
gui.Run(*configPath)
|
||||
}
|
||||
|
||||
+46
-10
@@ -14,10 +14,15 @@
|
||||
// teleportfling [--name NAME] [--port PORT] [--quality 1..100]
|
||||
// [--fps N] [--source screen|pattern] [--audio]
|
||||
// [--stream-index N] [--duration SECONDS]
|
||||
// [--config PATH]
|
||||
//
|
||||
// --source pattern selects the M1 synthetic test pattern (colour bars with a
|
||||
// moving box) instead of real screen capture, which is useful for testing
|
||||
// without granting screen-share permission.
|
||||
//
|
||||
// --config loads a saved config file first; any flag given explicitly on the
|
||||
// command line overrides the file value. When running under a service manager
|
||||
// (e.g. a systemd user unit) use --config to point at the daemon's profile.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -28,6 +33,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"teleportfling/internal/config"
|
||||
"teleportfling/internal/flinger"
|
||||
)
|
||||
|
||||
@@ -42,19 +48,33 @@ func main() {
|
||||
noAnnounce = flag.Bool("no-announce", false, "do not announce on the LAN (receiver must connect by IP)")
|
||||
streamIndex = flag.Int("stream-index", 0, "monitor index to capture (screen source)")
|
||||
duration = flag.Duration("duration", 0, "stream duration (0 = run until interrupted)")
|
||||
configPath = flag.String("config", "", "config file path (default: ~/.config/teleportfling/config.json)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
cfg := flinger.Config{
|
||||
Name: *name,
|
||||
Port: *port,
|
||||
Quality: *quality,
|
||||
FPS: *fps,
|
||||
Source: *source,
|
||||
Audio: *withAudio,
|
||||
StreamIndex: *streamIndex,
|
||||
Announce: !*noAnnounce,
|
||||
}
|
||||
// Base config: loaded from file (or defaults when absent), then overridden
|
||||
// by any flag the user explicitly set.
|
||||
cfg := loadCLIConfig(*configPath)
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
switch f.Name {
|
||||
case "name":
|
||||
cfg.Name = *name
|
||||
case "port":
|
||||
cfg.Port = *port
|
||||
case "quality":
|
||||
cfg.Quality = *quality
|
||||
case "fps":
|
||||
cfg.FPS = *fps
|
||||
case "source":
|
||||
cfg.Source = *source
|
||||
case "audio":
|
||||
cfg.Audio = *withAudio
|
||||
case "no-announce":
|
||||
cfg.Announce = !*noAnnounce
|
||||
case "stream-index":
|
||||
cfg.StreamIndex = *streamIndex
|
||||
}
|
||||
})
|
||||
|
||||
eng, err := flinger.New(cfg)
|
||||
if err != nil {
|
||||
@@ -85,3 +105,19 @@ func main() {
|
||||
eng.Stop()
|
||||
log.Printf("teleportfling: stopped")
|
||||
}
|
||||
|
||||
// loadCLIConfig returns the base flinger config. With a --config path it reads
|
||||
// that file; otherwise it reads the default user config. Missing files fall
|
||||
// back to defaults.
|
||||
func loadCLIConfig(path string) flinger.Config {
|
||||
p := path
|
||||
if p == "" {
|
||||
p = config.Path()
|
||||
}
|
||||
c, err := config.LoadFrom(p)
|
||||
if err != nil {
|
||||
log.Printf("teleportfling: config %s: %v (using defaults)", p, err)
|
||||
return flinger.DefaultConfig()
|
||||
}
|
||||
return c.ToFlinger()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Running TeleportFling as a daemon
|
||||
|
||||
`teleportfling` (the headless CLI) can run as a per-user systemd service so
|
||||
the stream starts automatically with your graphical session.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The binary must be built (the flake dev shell provides all cgo deps).
|
||||
- The screen-capture backend needs the session D-Bus and a PipeWire instance,
|
||||
which is why the unit is a *user* service tied to `graphical-session.target`.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
./contrib/install-daemon.sh
|
||||
```
|
||||
|
||||
This:
|
||||
|
||||
1. Builds `cmd/teleportfling` and installs it to `~/.local/bin/`.
|
||||
2. Installs `contrib/teleportfling.service` as a user unit.
|
||||
3. Creates `~/.config/teleportfling/daemon.json` on first run.
|
||||
4. Runs `systemctl --user daemon-reload` and enables the service.
|
||||
|
||||
## Manage
|
||||
|
||||
```sh
|
||||
systemctl --user start teleportfling # start now
|
||||
systemctl --user enable teleportfling # start at login (done by installer)
|
||||
systemctl --user status teleportfling # check status / logs
|
||||
journalctl --user -u teleportfling -f # follow logs
|
||||
systemctl --user stop teleportfling # stop
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
The unit passes `--config ~/.config/teleportfling/daemon.json`. Edit that
|
||||
file to change name, port, quality, fps, source, audio or announcement, then
|
||||
restart the service. Any flags you add to the `ExecStart=` line override the
|
||||
file values.
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install teleportfling as a per-user systemd service (daemon mode).
|
||||
#
|
||||
# Builds the headless binary, places it in ~/.local/bin, installs the user
|
||||
# unit from contrib/teleportfling.service, and creates a starter daemon
|
||||
# config. Run without sudo:
|
||||
#
|
||||
# ./contrib/install-daemon.sh
|
||||
#
|
||||
# Then start it with:
|
||||
#
|
||||
# systemctl --user enable --now teleportfling
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BIN_DIR="${HOME}/.local/bin"
|
||||
SERVICE_DIR="${HOME}/.config/systemd/user"
|
||||
CONFIG_DIR="${HOME}/.config/teleportfling"
|
||||
CONFIG="${CONFIG_DIR}/daemon.json"
|
||||
|
||||
echo "Building teleportfling..."
|
||||
(cd "${HERE}" && go build -o "${BIN_DIR}/teleportfling" ./cmd/teleportfling)
|
||||
|
||||
echo "Installing systemd user unit..."
|
||||
mkdir -p "${SERVICE_DIR}"
|
||||
install -m 0644 "${HERE}/contrib/teleportfling.service" "${SERVICE_DIR}/teleportfling.service"
|
||||
|
||||
if [[ ! -f "${CONFIG}" ]]; then
|
||||
echo "Creating default daemon config at ${CONFIG}..."
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
cat > "${CONFIG}" <<'EOF'
|
||||
{
|
||||
"name": "TeleportFling Daemon",
|
||||
"port": 9756,
|
||||
"quality": 80,
|
||||
"fps": 30,
|
||||
"source": "screen",
|
||||
"audio": true,
|
||||
"stream_index": 0,
|
||||
"announce": true
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Reloading systemd and enabling the service..."
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable teleportfling
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Installed. Start the daemon with:
|
||||
systemctl --user start teleportfling
|
||||
|
||||
Check status with:
|
||||
systemctl --user status teleportfling
|
||||
|
||||
Edit settings in ${CONFIG} (the service passes it via --config).
|
||||
EOF
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=TeleportFling screen and audio stream sender
|
||||
Documentation=https://github.com/fzwoch/obs-teleport
|
||||
After=graphical-session.target pipewire.service
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.local/bin/teleportfling --config %h/.config/teleportfling/daemon.json
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
# The capture backend needs the session bus and a PipeWire instance.
|
||||
Environment=XDG_RUNTIME_DIR=%t
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "TeleportFling Development Environment";
|
||||
description = "TeleportFling - standalone screen + audio sender for the OBS Teleport protocol";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
@@ -10,8 +10,111 @@
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
# Native/cgo build-time dependencies shared by both binaries.
|
||||
nativeBuildInputs = with pkgs; [
|
||||
go
|
||||
pkg-config
|
||||
];
|
||||
|
||||
# Libraries linked (or dlopened) by the capture/encode stack.
|
||||
# libpipewire is dlopened at runtime; libturbojpeg is linked.
|
||||
streamLibs = with pkgs; [
|
||||
libjpeg_turbo
|
||||
pipewire
|
||||
xdg-desktop-portal
|
||||
];
|
||||
|
||||
# Libraries required by the Fyne/GLFW GUI (cgo).
|
||||
guiLibs = with pkgs; [
|
||||
libGL
|
||||
mesa
|
||||
wayland
|
||||
libxkbcommon
|
||||
libX11
|
||||
libXrandr
|
||||
libXi
|
||||
libXcursor
|
||||
libXinerama
|
||||
libXxf86vm
|
||||
];
|
||||
|
||||
# Nix's libspa-0.2.pc emits -fno-strict-aliasing/-fno-strict-overflow
|
||||
# which Go's cgo rejects unless explicitly allowed.
|
||||
cgoFlagsAllow = "-fno-strict-overflow|-fno-strict-aliasing";
|
||||
|
||||
# Common build environment so cgo finds headers/libs.
|
||||
buildEnv = pkg: {
|
||||
nativeBuildInputs = nativeBuildInputs;
|
||||
buildInputs = pkg;
|
||||
# Make turbojpeg resolve via pkg-config for cgo builds.
|
||||
PKG_CONFIG_PATH = "${pkgs.libjpeg_turbo.dev}/lib/pkgconfig";
|
||||
# Fyne/GLFW link against X11, Wayland, GL and misc libs. Go's cgo
|
||||
# linker does not read NIX_LDFLAGS, so expose the runtime lib dirs
|
||||
# via LIBRARY_PATH for the link step.
|
||||
LIBRARY_PATH = with pkgs; lib.makeLibraryPath (pkg);
|
||||
CGO_CFLAGS_ALLOW = cgoFlagsAllow;
|
||||
};
|
||||
|
||||
# The headless CLI (no GLFW needed).
|
||||
teleportfling = pkgs.buildGoModule (buildEnv streamLibs // {
|
||||
pname = "teleportfling";
|
||||
version = "0.1.0";
|
||||
src = self;
|
||||
sourceRoot = self.sourceRoot or null;
|
||||
subPackages = [ "cmd/teleportfling" ];
|
||||
vendorHash = "sha256-1PDoL45BWhWNVX9wktR3WXpNbXJ+7V4mfp3la+Nv0wI=";
|
||||
|
||||
postInstall = ''
|
||||
mkdir -p $out/share/applications
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Stream a screen and audio to OBS Teleport receivers";
|
||||
homepage = "http://homeserver:3050/pedley/TeleportFling";
|
||||
license = licenses.gpl2;
|
||||
mainProgram = "teleportfling";
|
||||
platforms = platforms.linux;
|
||||
};
|
||||
});
|
||||
|
||||
# The desktop GUI + system tray.
|
||||
teleportfling-gui = pkgs.buildGoModule (buildEnv (streamLibs ++ guiLibs) // {
|
||||
pname = "teleportfling-gui";
|
||||
version = "0.1.0";
|
||||
src = self;
|
||||
sourceRoot = self.sourceRoot or null;
|
||||
subPackages = [ "cmd/teleportfling-gui" ];
|
||||
vendorHash = "sha256-1PDoL45BWhWNVX9wktR3WXpNbXJ+7V4mfp3la+Nv0wI=";
|
||||
|
||||
postInstall = ''
|
||||
mkdir -p $out/share/applications
|
||||
install -m 0644 ${./assets/teleportfling.desktop} $out/share/applications/teleportfling.desktop
|
||||
mkdir -p $out/share/icons/hicolor/512x512/apps
|
||||
install -m 0644 ${./assets/teleportfling.png} $out/share/icons/hicolor/512x512/apps/teleportfling.png
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "TeleportFling desktop GUI and system tray";
|
||||
homepage = "http://homeserver:3050/pedley/TeleportFling";
|
||||
license = licenses.gpl2;
|
||||
mainProgram = "teleportfling-gui";
|
||||
platforms = platforms.linux;
|
||||
};
|
||||
});
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
default = teleportfling-gui;
|
||||
inherit teleportfling teleportfling-gui;
|
||||
};
|
||||
|
||||
apps = {
|
||||
teleportfling = flake-utils.lib.mkApp { drv = teleportfling; };
|
||||
teleportfling-gui = flake-utils.lib.mkApp { drv = teleportfling-gui; };
|
||||
default = self.apps.${system}.teleportfling-gui;
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
inputsFrom = [];
|
||||
nativeBuildInputs = with pkgs; [
|
||||
@@ -27,21 +130,7 @@
|
||||
git
|
||||
direnv
|
||||
nix-direnv
|
||||
libjpeg_turbo
|
||||
pipewire
|
||||
xdg-desktop-portal
|
||||
# Fyne / GLFW (cgo) native dependencies.
|
||||
libGL
|
||||
mesa
|
||||
wayland
|
||||
libxkbcommon
|
||||
libX11
|
||||
libXrandr
|
||||
libXi
|
||||
libXcursor
|
||||
libXinerama
|
||||
libXxf86vm
|
||||
];
|
||||
] ++ streamLibs ++ guiLibs;
|
||||
|
||||
# Make turbojpeg resolve via pkg-config for cgo builds.
|
||||
PKG_CONFIG_PATH = "${pkgs.libjpeg_turbo.dev}/lib/pkgconfig";
|
||||
@@ -50,26 +139,15 @@
|
||||
# Fyne/GLFW link against X11, Wayland, GL and misc libs. Go's cgo
|
||||
# linker does not read NIX_LDFLAGS, so expose the runtime lib dirs
|
||||
# via LIBRARY_PATH for the link step.
|
||||
LIBRARY_PATH = with pkgs; lib.makeLibraryPath [
|
||||
libGL
|
||||
mesa
|
||||
wayland
|
||||
libxkbcommon
|
||||
libX11
|
||||
libXrandr
|
||||
libXi
|
||||
libXcursor
|
||||
libXinerama
|
||||
libXxf86vm
|
||||
];
|
||||
LIBRARY_PATH = with pkgs; lib.makeLibraryPath (streamLibs ++ guiLibs);
|
||||
|
||||
# Nix's libspa-0.2.pc emits -fno-strict-aliasing/-fno-strict-overflow
|
||||
# which Go's cgo rejects unless explicitly allowed.
|
||||
CGO_CFLAGS_ALLOW = "-fno-strict-overflow|-fno-strict-aliasing";
|
||||
CGO_CFLAGS_ALLOW = cgoFlagsAllow;
|
||||
|
||||
shellHook = ''
|
||||
echo "TeleportFling dev shell ready!"
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Monitor enumeration for the settings UI.
|
||||
//
|
||||
// The screen-capture backend itself selects a monitor by index; this file
|
||||
// provides a way to list the available monitors so the GUI can present a
|
||||
// friendly picker instead of a raw index.
|
||||
//
|
||||
// Hyprland exposes monitor info via the `hyprctl monitors` command. Other
|
||||
// Wayland compositors would need a portal-based enumeration; for now we only
|
||||
// implement the Hyprland path (the primary dev environment) and return a
|
||||
// clear error elsewhere.
|
||||
|
||||
package capture
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// Monitor describes one capturable output.
|
||||
type Monitor struct {
|
||||
Index int // capture StreamIndex to pass to OpenPipeWire
|
||||
Name string // compositor name, e.g. "eDP-1"
|
||||
Width int
|
||||
Height int
|
||||
Primary bool
|
||||
}
|
||||
|
||||
// ErrNoMonitors is returned when monitor enumeration is unsupported or fails.
|
||||
var ErrNoMonitors = errors.New("capture: monitor enumeration unavailable on this compositor")
|
||||
|
||||
// ListMonitors returns the available monitors for the GUI picker.
|
||||
func ListMonitors() ([]Monitor, error) {
|
||||
if hyprctlAvailable() {
|
||||
return hyprctlMonitors()
|
||||
}
|
||||
return nil, ErrNoMonitors
|
||||
}
|
||||
|
||||
// hyprctlAvailable reports whether the Hyprland monitor command exists.
|
||||
func hyprctlAvailable() bool {
|
||||
_, err := exec.LookPath("hyprctl")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// hyprctlMonitor is the JSON shape emitted by `hyprctl monitors -j`.
|
||||
type hyprctlMonitor struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Description string `json:"description"`
|
||||
Focused bool `json:"focused"`
|
||||
}
|
||||
|
||||
// hyprctlMonitors lists monitors via the Hyprland IPC command.
|
||||
func hyprctlMonitors() ([]Monitor, error) {
|
||||
out, err := exec.Command("hyprctl", "monitors", "-j").Output()
|
||||
if err != nil {
|
||||
return nil, ErrNoMonitors
|
||||
}
|
||||
|
||||
var raw []hyprctlMonitor
|
||||
if err := json.Unmarshal(out, &raw); err != nil {
|
||||
return nil, ErrNoMonitors
|
||||
}
|
||||
|
||||
monitors := make([]Monitor, 0, len(raw))
|
||||
for _, m := range raw {
|
||||
monitors = append(monitors, Monitor{
|
||||
Index: m.ID,
|
||||
Name: m.Name,
|
||||
Width: m.Width,
|
||||
Height: m.Height,
|
||||
Primary: m.Focused,
|
||||
})
|
||||
}
|
||||
return monitors, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHyprctlMonitors parses a representative `hyprctl monitors -j` payload.
|
||||
func TestHyprctlMonitors(t *testing.T) {
|
||||
payload := `[
|
||||
{
|
||||
"id": 0,
|
||||
"name": "eDP-1",
|
||||
"description": "BOE 0x094C",
|
||||
"width": 1920,
|
||||
"height": 1200,
|
||||
"focused": true
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "HDMI-A-1",
|
||||
"description": "Samsung",
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"focused": false
|
||||
}
|
||||
]`
|
||||
|
||||
var raw []hyprctlMonitor
|
||||
if err := json.Unmarshal([]byte(payload), &raw); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(raw) != 2 {
|
||||
t.Fatalf("got %d monitors, want 2", len(raw))
|
||||
}
|
||||
if raw[0].ID != 0 || raw[0].Name != "eDP-1" || raw[0].Width != 1920 {
|
||||
t.Errorf("monitor 0 wrong: %+v", raw[0])
|
||||
}
|
||||
if raw[1].ID != 1 || raw[1].Focused {
|
||||
t.Errorf("monitor 1 wrong: %+v", raw[1])
|
||||
}
|
||||
}
|
||||
@@ -71,15 +71,20 @@ var pathVar = func() string {
|
||||
return filepath.Join(dir, "teleportfling", "config.json")
|
||||
}()
|
||||
|
||||
// Path returns the config file location.
|
||||
// Path returns the default config file location.
|
||||
func Path() string {
|
||||
return pathVar
|
||||
}
|
||||
|
||||
// Load reads the config file, returning Default when it does not exist.
|
||||
// Load reads the default config file, returning Default when it does not exist.
|
||||
func Load() (Config, error) {
|
||||
p := Path()
|
||||
data, err := os.ReadFile(p)
|
||||
return LoadFrom(Path())
|
||||
}
|
||||
|
||||
// LoadFrom reads the config file at path, returning Default when it does not
|
||||
// exist. This lets the CLI and GUI support custom --config paths.
|
||||
func LoadFrom(path string) (Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Default(), nil
|
||||
@@ -113,15 +118,19 @@ func Load() (Config, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Save writes the config file, creating the directory if needed.
|
||||
// Save writes the default config file, creating the directory if needed.
|
||||
func Save(c Config) error {
|
||||
p := Path()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return SaveTo(Path(), c)
|
||||
}
|
||||
|
||||
// SaveTo writes the config file at path, creating the directory if needed.
|
||||
func SaveTo(path string, c Config) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, data, 0o600)
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
@@ -92,3 +92,33 @@ func TestToFlingerAnnounceDefault(t *testing.T) {
|
||||
t.Error("ToFlinger announce default should be true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFromMissing returns defaults for a non-existent custom path.
|
||||
func TestLoadFromMissing(t *testing.T) {
|
||||
c, err := LoadFrom(filepath.Join(t.TempDir(), "missing", "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFrom: %v", err)
|
||||
}
|
||||
if c.Port != 9756 {
|
||||
t.Errorf("default port = %d, want 9756", c.Port)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveToLoadFromRoundTrip uses an explicit path independent of pathVar.
|
||||
func TestSaveToLoadFromRoundTrip(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "custom", "daemon.json")
|
||||
|
||||
announce := false
|
||||
want := Config{Name: "Daemon", Port: 9901, Quality: 90, FPS: 30, Source: "screen", Audio: true, Announce: &announce}
|
||||
if err := SaveTo(p, want); err != nil {
|
||||
t.Fatalf("SaveTo: %v", err)
|
||||
}
|
||||
|
||||
got, err := LoadFrom(p)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFrom: %v", err)
|
||||
}
|
||||
if got.Name != want.Name || got.Port != want.Port || *got.Announce != false {
|
||||
t.Errorf("round trip mismatch: got %+v want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -91,6 +92,9 @@ type Status struct {
|
||||
Frames int64
|
||||
Dropped int64
|
||||
Conns int
|
||||
// Err is the most recent runtime error encountered (capture, encode,
|
||||
// packet or audio), or nil if the stream is healthy.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Engine owns the capture, encode and send pipeline.
|
||||
@@ -106,7 +110,16 @@ type Engine struct {
|
||||
start time.Time
|
||||
stop chan struct{}
|
||||
|
||||
frames atomic.Int64
|
||||
frames atomic.Int64
|
||||
errMu sync.RWMutex
|
||||
lastErr error
|
||||
}
|
||||
|
||||
// setErr records the most recent runtime error. Pass nil to clear it.
|
||||
func (e *Engine) setErr(err error) {
|
||||
e.errMu.Lock()
|
||||
e.lastErr = err
|
||||
e.errMu.Unlock()
|
||||
}
|
||||
|
||||
// New creates an engine from cfg. Capture is opened eagerly so that
|
||||
@@ -208,17 +221,25 @@ func (e *Engine) Stop() {
|
||||
|
||||
// Status returns a snapshot of the running engine.
|
||||
func (e *Engine) Status() Status {
|
||||
e.errMu.RLock()
|
||||
err := e.lastErr
|
||||
e.errMu.RUnlock()
|
||||
return Status{
|
||||
Running: e.stop != nil,
|
||||
Frames: e.frames.Load(),
|
||||
Dropped: e.sender.Dropped(),
|
||||
Conns: e.sender.NumConns(),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// audioLoop reads raw PCM and emits WAVE packets. start is the shared
|
||||
// reference clock used by the video loop so audio and video timestamps stay
|
||||
// aligned on the receiver.
|
||||
//
|
||||
// The PipeWire capture negotiates interleaved signed 16-bit stereo at 48 kHz
|
||||
// (the teleportfling stream's negotiated Format is S16LE), which is exactly
|
||||
// what the WAVE packets carry.
|
||||
func (e *Engine) audioLoop(src io.ReadCloser) {
|
||||
defer func() { _ = src.Close() }()
|
||||
|
||||
@@ -233,6 +254,7 @@ func (e *Engine) audioLoop(src io.ReadCloser) {
|
||||
ts := uint64(time.Since(e.start))
|
||||
packet, perr := protocol.BuildWavePacket(ts, protocol.AudioFormatS16, sampleRate, speakers, int32(frames), buf[:n])
|
||||
if perr != nil {
|
||||
e.setErr(perr)
|
||||
log.Printf("flinger: wave: %v", perr)
|
||||
} else {
|
||||
e.sender.Send(packet)
|
||||
@@ -245,6 +267,7 @@ func (e *Engine) audioLoop(src io.ReadCloser) {
|
||||
default:
|
||||
}
|
||||
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
|
||||
e.setErr(err)
|
||||
log.Printf("flinger: audio: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -265,6 +288,7 @@ func (e *Engine) videoLoop() {
|
||||
default:
|
||||
}
|
||||
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
|
||||
e.setErr(err)
|
||||
log.Printf("flinger: capture: %v", err)
|
||||
}
|
||||
continue
|
||||
@@ -281,6 +305,7 @@ func (e *Engine) videoLoop() {
|
||||
ts := uint64(now.Sub(e.start))
|
||||
buf, err := e.encoder.EncodeBGRA(frame.Pix, frame.Width, frame.Height, e.cfg.Quality)
|
||||
if err != nil {
|
||||
e.setErr(err)
|
||||
log.Printf("flinger: jpeg: %v", err)
|
||||
continue
|
||||
}
|
||||
@@ -292,6 +317,7 @@ func (e *Engine) videoLoop() {
|
||||
buf,
|
||||
)
|
||||
if err != nil {
|
||||
e.setErr(err)
|
||||
log.Printf("flinger: packet: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package flinger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -77,6 +78,34 @@ func TestNewRejectsBadSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetErrStatus verifies runtime errors are exposed via Status and can be
|
||||
// cleared.
|
||||
func TestSetErrStatus(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Source = "pattern"
|
||||
cfg.Port = 19757
|
||||
|
||||
eng, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
if st := eng.Status(); st.Err != nil {
|
||||
t.Fatalf("expected no error initially, got %v", st.Err)
|
||||
}
|
||||
|
||||
sentinel := errors.New("test capture failure")
|
||||
eng.setErr(sentinel)
|
||||
if st := eng.Status(); st.Err != sentinel {
|
||||
t.Errorf("expected sentinel error, got %v", st.Err)
|
||||
}
|
||||
|
||||
eng.setErr(nil)
|
||||
if st := eng.Status(); st.Err != nil {
|
||||
t.Errorf("expected cleared error, got %v", st.Err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate rejects out-of-range values.
|
||||
func TestValidate(t *testing.T) {
|
||||
bad := []func(*Config){
|
||||
|
||||
+95
-10
@@ -8,6 +8,7 @@ package gui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"log"
|
||||
"strconv"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"fyne.io/systray"
|
||||
|
||||
"teleportfling/assets"
|
||||
"teleportfling/internal/capture"
|
||||
"teleportfling/internal/config"
|
||||
"teleportfling/internal/flinger"
|
||||
)
|
||||
@@ -43,6 +45,9 @@ type App struct {
|
||||
win fyne.Window
|
||||
desk desktop.App
|
||||
|
||||
// configPath overrides the default config location ("" = default).
|
||||
configPath string
|
||||
|
||||
cfg config.Config
|
||||
eng *flinger.Engine
|
||||
lock bool // serialises start/stop against UI actions
|
||||
@@ -60,12 +65,15 @@ type App struct {
|
||||
audioChk *widget.Check
|
||||
announceChk *widget.Check
|
||||
srcSel *widget.Select
|
||||
monSel *widget.Select
|
||||
monitors []capture.Monitor
|
||||
statsDone chan struct{}
|
||||
}
|
||||
|
||||
// Run starts the GUI and blocks until the app exits.
|
||||
func Run() {
|
||||
g := &App{}
|
||||
// Run starts the GUI and blocks until the app exits. configPath selects a
|
||||
// non-default settings file ("" uses the default location).
|
||||
func Run(configPath string) {
|
||||
g := &App{configPath: configPath}
|
||||
|
||||
g.fyneApp = app.NewWithID(appID)
|
||||
g.win = g.fyneApp.NewWindow("TeleportFling")
|
||||
@@ -74,7 +82,7 @@ func Run() {
|
||||
g.iconActive = newTrayResource(color.NRGBA{R: 46, G: 125, B: 50, A: 255}, color.NRGBA{R: 150, G: 220, B: 140, A: 255})
|
||||
|
||||
// Load persisted settings (falling back to defaults).
|
||||
g.cfg = mustLoadConfig()
|
||||
g.cfg = g.mustLoadConfig()
|
||||
|
||||
g.buildUI()
|
||||
|
||||
@@ -89,9 +97,18 @@ func Run() {
|
||||
g.win.ShowAndRun()
|
||||
}
|
||||
|
||||
// mustLoadConfig loads the config, logging and falling back to defaults.
|
||||
func mustLoadConfig() config.Config {
|
||||
c, err := config.Load()
|
||||
// mustLoadConfig loads the config (from the configured path, or the default),
|
||||
// logging and falling back to defaults on error.
|
||||
func (g *App) mustLoadConfig() config.Config {
|
||||
var (
|
||||
c config.Config
|
||||
err error
|
||||
)
|
||||
if g.configPath != "" {
|
||||
c, err = config.LoadFrom(g.configPath)
|
||||
} else {
|
||||
c, err = config.Load()
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("gui: config load: %v (using defaults)", err)
|
||||
return config.Default()
|
||||
@@ -120,6 +137,7 @@ func (g *App) buildUI() {
|
||||
// Source.
|
||||
g.srcSel = widget.NewSelect([]string{"screen", "pattern"}, func(string) {})
|
||||
g.srcSel.SetSelected(g.cfg.Source)
|
||||
g.setupMonitorPicker()
|
||||
|
||||
// Quality.
|
||||
g.qualitySel = widget.NewSelect([]string{"50", "60", "70", "80", "90", "100"}, func(string) {})
|
||||
@@ -154,6 +172,7 @@ func (g *App) buildUI() {
|
||||
{Text: "Name", Widget: g.nameEnt},
|
||||
{Text: "Port", Widget: g.portEnt},
|
||||
{Text: "Source", Widget: g.srcSel},
|
||||
{Text: "Monitor", Widget: g.monSel},
|
||||
{Text: "Quality", Widget: g.qualitySel},
|
||||
{Text: "Frame rate", Widget: g.fpsSel},
|
||||
{Text: "", Widget: g.audioChk},
|
||||
@@ -172,6 +191,56 @@ func (g *App) buildUI() {
|
||||
g.win.Resize(fyne.NewSize(380, 0))
|
||||
}
|
||||
|
||||
// setupMonitorPicker populates the monitor dropdown and ties its visibility
|
||||
// to the source selector. When monitor enumeration is unavailable (non-
|
||||
// Hyprland), it falls back to a raw index entry driven by the saved config.
|
||||
func (g *App) setupMonitorPicker() {
|
||||
// Populate monitor names for the picker.
|
||||
g.monitors, _ = capture.ListMonitors()
|
||||
names := make([]string, 0, len(g.monitors))
|
||||
for _, m := range g.monitors {
|
||||
names = append(names, fmt.Sprintf("%s (%dx%d)", m.Name, m.Width, m.Height))
|
||||
}
|
||||
|
||||
// If we could not enumerate, present the saved index as a single option.
|
||||
if len(names) == 0 {
|
||||
names = []string{fmt.Sprintf("Monitor %d", g.cfg.StreamIndex)}
|
||||
g.monitors = []capture.Monitor{{Index: g.cfg.StreamIndex, Name: fmt.Sprintf("Monitor %d", g.cfg.StreamIndex)}}
|
||||
}
|
||||
|
||||
g.monSel = widget.NewSelect(names, func(string) {})
|
||||
if len(g.monitors) > 0 {
|
||||
// Preselect the configured index if it's within range.
|
||||
for i, m := range g.monitors {
|
||||
if m.Index == g.cfg.StreamIndex {
|
||||
g.monSel.SetSelectedIndex(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Monitor picker only applies to the "screen" source.
|
||||
g.srcSel.OnChanged = func(string) {
|
||||
g.monSel.Disable()
|
||||
if g.srcSel.Selected == "screen" {
|
||||
g.monSel.Enable()
|
||||
}
|
||||
}
|
||||
g.monSel.Disable()
|
||||
if g.cfg.Source == "screen" {
|
||||
g.monSel.Enable()
|
||||
}
|
||||
}
|
||||
|
||||
// selectedMonitorIndex returns the monitor index chosen in the picker, or the
|
||||
// saved config value when the picker is unavailable/disabled.
|
||||
func (g *App) selectedMonitorIndex() int {
|
||||
if g.monSel != nil && g.monSel.SelectedIndex() >= 0 && g.monSel.SelectedIndex() < len(g.monitors) {
|
||||
return g.monitors[g.monSel.SelectedIndex()].Index
|
||||
}
|
||||
return g.cfg.StreamIndex
|
||||
}
|
||||
|
||||
// toggleStream starts or stops the engine based on current UI state.
|
||||
func (g *App) toggleStream() {
|
||||
if g.lock {
|
||||
@@ -198,10 +267,14 @@ func (g *App) start() {
|
||||
FPS: fps,
|
||||
Source: g.srcSel.Selected,
|
||||
Audio: g.audioChk.Checked,
|
||||
StreamIndex: g.cfg.StreamIndex,
|
||||
StreamIndex: g.selectedMonitorIndex(),
|
||||
Announce: &announce,
|
||||
}
|
||||
if err := config.Save(g.cfg); err != nil {
|
||||
if g.configPath != "" {
|
||||
if err := config.SaveTo(g.configPath, g.cfg); err != nil {
|
||||
log.Printf("gui: config save: %v", err)
|
||||
}
|
||||
} else if err := config.Save(g.cfg); err != nil {
|
||||
log.Printf("gui: config save: %v", err)
|
||||
}
|
||||
|
||||
@@ -241,7 +314,16 @@ func (g *App) watchStats() {
|
||||
// Fyne UI calls must run on the main thread.
|
||||
fyne.Do(func() {
|
||||
g.statusLab.SetText(formatStatus(st))
|
||||
g.setTrayState(true, "TeleportFling · "+frames+" frames, "+dropped+" dropped")
|
||||
if st.Err != nil {
|
||||
g.statusLab.Importance = widget.DangerImportance
|
||||
} else {
|
||||
g.statusLab.Importance = widget.SuccessImportance
|
||||
}
|
||||
tip := "TeleportFling · " + frames + " frames, " + dropped + " dropped"
|
||||
if st.Err != nil {
|
||||
tip += " · error"
|
||||
}
|
||||
g.setTrayState(true, tip)
|
||||
})
|
||||
case <-done:
|
||||
return
|
||||
@@ -256,6 +338,9 @@ func formatStatus(st flinger.Status) string {
|
||||
if st.Dropped > 0 {
|
||||
base += " · " + itoa(int(st.Dropped)) + " dropped"
|
||||
}
|
||||
if st.Err != nil {
|
||||
base += "\nError: " + st.Err.Error()
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# AppImage AppRun: locate bundled libraries and launch the GUI.
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
export LD_LIBRARY_PATH="${HERE}/usr/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
exec "${HERE}/usr/bin/teleportfling-gui" "$@"
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a self-contained AppImage of the TeleportFling GUI.
|
||||
#
|
||||
# Approach: build the GUI binary, then bundle it plus every shared library it
|
||||
# needs (from ldd, recursively) plus the dlopened PipeWire stack into an
|
||||
# AppDir, then package it with appimagetool. Output:
|
||||
# dist/TeleportFling-<version>-x86_64.AppImage
|
||||
#
|
||||
# Usage:
|
||||
# ./packaging/appimage.sh
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT="${HERE}/dist"
|
||||
APPDIR="${OUT}/AppDir"
|
||||
LIBDIR="${APPDIR}/usr/lib"
|
||||
|
||||
VERSION="0.1.0"
|
||||
ARCH="x86_64"
|
||||
|
||||
echo "Building teleportfling-gui..."
|
||||
nix develop --command bash -c "go build -o '${OUT}/teleportfling-gui' ./cmd/teleportfling-gui"
|
||||
|
||||
echo "Preparing AppDir..."
|
||||
rm -rf "${APPDIR}"
|
||||
mkdir -p "${LIBDIR}" \
|
||||
"${APPDIR}/usr/bin" \
|
||||
"${APPDIR}/usr/share/applications" \
|
||||
"${APPDIR}/usr/share/icons/hicolor/512x512/apps"
|
||||
|
||||
install -m 0755 "${OUT}/teleportfling-gui" "${APPDIR}/usr/bin/teleportfling-gui"
|
||||
install -m 0644 "${HERE}/assets/teleportfling.desktop" "${APPDIR}/usr/share/applications/teleportfling.desktop"
|
||||
install -m 0644 "${HERE}/assets/teleportfling.png" "${APPDIR}/usr/share/icons/hicolor/512x512/apps/teleportfling.png"
|
||||
# appimagetool requires the desktop file and icon in the AppDir root.
|
||||
install -m 0644 "${HERE}/assets/teleportfling.desktop" "${APPDIR}/teleportfling.desktop"
|
||||
install -m 0644 "${HERE}/assets/teleportfling.png" "${APPDIR}/teleportfling.png"
|
||||
|
||||
echo "Bundling runtime libraries..."
|
||||
# Copy every library the binary links against, resolving dependencies
|
||||
# iteratively (ldd output may reference libs that themselves need libs).
|
||||
declare -A seen
|
||||
collect_libs() {
|
||||
local bin="$1"
|
||||
while IFS= read -r lib; do
|
||||
local name
|
||||
name="$(basename "${lib}")"
|
||||
if [[ -z "${seen[${name}]:-}" && -f "${lib}" ]]; then
|
||||
seen["${name}"]=1
|
||||
install -m 0755 "${lib}" "${LIBDIR}/${name}" 2>/dev/null || true
|
||||
collect_libs "${lib}"
|
||||
fi
|
||||
done < <(ldd "${bin}" 2>/dev/null | grep -oE '/[^ ]+\.so[^ ]*' | sort -u)
|
||||
}
|
||||
collect_libs "${APPDIR}/usr/bin/teleportfling-gui"
|
||||
echo " bundled $(ls "${LIBDIR}" | wc -l) libraries"
|
||||
|
||||
# The screencast backend dlopens libpipewire at runtime; ensure it and its
|
||||
# SPA plugin libs are present (they are pulled in via the recursive ldd walk
|
||||
# above, but guard against a missing libspa here).
|
||||
if [[ -f "${LIBDIR}/libpipewire-0.3.so.0" ]]; then
|
||||
echo " bundled libpipewire-0.3.so.0"
|
||||
fi
|
||||
|
||||
echo "Writing AppRun..."
|
||||
install -m 0755 "${HERE}/packaging/AppRun" "${APPDIR}/AppRun"
|
||||
|
||||
echo "Packaging AppImage with appimagetool..."
|
||||
if [[ ! -x "${OUT}/appimagetool" ]]; then
|
||||
echo "appimagetool not found at ${OUT}/appimagetool"
|
||||
echo "Download: https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
|
||||
exit 1
|
||||
fi
|
||||
"${OUT}/appimagetool" --appimage-extract-and-run "${APPDIR}" "${OUT}/TeleportFling-${VERSION}-${ARCH}.AppImage"
|
||||
|
||||
echo "Done: ${OUT}/TeleportFling-${VERSION}-${ARCH}.AppImage"
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a .deb package of TeleportFling for Debian/Ubuntu.
|
||||
#
|
||||
# The binary is built with cgo; runtime libraries (libturbojpeg, libpipewire,
|
||||
# and the GLFW/GL stack for the GUI) are declared as Debian package
|
||||
# dependencies so the .deb works on a stock Debian/Ubuntu install. Output:
|
||||
# dist/teleportfling_<version>_amd64.deb
|
||||
#
|
||||
# Usage:
|
||||
# ./packaging/deb.sh
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT="${HERE}/dist"
|
||||
ROOT="${OUT}/deb-root"
|
||||
|
||||
VERSION="0.1.0"
|
||||
ARCH="amd64"
|
||||
PKG_NAME="teleportfling"
|
||||
|
||||
echo "Building teleportfling and teleportfling-gui..."
|
||||
nix develop --command bash -c "go build -o '${OUT}/teleportfling' ./cmd/teleportfling && go build -o '${OUT}/teleportfling-gui' ./cmd/teleportfling-gui"
|
||||
|
||||
echo "Assembling .deb root..."
|
||||
rm -rf "${ROOT}"
|
||||
mkdir -p "${ROOT}/DEBIAN" \
|
||||
"${ROOT}/usr/bin" \
|
||||
"${ROOT}/usr/share/applications" \
|
||||
"${ROOT}/usr/share/icons/hicolor/512x512/apps"
|
||||
|
||||
install -m 0755 "${OUT}/teleportfling" "${ROOT}/usr/bin/teleportfling"
|
||||
install -m 0755 "${OUT}/teleportfling-gui" "${ROOT}/usr/bin/teleportfling-gui"
|
||||
install -m 0644 "${HERE}/assets/teleportfling.desktop" "${ROOT}/usr/share/applications/teleportfling.desktop"
|
||||
install -m 0644 "${HERE}/assets/teleportfling.png" "${ROOT}/usr/share/icons/hicolor/512x512/apps/teleportfling.png"
|
||||
|
||||
# Debian package control file.
|
||||
cat > "${ROOT}/DEBIAN/control" <<EOF
|
||||
Package: ${PKG_NAME}
|
||||
Version: ${VERSION}
|
||||
Section: video
|
||||
Priority: optional
|
||||
Architecture: ${ARCH}
|
||||
Maintainer: Pete Edley <[email protected]>
|
||||
Depends: libjpeg-turbo8 (>= 2.0), libpipewire-0.3-0 (>= 0.3), libgl1, libglx0,
|
||||
libwayland-client0, libwayland-cursor0, libwayland-egl1, libxkbcommon0,
|
||||
libx11-6, libxrandr2, libxi6, libxcursor1, libxinerama1, libxxf86vm1,
|
||||
libxrender1, libxext6, libxfixes3
|
||||
Description: Stream a screen and audio to OBS Teleport receivers
|
||||
Standalone Linux sender for the OBS Teleport protocol. Captures a Wayland
|
||||
screen and system audio, encodes video to JPEG, and streams them over TCP
|
||||
so OBS with the obs-teleport plugin can discover and display the stream.
|
||||
.
|
||||
Provides a headless CLI (teleportfling) and a desktop GUI with system tray
|
||||
(teleportfling-gui).
|
||||
Homepage: http://homeserver:3050/pedley/TeleportFling
|
||||
EOF
|
||||
|
||||
echo "Building .deb..."
|
||||
nix shell nixpkgs#dpkg --command dpkg-deb --build --root-owner-group "${ROOT}" "${OUT}/${PKG_NAME}_${VERSION}_${ARCH}.deb"
|
||||
|
||||
echo "Done: ${OUT}/${PKG_NAME}_${VERSION}_${ARCH}.deb"
|
||||
@@ -4,20 +4,32 @@ Backlog of deferred / planned work, tracked outside of milestone milestones.
|
||||
|
||||
## Planned
|
||||
|
||||
- [ ] **Monitor picker in the GUI** — `stream-index` is currently config-only.
|
||||
Enumerate monitors via the xdg-desktop-portal ScreenCast API and expose
|
||||
a dropdown in the settings window instead of a manual index.
|
||||
- [ ] **Surface runtime errors in the GUI** — capture/encode failures currently
|
||||
only hit the log. Show them in the status label (e.g. a denied
|
||||
screen-share dialog) so problems are visible without reading logs.
|
||||
- [ ] **`--config` flag** — allow the CLI and GUI to point at a custom config
|
||||
path (`teleportfling --config /path/config.json`). Useful for a future
|
||||
daemon or service and for running multiple profiles side by side.
|
||||
- [x] **Monitor picker in the GUI** — monitors are enumerated via hyprctl
|
||||
(Hyprland) and shown as a dropdown in the settings window; the selected
|
||||
monitor persists as `stream_index`. Falls back to a single indexed
|
||||
option on other compositors.
|
||||
- [x] **Surface runtime errors in the GUI** — the engine records the most
|
||||
recent capture/encode/packet/audio error and the GUI shows it in the
|
||||
status label and tray tooltip (highlighted as a danger).
|
||||
- [x] **`--config` flag** — the CLI and GUI accept a custom config path
|
||||
(`teleportfling --config /path/config.json`); explicit CLI flags
|
||||
override file values. Used by the systemd daemon unit in contrib/.
|
||||
- [x] **Run as a daemon** — `contrib/install-daemon.sh` installs a per-user
|
||||
systemd unit (`contrib/teleportfling.service`) that starts the headless
|
||||
CLI with its own config at `~/.config/teleportfling/daemon.json`.
|
||||
|
||||
## Ideas (not yet scoped)
|
||||
## Future / Ideas (not yet scoped)
|
||||
|
||||
- Live settings changes mid-stream (`flinger.SetConfig` for FPS/quality).
|
||||
- Bitrate/bandwidth tuning beyond the balanced 1080p30 default.
|
||||
- Screen capture scaling / downsampling.
|
||||
- Packaging (Nix package / AppImage / release binaries) — deliberately
|
||||
deferred.
|
||||
- [ ] **Live settings changes mid-stream** — `flinger.SetConfig` for FPS/quality
|
||||
without a restart.
|
||||
- [ ] **Bitrate / bandwidth tuning** — options beyond the balanced 1080p30
|
||||
default.
|
||||
- [ ] **Screen capture scaling / downsampling** — capture a scaled region rather
|
||||
than the monitor's native resolution.
|
||||
- [ ] **Multi-monitor verification** — the GUI monitor picker enumerates via
|
||||
hyprctl; confirm behaviour on a real multi-monitor setup.
|
||||
- [ ] **Non-Hyprland monitor enumeration** — portal-based fallback so the picker
|
||||
works on GNOME/KDE etc. (currently falls back to a single indexed option).
|
||||
- [ ] **Packaging** — Nix flake package, AppImage, and `.deb` for Debian/Ubuntu
|
||||
(in progress: flake package, AppImage and .deb scripts added under
|
||||
packaging/; verify on a real Debian/Ubuntu machine).
|
||||
Reference in New Issue
Block a user