Compare commits

..
10 Commits
Author SHA1 Message Date
petere 921e6d65df docs: fix release artifact links in README
Use the src/branch view URLs which resolve on this Gitea instance (raw
downloads require auth).
2026-09-19 10:35:01 +01:00
petere 0c97390fe2 build: track AppImage and .deb release artifacts
Commit the prebuilt AppImage and Debian .deb under dist/ so they are
downloadable from the remote. appimagetool and intermediate build output
remain gitignored. README links to both artifacts.
2026-09-19 10:34:07 +01:00
petere 5f5a2d8650 feat: add Nix flake package, AppImage and .deb packaging
- flake: build teleportfling and teleportfling-gui as nixpkgs packages
  (buildGoModule) with a .desktop entry and icon for the GUI
- packaging/appimage.sh: bundle the GUI + runtime libs (recursive ldd)
  into a self-contained AppImage via appimagetool
- packaging/deb.sh: build a .deb for Debian/Ubuntu with both binaries,
  desktop entry, icon and declared runtime dependencies
- README: document all three packaging methods
- .gitignore: ignore dist/
2026-09-19 10:31:35 +01:00
petere a8e2a860b4 docs: mark monitor picker and runtime errors complete in todo 2026-09-19 10:13:39 +01:00
petere 5663fb6b36 feat: add monitor picker to the GUI settings
Enumerate monitors via hyprctl (Hyprland) and present them as a
dropdown in the settings window instead of a raw index. The picker is
enabled only for the screen source and persists the selected monitor as
stream_index. Falls back to a single indexed option when enumeration is
unavailable (non-Hyprland).
2026-09-19 10:13:23 +01:00
petere 50955fd606 feat: surface runtime errors in engine status
Record the most recent runtime error (capture, encode, packet, audio)
in the engine and expose it via Status.Err. The GUI shows it in the
status label (highlighted as a danger) and in the tray tooltip, so a
denied screen-share or encoder failure is visible without reading logs.
2026-09-19 10:13:17 +01:00
petere c1ad3416a1 docs: clarify audio format in flinger audio loop
The PipeWire stream negotiates S16LE stereo at 48 kHz; document that the
WAVE packets carry this directly.
2026-09-19 10:03:32 +01:00
petere 3084c438ee docs: add README with build, usage and network requirements
Document the two ports TeleportFling needs (TCP 9756 stream, UDP 9999
multicast discovery) and how to open them on NixOS and other firewalls,
based on the two-machine test.
2026-09-19 09:22:58 +01:00
petere 973c75f3b4 feat: add systemd user service for daemon mode
- contrib/teleportfling.service: per-user unit running the headless CLI
  with its own config (~/.config/teleportfling/daemon.json)
- contrib/install-daemon.sh: builds the binary, installs the unit and a
  starter daemon config
- contrib/README.md: install and management instructions
- todo.md: mark --config and daemon items complete
2026-09-19 08:35:12 +01:00
petere 34efbaf26d feat: support custom --config path in CLI and GUI
Add config.LoadFrom/SaveTo for arbitrary paths and thread a --config
flag through both entry points:
- CLI: --config loads a file first; explicit flags override file values
- GUI: --config selects the settings file used for load and save
- tests for LoadFrom/SaveTo and default fallback
2026-09-19 08:35:07 +01:00
21 changed files with 907 additions and 75 deletions
+6
View File
@@ -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
+136
View File
@@ -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.
+10 -1
View File
@@ -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
View File
@@ -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()
}
+40
View File
@@ -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.
+58
View File
@@ -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
+16
View File
@@ -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
View File
Binary file not shown.
Binary file not shown.
+108 -30
View File
@@ -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!"
'';
};
});
}
}
+79
View File
@@ -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
}
+42
View File
@@ -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])
}
}
+17 -8
View File
@@ -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)
}
+30
View File
@@ -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)
}
}
+27 -1
View File
@@ -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
}
+29
View File
@@ -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
View File
@@ -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
}
+5
View File
@@ -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" "$@"
+75
View File
@@ -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"
+61
View File
@@ -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"
+27 -15
View File
@@ -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).