Compare commits
17
Commits
b9a539e133
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b3355e002 | ||
|
|
f1f2bfe1a3 | ||
|
|
aab2006e78 | ||
|
|
583274d9e6 | ||
|
|
95f47abadb | ||
|
|
f25b498fc1 | ||
|
|
2b85a78c22 | ||
|
|
921e6d65df | ||
|
|
0c97390fe2 | ||
|
|
5f5a2d8650 | ||
|
|
a8e2a860b4 | ||
|
|
5663fb6b36 | ||
|
|
50955fd606 | ||
|
|
c1ad3416a1 | ||
|
|
3084c438ee | ||
|
|
973c75f3b4 | ||
|
|
34efbaf26d |
@@ -6,3 +6,9 @@
|
||||
# nix
|
||||
result
|
||||
result-*
|
||||
|
||||
# Packaging build output. Keep the release artifacts (AppImage/.deb) tracked
|
||||
# so they are downloadable from the remote, but ignore intermediates.
|
||||
dist/*
|
||||
!dist/*.AppImage
|
||||
!dist/*.deb
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# TeleportFling
|
||||
|
||||
Standalone Linux screen + audio sender for the
|
||||
[OBS Teleport protocol](https://github.com/fzwoch/obs-teleport).
|
||||
|
||||
TeleportFling captures a Wayland screen (via `xdg-desktop-portal` + PipeWire)
|
||||
and the system's default audio, encodes the video to JPEG, packetizes both
|
||||
into the Teleport protocol, and streams them over TCP so any OBS instance
|
||||
with the `obs-teleport` plugin can discover and display the stream.
|
||||
|
||||
## Features
|
||||
|
||||
- **Real screen + audio capture** on Wayland (PipeWire / xdg-desktop-portal).
|
||||
- **LAN discovery** via UDP multicast, matching obs-teleport's `AnnouncePayload`.
|
||||
- **Multiple receivers** — every connected OBS streams from one sender.
|
||||
- **Headless CLI** (`teleportfling`) and **desktop GUI + system tray**
|
||||
(`teleportfling-gui`).
|
||||
- **Config persistence** at `~/.config/teleportfling/config.json`, customisable
|
||||
with `--config`.
|
||||
- **Daemon mode** via a per-user systemd service (see `contrib/`).
|
||||
|
||||
## Build & run
|
||||
|
||||
Requires a Nix dev shell (or Go 1.26+ with libjpeg-turbo and the Fyne/GLFW
|
||||
native deps for the GUI).
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
go build ./cmd/teleportfling # headless CLI
|
||||
go build ./cmd/teleportfling-gui # desktop GUI + tray
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
./teleportfling --name "Studio" --port 9756
|
||||
```
|
||||
|
||||
Flags: `--name`, `--port`, `--quality`, `--fps`, `--source screen|pattern`,
|
||||
`--audio`, `--no-announce`, `--stream-index`, `--duration`, `--config`.
|
||||
|
||||
## Network requirements
|
||||
|
||||
TeleportFling uses two kinds of traffic:
|
||||
|
||||
| Port | Proto | Direction | Purpose |
|
||||
|-------|-------|----------------|----------------------------------|
|
||||
| 9756 | TCP | Sender → listen | Video + audio stream to receivers |
|
||||
| 9999 | UDP | Sender → listen | multicast discovery (peerdiscovery) |
|
||||
|
||||
For a **sender** to be discoverable and reachable by OBS receivers on another
|
||||
machine, **both** machines need these ports open:
|
||||
|
||||
### NixOS
|
||||
|
||||
```nix
|
||||
networking.firewall = {
|
||||
enable = true;
|
||||
allowedTCPPorts = [ 9756 ]; # TeleportFling screen/audio streaming
|
||||
allowedUDPPorts = [ 9999 ]; # TeleportFling multicast discovery
|
||||
};
|
||||
```
|
||||
|
||||
Apply with `sudo nixos-rebuild switch --flake .#<hostname>`. If the machines
|
||||
are on separate/isolation LANs, Tailscale works as an alternative transport
|
||||
(point OBS at the receiver's Tailscale IP:port), but the multicast
|
||||
discovery list will only show senders reachable via LAN multicast.
|
||||
|
||||
### Other distros / firewalls
|
||||
|
||||
Open TCP **9756** inbound and UDP **9999** inbound on the sender machine;
|
||||
receivers just need outbound access (or the same rules if a firewall restricts
|
||||
it). mDNS (UDP 5353) is only needed for zero-config name resolution, not for
|
||||
TeleportFling itself.
|
||||
|
||||
## Daemon mode
|
||||
|
||||
See `contrib/README.md` — `contrib/install-daemon.sh` installs a per-user
|
||||
systemd unit that runs the headless CLI with its own config.
|
||||
|
||||
## Packaging
|
||||
|
||||
Three distribution formats are provided. Prebuilt artifacts are tracked in
|
||||
the repo under `dist/` (click to view on the remote; raw downloads require
|
||||
access to the Gitea server):
|
||||
|
||||
- [AppImage](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.
|
||||
@@ -3,12 +3,21 @@
|
||||
//
|
||||
// The streaming engine lives in internal/flinger; this command only wires the
|
||||
// GUI and tray around it. Headless use is handled by cmd/teleportfling.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// teleportfling-gui [--config PATH]
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
|
||||
"teleportfling/internal/gui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
gui.Run()
|
||||
configPath := flag.String("config", "", "config file path (default: ~/.config/teleportfling/config.json)")
|
||||
flag.Parse()
|
||||
|
||||
gui.Run(*configPath)
|
||||
}
|
||||
|
||||
+46
-10
@@ -14,10 +14,15 @@
|
||||
// teleportfling [--name NAME] [--port PORT] [--quality 1..100]
|
||||
// [--fps N] [--source screen|pattern] [--audio]
|
||||
// [--stream-index N] [--duration SECONDS]
|
||||
// [--config PATH]
|
||||
//
|
||||
// --source pattern selects the M1 synthetic test pattern (colour bars with a
|
||||
// moving box) instead of real screen capture, which is useful for testing
|
||||
// without granting screen-share permission.
|
||||
//
|
||||
// --config loads a saved config file first; any flag given explicitly on the
|
||||
// command line overrides the file value. When running under a service manager
|
||||
// (e.g. a systemd user unit) use --config to point at the daemon's profile.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -28,6 +33,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"teleportfling/internal/config"
|
||||
"teleportfling/internal/flinger"
|
||||
)
|
||||
|
||||
@@ -42,19 +48,33 @@ func main() {
|
||||
noAnnounce = flag.Bool("no-announce", false, "do not announce on the LAN (receiver must connect by IP)")
|
||||
streamIndex = flag.Int("stream-index", 0, "monitor index to capture (screen source)")
|
||||
duration = flag.Duration("duration", 0, "stream duration (0 = run until interrupted)")
|
||||
configPath = flag.String("config", "", "config file path (default: ~/.config/teleportfling/config.json)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
cfg := flinger.Config{
|
||||
Name: *name,
|
||||
Port: *port,
|
||||
Quality: *quality,
|
||||
FPS: *fps,
|
||||
Source: *source,
|
||||
Audio: *withAudio,
|
||||
StreamIndex: *streamIndex,
|
||||
Announce: !*noAnnounce,
|
||||
}
|
||||
// Base config: loaded from file (or defaults when absent), then overridden
|
||||
// by any flag the user explicitly set.
|
||||
cfg := loadCLIConfig(*configPath)
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
switch f.Name {
|
||||
case "name":
|
||||
cfg.Name = *name
|
||||
case "port":
|
||||
cfg.Port = *port
|
||||
case "quality":
|
||||
cfg.Quality = *quality
|
||||
case "fps":
|
||||
cfg.FPS = *fps
|
||||
case "source":
|
||||
cfg.Source = *source
|
||||
case "audio":
|
||||
cfg.Audio = *withAudio
|
||||
case "no-announce":
|
||||
cfg.Announce = !*noAnnounce
|
||||
case "stream-index":
|
||||
cfg.StreamIndex = *streamIndex
|
||||
}
|
||||
})
|
||||
|
||||
eng, err := flinger.New(cfg)
|
||||
if err != nil {
|
||||
@@ -85,3 +105,19 @@ func main() {
|
||||
eng.Stop()
|
||||
log.Printf("teleportfling: stopped")
|
||||
}
|
||||
|
||||
// loadCLIConfig returns the base flinger config. With a --config path it reads
|
||||
// that file; otherwise it reads the default user config. Missing files fall
|
||||
// back to defaults.
|
||||
func loadCLIConfig(path string) flinger.Config {
|
||||
p := path
|
||||
if p == "" {
|
||||
p = config.Path()
|
||||
}
|
||||
c, err := config.LoadFrom(p)
|
||||
if err != nil {
|
||||
log.Printf("teleportfling: config %s: %v (using defaults)", p, err)
|
||||
return flinger.DefaultConfig()
|
||||
}
|
||||
return c.ToFlinger()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Running TeleportFling as a daemon
|
||||
|
||||
`teleportfling` (the headless CLI) can run as a per-user systemd service so
|
||||
the stream starts automatically with your graphical session.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The binary must be built (the flake dev shell provides all cgo deps).
|
||||
- The screen-capture backend needs the session D-Bus and a PipeWire instance,
|
||||
which is why the unit is a *user* service tied to `graphical-session.target`.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
./contrib/install-daemon.sh
|
||||
```
|
||||
|
||||
This:
|
||||
|
||||
1. Builds `cmd/teleportfling` and installs it to `~/.local/bin/`.
|
||||
2. Installs `contrib/teleportfling.service` as a user unit.
|
||||
3. Creates `~/.config/teleportfling/daemon.json` on first run.
|
||||
4. Runs `systemctl --user daemon-reload` and enables the service.
|
||||
|
||||
## Manage
|
||||
|
||||
```sh
|
||||
systemctl --user start teleportfling # start now
|
||||
systemctl --user enable teleportfling # start at login (done by installer)
|
||||
systemctl --user status teleportfling # check status / logs
|
||||
journalctl --user -u teleportfling -f # follow logs
|
||||
systemctl --user stop teleportfling # stop
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
The unit passes `--config ~/.config/teleportfling/daemon.json`. Edit that
|
||||
file to change name, port, quality, fps, source, audio or announcement, then
|
||||
restart the service. Any flags you add to the `ExecStart=` line override the
|
||||
file values.
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install teleportfling as a per-user systemd service (daemon mode).
|
||||
#
|
||||
# Builds the headless binary, places it in ~/.local/bin, installs the user
|
||||
# unit from contrib/teleportfling.service, and creates a starter daemon
|
||||
# config. Run without sudo:
|
||||
#
|
||||
# ./contrib/install-daemon.sh
|
||||
#
|
||||
# Then start it with:
|
||||
#
|
||||
# systemctl --user enable --now teleportfling
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BIN_DIR="${HOME}/.local/bin"
|
||||
SERVICE_DIR="${HOME}/.config/systemd/user"
|
||||
CONFIG_DIR="${HOME}/.config/teleportfling"
|
||||
CONFIG="${CONFIG_DIR}/daemon.json"
|
||||
|
||||
echo "Building teleportfling..."
|
||||
(cd "${HERE}" && go build -o "${BIN_DIR}/teleportfling" ./cmd/teleportfling)
|
||||
|
||||
echo "Installing systemd user unit..."
|
||||
mkdir -p "${SERVICE_DIR}"
|
||||
install -m 0644 "${HERE}/contrib/teleportfling.service" "${SERVICE_DIR}/teleportfling.service"
|
||||
|
||||
if [[ ! -f "${CONFIG}" ]]; then
|
||||
echo "Creating default daemon config at ${CONFIG}..."
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
cat > "${CONFIG}" <<'EOF'
|
||||
{
|
||||
"name": "TeleportFling Daemon",
|
||||
"port": 9756,
|
||||
"quality": 80,
|
||||
"fps": 30,
|
||||
"source": "screen",
|
||||
"audio": true,
|
||||
"stream_index": 0,
|
||||
"announce": true
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Reloading systemd and enabling the service..."
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable teleportfling
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Installed. Start the daemon with:
|
||||
systemctl --user start teleportfling
|
||||
|
||||
Check status with:
|
||||
systemctl --user status teleportfling
|
||||
|
||||
Edit settings in ${CONFIG} (the service passes it via --config).
|
||||
EOF
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=TeleportFling screen and audio stream sender
|
||||
Documentation=https://github.com/fzwoch/obs-teleport
|
||||
After=graphical-session.target pipewire.service
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.local/bin/teleportfling --config %h/.config/teleportfling/daemon.json
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
# The capture backend needs the session bus and a PipeWire instance.
|
||||
Environment=XDG_RUNTIME_DIR=%t
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "TeleportFling Development Environment";
|
||||
description = "TeleportFling - standalone screen + audio sender for the OBS Teleport protocol";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
@@ -10,8 +10,111 @@
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
# Native/cgo build-time dependencies shared by both binaries.
|
||||
nativeBuildInputs = with pkgs; [
|
||||
go
|
||||
pkg-config
|
||||
];
|
||||
|
||||
# Libraries linked (or dlopened) by the capture/encode stack.
|
||||
# libpipewire is dlopened at runtime; libturbojpeg is linked.
|
||||
streamLibs = with pkgs; [
|
||||
libjpeg_turbo
|
||||
pipewire
|
||||
xdg-desktop-portal
|
||||
];
|
||||
|
||||
# Libraries required by the Fyne/GLFW GUI (cgo).
|
||||
guiLibs = with pkgs; [
|
||||
libGL
|
||||
mesa
|
||||
wayland
|
||||
libxkbcommon
|
||||
libX11
|
||||
libXrandr
|
||||
libXi
|
||||
libXcursor
|
||||
libXinerama
|
||||
libXxf86vm
|
||||
];
|
||||
|
||||
# Nix's libspa-0.2.pc emits -fno-strict-aliasing/-fno-strict-overflow
|
||||
# which Go's cgo rejects unless explicitly allowed.
|
||||
cgoFlagsAllow = "-fno-strict-overflow|-fno-strict-aliasing";
|
||||
|
||||
# Common build environment so cgo finds headers/libs.
|
||||
buildEnv = pkg: {
|
||||
nativeBuildInputs = nativeBuildInputs;
|
||||
buildInputs = pkg;
|
||||
# Make turbojpeg resolve via pkg-config for cgo builds.
|
||||
PKG_CONFIG_PATH = "${pkgs.libjpeg_turbo.dev}/lib/pkgconfig";
|
||||
# Fyne/GLFW link against X11, Wayland, GL and misc libs. Go's cgo
|
||||
# linker does not read NIX_LDFLAGS, so expose the runtime lib dirs
|
||||
# via LIBRARY_PATH for the link step.
|
||||
LIBRARY_PATH = with pkgs; lib.makeLibraryPath (pkg);
|
||||
CGO_CFLAGS_ALLOW = cgoFlagsAllow;
|
||||
};
|
||||
|
||||
# The headless CLI (no GLFW needed).
|
||||
teleportfling = pkgs.buildGoModule (buildEnv streamLibs // {
|
||||
pname = "teleportfling";
|
||||
version = "0.1.0";
|
||||
src = self;
|
||||
sourceRoot = self.sourceRoot or null;
|
||||
subPackages = [ "cmd/teleportfling" ];
|
||||
vendorHash = "sha256-1PDoL45BWhWNVX9wktR3WXpNbXJ+7V4mfp3la+Nv0wI=";
|
||||
|
||||
postInstall = ''
|
||||
mkdir -p $out/share/applications
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Stream a screen and audio to OBS Teleport receivers";
|
||||
homepage = "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!"
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# AppImage AppRun: locate bundled libraries and launch the GUI.
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
export LD_LIBRARY_PATH="${HERE}/usr/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
exec "${HERE}/usr/bin/teleportfling-gui" "$@"
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a self-contained AppImage of the TeleportFling GUI.
|
||||
#
|
||||
# Approach: build the GUI binary, then bundle it plus every shared library it
|
||||
# needs (from ldd, recursively) plus the dlopened PipeWire stack into an
|
||||
# AppDir, then package it with appimagetool. Output:
|
||||
# dist/TeleportFling-<version>-x86_64.AppImage
|
||||
#
|
||||
# Usage:
|
||||
# ./packaging/appimage.sh
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT="${HERE}/dist"
|
||||
APPDIR="${OUT}/AppDir"
|
||||
LIBDIR="${APPDIR}/usr/lib"
|
||||
|
||||
VERSION="0.1.0"
|
||||
ARCH="x86_64"
|
||||
|
||||
echo "Building teleportfling-gui..."
|
||||
nix develop --command bash -c "go build -o '${OUT}/teleportfling-gui' ./cmd/teleportfling-gui"
|
||||
|
||||
echo "Preparing AppDir..."
|
||||
rm -rf "${APPDIR}"
|
||||
mkdir -p "${LIBDIR}" \
|
||||
"${APPDIR}/usr/bin" \
|
||||
"${APPDIR}/usr/share/applications" \
|
||||
"${APPDIR}/usr/share/icons/hicolor/512x512/apps"
|
||||
|
||||
install -m 0755 "${OUT}/teleportfling-gui" "${APPDIR}/usr/bin/teleportfling-gui"
|
||||
install -m 0644 "${HERE}/assets/teleportfling.desktop" "${APPDIR}/usr/share/applications/teleportfling.desktop"
|
||||
install -m 0644 "${HERE}/assets/teleportfling.png" "${APPDIR}/usr/share/icons/hicolor/512x512/apps/teleportfling.png"
|
||||
# appimagetool requires the desktop file and icon in the AppDir root.
|
||||
install -m 0644 "${HERE}/assets/teleportfling.desktop" "${APPDIR}/teleportfling.desktop"
|
||||
install -m 0644 "${HERE}/assets/teleportfling.png" "${APPDIR}/teleportfling.png"
|
||||
|
||||
echo "Bundling runtime libraries..."
|
||||
# Copy every library the binary links against, resolving dependencies
|
||||
# iteratively (ldd output may reference libs that themselves need libs).
|
||||
declare -A seen
|
||||
collect_libs() {
|
||||
local bin="$1"
|
||||
while IFS= read -r lib; do
|
||||
local name
|
||||
name="$(basename "${lib}")"
|
||||
if [[ -z "${seen[${name}]:-}" && -f "${lib}" ]]; then
|
||||
seen["${name}"]=1
|
||||
install -m 0755 "${lib}" "${LIBDIR}/${name}" 2>/dev/null || true
|
||||
collect_libs "${lib}"
|
||||
fi
|
||||
done < <(ldd "${bin}" 2>/dev/null | grep -oE '/[^ ]+\.so[^ ]*' | sort -u)
|
||||
}
|
||||
collect_libs "${APPDIR}/usr/bin/teleportfling-gui"
|
||||
echo " bundled $(ls "${LIBDIR}" | wc -l) libraries"
|
||||
|
||||
# The screencast backend dlopens libpipewire at runtime; ensure it and its
|
||||
# SPA plugin libs are present (they are pulled in via the recursive ldd walk
|
||||
# above, but guard against a missing libspa here).
|
||||
if [[ -f "${LIBDIR}/libpipewire-0.3.so.0" ]]; then
|
||||
echo " bundled libpipewire-0.3.so.0"
|
||||
fi
|
||||
|
||||
echo "Writing AppRun..."
|
||||
install -m 0755 "${HERE}/packaging/AppRun" "${APPDIR}/AppRun"
|
||||
|
||||
echo "Packaging AppImage with appimagetool..."
|
||||
if [[ ! -x "${OUT}/appimagetool" ]]; then
|
||||
echo "appimagetool not found at ${OUT}/appimagetool"
|
||||
echo "Download: https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
|
||||
exit 1
|
||||
fi
|
||||
"${OUT}/appimagetool" --appimage-extract-and-run "${APPDIR}" "${OUT}/TeleportFling-${VERSION}-${ARCH}.AppImage"
|
||||
|
||||
echo "Done: ${OUT}/TeleportFling-${VERSION}-${ARCH}.AppImage"
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a .deb package of TeleportFling for Debian/Ubuntu.
|
||||
#
|
||||
# The binary is built with cgo; runtime libraries (libturbojpeg, libpipewire,
|
||||
# and the GLFW/GL stack for the GUI) are declared as Debian package
|
||||
# dependencies so the .deb works on a stock Debian/Ubuntu install. Output:
|
||||
# dist/teleportfling_<version>_amd64.deb
|
||||
#
|
||||
# Usage:
|
||||
# ./packaging/deb.sh
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT="${HERE}/dist"
|
||||
ROOT="${OUT}/deb-root"
|
||||
|
||||
VERSION="0.1.0"
|
||||
ARCH="amd64"
|
||||
PKG_NAME="teleportfling"
|
||||
|
||||
echo "Building teleportfling and teleportfling-gui..."
|
||||
nix develop --command bash -c "go build -o '${OUT}/teleportfling' ./cmd/teleportfling && go build -o '${OUT}/teleportfling-gui' ./cmd/teleportfling-gui"
|
||||
|
||||
echo "Assembling .deb root..."
|
||||
rm -rf "${ROOT}"
|
||||
mkdir -p "${ROOT}/DEBIAN" \
|
||||
"${ROOT}/usr/bin" \
|
||||
"${ROOT}/usr/share/applications" \
|
||||
"${ROOT}/usr/share/icons/hicolor/512x512/apps"
|
||||
|
||||
install -m 0755 "${OUT}/teleportfling" "${ROOT}/usr/bin/teleportfling"
|
||||
install -m 0755 "${OUT}/teleportfling-gui" "${ROOT}/usr/bin/teleportfling-gui"
|
||||
install -m 0644 "${HERE}/assets/teleportfling.desktop" "${ROOT}/usr/share/applications/teleportfling.desktop"
|
||||
install -m 0644 "${HERE}/assets/teleportfling.png" "${ROOT}/usr/share/icons/hicolor/512x512/apps/teleportfling.png"
|
||||
|
||||
# Debian package control file.
|
||||
cat > "${ROOT}/DEBIAN/control" <<EOF
|
||||
Package: ${PKG_NAME}
|
||||
Version: ${VERSION}
|
||||
Section: video
|
||||
Priority: optional
|
||||
Architecture: ${ARCH}
|
||||
Maintainer: Pete Edley <[email protected]>
|
||||
Depends: libjpeg-turbo8 (>= 2.0), libpipewire-0.3-0 (>= 0.3), libgl1, libglx0,
|
||||
libwayland-client0, libwayland-cursor0, libwayland-egl1, libxkbcommon0,
|
||||
libx11-6, libxrandr2, libxi6, libxcursor1, libxinerama1, libxxf86vm1,
|
||||
libxrender1, libxext6, libxfixes3
|
||||
Description: Stream a screen and audio to OBS Teleport receivers
|
||||
Standalone Linux sender for the OBS Teleport protocol. Captures a Wayland
|
||||
screen and system audio, encodes video to JPEG, and streams them over TCP
|
||||
so OBS with the obs-teleport plugin can discover and display the stream.
|
||||
.
|
||||
Provides a headless CLI (teleportfling) and a desktop GUI with system tray
|
||||
(teleportfling-gui).
|
||||
Homepage: http://homeserver:3050/pedley/TeleportFling
|
||||
EOF
|
||||
|
||||
echo "Building .deb..."
|
||||
nix shell nixpkgs#dpkg --command dpkg-deb --build --root-owner-group "${ROOT}" "${OUT}/${PKG_NAME}_${VERSION}_${ARCH}.deb"
|
||||
|
||||
echo "Done: ${OUT}/${PKG_NAME}_${VERSION}_${ARCH}.deb"
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
Submodule third_party/screencast added at 4cba613625
@@ -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.
|
||||
Reference in New Issue
Block a user