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