Compare commits

...
17 Commits
Author SHA1 Message Date
petere 3b3355e002 feat: selectable audio source
Add an audio source picker to the GUI so users can capture a specific
PipeWire device (sink monitor or microphone) instead of only the system
default output.

- vendor go2tv.app/screencast and patch the audio stream to accept a
  target PipeWire node serial (PW_KEY_TARGET_OBJECT); the upstream lib
  only ever auto-connected to the default
- capture: ListAudioSources enumerates PipeWire sinks/sources via pw-dump;
  OpenPipeWire takes the selected node serial
- flinger/config/gui: AudioSource config field, persisted and exposed as
  an Audio source dropdown (default output + enumerated devices)

Also fixes two pre-existing bugs surfaced by stop/start testing:
- engine Stop now waits for the video/audio/stats goroutines before
  destroying the encoder (was a use-after-free SIGSEGV)
- Start/Stop now pause/resume the engine instead of tearing down and
  re-opening the portal session, which the portal cannot reliably do
  in-process (2nd CreateSession returned Ended/cancelled). Capture
  session stays open across stop/start.
2026-09-19 21:44:10 +01:00
petere f1f2bfe1a3 feat: portal-based monitor enumeration fallback
Add a xdg-desktop-portal ScreenCast fallback to monitor enumeration so
the GUI picker works on compositors without hyprctl (GNOME/KDE etc.).
ListMonitors now tries hyprctl first, then creates a portal session,
selects monitor sources, starts capture and parses the stream list.

Verified on Hyprland: the portal path returns the 1920x1200 monitor,
confirming the compositor-agnostic mechanism works.
2026-09-19 20:40:56 +01:00
petere aab2006e78 feat: screen capture scaling / downsampling
Add a Scale option (1.0 native, 0.5 half, etc.) that downsamples the
captured BGRA frame with bilinear interpolation before JPEG encoding.
Config carries the scale factor; the GUI exposes a Scale dropdown
(100%/75%/50%/25%) that applies live via SetConfig.

Verified: OBS displays the image at the reduced resolution matching the
selected scale, without restarting the stream.
2026-09-19 20:33:40 +01:00
petere 583274d9e6 feat: add bitrate measurement and quality presets
- sender tracks payload bytes; engine measures stream bitrate over a
  sliding window and exposes it in Status
- GUI status label and tray tooltip show the live bitrate (e.g. 36 Mbps)
- add a Preset dropdown (Low/Medium/High/Ultra) that sets quality+fps
  together and applies live via SetConfig

Verified: 36 Mbps shown for Medium (quality 70 @ 30fps) matches the
wire measurement.
2026-09-19 17:53:49 +01:00
petere 95f47abadb feat: live settings changes mid-stream
Add flinger.SetConfig to update FPS/quality/name/audio/announce without
restarting the stream. Fields that require a restart (source, port,
stream-index) are preserved. The video loop reads the live config each
frame so changes apply immediately, and the GUI Quality/Frame-rate
dropdowns push changes to a running engine.

Verified live: fps 12->33 and JPEG size 210KB->1.3MB on quality change.
2026-09-19 17:37:47 +01:00
petere f25b498fc1 docs: move backlog to Gitea issue tracker
Replace todo.md with a pointer to the issue tracker. Feature requests
created as issues #1-#6 on gitea.edley.me.
2026-09-19 15:02:47 +01:00
petere 2b85a78c22 chore: move to public gitea instance and drop project-local gitea MCP
- Remove the project-local gitea MCP (opencode.json) and gitea-mcp-server
  from the dev shell — the gitea MCP is now installed globally and points
  at https://gitea.edley.me
- Update README/project.md links and the flake package homepage to the
  new public instance
- The GITEA_ACCESS_TOKEN for the old homeserver is removed from
  .secrets.env (gitignored); the global token from /run/secrets is used
2026-09-19 14:31:08 +01:00
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
30 changed files with 1994 additions and 129 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](https://gitea.edley.me/petere/TeleportFling/src/branch/main/dist/TeleportFling-0.1.0-x86_64.AppImage)
- [Debian/Ubuntu .deb](https://gitea.edley.me/petere/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 -31
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 = "https://gitea.edley.me/petere/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 = "https://gitea.edley.me/petere/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; [
@@ -21,27 +124,12 @@
gopls
nixd
nodejs
gitea-mcp-server
];
buildInputs = with pkgs; [
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 +138,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!"
'';
};
});
}
}
+2
View File
@@ -44,3 +44,5 @@ require (
golang.org/x/text v0.42.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace go2tv.app/screencast => ./third_party/screencast
+97
View File
@@ -0,0 +1,97 @@
// Audio source enumeration for the settings UI.
package capture
import (
"encoding/json"
"os/exec"
)
// AudioDevice describes a capturable PipeWire audio node.
type AudioDevice struct {
Serial uint64 // PipeWire object.serial, passed to the capture backend
ID uint32 // PipeWire node id (informational)
Name string // node name, e.g. "alsa_output...analog-stereo"
Desc string // human-readable description
IsOutput bool // true = a sink (system output); false = a source (mic)
}
// pwDumpNode is the subset of `pw-dump` output we parse.
type pwDumpNode struct {
ID uint32 `json:"id"`
Info struct {
Props map[string]any `json:"props"`
} `json:"info"`
}
// ListAudioSources enumerates PipeWire audio sinks and sources via `pw-dump`.
// To capture system audio we attach to a sink's monitor; to capture a
// microphone we attach to an Audio/Source node. This lists the capturable
// audio nodes so the GUI can present a picker instead of always using the
// system default output.
func ListAudioSources() ([]AudioDevice, error) {
out, err := exec.Command("pw-dump").Output()
if err != nil {
return nil, err
}
var nodes []pwDumpNode
if err := json.Unmarshal(out, &nodes); err != nil {
return nil, err
}
var sources []AudioDevice
for _, n := range nodes {
props := n.Info.Props
mediaClass, _ := props["media.class"].(string)
if !audioNodeClass(mediaClass) {
continue
}
desc, _ := props["node.description"].(string)
name, _ := props["node.name"].(string)
serial := toUint64(props["object.serial"])
if serial == 0 {
continue
}
sources = append(sources, AudioDevice{
Serial: serial,
ID: n.ID,
Name: name,
Desc: desc,
IsOutput: mediaClass == "Audio/Sink",
})
}
return sources, nil
}
// audioNodeClass reports whether a media.class is a capturable audio node.
func audioNodeClass(mediaClass string) bool {
switch mediaClass {
case "Audio/Sink", "Audio/Source":
return true
default:
return false
}
}
// toUint64 best-effort converts a pw-dump property value to uint64.
// PipeWire serials are small, so the conversions cannot overflow in practice.
//
//nolint:gosec // safe: JSON numbers from pw-dump are small node serials
func toUint64(v any) uint64 {
switch t := v.(type) {
case float64:
return uint64(t)
case int:
return uint64(t)
case int64:
return uint64(t)
case uint64:
return t
case json.Number:
if n, err := t.Int64(); err == nil {
return uint64(n)
}
}
return 0
}
+64
View File
@@ -0,0 +1,64 @@
package capture
import (
"encoding/json"
"testing"
)
// TestPWParseAudioNodes parses a representative pw-dump payload into the
// node shape used by ListAudioSources.
func TestPWParseAudioNodes(t *testing.T) {
payload := `[
{"id": 51, "info": {"props": {"media.class": "Audio/Sink", "node.name": "alsa_out_speaker", "node.description": "Speaker", "object.serial": 1179}}},
{"id": 56, "info": {"props": {"media.class": "Audio/Source", "node.name": "alsa_in_mic", "node.description": "Stereo Mic", "object.serial": 1183}}},
{"id": 64, "info": {"props": {"media.class": "Audio/Device", "node.description": "Not capturable", "object.serial": 1172}}}
]`
var nodes []pwDumpNode
if err := json.Unmarshal([]byte(payload), &nodes); err != nil {
t.Fatalf("unmarshal: %v", err)
}
var devs []AudioDevice
for _, n := range nodes {
props := n.Info.Props
mc, _ := props["media.class"].(string)
if !audioNodeClass(mc) {
continue
}
devs = append(devs, AudioDevice{
Serial: toUint64(props["object.serial"]),
ID: n.ID,
Name: props["node.name"].(string),
Desc: props["node.description"].(string),
IsOutput: mc == "Audio/Sink",
})
}
if len(devs) != 2 {
t.Fatalf("got %d capturable devices, want 2", len(devs))
}
// Sink serial parsed and flagged as output.
if devs[0].Serial != 1179 || !devs[0].IsOutput {
t.Errorf("sink wrong: %+v", devs[0])
}
// Source serial parsed and flagged as input.
if devs[1].Serial != 1183 || devs[1].IsOutput {
t.Errorf("source wrong: %+v", devs[1])
}
}
// TestAudioNodeClass verifies which media classes are capturable.
func TestAudioNodeClass(t *testing.T) {
cases := map[string]bool{
"Audio/Sink": true,
"Audio/Source": true,
"Audio/Device": false,
"Video/Source": false,
}
for cls, want := range cases {
if got := audioNodeClass(cls); got != want {
t.Errorf("audioNodeClass(%q) = %v, want %v", cls, got, want)
}
}
}
+83
View File
@@ -0,0 +1,83 @@
// 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. It prefers
// the Hyprland IPC when available, falling back to the xdg-desktop-portal
// ScreenCast API for other compositors.
func ListMonitors() ([]Monitor, error) {
if hyprctlAvailable() {
if mons, err := hyprctlMonitors(); err == nil {
return mons, nil
}
}
return portalMonitors()
}
// 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])
}
}
+7 -5
View File
@@ -24,12 +24,14 @@ type PipeWire struct {
}
// OpenPipeWire opens a PipeWire capture session. streamIndex selects which
// monitor to capture when multiple are present. Triggering the portal
// consent dialog is expected; the compositor decides whether to show it.
func OpenPipeWire(streamIndex int, audio bool) (*PipeWire, error) {
// monitor to capture when multiple are present; audioSourceSerial optionally
// selects a specific PipeWire audio node (0 = system default). Triggering the
// portal consent dialog is expected; the compositor decides whether to show it.
func OpenPipeWire(streamIndex int, audio bool, audioSourceSerial uint64) (*PipeWire, error) {
s, err := capture.Open(&capture.Options{
StreamIndex: streamIndex,
IncludeAudio: audio,
StreamIndex: streamIndex,
IncludeAudio: audio,
AudioSourceSerial: audioSourceSerial,
})
if err != nil {
return nil, err
+251
View File
@@ -0,0 +1,251 @@
// Portal-based monitor enumeration (fallback for compositors without
// `hyprctl`, e.g. GNOME/KDE).
//
// The xdg-desktop-portal ScreenCast API is compositor-agnostic: we create a
// session, select monitor sources, call Start, and parse the returned stream
// list into Monitor entries.
//
// NOTE: Start() may present the compositor's screen-sharing consent dialog,
// so this path is only used as a fallback when hyprctl is unavailable.
package capture
import (
"errors"
"fmt"
"time"
"github.com/godbus/dbus/v5"
)
// xdg-desktop-portal D-Bus names and interfaces.
const (
portalBusName = "org.freedesktop.portal.Desktop"
portalPath = "/org/freedesktop/portal/desktop"
screenCastIFace = "org.freedesktop.portal.ScreenCast"
requestIFace = "org.freedesktop.portal.Request"
sessionIFace = "org.freedesktop.portal.Session"
)
// portalTimeout is how long we wait for a portal request to complete.
const portalTimeout = 10 * time.Second
// portalMonitors enumerates monitors via the xdg-desktop-portal ScreenCast
// interface. Returns ErrNoMonitors if the portal is unavailable or the user
// cancels the selection.
func portalMonitors() ([]Monitor, error) {
conn, err := dbus.SessionBus()
if err != nil {
return nil, ErrNoMonitors
}
defer func() { _ = conn.Close() }()
obj := conn.Object(portalBusName, portalPath)
handle := fmt.Sprintf("teleportfling%d", time.Now().UnixNano())
sess, err := portalNewSession(conn, obj, handle)
if err != nil {
return nil, ErrNoMonitors
}
defer sess.close()
if err := sess.selectSources(); err != nil {
return nil, ErrNoMonitors
}
streams, err := sess.start()
if err != nil {
return nil, ErrNoMonitors
}
monitors := make([]Monitor, 0, len(streams))
for i, s := range streams {
monitors = append(monitors, Monitor{
Index: i,
Name: s.Name,
Width: s.Size[0],
Height: s.Size[1],
})
}
return monitors, nil
}
// portalStream is a parsed ScreenCast stream.
type portalStream struct {
Name string
Size [2]int
}
// portalNewSession creates a ScreenCast session and subscribes to portal
// Request signals on the connection.
func portalNewSession(conn *dbus.Conn, obj dbus.BusObject, handle string) (*portalSession, error) {
// Subscribe to portal Request::Response signals once for this connection.
sigCh := make(chan *dbus.Signal, 16)
conn.Signal(sigCh)
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(requestIFace),
dbus.WithMatchOption("member", "Response"),
); err != nil {
return nil, err
}
s := &portalSession{conn: conn, obj: obj}
// The Request signals arrive on paths we learn from each call; store the
// channel for the wait helper.
s.sig = sigCh
data := map[string]dbus.Variant{
"session_handle_token": dbus.MakeVariant(handle),
"handle_token": dbus.MakeVariant(handle + "_create"),
}
call := obj.Call(screenCastIFace+".CreateSession", 0, data)
if call.Err != nil {
return nil, call.Err
}
var reqPath dbus.ObjectPath
if err := call.Store(&reqPath); err != nil {
return nil, err
}
resp, err := s.waitResponse(reqPath)
if err != nil {
return nil, err
}
sv, ok := resp["session_handle"]
if !ok {
return nil, errors.New("portal: CreateSession response missing session_handle")
}
str, ok := sv.Value().(string)
if !ok {
return nil, errors.New("portal: session_handle has unexpected type")
}
s.path = dbus.ObjectPath(str)
return s, nil
}
// portalSession carries the signal channel used to await portal responses.
type portalSession struct {
conn *dbus.Conn
obj dbus.BusObject
path dbus.ObjectPath
sig chan *dbus.Signal
}
// selectSources configures the session to capture all monitors.
func (s *portalSession) selectSources() error {
data := map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(fmt.Sprintf("sel%d", time.Now().UnixNano())),
"types": dbus.MakeVariant(uint32(1)), // MONITOR
"multiple": dbus.MakeVariant(true),
}
call := s.obj.Call(screenCastIFace+".SelectSources", 0, s.path, data)
if call.Err != nil {
return call.Err
}
var reqPath dbus.ObjectPath
if err := call.Store(&reqPath); err != nil {
return err
}
_, err := s.waitResponse(reqPath)
return err
}
// start calls ScreenCast.Start and parses the stream list.
func (s *portalSession) start() ([]portalStream, error) {
data := map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(fmt.Sprintf("start%d", time.Now().UnixNano())),
}
call := s.obj.Call(screenCastIFace+".Start", 0, s.path, "", data)
if call.Err != nil {
return nil, call.Err
}
var reqPath dbus.ObjectPath
if err := call.Store(&reqPath); err != nil {
return nil, err
}
resp, err := s.waitResponse(reqPath)
if err != nil {
return nil, err
}
sv, ok := resp["streams"]
if !ok {
return nil, errors.New("portal: Start response missing streams")
}
var raw [][]any
switch v := sv.Value().(type) {
case [][]any:
raw = v
case []any:
for _, item := range v {
if sub, ok := item.([]any); ok {
raw = append(raw, sub)
}
}
default:
return nil, fmt.Errorf("portal: streams has unexpected type %T", sv.Value())
}
streams := make([]portalStream, 0, len(raw))
for i, s := range raw {
if len(s) < 2 {
continue
}
ps := portalStream{Name: fmt.Sprintf("Monitor %d", i)}
props, ok := s[1].(map[string]dbus.Variant)
if !ok {
continue
}
if v, ok := props["size"]; ok {
if a, ok := v.Value().([]any); ok && len(a) >= 2 {
if w, ok := a[0].(int32); ok {
ps.Size[0] = int(w)
}
if h, ok := a[1].(int32); ok {
ps.Size[1] = int(h)
}
}
}
if v, ok := props["id"]; ok {
if id, ok := v.Value().(string); ok && id != "" {
ps.Name = id
}
}
streams = append(streams, ps)
}
return streams, nil
}
// close best-effort closes the portal session.
func (s *portalSession) close() {
if s.path != "" {
_ = s.obj.Call(sessionIFace+".Close", 0, s.path).Err
}
}
// waitResponse waits for the Request::Response signal for the given request
// path and returns the response dict.
func (s *portalSession) waitResponse(reqPath dbus.ObjectPath) (map[string]dbus.Variant, error) {
deadline := time.NewTimer(portalTimeout)
defer deadline.Stop()
for {
select {
case sig := <-s.sig:
if sig.Path != reqPath {
continue
}
if len(sig.Body) < 2 {
return nil, errors.New("portal: malformed Response signal")
}
dict, ok := sig.Body[1].(map[string]dbus.Variant)
if !ok {
return nil, errors.New("portal: Response payload is not a dict")
}
return dict, nil
case <-deadline.C:
return nil, errors.New("portal: request timed out")
}
}
}
+33 -15
View File
@@ -16,13 +16,15 @@ import (
// Config mirrors flinger.Config with JSON tags for persistence.
type Config struct {
Name string `json:"name"`
Port int `json:"port"`
Quality int `json:"quality"`
FPS int `json:"fps"`
Source string `json:"source"`
Audio bool `json:"audio"`
StreamIndex int `json:"stream_index"`
Name string `json:"name"`
Port int `json:"port"`
Quality int `json:"quality"`
FPS int `json:"fps"`
Source string `json:"source"`
Audio bool `json:"audio"`
AudioSource uint64 `json:"audio_source"`
StreamIndex int `json:"stream_index"`
Scale float64 `json:"scale"`
// Announce is a *bool so an absent JSON key (older config files) keeps
// the default instead of silently disabling announcements.
Announce *bool `json:"announce"`
@@ -39,7 +41,9 @@ func Default() Config {
FPS: c.FPS,
Source: c.Source,
Audio: c.Audio,
AudioSource: c.AudioSource,
StreamIndex: c.StreamIndex,
Scale: c.Scale,
Announce: &announce,
}
}
@@ -57,7 +61,9 @@ func (c Config) ToFlinger() flinger.Config {
FPS: c.FPS,
Source: c.Source,
Audio: c.Audio,
AudioSource: c.AudioSource,
StreamIndex: c.StreamIndex,
Scale: c.Scale,
Announce: announce,
}
}
@@ -71,15 +77,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
@@ -106,6 +117,9 @@ func Load() (Config, error) {
if c.Source == "" {
c.Source = d.Source
}
if c.Scale <= 0 || c.Scale > 1 {
c.Scale = d.Scale
}
if c.Announce == nil {
announce := true
c.Announce = &announce
@@ -113,15 +127,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)
}
}
+296 -20
View File
@@ -10,11 +10,13 @@ package flinger
import (
"errors"
"fmt"
"image"
"image/color"
"io"
"log"
"strconv"
"sync"
"sync/atomic"
"time"
@@ -46,8 +48,14 @@ type Config struct {
Source string
// Audio enables system audio capture and streaming.
Audio bool
// AudioSource is the PipeWire node serial to capture audio from (0 = system
// default output). Only applies when Audio is true and Source is "screen".
AudioSource uint64
// StreamIndex selects which monitor to capture (screen source only).
StreamIndex int
// Scale downscales the captured frame before encoding. 1.0 = native
// resolution, 0.5 = half width/height, etc. Must be in (0, 1].
Scale float64
// Announce controls whether the stream is advertised via UDP multicast.
// When disabled, receivers must connect by IP manually.
Announce bool
@@ -61,6 +69,7 @@ func DefaultConfig() Config {
FPS: 30,
Source: "screen",
Audio: true,
Scale: 1.0,
Announce: true,
}
}
@@ -82,6 +91,9 @@ func (c Config) Validate() error {
if c.StreamIndex < 0 {
return errors.New("stream index must be >= 0")
}
if c.Scale <= 0 || c.Scale > 1 {
return errors.New("scale must be in (0, 1]")
}
return nil
}
@@ -91,11 +103,18 @@ type Status struct {
Frames int64
Dropped int64
Conns int
// Bitrate is the measured stream bandwidth in bits per second, averaged
// over the previous measurement window.
Bitrate int64
// 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.
type Engine struct {
cfg Config
cfgMu sync.RWMutex
cfg Config
sender *output.Sender
announcer *discovery.Announcer
@@ -106,7 +125,67 @@ type Engine struct {
start time.Time
stop chan struct{}
// running gates packet sending: when false the loops consume capture
// frames but do not transmit. This lets the GUI "stop" without tearing
// down the portal/PipeWire session (which the portal cannot reliably
// recreate in-process), so a subsequent Start just flips running on.
running atomic.Bool
frames atomic.Int64
// wg tracks the audio/video/stats goroutines so Stop can wait for them to
// finish before destroying shared resources (encoder, sender, capture).
wg sync.WaitGroup
// scaleBuf is the cached downscaled frame, reallocated only when the
// configured scale or source dimensions change.
scaleBuf *capture.VideoFrame
errMu sync.RWMutex
lastErr error
// bitrate tracking
bitMu sync.Mutex
bitLast time.Time
bitBytes int64
bitrate int64
}
// 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()
}
// SetConfig updates engine settings live (FPS, quality, name, etc.) without
// restarting the stream. It validates the new config first; on error the
// engine keeps its current settings.
//
// Not every field is live-applicable mid-stream: source, port and
// stream-index still require a restart (they are ignored if changed).
func (e *Engine) SetConfig(cfg Config) error {
if err := cfg.Validate(); err != nil {
return err
}
e.cfgMu.Lock()
defer e.cfgMu.Unlock()
// Fields that cannot change live keep their current values.
cfg.Source = e.cfg.Source
cfg.Port = e.cfg.Port
cfg.StreamIndex = e.cfg.StreamIndex
cfg.AudioSource = e.cfg.AudioSource
e.cfg = cfg
return nil
}
// getCfg returns a snapshot of the current config.
func (e *Engine) getCfg() Config {
e.cfgMu.RLock()
defer e.cfgMu.RUnlock()
return e.cfg
}
// New creates an engine from cfg. Capture is opened eagerly so that
@@ -134,7 +213,7 @@ func New(cfg Config) (*Engine, error) {
switch cfg.Source {
case "screen":
cam, err := capture.OpenPipeWire(cfg.StreamIndex, cfg.Audio)
cam, err := capture.OpenPipeWire(cfg.StreamIndex, cfg.Audio, cfg.AudioSource)
if err != nil {
e.encoder.Close()
sender.Close()
@@ -157,11 +236,13 @@ func New(cfg Config) (*Engine, error) {
// config) starts announcing the stream. It is idempotent.
func (e *Engine) Start() {
if e.stop != nil {
e.Resume()
return
}
e.start = time.Now()
e.stop = make(chan struct{})
e.running.Store(true)
if e.cfg.Announce {
e.announcer = discovery.Start(e.cfg.Name, e.sender.Port())
@@ -178,47 +259,124 @@ func (e *Engine) Start() {
src = capture.NewSilenceSource()
}
go e.audioLoop(src)
go e.videoLoop()
go e.statsLoop()
e.wg.Add(3)
go func() {
defer e.wg.Done()
e.audioLoop(src)
}()
go func() {
defer e.wg.Done()
e.videoLoop()
}()
go func() {
defer e.wg.Done()
e.statsLoop()
}()
}
// Stop halts the loops, stops announcing and closes all resources. It is
// idempotent. After Stop the engine must not be restarted.
// Stop pauses the stream: loops keep consuming capture frames but stop
// transmitting, and the portal/PipeWire session stays open so the engine can
// be resumed with Start without re-opening the portal (which the portal
// cannot reliably do in-process). Idempotent.
func (e *Engine) Stop() {
e.running.Store(false)
}
// Pause is an alias for Stop; kept for clarity at call sites.
func (e *Engine) Pause() { e.Stop() }
// Resume restarts transmission on a paused engine. It is a no-op if the
// engine was never started.
func (e *Engine) Resume() {
if e.stop == nil {
return
}
e.running.Store(true)
}
// Close fully tears down the engine: stops the loops, closes the portal
// session and frees the encoder/sender. After Close the engine must not be
// reused.
func (e *Engine) Close() {
if e.stop == nil {
return
}
close(e.stop)
// Give the loops a moment to observe the stop signal.
time.Sleep(50 * time.Millisecond)
// Closing the capture source unblocks the audio and video loops that may
// be stuck in a read. Do this before waiting so they can observe stop.
if e.cam != nil {
if err := e.cam.Close(); err != nil {
log.Printf("flinger: capture close: %v", err)
}
}
// Wait for the audio/video/stats goroutines to finish so they no longer
// touch the encoder or sender before we destroy them.
e.wg.Wait()
if e.announcer != nil {
e.announcer.Stop()
}
e.sender.Close()
e.encoder.Close()
if e.cam != nil {
if err := e.cam.Close(); err != nil {
log.Printf("flinger: capture close: %v", err)
}
}
}
// 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,
Running: e.running.Load() && e.stop != nil,
Frames: e.frames.Load(),
Dropped: e.sender.Dropped(),
Conns: e.sender.NumConns(),
Bitrate: e.measureBitrate(),
Err: err,
}
}
// bitrateWindow is the sliding window over which bitrate is averaged.
const bitrateWindow = 2 * time.Second
// measureBitrate computes the current stream bitrate (bits/sec) over a
// sliding window. It is called from Status.
func (e *Engine) measureBitrate() int64 {
e.bitMu.Lock()
defer e.bitMu.Unlock()
now := time.Now()
bytes := e.sender.BytesSent()
if e.bitLast.IsZero() {
e.bitLast = now
e.bitBytes = bytes
return 0
}
elapsed := now.Sub(e.bitLast)
if elapsed < bitrateWindow {
return e.bitrate
}
// Bytes accumulated since the previous sample.
delta := bytes - e.bitBytes
e.bitrate = int64(float64(delta*8) / elapsed.Seconds())
e.bitLast = now
e.bitBytes = bytes
return e.bitrate
}
// 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() }()
@@ -227,12 +385,22 @@ func (e *Engine) audioLoop(src io.ReadCloser) {
buf := make([]byte, chunkBytes)
for {
select {
case <-e.stop:
return
default:
}
n, err := io.ReadFull(src, buf)
if n > 0 {
if !e.running.Load() {
continue
}
frames := n / (speakers * 2)
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,18 +413,93 @@ 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)
}
}
}
}
// videoLoop pulls frames and sends them at the configured fps.
// scaleFrame downscales frame by the configured factor, returning the original
// when scale is 1.0 (native). The result is a cached buffer reused across
// frames, reallocated only when the dimensions or scale change.
func (e *Engine) scaleFrame(frame *capture.VideoFrame, scale float64) *capture.VideoFrame {
if scale >= 1.0 || frame == nil {
return frame
}
sw := int(float64(frame.Width) * scale)
sh := int(float64(frame.Height) * scale)
if sw < 1 {
sw = 1
}
if sh < 1 {
sh = 1
}
// Reuse the cached buffer if it matches the target size.
if e.scaleBuf == nil || e.scaleBuf.Width != sw || e.scaleBuf.Height != sh {
e.scaleBuf = &capture.VideoFrame{
Pix: make([]byte, sw*sh*4),
Width: sw,
Height: sh,
Stride: sw * 4,
}
}
scaleBGRA(frame, e.scaleBuf)
return e.scaleBuf
}
// scaleBGRA bilinearly downsamples an interleaved BGRA frame into dst.
func scaleBGRA(src, dst *capture.VideoFrame) {
sw, sh := float64(src.Width), float64(src.Height)
for y := 0; y < dst.Height; y++ {
srcY := (float64(y) + 0.5) * sh / float64(dst.Height)
y0 := int(srcY)
if y0 >= src.Height-1 {
y0 = src.Height - 2
}
yFrac := srcY - float64(y0)
row0 := y0 * src.Stride
row1 := (y0 + 1) * src.Stride
di := y * dst.Stride
for x := 0; x < dst.Width; x++ {
srcX := (float64(x) + 0.5) * sw / float64(dst.Width)
x0 := int(srcX)
if x0 >= src.Width-1 {
x0 = src.Width - 2
}
xFrac := srcX - float64(x0)
p00 := row0 + x0*4
p01 := row0 + (x0+1)*4
p10 := row1 + x0*4
p11 := row1 + (x0+1)*4
for c := 0; c < 4; c++ {
top := float64(src.Pix[p00+c])*(1-xFrac) + float64(src.Pix[p01+c])*xFrac
bot := float64(src.Pix[p10+c])*(1-xFrac) + float64(src.Pix[p11+c])*xFrac
dst.Pix[di+x*4+c] = uint8(top*(1-yFrac) + bot*yFrac)
}
}
}
}
// videoLoop pulls frames and sends them at the configured fps. The frame rate
// and JPEG quality are read from the live config so SetConfig takes effect
// without restarting.
func (e *Engine) videoLoop() {
frameInterval := time.Second / time.Duration(e.cfg.FPS)
frameInterval := time.Second / time.Duration(e.getCfg().FPS)
next := e.start
for {
select {
case <-e.stop:
return
default:
}
frame, err := e.loop.Next()
if err != nil {
select {
@@ -265,11 +508,21 @@ 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
}
// Re-read the live config each frame so FPS/quality changes apply
// immediately. When the interval changes, resync `next` to now.
cfg := e.getCfg()
interval := time.Second / time.Duration(cfg.FPS)
if interval != frameInterval {
frameInterval = interval
next = time.Now()
}
// Drop frames when running ahead of the target fps to keep
// timestamps monotonic (e.g. a 60 Hz monitor captured at 30 fps).
now := time.Now()
@@ -278,9 +531,18 @@ func (e *Engine) videoLoop() {
}
next = now.Add(frameInterval)
// When paused (Stop), keep consuming frames to stay alive but do not
// encode or transmit. The portal session is left open so a subsequent
// Start can resume without re-opening the portal.
if !e.running.Load() {
continue
}
ts := uint64(now.Sub(e.start))
buf, err := e.encoder.EncodeBGRA(frame.Pix, frame.Width, frame.Height, e.cfg.Quality)
enc := e.scaleFrame(frame, cfg.Scale)
buf, err := e.encoder.EncodeBGRA(enc.Pix, enc.Width, enc.Height, cfg.Quality)
if err != nil {
e.setErr(err)
log.Printf("flinger: jpeg: %v", err)
continue
}
@@ -292,6 +554,7 @@ func (e *Engine) videoLoop() {
buf,
)
if err != nil {
e.setErr(err)
log.Printf("flinger: packet: %v", err)
continue
}
@@ -308,10 +571,11 @@ func (e *Engine) statsLoop() {
select {
case <-tick.C:
st := e.Status()
rate := formatBitrate(st.Bitrate)
if st.Dropped > 0 {
log.Printf("flinger: %d frames, %d dropped, %d conns", st.Frames, st.Dropped, st.Conns)
log.Printf("flinger: %d frames, %d dropped, %d conns, %s", st.Frames, st.Dropped, st.Conns, rate)
} else {
log.Printf("flinger: %d frames, %d conns", st.Frames, st.Conns)
log.Printf("flinger: %d frames, %d conns, %s", st.Frames, st.Conns, rate)
}
case <-e.stop:
return
@@ -319,6 +583,18 @@ func (e *Engine) statsLoop() {
}
}
// formatBitrate renders a bits/sec value in a human-readable form.
func formatBitrate(bps int64) string {
switch {
case bps >= 1_000_000:
return fmt.Sprintf("%.1f Mbps", float64(bps)/1_000_000)
case bps >= 1_000:
return fmt.Sprintf("%.0f kbps", float64(bps)/1_000)
default:
return fmt.Sprintf("%d bps", bps)
}
}
// frameSource abstracts the frame source: real capture or the test pattern.
type frameSource interface {
Next() (*capture.VideoFrame, error)
+189 -1
View File
@@ -1,9 +1,12 @@
package flinger
import (
"errors"
"net"
"testing"
"time"
"teleportfling/internal/capture"
)
// TestEnginePatternStartStop runs the engine with the synthetic pattern source
@@ -19,7 +22,7 @@ func TestEnginePatternStartStop(t *testing.T) {
}
eng.Start()
defer eng.Stop()
defer eng.Close()
// Connect a raw receiver and read until the engine reports frames sent.
conn, err := net.Dial("tcp", "127.0.0.1:"+itoa(eng.sender.Port()))
@@ -77,6 +80,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){
@@ -100,6 +131,116 @@ func TestValidate(t *testing.T) {
}
}
// TestBitrateMeasurement verifies the engine reports a non-zero bitrate once
// it has been streaming for a bit.
func TestBitrateMeasurement(t *testing.T) {
cfg := DefaultConfig()
cfg.Source = "pattern"
cfg.Port = 19759
eng, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
eng.Start()
defer eng.Close()
// Connect a receiver so packets actually flow, and wait for the bitrate
// window to produce a measurement.
conn, err := net.Dial("tcp", "127.0.0.1:"+itoa(eng.sender.Port()))
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
go func() {
buf := make([]byte, 64*1024)
for {
if _, err := conn.Read(buf); err != nil {
return
}
}
}()
deadline := time.Now().Add(4 * time.Second)
for time.Now().Before(deadline) {
if eng.Status().Bitrate > 0 {
return
}
time.Sleep(200 * time.Millisecond)
}
t.Error("bitrate stayed 0 after 4s of streaming")
}
// TestFormatBitrate checks the human-readable bitrate formatting.
func TestFormatBitrate(t *testing.T) {
cases := []struct {
bps int64
want string
}{
{500, "500 bps"},
{5_000, "5 kbps"},
{5_000_000, "5.0 Mbps"},
}
for _, c := range cases {
if got := formatBitrate(c.bps); got != c.want {
t.Errorf("formatBitrate(%d) = %q, want %q", c.bps, got, c.want)
}
}
}
// TestScaleBGRA verifies downsampling produces the expected dimensions and
// preserves the dominant colour of a solid frame.
func TestScaleBGRA(t *testing.T) {
src := &capture.VideoFrame{
Pix: make([]byte, 100*80*4),
Width: 100,
Height: 80,
Stride: 100 * 4,
}
// Fill with solid red (BGRA: B=0, G=0, R=255).
for i := 0; i+4 <= len(src.Pix); i += 4 {
src.Pix[i], src.Pix[i+1], src.Pix[i+2], src.Pix[i+3] = 0, 0, 255, 255
}
dst := &capture.VideoFrame{
Pix: make([]byte, 50*40*4),
Width: 50,
Height: 40,
Stride: 50 * 4,
}
scaleBGRA(src, dst)
if dst.Width != 50 || dst.Height != 40 {
t.Errorf("dst dims = %dx%d, want 50x40", dst.Width, dst.Height)
}
// Check a few pixels are solid red.
for _, idx := range []int{0, 4, 100, 200} {
if dst.Pix[idx] != 0 || dst.Pix[idx+1] != 0 || dst.Pix[idx+2] != 255 {
t.Errorf("pixel %d not red: B=%d G=%d R=%d", idx, dst.Pix[idx], dst.Pix[idx+1], dst.Pix[idx+2])
}
}
}
// TestEngineScalePreservesNative verifies scaleFrame returns the original
// frame at scale 1.0.
func TestEngineScalePreservesNative(t *testing.T) {
cfg := DefaultConfig()
cfg.Source = "pattern"
cfg.Port = 19760
eng, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
frame := &capture.VideoFrame{Pix: make([]byte, 4*4*4), Width: 4, Height: 4, Stride: 16}
if got := eng.scaleFrame(frame, 1.0); got != frame {
t.Error("scale 1.0 should return the original frame")
}
if got := eng.scaleFrame(frame, 0.5); got == frame {
t.Error("scale 0.5 should return a new frame")
}
}
func itoa(v int) string {
if v == 0 {
return "0"
@@ -113,3 +254,50 @@ func itoa(v int) string {
}
return string(buf[i:])
}
// TestSetConfig verifies live config updates apply FPS/quality and reject
// invalid values, while preserving fields that can't change live.
func TestSetConfig(t *testing.T) {
cfg := DefaultConfig()
cfg.Source = "pattern"
cfg.Port = 19758
eng, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
// Update FPS/quality live.
newCfg := DefaultConfig()
newCfg.Source = "screen" // must be ignored (requires restart)
newCfg.Port = 9999 // must be ignored
newCfg.Quality = 95
newCfg.FPS = 60
if err := eng.SetConfig(newCfg); err != nil {
t.Fatalf("SetConfig: %v", err)
}
got := eng.getCfg()
if got.Quality != 95 {
t.Errorf("quality = %d, want 95", got.Quality)
}
if got.FPS != 60 {
t.Errorf("fps = %d, want 60", got.FPS)
}
// Source/port preserved.
if got.Source != "pattern" {
t.Errorf("source = %q, want pattern (unchanged live)", got.Source)
}
if got.Port != 19758 {
t.Errorf("port = %d, want 19758 (unchanged live)", got.Port)
}
// Invalid config is rejected and current settings kept.
if err := eng.SetConfig(Config{Quality: 200}); err == nil {
t.Error("expected error for invalid quality")
}
got = eng.getCfg()
if got.Quality != 95 {
t.Errorf("quality changed after rejected update: %d", got.Quality)
}
}
+315 -20
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"
)
@@ -34,6 +36,34 @@ func itoa(v int) string { return strconv.Itoa(v) }
func atoi(s string) (int, error) { return strconv.Atoi(s) }
// scaleLabel renders a scale factor as a percentage option.
func scaleLabel(scale float64) string {
switch {
case scale >= 0.95:
return "100%"
case scale >= 0.7:
return "75%"
case scale >= 0.45:
return "50%"
default:
return "25%"
}
}
// parseScale converts a scale option label back to a factor.
func parseScale(label string) float64 {
switch label {
case "75%":
return 0.75
case "50%":
return 0.5
case "25%":
return 0.25
default:
return 1.0
}
}
// appID is the Fyne application ID used for preferences/settings storage.
const appID = "io.teleportfling"
@@ -43,6 +73,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
@@ -56,16 +89,27 @@ type App struct {
portEnt *widget.Entry
qualitySel *widget.Select
fpsSel *widget.Select
presetSel *widget.Select
scaleSel *widget.Select
nameEnt *widget.Entry
audioChk *widget.Check
audioSel *widget.Select
audioDevs []capture.AudioDevice
announceChk *widget.Check
srcSel *widget.Select
monSel *widget.Select
monitors []capture.Monitor
statsDone chan struct{}
// lastStart records the capture-affecting config the current engine was
// created with, so Start can resume instead of reopening the portal.
lastStart config.Config
}
// 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 +118,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 +133,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,18 +173,30 @@ 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) {})
g.qualitySel = widget.NewSelect([]string{"50", "60", "70", "80", "90", "100"}, g.applyLiveSettings)
g.qualitySel.SetSelected(itoa(g.cfg.Quality))
// FPS.
g.fpsSel = widget.NewSelect([]string{"15", "30", "60"}, func(string) {})
g.fpsSel = widget.NewSelect([]string{"15", "30", "60"}, g.applyLiveSettings)
g.fpsSel.SetSelected(itoa(g.cfg.FPS))
// Scale: downsampling factor (1.0 = native, 0.5 = half, etc).
g.scaleSel = widget.NewSelect([]string{"100%", "75%", "50%", "25%"}, g.applyLiveSettings)
g.scaleSel.SetSelected(scaleLabel(g.cfg.Scale))
// Preset: one-click quality/fps combos. Choosing one sets the Quality and
// Frame rate selectors and applies them (live if running). Created after
// the quality/fps selects so applyPreset's references are valid.
g.presetSel = widget.NewSelect([]string{"Low", "Medium", "High", "Ultra"}, g.applyPreset)
g.presetSel.SetSelected("High")
// Audio.
g.audioChk = widget.NewCheck("Capture system audio", nil)
g.audioChk = widget.NewCheck("Capture system audio", func(bool) { g.applyLiveSettings("") })
g.audioChk.SetChecked(g.cfg.Audio)
g.setupAudioPicker()
// Announce over multicast.
g.announceChk = widget.NewCheck("Announce on LAN", nil)
@@ -154,9 +219,13 @@ 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: "Preset", Widget: g.presetSel},
{Text: "Quality", Widget: g.qualitySel},
{Text: "Frame rate", Widget: g.fpsSel},
{Text: "", Widget: g.audioChk},
{Text: "Scale", Widget: g.scaleSel},
{Text: "Audio", Widget: g.audioChk},
{Text: "Audio source", Widget: g.audioSel},
{Text: "", Widget: g.announceChk},
},
}
@@ -172,50 +241,246 @@ 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
}
// setupAudioPicker populates the audio source dropdown from PipeWire. The
// first option is "Default output"; the rest are the enumerated sinks and
// microphones. A no-op if enumeration is unavailable.
func (g *App) setupAudioPicker() {
g.audioDevs, _ = capture.ListAudioSources()
names := []string{"Default output"}
for _, d := range g.audioDevs {
label := d.Desc
if label == "" {
label = d.Name
}
if d.IsOutput {
label = "Output: " + label
} else {
label = "Input: " + label
}
names = append(names, label)
}
g.audioSel = widget.NewSelect(names, func(string) { g.applyLiveSettings("") })
// Preselect the configured serial if it matches an enumerated device.
if g.cfg.AudioSource > 0 {
for i, d := range g.audioDevs {
if d.Serial == g.cfg.AudioSource {
g.audioSel.SetSelectedIndex(i + 1)
break
}
}
} else {
g.audioSel.SetSelectedIndex(0)
}
}
// selectedAudioSerial returns the PipeWire serial chosen in the picker, or 0
// for the default output.
func (g *App) selectedAudioSerial() uint64 {
if g.audioSel == nil || g.audioSel.SelectedIndex() <= 0 {
return 0
}
idx := g.audioSel.SelectedIndex() - 1
if idx >= 0 && idx < len(g.audioDevs) {
return g.audioDevs[idx].Serial
}
return 0
}
// applyPreset applies a named quality/fps preset to the Quality and Frame
// rate selectors, then pushes it live if the engine is running.
func (g *App) applyPreset(string) {
var q, f int
switch g.presetSel.Selected {
case "Low":
q, f = 50, 15
case "Medium":
q, f = 70, 30
case "Ultra":
q, f = 100, 60
default: // High
q, f = 85, 30
}
g.qualitySel.SetSelected(itoa(q))
g.fpsSel.SetSelected(itoa(f))
g.applyLiveSettings("")
}
// applyLiveSettings pushes the current form values (quality, fps, name,
// audio, announce) into a running engine via SetConfig so changes take
// effect without restarting the stream. When the engine is not running it is
// a no-op; the values are still captured on the next Start.
func (g *App) applyLiveSettings(string) {
if g.eng == nil {
return
}
quality, _ := atoi(g.qualitySel.Selected)
fps, _ := atoi(g.fpsSel.Selected)
announce := g.announceChk.Checked
cfg := flinger.Config{
Name: g.nameEnt.Text,
Port: g.cfg.Port,
Quality: quality,
FPS: fps,
Source: g.cfg.Source,
Audio: g.audioChk.Checked,
AudioSource: g.selectedAudioSerial(),
StreamIndex: g.cfg.StreamIndex,
Scale: parseScale(g.scaleSel.Selected),
Announce: announce,
}
if err := g.eng.SetConfig(cfg); err != nil {
log.Printf("gui: live settings: %v", err)
return
}
// Keep the persisted config in sync with what we just applied.
g.cfg.Quality = quality
g.cfg.FPS = fps
g.cfg.Audio = cfg.Audio
g.cfg.AudioSource = cfg.AudioSource
g.cfg.Name = cfg.Name
g.cfg.Scale = cfg.Scale
ann := announce
g.cfg.Announce = &ann
}
// toggleStream starts or stops the engine based on current UI state.
func (g *App) toggleStream() {
if g.lock {
return
}
if g.eng != nil {
// Engine exists and is currently running → pause it.
if g.eng != nil && g.eng.Status().Running {
g.stop()
return
}
g.start()
}
// start reads the form into cfg, saves it, and boots the engine.
// start reads the form into cfg, saves it, and boots the engine. If an engine
// already exists and the capture-affecting settings are unchanged, it resumes
// the paused engine instead of reopening the portal session (which cannot be
// reliably re-created in-process).
func (g *App) start() {
port, _ := atoi(g.portEnt.Text)
quality, _ := atoi(g.qualitySel.Selected)
fps, _ := atoi(g.fpsSel.Selected)
announce := g.announceChk.Checked
g.cfg = config.Config{
newCfg := config.Config{
Name: g.nameEnt.Text,
Port: port,
Quality: quality,
FPS: fps,
Source: g.srcSel.Selected,
Audio: g.audioChk.Checked,
StreamIndex: g.cfg.StreamIndex,
AudioSource: g.selectedAudioSerial(),
StreamIndex: g.selectedMonitorIndex(),
Scale: parseScale(g.scaleSel.Selected),
Announce: &announce,
}
if err := config.Save(g.cfg); err != nil {
g.cfg = newCfg
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)
}
// Resume the existing engine if capture-affecting fields are unchanged.
if g.eng != nil && captureConfigEqual(g.lastStart, newCfg) {
// Push live-applicable changes, then resume.
g.applyLiveSettings("")
g.eng.Resume()
g.startedUI(newCfg)
return
}
// Otherwise close any existing engine and create a fresh one.
if g.eng != nil {
g.eng.Close()
g.eng = nil
}
eng, err := flinger.New(g.cfg.ToFlinger())
if err != nil {
dialog.ShowError(err, g.win)
return
}
g.eng = eng
g.lastStart = newCfg
g.eng.Start()
g.startedUI(newCfg)
}
// captureConfigEqual reports whether two configs agree on the fields that
// require reopening the capture session (source, port, monitor, audio
// source). Live-applicable fields (quality, fps, scale, name, announce,
// audio-on) are ignored.
func captureConfigEqual(a, b config.Config) bool {
return a.Source == b.Source && a.Port == b.Port &&
a.StreamIndex == b.StreamIndex && a.AudioSource == b.AudioSource
}
// startedUI updates the UI to the streaming state after a start/resume.
func (g *App) startedUI(cfg config.Config) {
g.startBtn.SetText("Stop")
g.startBtn.Importance = widget.DangerImportance
g.statusLab.SetText("Streaming (port " + itoa(g.cfg.Port) + ")")
g.statusLab.SetText("Streaming (port " + itoa(cfg.Port) + ")")
g.statusLab.Importance = widget.SuccessImportance
g.setTrayState(true, "TeleportFling · Streaming")
g.refresh()
@@ -241,7 +506,19 @@ 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.Bitrate > 0 {
tip += " · " + formatBitrate(st.Bitrate)
}
if st.Err != nil {
tip += " · error"
}
g.setTrayState(true, tip)
})
case <-done:
return
@@ -256,10 +533,29 @@ func formatStatus(st flinger.Status) string {
if st.Dropped > 0 {
base += " · " + itoa(int(st.Dropped)) + " dropped"
}
if st.Bitrate > 0 {
base += " · " + formatBitrate(st.Bitrate)
}
if st.Err != nil {
base += "\nError: " + st.Err.Error()
}
return base
}
// stop halts the engine and returns the UI to the stopped state.
// formatBitrate renders a bits/sec value in a human-readable form.
func formatBitrate(bps int64) string {
switch {
case bps >= 1_000_000:
return fmt.Sprintf("%.1f Mbps", float64(bps)/1_000_000)
case bps >= 1_000:
return fmt.Sprintf("%.0f kbps", float64(bps)/1_000)
default:
return fmt.Sprintf("%d bps", bps)
}
}
// stop pauses the engine (keeping the portal session open) and returns the
// UI to the stopped state.
func (g *App) stop() {
if g.statsDone != nil {
close(g.statsDone)
@@ -267,7 +563,6 @@ func (g *App) stop() {
}
if g.eng != nil {
g.eng.Stop()
g.eng = nil
}
g.startBtn.SetText("Start")
g.startBtn.Importance = widget.HighImportance
+10
View File
@@ -37,6 +37,7 @@ type Sender struct {
port int
dropped atomic.Int64
bytes atomic.Int64
}
// New creates an unconnected Sender.
@@ -95,6 +96,10 @@ func (s *Sender) Send(b []byte) {
s.mu.Lock()
defer s.mu.Unlock()
// Count the packet once as produced bandwidth (independent of how many
// receivers are attached).
s.bytes.Add(int64(len(b)))
for c, ch := range s.conns {
switch {
case len(ch) > dropAt:
@@ -125,6 +130,11 @@ func (s *Sender) Dropped() int64 {
return s.dropped.Load()
}
// BytesSent returns the total number of payload bytes handed to Send.
func (s *Sender) BytesSent() int64 {
return s.bytes.Load()
}
// Close shuts down the listener and waits for all writer goroutines to
// drain. After Close returns the Sender must not be reused.
func (s *Sender) Close() {
-8
View File
@@ -7,14 +7,6 @@
}
},
"mcp": {
"gitea": {
"type": "local",
"command": ["gitea-mcp", "-t", "stdio"],
"environment": {
"GITEA_HOST": "http://homeserver:3050",
"GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}"
}
},
"obs": {
"type": "local",
"command": ["npx", "-y", "obs-mcp"],
+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"
+1 -1
View File
@@ -42,7 +42,7 @@ and streams it. No NDI compatibility of any form.
| Discovery | UDP multicast peer discovery (identical to `obs-teleport`) |
| Target quality | Initial release: 1920x1080 @ 30 fps, balanced JPEG quality |
| Protocol target | Latest `obs-teleport` release (protocol contract below) |
| Remote git | Self-hosted Gitea at `http://homeserver:3050/` |
| Remote git | Public self-hosted Gitea at `https://gitea.edley.me/` |
## Teleport Protocol Contract
Vendored Submodule
+1
Submodule third_party/screencast added at 4cba613625
+12 -17
View File
@@ -1,23 +1,18 @@
# TeleportFling TODO
Backlog of deferred / planned work, tracked outside of milestone milestones.
Backlog items have moved to the issue tracker on the Gitea instance:
## Planned
https://gitea.edley.me/petere/TeleportFling/issues
- [ ] **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.
Open feature requests:
## Ideas (not yet scoped)
1. Live settings changes mid-stream
2. Bitrate / bandwidth tuning
3. Screen capture scaling / downsampling
4. Multi-monitor verification
5. Non-Hyprland monitor enumeration
6. Packaging verification on real machines
- 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.
Completed items (monitor picker, runtime errors in GUI, `--config` flag,
daemon mode, packaging scripts) are closed in git history — see the README
and project.md for current capabilities.