Watch
1
Fork
You've already forked afogados_desktop
0
0

Latest commit

maotovisk
d757415 2026-10-04 02:53:54 -03:00 10 commits
2026-10-04 02:53:54 -03:00
2026-09-30 14:48:11 -03:00
2026-09-30 14:48:11 -03:00
2026-10-04 02:50:41 -03:00
2026-10-04 02:50:41 -03:00
2026-09-29 17:28:38 -03:00
2026-09-30 14:48:11 -03:00
2026-10-04 02:50:41 -03:00

Afogados desktop

Native desktop client for afogados.com: watch your groups' streams and go live from the app, with every video frame kept on the GPU. Rust, GPUI for the UI, FFmpeg's Vulkan video for codecs, str0m for WebRTC. No web view, no GStreamer. Licensed GPL-3.0-or-later.

Linux (Wayland) and Windows x86_64 both work: watching streams, going live, chat, and updates. The media paths differ per platform (see How it works): Linux keeps every frame on the GPU (Vulkan), Windows decodes/encodes in software (FFmpeg + libx264) with Desktop Duplication capture and WASAPI audio.

Install

Linux x86_64, any distro with glibc 2.36 or newer (Debian 12, Ubuntu 24.04, Fedora, Arch…):

curl -fsSL https://git.maot.dev/maot/afogados_desktop/raw/branch/main/install.sh | sh

Windows x86_64 (PowerShell, per-user, no admin rights):

irm https://git.maot.dev/maot/afogados_desktop/raw/branch/main/install.ps1 | iex

It installs the runtime dependencies with the system package manager (PipeWire, the Vulkan loader, xkbcommon, secret-tool, xdg-desktop-portal; skip with sh -s -- --no-deps), puts the app in ~/.local/lib/afogados with FFmpeg bundled, links ~/.local/bin/afogados and adds a menu entry. --version vX.Y.Z picks a release, --uninstall removes it. The GPU still needs Vulkan Video from its driver (Mesa 25 or newer on AMD).

Updates (src/update.rs): the app checks /releases/latest 10 s after starting and every 6 hours, downloads a newer release in the background, checks its .sha256, unpacks it into versions/<tag> next to the running one and switches to it — repointing the current symlink on Linux, rewriting current.txt on Windows — so the next launch is the new version. A download square at the bottom of the rail restarts into it right away (not while you're live). Configurações → Atualizações shows the version, checks by hand and turns automatic updates off. Only an install.sh/install.ps1 layout updates itself; cargo run and other installs just say a release exists. afogados --update does the same from a terminal, afogados --version prints the version, AFOGADOS_UPDATE=0 turns it off.

Releases

.forgejo/workflows/release.yml builds on a v* tag that is on main and matches app/Cargo.toml's version:

# bump version in app/Cargo.toml, commit, then
git tag v0.2.0 && git push origin main v0.2.0

scripts/ci_build.sh runs the Linux build in a Debian bookworm SDK container (ci/Dockerfile, cached per ci/ contents; the cargo registry and target dir are Docker volumes, so builds are incremental). The runner is ARM, so it cross-compiles for x86_64 with Debian multiarch libraries and the cross gcc, without emulation; on an x86_64 machine the same script builds natively (TAG=v0.2.0 sh scripts/ci_build.sh leaves the archive in dist/). scripts/ci_compile_windows.ps1 builds natively on the Windows runner instead (Rust MSVC + LLVM for bindgen; no cross-compile from the Linux SDK). FFmpeg isn't compiled: it's BtbN's shared GPL build pinned by hash in ci/versions.env (glibc 2.28, Vulkan encoders, libopus, no other shared deps; the Windows twin from the same autobuild), mirrored to the ffmpeg-btbn generic package on the Forgejo the first time (BtbN prunes old autobuilds). The Linux binary finds it through RUNPATH=$ORIGIN/lib; the Windows DLLs sit next to afogados.exe. The release gets afogados-vX.Y.Z-linux-x86_64.tar.gz and afogados-vX.Y.Z-windows-x86_64.tar.gz, their .sha256, and both installers. Running the workflow by hand builds without publishing.

Secrets: FORGEJO_TOKEN (write access to the repository and packages) publishes the release and fills the FFmpeg mirror; without it the release falls back to the job token and FFmpeg comes from upstream.

To move to a newer FFmpeg, point ci/versions.env at another BtbN autobuild with the same soname (a new major needs the code checked against it) and update the hash.

Run from source

Needs FFmpeg ≥ 7 with Vulkan (av1_vulkan/h264_vulkan, Vulkan hwaccel), PipeWire, xdg-desktop-portal, libclang (for the bindings) and a GPU with Vulkan Video. Account sign-in on Linux also needs secret-tool and an unlocked Secret Service keyring. The app opens the system browser for OAuth, then loads the account's groups. A /ws realtime socket keeps the rail, stream list, members, viewer counts, channel state and chat current.

cargo run --release -p afogados

The app follows the web app's flow one to one (docs/spec/ui.md in the web repo):

  • Rail: Home (a.) with your DMs, each DM with unread messages as its own square with the count (Discord's badge), then your servers with their live and unread dots. + creates a group or joins one with an invite link.
  • Home: the DM list in the sidebar (+ starts a DM with someone you share a server with) and your servers as cards, with Criar grupo and Entrar com convite.
  • Server: the sidebar has the server menu (invite, group settings, nickname, leave), text channels with unread dots (admins create, rename and delete them), Ao vivo and the member list. The stage shows the live streams as a grid; watching is opt-in (Assistir). Clicking a stream focuses it with the others in a strip and the live chat on the right; one stream alone is focused right away. T toggles theater (the rail and sidebar slide away), C the chat, Esc leaves theater. Right-clicking a tile opens its menu: per-stream volume (remembered), mute, focus, stats, watch or stop. A text channel fills the stage with its chat, with the members island beside it. Clicking a member opens your DM with them, or their stream if they're live.
  • DM: the conversation fills the stage, with the streams above it like a call while someone is live.
  • PIP: streams you watch that aren't on screen keep playing in a stack over the content; drag it by its title bar, it snaps to edges within 32 px and remembers where it was.
  • Group settings (mods and up, like the web): overview (name, address, icon, deleting the group), members (role, ownership transfer, chat timeout, kick, ban), invites, bans, chat (slow mode, who may attach) and emojis (upload, rename, delete).
  • Interaction: GPUI eases hover, focus and modal transitions; mouse-wheel scrolling glides while touchpad scrolling stays direct. Scrollable surfaces paint draggable scrollbars, following the operating system's auto-hide preference; dismissing a modal keeps the page blocked until its fade-out finishes.
  • Chat extras: link cards from /links/preview (a direct image link shows the picture), KLIPY-only messages drawn as the GIF, images at their own proportions with a viewer on click, and video and audio attachments playing inline (see below). The emoji picker and the : completion use the web's own data (assets/emoji-pt.json, a copy of the frontend's generated emoji-pt.json): Portuguese names, English shortcodes, and a closed :shortcode: turns into its emoji. Like the web, there are no replies. A went-live notice offers Assistir while that session is still on.
  • Chat navigation and previews: each text room restores its own scroll position. Link-preview images, including YouTube thumbnails, fill a 16:9 card instead of showing the thumbnail's baked-in letterbox bars.
  • Sounds (the web's own files, assets/sounds): a stream opening or closing in any of your groups, a DM you aren't looking at, and you or someone else joining or leaving a stream you're in (yours, or one you watch).
  • Notifications: with the window in the background, someone going live in any of your groups or mentioning you shows a desktop notification (XDG portal); clicking it opens the stream or the group.
  • Ir ao vivo (the broadcast button by your name, for the group you're in): title and category, who can watch (and the public link), and Compartilhar tela with quality, desktop audio (with excluded apps) and microphone. The portal asks which screen or window every time; going live replaces a stream already running from another tool.
  • Closing the app stops screen/audio capture and signals the outgoing WHIP session to end within GPUI's bounded shutdown window.
  • Configurações (the gear): account (profile and connections open on the web), audio output and microphone (PipeWire devices, in dropdowns), and Avançado with codec and maximum bitrate.

Streaming is only through the account: there's no WHIP or WHEP URL anywhere in the UI. The headless checks below still take bare URLs, for testing the media pipeline against a local SFU.

Native OAuth opens {AFOGADOS_APP_URL}/oauth/authorize and calls the API at {AFOGADOS_API_URL}. Both default to https://afogados.com and https://api.afogados.com; set these variables to the frontend and API origins when developing locally. The consent page lives in the web app (frontend/src/routes/oauth-authorize.tsx): it signs the user in like any page, asks the API what the request would grant, and returns the decision. Refresh tokens are stored in Secret Service, one per API origin; access tokens stay in memory. The realtime socket uses the API origin's wss://…/ws with the afg.v1 subprotocol.

Development flags and headless checks:

Command Does
afogados --go-live Opens the UI in the first server and goes live there with the saved settings
afogados --panel Opens the UI with the Go Live panel and the exclusion picker expanded
afogados --api-check Signs in (saved session, else the browser, also when the saved grant lacks a scope this build needs) and checks the account, groups, streams, members, chat history, GIFs and realtime acks against AFOGADOS_API_URL, one line per step. AFOGADOS_GROUP picks the group; AFOGADOS_CHAT_WRITE=1 also sends, edits, reacts to and deletes a message and expects each over realtime
afogados --play-file PATH|URL The chat's attachment player without a window: prints position, frames and errors each second, and seeks back to the start halfway (AFOGADOS_SECONDS)
afogados --watch-headless URL WHEP → decode → bridge without a window, printing frames, errors, audio level, queue delay (arrival vs RTP time; growth means a queue upstream) and the longest frame gap each second
afogados --publish-test URL [av1|h264] GPU test pattern → encode → WHIP (H.264 by default). AFOGADOS_AUDIO=desktop|mic captures PipeWire audio instead of a tone; AFOGADOS_PATTERN=noisy fills half the frame with noise to hold the encoder at its cap
afogados --publish-screen URL Screen capture → encode → WHIP without the UI; AFOGADOS_REPEAT=n stops and starts again n times in one process

AFOGADOS_TOKEN is the Bearer token for the headless modes, AFOGADOS_EXCLUDE=Firefox,Spotify their excluded apps, AFOGADOS_SECONDS their duration, AFOGADOS_VK_DEVICE the Vulkan device index. On this machine the lsfg-vk implicit layer breaks Vulkan: run with VK_LOADER_LAYERS_DISABLE='*lsfg*'.

Local SFU for testing, the same one production uses:

docker run --rm -d --name afg-probe-bbox --net=host -e HTTP_ADDRESS=:8090 -e NETWORK_TEST_ON_START=false seaduboi/broadcast-box
afogados --publish-test http://localhost:8090/api/whip av1        # AFOGADOS_TOKEN=mykey
AFOGADOS_TOKEN=mykey afogados --watch-headless http://localhost:8090/api/whep

How it works

publish  PipeWire DMA-BUF ─import─▶ VkImage (RGB) ─compute: scale + RGB→NV12 BT.709─▶ encoder pool frame
                                                                          │            ├─▶ preview (bridge)
                                                          av1_vulkan / h264_vulkan ◀───┘
                                                                          │
         PipeWire desktop + mic ─mix─▶ libopus ──────────────▶ str0m (WHIP, TWCC) ─UDP─▶ SFU

watch    SFU ─UDP─▶ str0m (WHEP) ─frames─▶ FFmpeg decoder, Vulkan hwaccel ─▶ bridge ─▶ GPUI surface
                                 └─Opus─▶ libopus ─▶ mixer ─▶ PipeWire ─▶ speakers
  • One Vulkan device for media. FFmpeg creates it (src/media/gpu.rs); decode, encode, the capture converter and the bridge all use it through ash, taking FFmpeg's queue locks and each frame's timeline semaphore (with_vk_frame).
  • Capture (src/capture/screen.rs): the ScreenCast portal picks the source (asked every time; the session is closed on stop, and portal calls share one long-lived runtime because ashpd caches its D-Bus connection per process); PipeWire negotiates DMA-BUFs with the DRM modifiers the device can sample; each buffer is imported once (VK_EXT_image_drm_format_modifier) and acquired from the foreign queue family per frame. A still screen is re-sent every 250 ms so keyframe requests and late viewers get a picture. On Windows (src/capture/screen_win.rs) DXGI Desktop Duplication captures the primary monitor to BGRA system memory instead, with the same 250 ms re-send.
  • Conversion (src/capture/convert.rs): a WGSL compute shader, compiled to SPIR-V with naga at startup, scales with a bilinear sampler and writes NV12 straight into the encoder's pool frame.
  • Encoding (src/media/encode.rs): tune=ull, usage=stream, content=desktop, VBR capped at the chosen bitrate (AV1 aims at 3/4 of it: av1_vulkan on RADV overshoots ~30 %), 2 s GOP, no B-frames, BT.709 limited range. Keyframes on PLI. Auto codec is AV1 when av1_vulkan opens, else H.264. Headers are in-band on every keyframe. On Windows SoftwareEncoder (libx264, tune=zerolatency, in-band headers) encodes I420 converted from the captured BGRA; publishing is H.264 there.
  • WebRTC (src/net/rtc.rs): no pacing or bandwidth estimation: str0m's estimator gets no usable feedback from Broadcast Box, stays at its start value and throttles to ~3.6 Mb/s, which queued 10 s of delay within 20 s at 8 Mb/s (AFOGADOS_BWE=1 turns it on for experiments). 4 MB socket buffers absorb unpaced keyframe bursts. One POST of a fully gathered offer, host candidates on every local IPv4 address, one UDP socket; the local address of each packet comes from a route lookup. The watcher asks for a keyframe on connect and after decode errors.
  • To the UI (src/media/bridge.rs): decoded or captured NV12 frames are copied plane by plane, in VRAM, into images exported as opaque fds; GPUI's Blade renderer imports each slot once and samples it. No CPU copies. Removing the remaining VRAM copy would need FFmpeg and Blade to share one VkDevice.
  • Patched dependencies (vendor/, via [patch.crates-io]):
    • vendor/gpui (0.2.2): ExternalVideoFrame, surface() and Window::paint_surface on Linux, the Blade import cache (a frame's Arc is held until the GPU finished sampling it) and colorimetry (BT.601/709, limited/full) in the surface shader. GPUI already had the YUV surface pipeline for macOS; Linux skipped it. The frame types compile on every target (the fd handle is Linux-only) so the media layer builds on Windows before its video port lands.
    • vendor/blade-graphics (0.7.1): imports external memory into the lowest device-local memory type (it picked the highest, which can be system RAM) with a dedicated allocation, and exposes device_uuid() and external_memory_type_index() so exporters can match.
  • Desktop audio (src/capture/apps.rs, audio.rs): not the output device's monitor but every application's playback stream linked into our own capture node, which PipeWire mixes. The registry is watched, so apps that start or stop while live are picked up; the exclusion list (Go Live panel: + adds apps playing now or by name, and it is remembered even when they are closed) applies live. This process is always left out, detected by PID on the node or its client, so watching while live doesn't echo. On Windows (src/capture/audio_win.rs) WASAPI loopback captures the output mix and the microphone is mixed in with the same pacing; both playback (src/media/audio_out_win.rs) and capture run at 48 kHz stereo, and devices come from endpoint enumeration (src/capture/apps_win.rs).
  • Attachments (src/media/file_player.rs): FFmpeg's libavformat opens the file or URL; video decodes on the Vulkan device when the codec has Vulkan decoding (H.264, H.265, AV1), otherwise in software, converted to NV12 with libswscale and uploaded into a Vulkan frame, so both reach the UI through the same bridge as live streams. On Windows everything decodes in software to BGRA pictures (SoftwareDecoder), shown as atlas images that are evicted after presenting (drain_retired). Audio is resampled into a mixer voice. One clock: audio reaches the mixer 120 ms before it is due and video is shown 120 ms late to match; seeking re-anchors the clock on the first frame at the target.
  • Audio out (src/media/audio_out.rs): each stream is a voice with its own volume, mixed into one 48 kHz PipeWire output stream with a 60 ms start buffer and a 200 ms cap. The stream targets the sink picked in the settings (target.object) and moves when it changes; the device lists (sinks and sources) come from the same registry watcher as the applications.
  • Afogados account, realtime and chat (src/net/api.rs, src/net/realtime.rs, src/ui/chat.rs): OAuth 2.1 PKCE runs in the system browser; refresh tokens rotate and live in Secret Service. One /ws socket (afg.v1, ping every 25 s, presence frames) multiplexes topics, and because delivery is at-most-once every subscription ack, sequence gap and reconnect resyncs the topic over REST (streams, viewer counts, channel state, chat). Playback reconnects follow the spec: 1 s/3 s after ICE trouble, min(1000·2^n, 10000) ms otherwise, and a 410 heartbeat renegotiates the WHEP session in place. The chat island renders only from parts[]: history paging, optimistic sends keyed by client_id, edits, deletes, reactions, attachments (paperclip, paste, drop), server emojis, KLIPY GIFs, unread markers and @/: autocomplete; the window picks its room (the live chat beside a stream, or the text channel on the stage). Going live through the API posts the offer to /groups/{slug}/me/channel/whip with X-Replace-Active: true and cleans up through the control URL from Location.

Measured here (RX 9060 XT, RADV, FFmpeg 9.0.2, release build)

Test Result
WHEP H.264 1080p60 from FFmpeg's WHIP muxer 60 fps, 0 dropped, 0 decode errors
AV1 1080p60: test pattern → av1_vulkan → Broadcast Box → our WHEP 60 fps both ends, 0 dropped, 0 decode errors
H.264 1080p60 the same way with h264_vulkan Same
CPU at 1080p60, publisher / watcher about 1 % of one core each, ~75 MB RSS each
Desktop audio (routed app streams) → Opus → WHIP → WHEP Tone received (peak 0.01–0.03); with the tone's app excluded, exactly 0.00
Noisy 1080p60 at the cap, H.264 and AV1 (pacer off) Queue delay 0–4 ms, longest frame gap ~20 ms. gpu-screen-recorder's settings (CBR, tune=ll, 1 s VBV, async_depth=3, content=rendered) measured the same
Vulkan validation on our code Clean. FFmpeg's av1_vulkan itself triggers vkCmdEncodeVideoKHR AV1 VUIDs (upstream)

The UI layout was checked on screen; live video in the UI and screen capture through the portal need an unlocked session and were not verified yet.

Not done yet

  1. Frame pacing of screen capture on other compositors and GPUs (verified on KWin + RADV: DMA-BUF BGRx with an AMD modifier, about 60 fps, repeated starts in one process).
  2. Web parity leftovers: profile, connected accounts and appearance settings open the web. Not in the app yet: tile promotion animations (tiles fade in, but don't slide between grid and focus), a YouTube player in link cards (they open the browser), and a tray icon (notifications work without one).
  3. Adapting bitrate to network conditions: needs congestion feedback we can trust (TWCC/REMB from the SFU, or our own receiver reports), then a pacer and encoder bitrate changes. Also lip sync between audio and video.
  4. Windows gaps: single-window picking (capture is the whole primary monitor for now), the mouse cursor isn't composited into captures, no per-application audio exclusion (WASAPI loopback hears the device mix), toast clicks do nothing yet, and publishing is H.264 only (libx264; AV1 stays Linux-only).
  5. NVIDIA/Intel: only RADV was tested. SHM capture fallback when DMA-BUF import fails, and a VA-API/NVENC fallback when the Vulkan encoders are missing.
  6. UI polish: transitions, tile promotion animation, chat, settings, light theme, other palettes.