Compare commits

..
Author SHA1 Message Date
molluskandClaude Fable 5 7b9cb57003 core: survive a failed net-stack rebuild instead of silently dying
CI / check (push) Failing after 13m37s
The four live-rebuild sites (deferred rebuild on Join/Leave, idle
SetNetworkMode, idle RegenerateIdentity) all did
`net.shutdown().await` then `build_net_stack(...).await?` — a build
failure propagated out of run_core_loop, which its supervisor only
logs. Every subsequent command went nowhere: window alive, app dead,
user told nothing. (The initial startup build already reported.)

New replace_net_stack() helper: tear down the old stack, build for the
requested posture, and on failure fall back to the posture the old
stack was actually running (tracked in the new `net_mode` local; when
the postures are equal the fallback is a plain retry — e.g. identity
regeneration, where reverting the already-persisted key would be
wrong). If the fallback lands, the UI is told the change didn't stick
and `network_mode` reverts so state stays honest and the change stays
re-attemptable. If both builds fail the UI gets a fatal 'Networking
lost … restart' error before the loop exits — informed, not a zombie.

Retry policy isolated in rebuild_with_fallback(), generic over the
builder: 4 new unit tests cover first-try success, fall-back, plain
retry, and double failure without binding sockets.

Fixes finding 2 of the 2026-07-16 full-codebase review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:58:58 -04:00
molluskandClaude Fable 5 af7a42a049 ci: remove zombie workflows from the per-push pipeline
cargo-deny.yml (runs-on: ubuntu-latest) and windows-build.yml (runs-on:
windows-latest) target runner labels no registered runner advertises, so
every push queued two runs Gitea auto-cancelled ~24h later — the Actions
page has shown 2 cancelled runs per push since the runner went live.

- cargo-deny.yml: deleted; redundant with ci.yml's deny step, which now
  runs `cargo deny --locked check` to preserve the locked-tree stance.
- windows-build.yml: kept but workflow_dispatch-only until a Windows
  runner exists; restore instructions in the header comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:46:56 -04:00
molluskandClaude Fable 5 e78e7bc2a5 supply-chain: ignore quick-xml build-time DoS advisories + ttf-parser unmaintained
RUSTSEC-2026-0194/0195 (quick-xml 0.39.4, published 2026-06-29) broke the
deny/audit CI gates on every push since June 29. quick-xml is reached only
via the wayland-scanner proc-macro parsing vendored protocol XML at compile
time — attacker input never touches it and it is absent from the shipped
binary. The fixed 0.41.0 is semver-incompatible with wayland-scanner's
`^0.39` req (no upstream bump yet); documented ignores until one exists.

RUSTSEC-2026-0192 (ttf-parser unmaintained, via iced/cosmic-text) joins the
existing unmaintained ignores (paste, audiopus_sys) — same class, same
lockfile-pinning protection.

New .cargo/audit.toml keeps cargo-audit in sync with deny.toml.

Known leftover warning (allowed, non-failing): spin 0.10.0 is yanked but
futures-buffered (via iroh) requires ^0.10 and no unyanked 0.10.x exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:46:13 -04:00
molluskandClaude Fable 5 52ab374b74 style: cargo fmt under rustfmt 1.9.0 (toolchain update 2026-07-08)
Six diffs across four files: the 2026-07-08 stable toolchain update
(rustc 1.96.1 / rustfmt 1.9.0) re-flags code that was fmt-clean when
committed under the previous rustfmt. No semantic change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:45:18 -04:00
mollusk 8825707c17 chore: patch crossbeam-epoch RustSec advisory
CI / check (push) Failing after 7s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-07-15 06:31:09 -04:00
molluskandClaude Fable 5 76c62e5ac3 docs: mark connection badge field-verified (2-machine call 2026-07-08)
CI / check (push) Failing after 5s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:07:54 -04:00
molluskandClaude Fable 5 d2432740c1 network: per-peer connection badge (direct/relay, RTT, loss, bitrate)
Answer "am I actually P2P right now?" per peer. A 1 Hz session task
snapshots the selected QUIC path of every live audio connection
(IrohTransport::connection_stats), core::connstats::derive turns
consecutive snapshots into RTT/loss/bitrate (path switches and counter
resets invalidate the rate window), and the peer card shows a
Direct/Relay badge with a hover tooltip for address, loss, and up/down
bitrate. No new dependencies, no wire change.

Loopback-integration-tested against real iroh endpoints; not yet
field-verified on a 2-machine call (FEATURES.md row marked 🧪).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:15:22 -04:00
molluskandClaude Opus 4.8 99a4a336ad Release 0.6.3 — in-app screen-sharing controls + hwdec fixes
CI / check (push) Failing after 4s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Bump to 0.6.3 and document the screen-share work merged on this branch:
the advanced Settings section + per-call quality picker (96e41de), the
hardware-decode-defaults-off frame-1 freeze fix (96e41de), the per-call
quality override fix (e378b2e), and the VLC-honors-viewer-settings fix
(faad8ce). All local-only — no wire-protocol change, old configs load
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:20:00 -04:00
molluskandClaude Opus 4.8 df45c0bfeb screenshare: log the pixelpass-host and player argv on spawn
The screen-share code only logged pixelpass's high-level JSON events, never
the argv it spawned children with, so a field log couldn't confirm which
encode/viewer settings actually reached the helpers — e.g. the per-call
quality's --bitrate (host) or the hardware-decode --avcodec-hw/--hwdec flag
(player). Log both verbatim at spawn: host args carry no secret, and the
player line omits the local stream URL. Logged per attempt so a player
fallback is visible too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:47:06 -04:00
molluskandClaude Opus 4.8 faad8ce26a screenshare: honor viewer settings for the VLC player too
The viewer playback settings (hardware decode + buffering) only shaped
mpv's argv; vlc_args() was fixed, so a VLC viewer silently ignored them.
The load-bearing case is hardware decode: mpv defaults to software decode
(the A-bug fix), but VLC hardware-decodes by default, so a VLC viewer with
the default hardware_decode=false still got GPU decode and could hit the
frame-1 freeze the default exists to avoid — the toggle did nothing.

vlc_args() now takes the settings and maps the knobs that translate
cleanly to VLC: hardware decode (--avcodec-hw=none/any) and buffering
posture (network/live caching ms). The genuinely mpv-specific knobs
(cache_mb byte-cache, extra_mpv_args) stay mpv-only; the Settings UI
hints are reworded to say which knobs are mpv-only vs universal. +2 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:22:31 -04:00
molluskandClaude Opus 4.8 e378b2e33b screenshare: fix inline per-call quality override being discarded
The Share control's inline quality dropdown sets a session-only
`share_quality_selection`, but ToggleScreenShare (which opens the audio
picker on the only real path to a share) unconditionally reset it back to
the saved config default before ConfirmShareScreen read it. The picker has
no quality control of its own, so the user's per-call pick was silently
dropped 100% of the time and every share used the persisted default.

Drop the reset; add a regression test asserting the override survives
picker-open and reaches the confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:09:22 -04:00
molluskandClaude Opus 4.8 96e41de1b1 screenshare: advanced in-app streaming controls + hwdec toggle
Add a local-only "Screen sharing" section to Settings plus a per-call quality
picker on the Share control: in-app control over how a share is encoded
(quality/bitrate/framerate/max-height/max-viewers/software-x264, + extra
pixelpass args) and how it's played back (mpv/vlc, hardware decode, buffering,
cache, + extra mpv args). Settings live in AppConfig.screen_share (all
serde-defaulted, so old configs load unchanged) and become pixelpass host CLI
flags / mpv args at share/view launch.

Hardware decode defaults OFF, which also fixes the frozen-frame-with-audio bug:
forcing --hwdec=auto stalled some viewers' HW decoder on frame 1 while audio
kept playing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:15:11 -04:00
molluskandClaude Opus 4.8 5c888f8357 context_input: middle-click pastes the X11 PRIMARY selection
Joe (X11) reported that middle-click paste did nothing in the ticket and
node-ID fields. iced's base text_input only binds Ctrl+V to the Standard
(CLIPBOARD) selection and never reads PRIMARY or binds mouse button 2, so
the "select text, middle-click to paste" workflow was dead.

Add a Button::Middle branch to ContextInput::update that reads
clipboard::Kind::Primary, sanitizes it, and pastes at the cursor (reusing
the already-tested pure paste()). Factor the control-char stripping into a
shared, unit-tested sanitize_clip() helper also used by the menu Paste, so
a trailing newline on the PRIMARY selection is dropped. Respects `locked`
so read-only display fields still reject paste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:25:01 -04:00
molluskandClaude Opus 4.8 074f004227 core: one screen-share player per share — re-watch replaces, not stacks
Every click of Watch (`CoreCommand::ViewShare`) spawned a fresh pixelpass
viewer + mpv and pushed it onto an untracked Vec. A field test hit the
consequence: the first click gave a frozen player (the host's capture was
stalling), so the viewer clicked again to retry — and got a SECOND mpv,
doubling the shared audio.

Track viewers paired with their share ticket. On ViewShare, reap players
whose window already closed (try_wait), then if a live player for the same
ticket exists, kill it before spawning the replacement. Re-watching a
share now swaps its player instead of stacking a second one. Pure
`replace_viewer_index` seam + test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:48:52 -04:00
molluskandClaude Opus 4.8 2d22036930 screenshare: drop mpv --untimed so shared video stays A/V-synced
The viewer launched mpv with `--untimed`, which displays each video
frame the instant it decodes and ignores audio timestamps. Sharing a
desktop (no audio) that just minimizes latency, but sharing a *video*
made its audio drift progressively out of sync — confirmed in a field
test watching a video together. Remove the flag so mpv paces video to
the audio clock; the remaining low-latency flags keep lag negligible for
desktop pointing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:29:59 -04:00
molluskandClaude Opus 4.8 8014edf91c Release 0.6.2 + AppImage packaging
CI / check (push) Failing after 3m41s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Bump to 0.6.2 (Cargo + Inno .iss), promote CHANGELOG [Unreleased] -> [0.6.2].
0.6.2 rolls up the licensing (MIT + THIRD_PARTY_LICENSES) and the friends-list
liveness fixes (active offline marking, 15s refresh, manual Rescan) on the
0.6.x wire format (gossip v5, compatible with 0.6.0/0.6.1).

Adds packaging/appimage: a thin AppImage recipe (linuxdeploy) that bundles the
pixelpass screen-share helper in usr/bin so peerspeak's $PATH lookup finds it
with no code change. Assets are include_bytes!-embedded; the graphics stack and
pixelpass's gstreamer/mpv tools are left to the host. Built on Ubuntu 24.04
(glibc 2.39) for reach across Debian 13+/Fedora 40+/rolling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:07:41 -04:00
molluskandClaude Opus 4.8 89d5218d25 Housekeeping: Windows doc refresh + scan.rs test-import cleanup
Codex-authored refresh of docs/WINDOWS.md and packaging/windows/{README,INSTALL}.md
from the 2026-07-01 Windows session; scan.rs qualifies super::running_executables()
to drop an unused glob import. PKGBUILD pkgver reflects the last Arch build
(auto-regenerated by makepkg's pkgver()).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:59:26 -04:00
mollusk f3c7aa7050 Merge A6 playback handoff fix 2026-07-01 13:50:44 -04:00
29 changed files with 2119 additions and 185 deletions
+11
View File
@@ -0,0 +1,11 @@
# cargo-audit configuration. Keep the ignore list in sync with deny.toml,
# which carries the full justification for each entry.
[advisories]
ignore = [
# quick-xml DoS advisories: build-time only, reached solely via the
# wayland-scanner proc-macro parsing trusted vendored protocol XML.
# Fix (0.41.0) is semver-incompatible with wayland-scanner's `^0.39`;
# drop once wayland-scanner bumps. See deny.toml.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
]
-34
View File
@@ -1,34 +0,0 @@
name: cargo-deny
# Enforce the supply-chain policy in deny.toml (advisories / bans / licenses /
# sources) on every push to main and every PR. Runs on a *locked* tree so the
# pinned, vetted versions in Cargo.lock are exactly what get audited — see the
# deny.toml header and VERSIONING.md. A new poisoned release of a dependency
# cannot reach CI until Cargo.lock is deliberately updated.
on:
push:
branches: [main]
pull_request:
jobs:
cargo-deny:
runs-on: ubuntu-latest
# rust:1 provides the cargo toolchain that cargo-deny shells out to for
# `cargo metadata`. Adjust the runner label if your act_runner uses a
# different one.
container: rust:1
steps:
- uses: actions/checkout@v4
- name: Install cargo-deny (pinned prebuilt)
run: |
set -euo pipefail
version=0.19.9
curl -sSfL \
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${version}/cargo-deny-${version}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz -C /usr/local/bin --strip-components=1 --wildcards '*/cargo-deny'
cargo-deny --version
- name: cargo deny check
run: cargo deny --locked check
+3 -1
View File
@@ -36,7 +36,9 @@ jobs:
run: cargo test --doc
- name: cargo-deny (advisories, bans, licenses, sources)
run: cargo deny check
# --locked so the pinned, vetted versions in Cargo.lock are exactly
# what get audited (the lockfile-as-review-checkpoint model).
run: cargo deny --locked check
- name: cargo-audit
run: cargo audit
+15 -11
View File
@@ -7,11 +7,20 @@ name: windows-build
# alias) so a Unix-only assumption can't sneak back in and break Windows.
#
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
# `windows-latest` label (the Linux `cargo-deny` job's container approach does
# NOT apply here — Windows jobs run on the host, not a Linux container). If your
# runner advertises a different label, change `runs-on` below. Until a Windows
# runner exists this workflow is simply skipped/queued, not a failure of the
# Linux CI.
# `windows-latest` label (a Linux-container approach does NOT apply here —
# Windows jobs run on the host, not a Linux container). If your runner
# advertises a different label, change `runs-on` below.
#
# MANUAL-ONLY until that runner exists: with push/PR triggers enabled, every
# push queued a run no runner could claim and Gitea auto-cancelled it ~24h
# later, littering the Actions page with cancelled runs. Restore the push/PR
# triggers when a Windows runner is registered:
#
# on:
# push:
# branches: [main, "windows-port-**"]
# pull_request:
# workflow_dispatch:
#
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see
# peerspeak-windows-opus-spike.md):
@@ -23,12 +32,7 @@ name: windows-build
# must provide both.
on:
push:
# `main` plus the in-progress port branches, so the Windows path is exercised
# before merge rather than only after.
branches: [main, "windows-port-**"]
pull_request:
# Allow manual runs from the Gitea Actions UI.
# Manual runs from the Gitea Actions UI only — see the header comment.
workflow_dispatch:
permissions:
+15 -1
View File
@@ -2,7 +2,19 @@
All notable changes to PeerSpeak are documented here.
## [Unreleased]
## [0.6.3] — 2026-07-06
### Added
- **In-app screen-sharing controls.** A new **Screen sharing** section in Settings, plus a per-call **quality picker** on the Share control, put the whole share pipeline under your control without editing config files. Encode side: quality preset, bitrate, framerate, maximum resolution, maximum viewers, a force-software-encode switch, and an escape hatch for extra pixelpass arguments. Playback side: choose **mpv or VLC**, toggle **hardware decoding**, pick a buffering posture (low-latency vs. smooth), set the demuxer cache, and pass extra mpv arguments. Everything is stored locally in your config and defaults are unchanged, so existing setups keep working as-is.
### Fixed
- **Shared video no longer freezes on the first frame while audio keeps playing.** Hardware decoding now defaults **off**; forcing `--hwdec=auto` stalled some viewers' hardware decoder on frame 1. You can re-enable hardware decoding from the new Screen sharing settings if your machine handles it well.
- **The per-call quality picker is now honored.** The inline quality dropdown next to the Share button was being reset to the saved default before a share started, so every share silently used the default quality regardless of what you picked.
- **VLC now respects your playback settings.** VLC hardware-decodes by default, so a VLC viewer previously ignored the hardware-decode toggle (and could hit the same frame-1 freeze) and the buffering posture. VLC viewers now map both settings onto VLC's own options.
[0.6.3]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.3
## [0.6.2] — 2026-07-03
### Fixed
- **Friends list now reflects status changes without a restart.** A presence probe that fails now actively marks the friend **offline**, so a friend who goes offline, leaves a room, or turns invisible no longer lingers showing a stale "online" / "in a room" status until PeerSpeak is relaunched. Previously only successful probes updated the list, so it could ratchet a friend's status up but never down. The auto-refresh interval was also shortened from 60s to **15s** so the list tracks changes more closely.
@@ -13,6 +25,8 @@ All notable changes to PeerSpeak are documented here.
### Licensing
- **PeerSpeak is now released under the MIT License** (previously an unlicensed private build). Added a `LICENSE` file and a `THIRD_PARTY_LICENSES` file enumerating the full dependency manifest plus the canonical text of every referenced license, with notices for the statically bundled Opus codec and the embedded fonts (Iced-Icons, Cantarell/OFL-1.1). Both files ship in the Arch and Debian packages.
[0.6.2]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.2
## [0.6.0] — 2026-06-28
### Added
Generated
+3 -3
View File
@@ -1207,9 +1207,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peerspeak"
version = "0.6.1"
version = "0.6.3"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "peerspeak"
version = "0.6.1"
version = "0.6.3"
edition = "2024"
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
license = "MIT"
+13
View File
@@ -24,6 +24,19 @@ ignore = [
# audiopus_sys: unmaintained FFI bindings to the stable libopus C library,
# pulled in via our direct `opus 0.3.1` dep. No drop-in replacement.
"RUSTSEC-2026-0150",
# ttf-parser: unmaintained, transitive via iced/cosmic-text (font parsing
# for the GUI). Inputs are system + embedded fonts, not network data. No
# upstream migration yet; revisit when iced moves off it.
"RUSTSEC-2026-0192",
# quick-xml 0.39.4 DoS advisories (quadratic dup-attr check; unbounded
# namespace allocation). Build-time only: quick-xml is reached solely via
# the wayland-scanner PROC-MACRO, which parses the wayland protocol XML
# files vendored inside the wayland-* crates at compile time. Attacker
# input never reaches it and it is not in the shipped binary. The fix
# (0.41.0) is semver-incompatible with wayland-scanner 0.31.x's `^0.39`
# requirement; drop both ignores once wayland-scanner releases a bump.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
]
# ---------------------------------------------------------------------------
+1
View File
@@ -102,6 +102,7 @@ covers internals). When you ship a feature, add it here.
| iroh QUIC transport | ✅ | |
| Network mode picker | ✅ | `RelayNoDiscovery` (default), `N0Full`, `DirectOnly`. Takes effect next join. |
| Retained-address reconnect | ✅ | Dials last-known full addr before falling back to bare id. |
| Per-peer connection badge (direct/relay + RTT, hover for addr/loss/bitrate) | ✅ | Peer-card badge fed by a 1 Hz poll of the live audio link's selected QUIC path (`connection_stats``core::connstats::derive`). Field-verified on a real 2-machine call 2026-07-08. |
| Reconnect + eviction model | ✅ | Incl. two-outage reconnect-eviction fix + regression test. |
| Self-hosted relay | ❌ | Decided against — rely on n0 relays, `RelayNoDiscovery` default. |
+65 -39
View File
@@ -1,19 +1,28 @@
# PeerSpeak on Windows
Current status: the Windows port cross-compiles to `x86_64-pc-windows-gnu` and the `.exe`
launches under Wine. A real Windows/WASAPI host is still needed for the final audio-device
checks listed below.
Current status: PeerSpeak cross-compiles to `x86_64-pc-windows-gnu` from Linux and
has passed an older native Windows 11 VM smoke test for launch, GUI render, call
join, and audio flow. The build environment is **not** the Windows VM; current
Windows binaries are built from Linux, normally inside the `peerspeak-win`
distrobox or with the same GNU target environment.
The Windows runtime still trails Linux in a few important areas. See the Claude
handoff file `windows-parity-audit.md` for the full audit and task breakdown.
## What works today
| Area | Status |
|---|---|
| GUI | Iced/wgpu builds and renders under Wine. |
| Networking | Iroh QUIC transport and gossip compile on Windows. |
| GUI | Iced/wgpu builds for Windows and rendered in the Windows 11 VM. |
| Networking | Iroh QUIC transport and gossip compile on Windows; VM call reached two peers. |
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
| Device selection | cpal enumerates input/output devices; see caveat below about stable IDs. |
| Resampling/remap | WASAPI devices can run non-48 kHz formats; PeerSpeak converts at the backend boundary. |
| Codec | Opus remains 48 kHz mono, 20 ms frames. |
| Identity | `ring` identity generation/load is platform-neutral. |
| Identity/config | Stored through `dirs` under the Windows profile. |
| Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. |
| Game detection | Steam registry `RunningAppID` plus Toolhelp process-scan fallback compile on Windows. |
| File dialogs | `rfd` uses the native Win32 dialog backend. |
Windows paths are resolved through `dirs`:
@@ -23,55 +32,72 @@ Windows paths are resolved through `dirs`:
## Building
### Native Windows
### Cross-compile from Linux
Install MSVC Build Tools and CMake, then build normally:
Preferred local path:
```powershell
cargo build --release
```sh
distrobox enter peerspeak-win -- bash -lc '
cd ~/git/butter/peerspeak &&
RUSTC_BOOTSTRAP=1 ./win-cross-build.sh -Z build-std=std,panic_abort
'
```
If CMake is 4.x or newer, the vendored `opus`/`libopus` build may need:
Equivalent direct command when the host has the GNU target, MinGW, `rust-src`, and
CMake available:
```sh
CMAKE_POLICY_VERSION_MINIMUM=3.5 RUSTC_BOOTSTRAP=1 \
cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak \
-Z build-std=std,panic_abort
```
`CMAKE_POLICY_VERSION_MINIMUM=3.5` is required with host CMake 4.x because the
vendored Opus build used by `audiopus_sys` still declares an old minimum CMake
version. Without that env var, the Windows build/check fails during Opus configure.
### Native Windows
A native MSVC build is not the active development path. If used, install MSVC Build
Tools and CMake, then build normally:
```powershell
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
cargo build --release
```
### Cross-compile from Linux
The current dev path cross-compiles from an Arch environment to the GNU Windows target:
```sh
rustup target add x86_64-pc-windows-gnu
sudo pacman -S mingw-w64-gcc cmake
CMAKE_POLICY_VERSION_MINIMUM=3.5 cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak
```
Wine is useful for launch/render smoke tests, but it is not a substitute for a real
Windows audio-device pass. The deeper migration plan (phases, decisions, the opus build
spike) lives in the maintainer's handoff docs, outside the repo.
## First run and networking
Expect a Windows Firewall prompt the first time the app opens network sockets. Allow it:
PeerSpeak uses UDP for QUIC, plus relay traffic when direct NAT traversal is not available.
Expect a Windows Firewall prompt the first time the app opens network sockets, or
use the Inno installer option that pre-adds a firewall allow rule. PeerSpeak uses
UDP for QUIC plus relay traffic when direct NAT traversal is unavailable.
The default network mode keeps the n0 relay available for NAT traversal without publishing
presence to n0 DNS. Direct peer-to-peer paths may work when both networks allow them; relayed
connections are expected and valid.
The default network mode keeps the n0 relay available for NAT traversal without
publishing presence to n0 DNS. Relayed connections are expected and valid.
## Known gaps
| Item | Status |
|---|---|
| Echo cancellation | Linux-only PipeWire feature. The Windows UI shows it disabled as unavailable. |
| Screen share | Requires a Windows `pixelpass.exe` on `PATH` or a configured override. |
| Chimes | Now routed through Windows `SoundPlayer`; needs a real Windows host to audibly verify. |
| Resampling/device format | Cross-compiled. cpal/WASAPI now chooses native 48 kHz when available and otherwise resamples/remaps at the device boundary; needs real Windows hardware audio verification. |
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
| Playback pacing | Cross-compiled. The fixed playback target under WASAPI shared mode still needs real-hardware verification with `audio_probe`. |
| Echo cancellation | Linux-only today. The Windows UI shows it disabled as unavailable. |
| Screen share | Blocked by PixelPass, which is currently Linux-only in practice. PeerSpeak can spawn `pixelpass.exe`, but there is no Windows PixelPass host/viewer parity yet. |
| Device persistence | Uses cpal friendly names as keys. These can duplicate or change across Windows driver/profile changes; stable WASAPI endpoint IDs are still needed. |
| Release hygiene | Keep `.iss` and installer output in sync with `Cargo.toml`; rebuild Windows artifacts during each release. |
| Runtime coverage | The Windows VM smoke test proved an older tester build. Current `main` needs a fresh VM smoke matrix before calling parity current. |
Before calling Windows support done, verify a real Windows machine can create/join a room,
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
play notification chimes.
## Current smoke checklist
Before calling a Windows build current, verify on the Windows VM or real Windows
hardware:
- Launch current `peerspeak.exe`; GUI renders and settings open.
- Run `audio_probe.exe 440 30`; listen for glitches and inspect `playout-health`.
- Create/join a Linux <-> Windows room; confirm mic and playback both directions.
- Select input/output devices, restart, and confirm selections persist or fall back clearly.
- Play chimes and custom chime paths.
- Send chat, image/file attachments, and save an attachment through the native dialog.
- Import/play/share/listen to music from Windows file paths.
- Record mixed/stems/both and inspect the WAV output path.
- Exercise friends/presence/recents and the clock-skew banner.
- Test Steam and non-Steam game detection on a real Windows Steam install.
- Install/upgrade/uninstall through the Inno installer, including firewall rule cleanup.
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
pkgname=peerspeak-git
_pkgname=peerspeak
pkgver=0.6.1.r310.g660261a
pkgver=0.6.2.r319.g8014edf
pkgrel=1
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
arch=('x86_64')
+4
View File
@@ -0,0 +1,4 @@
.tools/
AppDir/
*.AppImage
squashfs-root/
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# AppRun for the PeerSpeak AppImage.
#
# PeerSpeak bundles the pixelpass screen-share helper in usr/bin. We prepend our
# own usr/bin to PATH so peerspeak's $PATH lookup for `pixelpass` finds the
# bundled copy, while the host's tools (gst-launch-1.0, pactl, mpv — which
# pixelpass in turn shells out to) remain reachable via the appended host PATH.
# That no-sandbox spawning is exactly why this app suits AppImage over Flatpak.
HERE="$(dirname "$(readlink -f "$0")")"
export PATH="$HERE/usr/bin:$PATH"
exec "$HERE/usr/bin/peerspeak" "$@"
+76
View File
@@ -0,0 +1,76 @@
# PeerSpeak AppImage
A "thin" AppImage: the `peerspeak` binary, the bundled `pixelpass` screen-share
helper, a launcher (`AppRun`), and the desktop entry + icon. Run
`./build-appimage.sh` to produce `peerspeak-<version>-x86_64.AppImage`.
## Why thin, and why pixelpass is bundled
PeerSpeak owns voice; **pixelpass** owns pixels. They are never Cargo
dependencies of each other — peerspeak shells out to the `pixelpass` binary over
its CLI. The AppImage co-locates `pixelpass` in `usr/bin`, and `AppRun` prepends
`usr/bin` to `PATH`, so peerspeak's normal `$PATH` lookup finds it with no code
change. Joe gets one file, and screen-share works out of the box.
Almost nothing is bundled: peerspeak's own assets (notification WAVs, avatar
presets, window icon, fonts) are `include_bytes!`-embedded, and the graphics
stack (`libGL`, `libvulkan`, `libwayland-*`, `libxkbcommon`, X11) is dlopen'd at
runtime and on the AppImage excludelist because it must match the host driver.
So the image carries just the two binaries plus a handful of small libs.
## Host requirements
The AppImage runs on any reasonably current glibc-based distro that has:
- **A Vulkan-capable GPU + driver** (peerspeak's iced/wgpu renderer). Mesa/RADV
on AMD/Intel or the NVIDIA driver all work.
- **PipeWire** (with the PulseAudio shim, for `pactl`).
- For **screen-share only** — pixelpass shells out to these on the host `PATH`;
it prints the exact package names for your distro if any are missing:
- **GStreamer + plugins** (`gst-launch-1.0`/`gst-inspect-1.0`, base,
good/bad/ugly, libav, and the PipeWire plugin),
- **mpv** (or vlc) for the viewer side,
- on X11, `xwininfo` for single-window capture.
On Arch/Artix that is one pacman line, e.g.:
```sh
sudo pacman -S gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad \
gst-plugins-ugly gst-libav gst-plugin-pipewire mpv xorg-xwininfo libpulse
```
(Add `gstreamer-vaapi` for hardware H.264 encode on AMD/Intel; the software
x264 path always works. On XLibre / X11 the capture path uses `ximagesrc` and
needs no XDG portal — no systemd required.)
## Building for broad compatibility (glibc baseline)
An AppImage requires a host glibc **at least as new** as the build host's. Built
on a rolling distro (glibc 2.43) it only runs on equally-new systems. Build
inside **Ubuntu 24.04** (glibc 2.39, PipeWire 1.0.5) for wide reach — pixelpass's
`pipewire` crate binds the system PipeWire headers and needs PipeWire >= 1.0, so
the older Debian 12 `peerspeak-bookworm` box (PW 0.3.65) cannot build it. 2.39
covers Debian 13+, Fedora 40+, and current rolling distros.
```sh
# One-time: an Ubuntu 24.04 distrobox that reuses the host rustup toolchain.
distrobox create --yes --image ubuntu:24.04 --name peerspeak-appimage
distrobox enter peerspeak-appimage -- sudo apt-get update
distrobox enter peerspeak-appimage -- sudo apt-get install -y \
build-essential cmake clang libclang-dev pkg-config \
libpipewire-0.3-dev libspa-0.2-dev libasound2-dev libxcb1-dev \
curl ca-certificates file patchelf git
# Build (the host's ~/.rustup toolchain is glibc-2.17-baseline, so it runs in the
# box; isolated CARGO_TARGET_DIRs keep it off the host target/):
distrobox enter peerspeak-appimage -- env \
PATH="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH" \
./packaging/appimage/build-appimage.sh
```
## Caveats
- **Hardware encode (VAAPI)** uses the host GPU driver and can't be bundled; the
software x264 path always works.
- The bundled `pixelpass` is built headless (no `gui` feature) — it is only ever
driven by peerspeak, never launched standalone from this image.
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Build a "thin" PeerSpeak AppImage that also bundles the pixelpass screen-share
# helper.
#
# PeerSpeak is an iced/wgpu GUI app; pixelpass is the separate screen-share
# orchestrator peerspeak shells out to (never a Cargo dependency). Both link
# almost nothing — the graphics stack (libGL, libvulkan, wayland, xkbcommon,
# X11) is dlopen'd at runtime and is on the AppImage excludelist because it must
# match the host driver, and pixelpass's capture/encode tools (gst-launch-1.0,
# pactl, mpv) are expected on the host PATH. So the AppImage carries just the two
# binaries plus their handful of non-excludelisted libs. The custom AppRun
# prepends usr/bin to PATH so peerspeak's own $PATH lookup finds the bundled
# pixelpass, while the host's tools stay reachable.
#
# All runtime assets (notification WAVs, avatar presets, window icon, fonts) are
# include_bytes!-embedded in the peerspeak binary, so nothing else is bundled.
#
# Usage: packaging/appimage/build-appimage.sh
# Output: packaging/appimage/peerspeak-<version>-x86_64.AppImage
#
# Build inside an Ubuntu 24.04 distrobox (glibc 2.39, PipeWire 1.0.5) for broad
# reach — pixelpass's `pipewire` crate needs PipeWire >= 1.0 headers, so the
# older peerspeak-bookworm box (PW 0.3.65) cannot build it. The 2.39 baseline
# covers Debian 13+, Fedora 40+, and all current rolling distros. See README.md.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
tools="$here/.tools"
appdir="$here/AppDir"
mkdir -p "$tools"
# linuxdeploy is itself an AppImage; run it without FUSE so this works in a
# container / on CI without libfuse2.
export APPIMAGE_EXTRACT_AND_RUN=1
VERSION="$(grep -m1 '^version' "$repo/Cargo.toml" | sed -E 's/.*"(.*)".*/\1/')"
export VERSION
# Isolated target dirs so an old-glibc box build never clobbers the host target/.
cache="${PEERSPEAK_APPIMAGE_CACHE:-$HOME/.cache/peerspeak-appimage}"
ps_target="$cache/peerspeak-target"
pp_target="$cache/pixelpass-target"
# The pixelpass screen-share helper we bundle. Sibling checkout by default.
pixelpass_repo="${PIXELPASS_REPO:-$repo/../pixelpass}"
if [ ! -d "$pixelpass_repo" ]; then
echo "!! pixelpass repo not found at $pixelpass_repo (set PIXELPASS_REPO)" >&2
exit 1
fi
echo ">> building peerspeak (release)"
( cd "$repo" && CARGO_TARGET_DIR="$ps_target" cargo build --release )
ps_bin="$ps_target/release/peerspeak"
# Headless pixelpass: peerspeak drives it via `--host`/viewer + `--output json`,
# never its GUI, so the default (no `gui` feature) keeps the GL toolkit out.
echo ">> building pixelpass (release, headless) from $pixelpass_repo"
( cd "$pixelpass_repo" && CARGO_TARGET_DIR="$pp_target" cargo build --release )
pp_bin="$pp_target/release/pixelpass"
echo ">> fetching linuxdeploy"
ld="$tools/linuxdeploy-x86_64.AppImage"
if [ ! -x "$ld" ]; then
curl -fL --retry 3 -o "$ld" \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
chmod +x "$ld"
fi
echo ">> assembling AppDir"
rm -rf "$appdir"
mkdir -p "$appdir/usr/bin"
install -m755 "$ps_bin" "$appdir/usr/bin/peerspeak"
install -m755 "$pp_bin" "$appdir/usr/bin/pixelpass"
echo ">> running linuxdeploy (bundles libs, builds the AppImage)"
# -e (repeated): analyse both binaries for libraries to bundle; excludelisted
# graphics/glibc libs are skipped. -d/-i: desktop entry + icon.
# --custom-apprun: our launcher that puts the bundled pixelpass on PATH.
( cd "$here" && OUTPUT="peerspeak-${VERSION}-x86_64.AppImage" "$ld" \
--appdir "$appdir" \
-e "$appdir/usr/bin/peerspeak" \
-e "$appdir/usr/bin/pixelpass" \
-d "$repo/packaging/peerspeak.desktop" \
-i "$repo/assets/icons/peerspeak-256.png" \
--icon-filename peerspeak \
--custom-apprun "$here/AppRun" \
--output appimage )
echo ">> done: $here/peerspeak-${VERSION}-x86_64.AppImage"
+1 -1
View File
@@ -8,7 +8,7 @@ it once, then you and I connect directly to each other.
## 1. Install it
1. Double-click **`peerspeak-0.4.0-setup.exe`** (the file I sent you).
1. Double-click **`peerspeak-<version>-setup.exe`** (the file I sent you).
2. **Windows will probably show a blue "Windows protected your PC" warning.**
This is normal — it shows up for any app that isn't from a big company with a
+3 -2
View File
@@ -11,8 +11,9 @@ runtime, so there are no extra DLLs to bundle. The installer payload is just the
## Version compatibility
The installer version tracks the crate version in `Cargo.toml` (currently
**0.4.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes.
The installer version tracks the release version in `Cargo.toml` — keep
`MyAppVersion` in `peerspeak.iss` in sync when cutting a release. Do not reuse an
old installer filename after a crate-version bump.
Per `VERSIONING.md`, a **MINOR** bump in `0.x` is a **breaking wire change**:
peers on different MINOR versions can't connect (they fail fast at the
+1 -1
View File
@@ -12,7 +12,7 @@
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak"
#define MyAppVersion "0.6.0"
#define MyAppVersion "0.6.3"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
+622 -7
View File
@@ -4,7 +4,10 @@ use crate::audio::clip_player::{
};
use crate::audio::eq::{EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN, EqSettings};
use crate::audio::{AudioDevice, enumerate_audio_devices};
use crate::config::{AppConfig, AudioProfile, NetworkMode, RecordingMode, RoomLayout};
use crate::config::{
AppConfig, AudioProfile, NetworkMode, RecordingMode, RoomLayout, ShareBuffering, SharePlayer,
ShareQuality,
};
use crate::core::{
CoreController,
messages::{CoreCommand, UiEvent},
@@ -60,18 +63,20 @@ pub enum SettingsCategory {
Profile,
Appearance,
Network,
Advanced,
Notifications,
Games,
}
impl SettingsCategory {
const ALL: [SettingsCategory; 8] = [
const ALL: [SettingsCategory; 9] = [
SettingsCategory::Audio,
SettingsCategory::Hotkeys,
SettingsCategory::Recording,
SettingsCategory::Profile,
SettingsCategory::Appearance,
SettingsCategory::Network,
SettingsCategory::Advanced,
SettingsCategory::Notifications,
SettingsCategory::Games,
];
@@ -84,6 +89,7 @@ impl SettingsCategory {
SettingsCategory::Profile => "Profile",
SettingsCategory::Appearance => "Appearance",
SettingsCategory::Network => "Network",
SettingsCategory::Advanced => "Advanced",
SettingsCategory::Notifications => "Notifications",
SettingsCategory::Games => "Games",
}
@@ -97,6 +103,7 @@ impl SettingsCategory {
SettingsCategory::Profile => "Avatar and identity",
SettingsCategory::Appearance => "Layout and theme",
SettingsCategory::Network => "Relay and privacy mode",
SettingsCategory::Advanced => "Screen sharing",
SettingsCategory::Notifications => "Chimes and sounds",
SettingsCategory::Games => "Detection, presence, backgrounds",
}
@@ -109,6 +116,176 @@ impl std::fmt::Display for SettingsCategory {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShareMaxHeightChoice {
Source,
P720,
P1080,
P1440,
}
impl ShareMaxHeightChoice {
const ALL: [ShareMaxHeightChoice; 4] = [
ShareMaxHeightChoice::Source,
ShareMaxHeightChoice::P720,
ShareMaxHeightChoice::P1080,
ShareMaxHeightChoice::P1440,
];
fn from_config(value: Option<u32>) -> Self {
match value {
Some(720) => ShareMaxHeightChoice::P720,
Some(1080) => ShareMaxHeightChoice::P1080,
Some(1440) => ShareMaxHeightChoice::P1440,
_ => ShareMaxHeightChoice::Source,
}
}
fn to_config(self) -> Option<u32> {
match self {
ShareMaxHeightChoice::Source => None,
ShareMaxHeightChoice::P720 => Some(720),
ShareMaxHeightChoice::P1080 => Some(1080),
ShareMaxHeightChoice::P1440 => Some(1440),
}
}
}
impl std::fmt::Display for ShareMaxHeightChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareMaxHeightChoice::Source => "Source",
ShareMaxHeightChoice::P720 => "720p",
ShareMaxHeightChoice::P1080 => "1080p",
ShareMaxHeightChoice::P1440 => "1440p",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShareFramerateChoice {
Preset,
Fps15,
Fps24,
Fps30,
Fps60,
}
impl ShareFramerateChoice {
const ALL: [ShareFramerateChoice; 5] = [
ShareFramerateChoice::Preset,
ShareFramerateChoice::Fps15,
ShareFramerateChoice::Fps24,
ShareFramerateChoice::Fps30,
ShareFramerateChoice::Fps60,
];
fn from_config(value: Option<u32>) -> Self {
match value {
Some(15) => ShareFramerateChoice::Fps15,
Some(24) => ShareFramerateChoice::Fps24,
Some(30) => ShareFramerateChoice::Fps30,
Some(60) => ShareFramerateChoice::Fps60,
_ => ShareFramerateChoice::Preset,
}
}
fn to_config(self) -> Option<u32> {
match self {
ShareFramerateChoice::Preset => None,
ShareFramerateChoice::Fps15 => Some(15),
ShareFramerateChoice::Fps24 => Some(24),
ShareFramerateChoice::Fps30 => Some(30),
ShareFramerateChoice::Fps60 => Some(60),
}
}
}
impl std::fmt::Display for ShareFramerateChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareFramerateChoice::Preset => "Preset default",
ShareFramerateChoice::Fps15 => "15 fps",
ShareFramerateChoice::Fps24 => "24 fps",
ShareFramerateChoice::Fps30 => "30 fps",
ShareFramerateChoice::Fps60 => "60 fps",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShareMaxViewersChoice {
Auto,
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
impl ShareMaxViewersChoice {
const ALL: [ShareMaxViewersChoice; 9] = [
ShareMaxViewersChoice::Auto,
ShareMaxViewersChoice::One,
ShareMaxViewersChoice::Two,
ShareMaxViewersChoice::Three,
ShareMaxViewersChoice::Four,
ShareMaxViewersChoice::Five,
ShareMaxViewersChoice::Six,
ShareMaxViewersChoice::Seven,
ShareMaxViewersChoice::Eight,
];
fn from_config(value: Option<u32>) -> Self {
match value {
Some(1) => ShareMaxViewersChoice::One,
Some(2) => ShareMaxViewersChoice::Two,
Some(3) => ShareMaxViewersChoice::Three,
Some(4) => ShareMaxViewersChoice::Four,
Some(5) => ShareMaxViewersChoice::Five,
Some(6) => ShareMaxViewersChoice::Six,
Some(7) => ShareMaxViewersChoice::Seven,
Some(8) => ShareMaxViewersChoice::Eight,
_ => ShareMaxViewersChoice::Auto,
}
}
fn to_config(self) -> Option<u32> {
match self {
ShareMaxViewersChoice::Auto => None,
ShareMaxViewersChoice::One => Some(1),
ShareMaxViewersChoice::Two => Some(2),
ShareMaxViewersChoice::Three => Some(3),
ShareMaxViewersChoice::Four => Some(4),
ShareMaxViewersChoice::Five => Some(5),
ShareMaxViewersChoice::Six => Some(6),
ShareMaxViewersChoice::Seven => Some(7),
ShareMaxViewersChoice::Eight => Some(8),
}
}
}
impl std::fmt::Display for ShareMaxViewersChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareMaxViewersChoice::Auto => "Auto",
ShareMaxViewersChoice::One => "1",
ShareMaxViewersChoice::Two => "2",
ShareMaxViewersChoice::Three => "3",
ShareMaxViewersChoice::Four => "4",
ShareMaxViewersChoice::Five => "5",
ShareMaxViewersChoice::Six => "6",
ShareMaxViewersChoice::Seven => "7",
ShareMaxViewersChoice::Eight => "8",
})
}
}
const SHARE_CACHE_MB_OPTIONS: [u32; 8] = [1, 2, 4, 8, 16, 32, 64, 128];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HomeLayoutMode {
FocusedEmpty,
@@ -379,6 +556,18 @@ pub enum AppMessage {
NetworkModeSelected(NetworkMode),
AudioProfileSelected(AudioProfile),
RecordingModeSelected(RecordingMode),
ScreenShareQualitySelected(ShareQuality),
ScreenSharePlayerSelected(SharePlayer),
ScreenShareBufferingSelected(ShareBuffering),
ScreenShareMaxHeightSelected(ShareMaxHeightChoice),
ScreenShareFramerateSelected(ShareFramerateChoice),
ScreenShareMaxViewersSelected(ShareMaxViewersChoice),
ScreenShareCacheMbSelected(u32),
ScreenShareBitrateChanged(String),
ToggleScreenShareForceSoftwareEncode(bool),
ToggleScreenShareHardwareDecode(bool),
ScreenShareExtraMpvArgsChanged(String),
ScreenShareExtraHostArgsChanged(String),
/// Choose the friends presence posture (W7): invisible / normal / discoverable.
PresenceModeSelected(PresenceMode),
/// Friends list (W7 P5): add-form edits, add, remove, and local rename.
@@ -537,6 +726,8 @@ pub enum AppMessage {
/// Select which app's audio to share in the picker: `Some(name)` for one app,
/// `None` for the whole desktop ("All system audio").
SelectShareAudioApp(Option<String>),
/// Session-only quality preset for the next share start.
SelectShareQualityOverride(ShareQuality),
/// Confirm the picker: start the share with the currently selected audio app.
ConfirmShareScreen,
/// Watch a peer's screen share, identified by their pixelpass ticket.
@@ -678,6 +869,10 @@ pub struct AppState {
game_override: GameOverrideChoice,
peers: HashMap<EndpointId, PeerState>,
audio_levels: HashMap<EndpointId, f32>,
/// Latest per-peer connection transparency info (direct/relay, RTT, window
/// loss/bitrate), replaced wholesale by each `UiEvent::ConnectionStats`
/// (~1/sec). A peer with no entry has no live audio link right now.
conn_stats: HashMap<EndpointId, crate::core::connstats::PeerConnInfo>,
/// Peers we've locally muted (their audio isn't mixed into our output).
locally_muted: HashSet<EndpointId>,
/// When we joined the current room, for the in-room call-duration timer.
@@ -761,6 +956,8 @@ pub struct AppState {
/// The picker's current selection: `Some(name)` = capture that app's audio,
/// `None` = "All system audio" (whole desktop; may echo the call).
share_audio_selection: Option<String>,
/// Session-only quality override for the next screen-share start.
share_quality_selection: ShareQuality,
/// A share start is in flight: `ConfirmShareScreen` was sent but the core
/// hasn't yet replied with `ScreenShareStarted`/an error. Blocks reopening
/// the picker (and re-confirming) during that startup window. Cleared on
@@ -853,6 +1050,7 @@ impl AppState {
self.music_prefetch_inflight = None;
self.peers.clear();
self.audio_levels.clear();
self.conn_stats.clear();
self.locally_muted.clear();
self.chat_messages.clear();
self.chat_input.clear();
@@ -871,6 +1069,7 @@ impl AppState {
self.share_picker_open = false;
self.share_audio_apps.clear();
self.share_audio_selection = None;
self.share_quality_selection = self.config.screen_share.quality;
self.share_starting = false;
self.share_audio_dropped = false;
self.share_audio_app_active = false;
@@ -975,6 +1174,7 @@ impl Default for AppState {
let (clip_player, clip_status) = ClipPlayer::new(config.clip_volume);
let (music_player, music_status) = ClipPlayer::new(config.music_volume);
let music_broadcasting = config.music_broadcast;
let share_quality_selection = config.screen_share.quality;
let music_playlist = config
.music_playlist
.iter()
@@ -1014,6 +1214,7 @@ impl Default for AppState {
game_override: GameOverrideChoice::Auto,
peers: HashMap::new(),
audio_levels: HashMap::new(),
conn_stats: HashMap::new(),
locally_muted: HashSet::new(),
call_started: None,
recording: false,
@@ -1049,6 +1250,7 @@ impl Default for AppState {
share_picker_open: false,
share_audio_apps: Vec::new(),
share_audio_selection: None,
share_quality_selection,
share_starting: false,
share_audio_dropped: false,
share_audio_app_active: false,
@@ -1490,6 +1692,37 @@ fn pan_label(pan: f32) -> String {
}
}
/// Connection badge text on the peer card: path type + RTT ("Direct · 12 ms").
fn conn_badge_label(info: &crate::core::connstats::PeerConnInfo) -> String {
let kind = if info.relay { "Relay" } else { "Direct" };
format!("{kind} · {} ms", info.rtt_ms)
}
/// First tooltip line: path type + remote address ("Direct (1.2.3.4:5)" /
/// "Relay (https://relay.example./)").
fn conn_tooltip_path(info: &crate::core::connstats::PeerConnInfo) -> String {
let kind = if info.relay { "Relay" } else { "Direct" };
format!("{kind} ({})", info.remote_addr)
}
/// Loss line for the tooltip. `None` (first poll / idle window) reads as clean.
fn conn_loss_label(loss_pct: Option<f32>) -> String {
match loss_pct {
Some(pct) => format!("Loss {:.1}% (last second)", pct.clamp(0.0, 100.0)),
None => "Loss — (last second)".to_string(),
}
}
/// One direction of the bitrate line ("32 kbps", "1.5 Mbps", or "—" until a
/// full poll window has elapsed on the current path).
fn conn_rate_label(kbps: Option<f32>) -> String {
match kbps {
None => "".to_string(),
Some(k) if k >= 1000.0 => format!("{:.1} Mbps", k / 1000.0),
Some(k) => format!("{k:.0} kbps"),
}
}
fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
match message {
AppMessage::NicknameChanged(val) => {
@@ -1563,6 +1796,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// so the picker can't be reopened during the startup window.
state.share_picker_open = true;
state.share_audio_selection = None;
// NB: do NOT reset `share_quality_selection` here. It is the
// per-call override set by the inline quality dropdown next to
// the Share button, and the picker has no quality control of its
// own — resetting it would silently discard the user's pick
// before `ConfirmShareScreen` reads it.
state.share_audio_apps.clear();
let _ = state.controller.send(CoreCommand::ListAudioApps);
}
@@ -1573,6 +1811,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::SelectShareAudioApp(app) => {
state.share_audio_selection = app;
}
AppMessage::SelectShareQualityOverride(quality) => {
state.share_quality_selection = quality;
}
AppMessage::ConfirmShareScreen => {
// Only a confirm from an open picker starts a share; a stray confirm
// (or one arriving while a start is already in flight) is ignored, so
@@ -1581,14 +1822,21 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.share_picker_open = false;
state.share_starting = true;
let audio_app = state.share_audio_selection.clone();
let _ = state
.controller
.send(CoreCommand::StartScreenShare { audio_app });
let settings = state.config.screen_share.clone();
let quality = state.share_quality_selection;
let _ = state.controller.send(CoreCommand::StartScreenShare {
audio_app,
settings,
quality,
});
state.status_message = "Starting screen share…".to_string();
}
}
AppMessage::WatchShare(ticket) => {
let _ = state.controller.send(CoreCommand::ViewShare(ticket));
let settings = state.config.screen_share.clone();
let _ = state
.controller
.send(CoreCommand::ViewShare { ticket, settings });
state.status_message = "Opening screen share…".to_string();
}
AppMessage::ToggleMutePressed => {
@@ -1723,6 +1971,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.audio_levels.insert(id, val);
}
}
UiEvent::ConnectionStats(infos) => {
// Full replacement: a peer missing from this round has no
// live link, so its (stale) badge must go away too.
state.conn_stats = infos.into_iter().collect();
}
UiEvent::MicLevel(level) => {
state.mic_level = level;
}
@@ -2064,6 +2317,61 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// Takes effect on the next recording start.
let _ = state.controller.send(CoreCommand::SetRecordingMode(mode));
}
AppMessage::ScreenShareQualitySelected(quality) => {
state.config.screen_share.quality = quality;
state.share_quality_selection = quality;
state.config.save();
}
AppMessage::ScreenSharePlayerSelected(player) => {
state.config.screen_share.player = player;
state.config.save();
}
AppMessage::ScreenShareBufferingSelected(buffering) => {
state.config.screen_share.buffering = buffering;
state.config.save();
}
AppMessage::ScreenShareMaxHeightSelected(choice) => {
state.config.screen_share.max_height = choice.to_config();
state.config.save();
}
AppMessage::ScreenShareFramerateSelected(choice) => {
state.config.screen_share.framerate = choice.to_config();
state.config.save();
}
AppMessage::ScreenShareMaxViewersSelected(choice) => {
state.config.screen_share.max_viewers = choice.to_config();
state.config.save();
}
AppMessage::ScreenShareCacheMbSelected(cache_mb) => {
state.config.screen_share.cache_mb = cache_mb;
state.config.save();
}
AppMessage::ScreenShareBitrateChanged(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
state.config.screen_share.bitrate_mbps = None;
state.config.save();
} else if let Ok(mbps) = trimmed.parse::<u32>() {
state.config.screen_share.bitrate_mbps = Some(mbps);
state.config.save();
}
}
AppMessage::ToggleScreenShareForceSoftwareEncode(enabled) => {
state.config.screen_share.force_software_encode = enabled;
state.config.save();
}
AppMessage::ToggleScreenShareHardwareDecode(enabled) => {
state.config.screen_share.hardware_decode = enabled;
state.config.save();
}
AppMessage::ScreenShareExtraMpvArgsChanged(args) => {
state.config.screen_share.extra_mpv_args = args;
state.config.save();
}
AppMessage::ScreenShareExtraHostArgsChanged(args) => {
state.config.screen_share.extra_host_args = args;
state.config.save();
}
AppMessage::PresenceModeSelected(mode) => {
state.config.presence_mode = mode;
state.config.save();
@@ -3166,6 +3474,30 @@ fn presence_mode_hint(mode: PresenceMode) -> &'static str {
}
}
fn share_quality_hint(quality: ShareQuality) -> &'static str {
match quality {
ShareQuality::Auto => "Use pixelpass bandwidth pre-flight; falls back to Medium.",
ShareQuality::Low => "Lower bandwidth: up to 480p, about 1 Mbps.",
ShareQuality::Medium => "Balanced preset: up to 720p, about 2.5 Mbps.",
ShareQuality::High => "Sharper preset: up to 1080p, about 4 Mbps.",
ShareQuality::Source => "Native source resolution, about 6 Mbps.",
}
}
fn share_player_hint(player: SharePlayer) -> &'static str {
match player {
SharePlayer::Mpv => "Try mpv first, then VLC if mpv is unavailable.",
SharePlayer::Vlc => "Try VLC first, then mpv if VLC is unavailable.",
}
}
fn share_buffering_hint(buffering: ShareBuffering) -> &'static str {
match buffering {
ShareBuffering::LowLatency => "Small buffers for interactive screen sharing.",
ShareBuffering::Smooth => "Larger cache/readahead for steadier playback.",
}
}
/// Formats a call duration as `m:ss` (or `h:mm:ss` past an hour).
fn format_duration(total_secs: u64) -> String {
let h = total_secs / 3600;
@@ -4759,6 +5091,135 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
// Spacing between one category and the next.
let section_gap = 18.0;
let screen_share = &state.config.screen_share;
let screen_share_bitrate = screen_share
.bitrate_mbps
.map(|v| v.to_string())
.unwrap_or_default();
let screen_share_section = column![
column![
text("Host encoding").size(13).color(color_subtext),
row![
column![
text("Quality").size(12).color(color_subtext),
pick_list(
&ShareQuality::ALL[..],
Some(screen_share.quality),
AppMessage::ScreenShareQualitySelected,
).width(iced::Length::Fill),
text(share_quality_hint(screen_share.quality)).size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
column![
text("Max resolution").size(12).color(color_subtext),
pick_list(
&ShareMaxHeightChoice::ALL[..],
Some(ShareMaxHeightChoice::from_config(screen_share.max_height)),
AppMessage::ScreenShareMaxHeightSelected,
).width(iced::Length::Fill),
text("Source keeps the captured display height.").size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
].spacing(16).width(iced::Length::Fill),
row![
column![
text("Framerate").size(12).color(color_subtext),
pick_list(
&ShareFramerateChoice::ALL[..],
Some(ShareFramerateChoice::from_config(screen_share.framerate)),
AppMessage::ScreenShareFramerateSelected,
).width(iced::Length::Fill),
text("Preset default lets pixelpass choose.").size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
column![
text("Bitrate Mbps").size(12).color(color_subtext),
context_input("preset default", &screen_share_bitrate)
.on_input(AppMessage::ScreenShareBitrateChanged)
.style(t_style)
.padding(8)
.width(iced::Length::Fill),
text("Blank uses the quality preset.").size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
].spacing(16).width(iced::Length::Fill),
row![
column![
text("Max viewers").size(12).color(color_subtext),
pick_list(
&ShareMaxViewersChoice::ALL[..],
Some(ShareMaxViewersChoice::from_config(screen_share.max_viewers)),
AppMessage::ScreenShareMaxViewersSelected,
).width(iced::Length::Fill),
text("Auto uses pixelpass' connection-aware recommendation.").size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
column![
checkbox(screen_share.force_software_encode)
.label("Force software encode")
.on_toggle(AppMessage::ToggleScreenShareForceSoftwareEncode),
text("Passes --no-hwencode to pixelpass.").size(11).color(color_subtext),
].spacing(8).width(iced::Length::Fill),
].spacing(16).width(iced::Length::Fill),
].spacing(10).width(iced::Length::Fill),
vertical_space(section_gap),
column![
text("Viewer playback").size(13).color(color_subtext),
row![
column![
text("Player").size(12).color(color_subtext),
pick_list(
&SharePlayer::ALL[..],
Some(screen_share.player),
AppMessage::ScreenSharePlayerSelected,
).width(iced::Length::Fill),
text(share_player_hint(screen_share.player)).size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
column![
text("Buffering").size(12).color(color_subtext),
pick_list(
&ShareBuffering::ALL[..],
Some(screen_share.buffering),
AppMessage::ScreenShareBufferingSelected,
).width(iced::Length::Fill),
text(share_buffering_hint(screen_share.buffering)).size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
].spacing(16).width(iced::Length::Fill),
row![
column![
text("Cache MB").size(12).color(color_subtext),
pick_list(
&SHARE_CACHE_MB_OPTIONS[..],
Some(screen_share.cache_mb),
AppMessage::ScreenShareCacheMbSelected,
).width(iced::Length::Fill),
text("mpv demuxer cache size (mpv only).").size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
column![
checkbox(screen_share.hardware_decode)
.label("Hardware video decode")
.on_toggle(AppMessage::ToggleScreenShareHardwareDecode),
text("GPU decode (mpv --hwdec=auto / VLC hardware decode). Off avoids the known frame-freeze bug.").size(11).color(color_subtext),
].spacing(8).width(iced::Length::Fill),
].spacing(16).width(iced::Length::Fill),
].spacing(10).width(iced::Length::Fill),
vertical_space(section_gap),
column![
text("Extra mpv args").size(12).color(color_subtext),
context_input("--no-osc --vd-lavc-threads=2", &screen_share.extra_mpv_args)
.on_input(AppMessage::ScreenShareExtraMpvArgsChanged)
.style(t_style)
.padding(8)
.width(iced::Length::Fill),
text("⚠ Advanced — may break playback").size(11).color(color_yellow),
text("Extra pixelpass args").size(12).color(color_subtext),
context_input("--relay https://relay.example/", &screen_share.extra_host_args)
.on_input(AppMessage::ScreenShareExtraHostArgsChanged)
.style(t_style)
.padding(8)
.width(iced::Length::Fill),
text("⚠ Advanced — may break playback").size(11).color(color_yellow),
].spacing(6).width(iced::Length::Fill),
text("Applies the next time you start or watch a screen share. These are local preferences only.").size(11).color(color_subtext),
]
.spacing(10)
.width(iced::Length::Fill);
// One recording-mode radio with a hover tooltip explaining it. (iced's
// pick_list can't host per-option tooltips, so the modes are radios.)
let mode_radio = |mode: RecordingMode, label: &'static str| -> Element<'_, AppMessage> {
@@ -5082,6 +5543,13 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.spacing(10)
.width(iced::Length::Fill)
.into(),
SettingsCategory::Advanced => column![
section_header("Screen sharing"),
screen_share_section,
]
.spacing(10)
.width(iced::Length::Fill)
.into(),
SettingsCategory::Notifications => column![
section_header("Notifications & Sounds"),
column![
@@ -5766,6 +6234,49 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
name_col = name_col
.push(text(format!("Playing {game}")).size(11).color(color_blue));
}
// Connection-transparency badge: path type + RTT, with
// the full story (address, loss, bitrate) on hover.
// Rendered only while the audio link is live — the
// Connecting/Reconnecting indicator covers the rest.
if let Some(info) = state.conn_stats.get(peer_id) {
let dot_color = if info.relay {
color_yellow
} else {
color_green
};
let badge = row![
text("").size(9).color(dot_color),
text(conn_badge_label(info)).size(11).color(color_subtext),
]
.spacing(4)
.align_y(iced::alignment::Vertical::Center);
let detail = column![
text(conn_tooltip_path(info)).size(11).color(color_text),
text(conn_loss_label(info.loss_pct))
.size(11)
.color(color_subtext),
text(format!(
"↑ {} ↓ {}",
conn_rate_label(info.up_kbps),
conn_rate_label(info.down_kbps)
))
.size(11)
.color(color_subtext),
]
.spacing(2);
name_col = name_col.push(
tooltip(
badge,
container(detail).padding(8).style(c_style(
color_crust,
color_surface,
6.0,
)),
iced::widget::tooltip::Position::Bottom,
)
.gap(6),
);
}
name_col
},
add_friend_el,
@@ -6479,11 +6990,29 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
} else {
AppMessage::OpenPixelpassHelp
};
button(btn_content(share_kind, share_label, share_fg))
let share_button = button(btn_content(share_kind, share_label, share_fg))
.on_press(share_press)
.style(b_style(share_bg, share_hover, share_fg, 8.0))
.padding(14)
.width(iced::Length::Fill);
let share_control: Element<'_, AppMessage> = if state.self_sharing {
share_button.into()
} else {
row![
share_button,
pick_list(
&ShareQuality::ALL[..],
Some(state.share_quality_selection),
AppMessage::SelectShareQualityOverride,
)
.width(iced::Length::Fixed(112.0)),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center)
.width(iced::Length::Fill)
.into()
};
share_control
},
];
@@ -8656,6 +9185,17 @@ mod tests {
state.invalid_audio.insert(attachment_id);
state.connecting.insert(peer);
state.ever_connected.insert(peer);
state.conn_stats.insert(
peer,
crate::core::connstats::PeerConnInfo {
relay: false,
remote_addr: "1.2.3.4:5".to_string(),
rtt_ms: 12,
loss_pct: None,
up_kbps: None,
down_kbps: None,
},
);
state.recording = true;
state.recording_started = Some(now);
state.call_started = Some(now);
@@ -8695,6 +9235,7 @@ mod tests {
assert!(state.invalid_audio.is_empty());
assert!(state.connecting.is_empty());
assert!(state.ever_connected.is_empty());
assert!(state.conn_stats.is_empty());
assert!(!state.recording);
assert!(state.recording_started.is_none());
assert!(state.call_started.is_none());
@@ -8703,6 +9244,10 @@ mod tests {
assert!(!state.share_picker_open);
assert!(state.share_audio_apps.is_empty());
assert!(state.share_audio_selection.is_none());
assert_eq!(
state.share_quality_selection,
state.config.screen_share.quality
);
assert!(!state.share_starting);
assert!(!state.share_audio_dropped);
assert!(!state.share_audio_app_active);
@@ -8827,6 +9372,42 @@ mod tests {
assert!(!state.share_picker_open);
}
#[test]
fn inline_quality_override_survives_opening_the_picker() {
// The inline quality dropdown (next to the Share button) sets a
// per-call `share_quality_selection`. Opening the audio picker via
// ToggleScreenShare must NOT reset it back to the saved config default,
// or the override the user just made is silently discarded before
// ConfirmShareScreen reads it into StartScreenShare.
use crate::config::ShareQuality;
let mut state = AppState::default();
// Saved default is Auto; the user picks a different per-call quality.
assert_eq!(state.config.screen_share.quality, ShareQuality::Auto);
let _ = update(
&mut state,
AppMessage::SelectShareQualityOverride(ShareQuality::High),
);
assert_eq!(state.share_quality_selection, ShareQuality::High);
// Clicking Share opens the picker — the override must be preserved.
let _ = update(&mut state, AppMessage::ToggleScreenShare);
assert!(state.share_picker_open);
assert_eq!(
state.share_quality_selection,
ShareQuality::High,
"opening the picker must not clobber the inline per-call override"
);
// Confirming reads that same override into the share start.
let _ = update(&mut state, AppMessage::ConfirmShareScreen);
assert!(state.share_starting);
assert_eq!(
state.share_quality_selection,
ShareQuality::High,
"the override the picker preserved must still be what ConfirmShareScreen sends"
);
}
#[test]
fn share_start_failure_clears_in_flight_flag() {
// A failed spawn surfaces as UiEvent::Error (not ScreenShareStopped); the
@@ -9198,12 +9779,14 @@ mod tests {
"Profile",
"Appearance",
"Network",
"Advanced",
"Notifications",
"Games"
]
);
assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
assert_eq!(SettingsCategory::Advanced.hint(), "Screen sharing");
assert_eq!(
SettingsCategory::Games.hint(),
"Detection, presence, backgrounds"
@@ -9300,6 +9883,38 @@ mod tests {
assert!(ch.is_finite() && ch >= CHAT_MIN_H);
}
#[test]
fn conn_badge_and_tooltip_labels() {
use super::{conn_badge_label, conn_loss_label, conn_rate_label, conn_tooltip_path};
let direct = crate::core::connstats::PeerConnInfo {
relay: false,
remote_addr: "192.168.1.7:53340".to_string(),
rtt_ms: 12,
loss_pct: Some(0.44),
up_kbps: Some(32.4),
down_kbps: None,
};
assert_eq!(conn_badge_label(&direct), "Direct · 12 ms");
assert_eq!(conn_tooltip_path(&direct), "Direct (192.168.1.7:53340)");
let relay = crate::core::connstats::PeerConnInfo {
relay: true,
remote_addr: "https://relay.example./".to_string(),
..direct.clone()
};
assert_eq!(conn_badge_label(&relay), "Relay · 12 ms");
assert_eq!(conn_tooltip_path(&relay), "Relay (https://relay.example./)");
assert_eq!(conn_loss_label(Some(0.44)), "Loss 0.4% (last second)");
// Out-of-range inputs clamp instead of reading nonsense.
assert_eq!(conn_loss_label(Some(250.0)), "Loss 100.0% (last second)");
assert_eq!(conn_loss_label(None), "Loss — (last second)");
assert_eq!(conn_rate_label(Some(32.4)), "32 kbps");
assert_eq!(conn_rate_label(Some(1500.0)), "1.5 Mbps");
assert_eq!(conn_rate_label(None), "");
}
#[test]
fn controls_and_drawer_width_clamps() {
use super::{CHAT_MIN_W, CONTROLS_MIN_W, clamp_chat_drawer_width, clamp_controls_width};
+145
View File
@@ -168,6 +168,139 @@ impl std::fmt::Display for NetworkMode {
}
}
/// Pixelpass host quality preset for screen shares. `Auto` leaves pixelpass free
/// to choose from its bandwidth pre-flight; fixed presets are passed as CLI flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ShareQuality {
#[default]
Auto,
Low,
Medium,
High,
Source,
}
impl ShareQuality {
pub const ALL: [ShareQuality; 5] = [
ShareQuality::Auto,
ShareQuality::Low,
ShareQuality::Medium,
ShareQuality::High,
ShareQuality::Source,
];
}
impl std::fmt::Display for ShareQuality {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareQuality::Auto => "Auto",
ShareQuality::Low => "Low",
ShareQuality::Medium => "Medium",
ShareQuality::High => "High",
ShareQuality::Source => "Source",
})
}
}
/// Preferred local player for watching a peer's screen share.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SharePlayer {
#[default]
Mpv,
Vlc,
}
impl SharePlayer {
pub const ALL: [SharePlayer; 2] = [SharePlayer::Mpv, SharePlayer::Vlc];
}
impl std::fmt::Display for SharePlayer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
SharePlayer::Mpv => "mpv",
SharePlayer::Vlc => "VLC",
})
}
}
/// Local player buffering posture for screen-share playback.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ShareBuffering {
#[default]
LowLatency,
Smooth,
}
impl ShareBuffering {
pub const ALL: [ShareBuffering; 2] = [ShareBuffering::LowLatency, ShareBuffering::Smooth];
}
impl std::fmt::Display for ShareBuffering {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareBuffering::LowLatency => "Low latency",
ShareBuffering::Smooth => "Smooth",
})
}
}
fn default_screen_share_cache_mb() -> u32 {
2
}
/// Local-only screen-share preferences. Host fields become pixelpass host CLI
/// flags; viewer fields shape local mpv/VLC launch. None/empty/default values
/// deliberately let pixelpass/player defaults stand.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScreenShareSettings {
#[serde(default)]
pub quality: ShareQuality,
#[serde(default)]
pub bitrate_mbps: Option<u32>,
#[serde(default)]
pub framerate: Option<u32>,
#[serde(default)]
pub max_height: Option<u32>,
#[serde(default)]
pub max_viewers: Option<u32>,
#[serde(default)]
pub force_software_encode: bool,
#[serde(default)]
pub extra_host_args: String,
#[serde(default)]
pub player: SharePlayer,
#[serde(default)]
pub hardware_decode: bool,
#[serde(default)]
pub buffering: ShareBuffering,
#[serde(default = "default_screen_share_cache_mb")]
pub cache_mb: u32,
#[serde(default)]
pub extra_mpv_args: String,
}
impl Default for ScreenShareSettings {
fn default() -> Self {
Self {
quality: ShareQuality::default(),
bitrate_mbps: None,
framerate: None,
max_height: None,
max_viewers: None,
force_software_encode: false,
extra_host_args: String::new(),
player: SharePlayer::default(),
hardware_decode: false,
buffering: ShareBuffering::default(),
cache_mb: default_screen_share_cache_mb(),
extra_mpv_args: String::new(),
}
}
}
fn default_true() -> bool {
true
}
@@ -369,6 +502,9 @@ pub struct AppConfig {
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
#[serde(default)]
pub pixelpass_path: Option<String>,
/// Local-only host/player controls for screen sharing.
#[serde(default)]
pub screen_share: ScreenShareSettings,
/// Recently-joined rooms (W7), most-recent-first. Purely local UI state for a
/// one-click rejoin; never sent over the wire. De-duped by room topic and
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
@@ -474,6 +610,7 @@ impl Default for AppConfig {
sound_mic_toggle_enabled: true,
sound_reconnect_failed_enabled: true,
pixelpass_path: None,
screen_share: ScreenShareSettings::default(),
recents: Vec::new(),
peer_eq: HashMap::new(),
peer_pan: HashMap::new(),
@@ -803,6 +940,14 @@ mod tests {
assert!(deserialized.custom_sound_self_leave.is_none());
assert!(deserialized.custom_sound_mic_toggle.is_none());
assert!(deserialized.custom_sound_reconnect_failed.is_none());
assert_eq!(deserialized.screen_share, ScreenShareSettings::default());
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
assert_eq!(
deserialized.screen_share.buffering,
ShareBuffering::LowLatency
);
assert_eq!(deserialized.screen_share.cache_mb, 2);
// Configs predating the per-sound flags (W6) enable every chime, so an
// upgrade is silent-change-free.
for sound in Sound::ALL {
+187
View File
@@ -0,0 +1,187 @@
//! Per-peer connection-transparency derivation.
//!
//! The transport hands us cumulative counters for each peer's selected QUIC
//! path ([`PathSnapshot`]); this module turns two consecutive snapshots into
//! the human-facing [`PeerConnInfo`] the UI renders (badge + tooltip): path
//! type, RTT, and loss/bitrate over the poll window. Pure functions only —
//! the polling task in `core::mod` owns the clock and the previous-snapshot
//! map.
use crate::network::PathSnapshot;
use std::time::Duration;
/// How often the core polls the transport for path snapshots.
pub const POLL_INTERVAL: Duration = Duration::from_secs(1);
/// Derived, display-ready connection info for one peer, sent to the UI via
/// `UiEvent::ConnectionStats`. Window-relative fields are `None` when they
/// can't be derived yet (first poll, path switch, or an idle window).
#[derive(Debug, Clone, PartialEq)]
pub struct PeerConnInfo {
/// True = relayed path, false = direct IP path.
pub relay: bool,
/// `ip:port` for a direct path, the relay URL for a relayed one.
pub remote_addr: String,
/// Path round-trip time, rounded to whole milliseconds.
pub rtt_ms: u32,
/// Percentage of packets sent in the window that were detected lost.
pub loss_pct: Option<f32>,
/// Outbound bitrate over the window, kilobits per second.
pub up_kbps: Option<f32>,
/// Inbound bitrate over the window, kilobits per second.
pub down_kbps: Option<f32>,
}
/// Derive display info from the current snapshot and (when comparable) the
/// previous one. `prev` is comparable only if it's the same path — a relay→
/// direct migration or a reconnect resets the counters, so those windows
/// yield `None` rates rather than garbage (negative deltas show up as
/// `cur < prev` and are treated the same way).
pub fn derive(prev: Option<&PathSnapshot>, cur: &PathSnapshot, elapsed: Duration) -> PeerConnInfo {
let rates = prev
.filter(|p| comparable(p, cur))
.and_then(|p| window_rates(p, cur, elapsed));
PeerConnInfo {
relay: cur.is_relay,
remote_addr: cur.remote_addr.clone(),
rtt_ms: cur.rtt.as_millis().min(u128::from(u32::MAX)) as u32,
loss_pct: rates.and_then(|r| r.loss_pct),
up_kbps: rates.map(|r| r.up_kbps),
down_kbps: rates.map(|r| r.down_kbps),
}
}
/// True when `cur`'s counters continue `prev`'s: same path (address) and
/// monotonically non-decreasing counters (a reconnect on the same address
/// restarts them from zero).
fn comparable(prev: &PathSnapshot, cur: &PathSnapshot) -> bool {
prev.remote_addr == cur.remote_addr
&& cur.tx_bytes >= prev.tx_bytes
&& cur.rx_bytes >= prev.rx_bytes
&& cur.tx_datagrams >= prev.tx_datagrams
&& cur.lost_packets >= prev.lost_packets
}
#[derive(Debug, Clone, Copy)]
struct WindowRates {
loss_pct: Option<f32>,
up_kbps: f32,
down_kbps: f32,
}
fn window_rates(prev: &PathSnapshot, cur: &PathSnapshot, elapsed: Duration) -> Option<WindowRates> {
let secs = elapsed.as_secs_f64();
if secs <= 0.0 {
return None;
}
let sent = cur.tx_datagrams - prev.tx_datagrams;
let lost = cur.lost_packets - prev.lost_packets;
// Loss detection lags sending (it needs ACK timeouts), so a window can see
// more losses than sends; clamp to 100% rather than exceeding it. An idle
// window (nothing sent or lost) has no loss story to tell.
let loss_pct = if sent == 0 && lost == 0 {
None
} else {
Some(((lost as f64 / (sent.max(lost)) as f64) * 100.0) as f32)
};
let kbps = |bytes: u64| ((bytes as f64 * 8.0 / 1000.0) / secs) as f32;
Some(WindowRates {
loss_pct,
up_kbps: kbps(cur.tx_bytes - prev.tx_bytes),
down_kbps: kbps(cur.rx_bytes - prev.rx_bytes),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(addr: &str, tx_b: u64, rx_b: u64, tx_d: u64, lost: u64) -> PathSnapshot {
PathSnapshot {
is_relay: false,
remote_addr: addr.to_string(),
rtt: Duration::from_millis(12),
tx_bytes: tx_b,
rx_bytes: rx_b,
tx_datagrams: tx_d,
lost_packets: lost,
}
}
#[test]
fn first_poll_has_type_and_rtt_but_no_rates() {
let cur = snap("1.2.3.4:5", 1000, 2000, 50, 0);
let info = derive(None, &cur, POLL_INTERVAL);
assert_eq!(info.rtt_ms, 12);
assert!(!info.relay);
assert_eq!(info.remote_addr, "1.2.3.4:5");
assert_eq!(info.loss_pct, None);
assert_eq!(info.up_kbps, None);
assert_eq!(info.down_kbps, None);
}
#[test]
fn steady_window_yields_rates_and_loss() {
let prev = snap("1.2.3.4:5", 0, 0, 0, 0);
// 1s window: 4000 bytes up (32 kbps), 2000 down (16 kbps), 2 of 100 lost.
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2);
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
assert_eq!(info.up_kbps, Some(32.0));
assert_eq!(info.down_kbps, Some(16.0));
assert_eq!(info.loss_pct, Some(2.0));
}
#[test]
fn idle_window_has_no_loss_story() {
let prev = snap("1.2.3.4:5", 4000, 2000, 100, 2);
let cur = prev.clone();
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
assert_eq!(info.loss_pct, None);
assert_eq!(info.up_kbps, Some(0.0));
}
#[test]
fn loss_detected_in_an_idle_window_clamps_to_full() {
// Losses can be *detected* after sending stops (ACK timeouts fire late).
let prev = snap("1.2.3.4:5", 4000, 2000, 100, 0);
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 3);
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
assert_eq!(info.loss_pct, Some(100.0));
}
#[test]
fn path_switch_resets_the_window() {
let prev = snap("relay.example:443", 9000, 9000, 900, 5);
let cur = snap("1.2.3.4:5", 100, 100, 10, 0);
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
assert_eq!(info.up_kbps, None);
assert_eq!(info.loss_pct, None);
}
#[test]
fn counter_reset_on_same_address_resets_the_window() {
// Same address but the connection was rebuilt → counters restarted.
let prev = snap("1.2.3.4:5", 9000, 9000, 900, 5);
let cur = snap("1.2.3.4:5", 100, 100, 10, 0);
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
assert_eq!(info.up_kbps, None);
assert_eq!(info.loss_pct, None);
}
#[test]
fn zero_elapsed_yields_no_rates() {
let prev = snap("1.2.3.4:5", 0, 0, 0, 0);
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2);
let info = derive(Some(&prev), &cur, Duration::ZERO);
assert_eq!(info.up_kbps, None);
assert_eq!(info.loss_pct, None);
}
#[test]
fn oversized_rtt_saturates_instead_of_wrapping() {
let mut cur = snap("1.2.3.4:5", 0, 0, 0, 0);
cur.rtt = Duration::from_secs(u64::MAX);
let info = derive(None, &cur, POLL_INTERVAL);
assert_eq!(info.rtt_ms, u32::MAX);
}
}
+30 -6
View File
@@ -1,4 +1,4 @@
use crate::config::{AudioProfile, NetworkMode, RecordingMode};
use crate::config::{AudioProfile, NetworkMode, RecordingMode, ScreenShareSettings, ShareQuality};
use crate::friends::Friend;
use crate::network::PeerState;
use crate::presence::{FriendPresence, PresenceMode};
@@ -119,13 +119,18 @@ pub enum CoreCommand {
/// whole desktop audio (the legacy behavior).
StartScreenShare {
audio_app: Option<String>,
settings: ScreenShareSettings,
quality: ShareQuality,
},
/// Stop sharing our screen: kill the pixelpass host and clear the presence
/// ticket. No-op when not sharing.
StopScreenShare,
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
/// open it in a local player.
ViewShare(String),
ViewShare {
ticket: String,
settings: ScreenShareSettings,
},
/// Mint a fresh persistent identity (W7), discarding the old one. Takes effect
/// on the next room join (the endpoint is rebuilt then). The core replies with
/// an updated [`UiEvent::IdentityStatus`].
@@ -244,9 +249,16 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
}
| CoreCommand::SetPixelpassPath(_)
| CoreCommand::ListAudioApps
| CoreCommand::StartScreenShare { audio_app: _ }
| CoreCommand::StartScreenShare {
audio_app: _,
settings: _,
quality: _,
}
| CoreCommand::StopScreenShare
| CoreCommand::ViewShare(_)
| CoreCommand::ViewShare {
ticket: _,
settings: _,
}
| CoreCommand::RegenerateIdentity
| CoreCommand::AddFriend {
id: _,
@@ -325,9 +337,16 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
}
| CoreCommand::SetPixelpassPath(_)
| CoreCommand::ListAudioApps
| CoreCommand::StartScreenShare { audio_app: _ }
| CoreCommand::StartScreenShare {
audio_app: _,
settings: _,
quality: _,
}
| CoreCommand::StopScreenShare
| CoreCommand::ViewShare(_)
| CoreCommand::ViewShare {
ticket: _,
settings: _,
}
| CoreCommand::RegenerateIdentity
| CoreCommand::AddFriend {
id: _,
@@ -382,6 +401,11 @@ pub enum UiEvent {
id: EndpointId,
},
AudioLevels(Vec<(EndpointId, f32)>),
/// Periodic per-peer connection transparency snapshot (~1/sec): path type
/// (direct/relay), RTT, and window loss/bitrate for every peer with a live
/// audio link. A FULL replacement each time — a peer absent from the list
/// has no live link right now, so its badge should disappear.
ConnectionStats(Vec<(EndpointId, crate::core::connstats::PeerConnInfo)>),
/// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`,
/// for the settings level meter. Throttled to ~10/sec.
MicLevel(f32),
+344 -38
View File
@@ -1,3 +1,4 @@
pub mod connstats;
pub mod jitter;
pub mod messages;
mod recovery;
@@ -652,6 +653,7 @@ struct ActiveSession {
mixer_task: tokio::task::JoinHandle<()>,
event_task: tokio::task::JoinHandle<()>,
conn_event_task: tokio::task::JoinHandle<()>,
conn_stats_task: tokio::task::JoinHandle<()>,
recovery_task: tokio::task::JoinHandle<()>,
recovery_terminal_task: tokio::task::JoinHandle<()>,
grace_timers: GraceTimers,
@@ -662,9 +664,11 @@ struct ActiveSession {
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
/// also dies if the session is dropped without an explicit stop).
screenshare_host: Option<tokio::process::Child>,
/// pixelpass viewer children we spawned to watch peers' shares; killed on
/// session teardown (each also self-exits when its player window closes).
screenshare_viewers: Vec<tokio::process::Child>,
/// pixelpass viewer children we spawned to watch peers' shares, each paired
/// with the share ticket it's viewing so a re-watch of the same share can
/// replace (not stack) its player. Killed on session teardown (each also
/// self-exits when its player window closes).
screenshare_viewers: Vec<(String, tokio::process::Child)>,
}
impl ActiveSession {
@@ -676,13 +680,14 @@ impl ActiveSession {
if let Some(mut host) = self.screenshare_host.take() {
let _ = host.kill().await;
}
for mut viewer in self.screenshare_viewers.drain(..) {
for (_, mut viewer) in self.screenshare_viewers.drain(..) {
let _ = viewer.kill().await;
}
self.datagram_task.abort();
self.mixer_task.abort();
self.event_task.abort();
self.conn_event_task.abort();
self.conn_stats_task.abort();
// Abort any pending reconnect grace timers so they can't fire a stray
// eviction (or touch a torn-down transport) after the session is gone.
for (_, handle) in self.grace_timers.lock().unwrap().drain() {
@@ -883,6 +888,103 @@ async fn build_net_stack(
})
}
/// Retry policy for a live net-stack replacement, generic over the builder so
/// it is unit-testable without binding sockets: build for `requested`; if that
/// fails, build for `live` (the posture the old stack was actually running) so
/// a bad posture change degrades to the previous posture instead of leaving no
/// stack at all. When `requested == live` the second attempt is a plain retry.
///
/// `Ok((stack, mode, primary_err))` — a stack is up on `mode`; `primary_err`
/// is `Some` when the first attempt failed. `Err((primary, fallback))` — both
/// attempts failed and networking is gone.
async fn rebuild_with_fallback<T, E, F, Fut>(
mut build: F,
requested: NetworkMode,
live: NetworkMode,
) -> Result<(T, NetworkMode, Option<E>), (E, E)>
where
F: FnMut(NetworkMode) -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
{
match build(requested).await {
Ok(stack) => Ok((stack, requested, None)),
Err(primary) => match build(live).await {
Ok(stack) => Ok((stack, live, Some(primary))),
Err(fallback) => Err((primary, fallback)),
},
}
}
/// Tear down `old` and stand up a replacement stack for `requested_mode`.
///
/// A build failure here is rare (only the local socket bind can fail; the
/// relay handshake is backgrounded), but it used to propagate straight out of
/// `run_core_loop` with no `UiEvent`, silently killing every future command —
/// the app looked alive and did nothing. Instead, fall back to `live_mode`
/// via `rebuild_with_fallback`, tell the UI when the requested change did not
/// stick, and return the mode the new stack actually runs so the caller can
/// keep its state honest. `Err` only when both builds fail: networking is
/// gone (already reported to the UI as fatal) and the caller should exit.
#[allow(clippy::too_many_arguments)]
async fn replace_net_stack(
old: NetStack,
what: &str,
secret_key: &SecretKey,
requested_mode: NetworkMode,
live_mode: NetworkMode,
friends_handler: &crate::presence_net::Handler,
publish: bool,
ui_tx: &mpsc::Sender<UiEvent>,
) -> Result<(NetStack, NetworkMode), anyhow::Error> {
let lookup = old.memory_lookup.clone();
old.shutdown().await;
let outcome = rebuild_with_fallback(
|mode| {
build_net_stack(
secret_key.clone(),
mode,
lookup.clone(),
friends_handler.clone(),
publish,
)
},
requested_mode,
live_mode,
)
.await;
match outcome {
Ok((stack, mode, None)) => Ok((stack, mode)),
Ok((stack, mode, Some(primary))) => {
if mode == requested_mode {
// Same-posture retry succeeded — everything the user asked for
// is in effect, so log it rather than raising a UI error.
crate::log_msg(&format!(
"{what}: net stack build failed once ({primary:#}); retry succeeded"
));
} else {
let _ = ui_tx
.send(UiEvent::Error(format!(
"{what} failed ({primary:#}); staying on the previous \
network mode for this session"
)))
.await;
}
Ok((stack, mode))
}
Err((primary, fallback)) => {
let _ = ui_tx
.send(UiEvent::Error(format!(
"Networking lost: {primary:#} (recovery attempt also failed: \
{fallback:#}). Restart PeerSpeak to reconnect."
)))
.await;
Err(anyhow::anyhow!(
"net stack rebuild failed: {primary:#}; fallback: {fallback:#}"
))
}
}
}
/// Maximum number of *automatic* chat-attachment fetches in flight at once.
///
/// Auto-fetch (inline image preview) is triggered by an untrusted peer's chat
@@ -1330,6 +1432,10 @@ async fn run_core_loop(
// rebuilt on the next Leave (or before the next Join), preserving the old
// "applies on next join" semantics while keeping the endpoint up while idle.
let mut net_rebuild_pending = false;
// The posture the live stack was actually built with. Trails `network_mode`
// while a rebuild is pending, and is the fallback posture when a rebuild
// fails (see `replace_net_stack`).
let mut net_mode = network_mode;
// When Discoverable is on, the instant it auto-reverts to Normal (W7 P6 time-box).
// `None` = not Discoverable, no pending revert. Set on SetPresenceMode(Discoverable),
@@ -1547,17 +1653,24 @@ async fn run_core_loop(
// active, rebuild the persistent stack now — after the old session is
// gone, before the new one binds — so this join uses the new posture.
if net_rebuild_pending {
let lookup = net.memory_lookup.clone();
net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(
secret_key.clone(),
let (stack, live) = replace_net_stack(
net,
"Applying deferred network settings",
&secret_key,
network_mode,
lookup,
friends_handler.clone(),
net_mode,
&friends_handler,
publish,
&ui_tx,
)
.await?;
net = stack;
net_mode = live;
// If the new posture failed and we fell back, keep the mode
// state honest (and re-attemptable) rather than pretending
// the change applied. The join proceeds on the live stack.
network_mode = live;
net_rebuild_pending = false;
}
@@ -2483,6 +2596,40 @@ async fn run_core_loop(
}
});
// Connection-transparency poll: ~1/sec, snapshot every live audio
// link's selected path and hand the UI derived badge info (path
// type, RTT, window loss/bitrate). Read-only against the
// transport; owns the previous-snapshot map the derivation diffs
// against.
let transport_stats = transport.clone();
let ui_tx_stats = ui_tx.clone();
let conn_stats_task = tokio::spawn(async move {
let mut prev: HashMap<EndpointId, crate::network::PathSnapshot> =
HashMap::new();
let mut last = tokio::time::Instant::now();
let mut ticker = tokio::time::interval(connstats::POLL_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let now = tokio::time::Instant::now();
let elapsed = now - last;
last = now;
let snaps = transport_stats.connection_stats();
let infos = snaps
.iter()
.map(|(id, cur)| (*id, connstats::derive(prev.get(id), cur, elapsed)))
.collect();
prev = snaps.into_iter().collect();
if ui_tx_stats
.send(UiEvent::ConnectionStats(infos))
.await
.is_err()
{
break;
}
}
});
let session = ActiveSession {
room_state: room_state.clone(),
capture_thread,
@@ -2490,6 +2637,7 @@ async fn run_core_loop(
mixer_task,
event_task,
conn_event_task,
conn_stats_task,
recovery_task,
recovery_terminal_task,
grace_timers,
@@ -2497,7 +2645,7 @@ async fn run_core_loop(
#[cfg(target_os = "linux")]
echo_cancel: echo_cancel_guard,
screenshare_host: None,
screenshare_viewers: Vec::new(),
screenshare_viewers: Vec::<(String, tokio::process::Child)>::new(),
};
let self_id = endpoint.id().to_string();
@@ -2554,17 +2702,21 @@ async fn run_core_loop(
// Apply any network-mode / identity change that was deferred while we
// were in the call (rebuild while idle keeps the endpoint reachable).
if net_rebuild_pending {
let lookup = net.memory_lookup.clone();
net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(
secret_key.clone(),
let (stack, live) = replace_net_stack(
net,
"Applying deferred network settings",
&secret_key,
network_mode,
lookup,
friends_handler.clone(),
net_mode,
&friends_handler,
publish,
&ui_tx,
)
.await?;
net = stack;
net_mode = live;
network_mode = live;
net_rebuild_pending = false;
}
}
@@ -2710,17 +2862,21 @@ async fn run_core_loop(
// idle; if a call is active, defer to the next Leave/Join so the
// live call isn't disrupted (preserves "applies on next join").
if active_session.is_none() {
let lookup = net.memory_lookup.clone();
net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(
secret_key.clone(),
let (stack, live) = replace_net_stack(
net,
"Network mode change",
&secret_key,
network_mode,
lookup,
friends_handler.clone(),
net_mode,
&friends_handler,
publish,
&ui_tx,
)
.await?;
net = stack;
net_mode = live;
network_mode = live;
} else {
net_rebuild_pending = true;
}
@@ -2758,17 +2914,22 @@ async fn run_core_loop(
// key unchanged, so a rebuild would be pointless churn).
if regenerated {
if active_session.is_none() {
let lookup = net.memory_lookup.clone();
net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(
secret_key.clone(),
// Same mode both attempts — the fallback is a plain
// retry under the (already persisted) new key.
let (stack, live) = replace_net_stack(
net,
"Endpoint restart after identity change",
&secret_key,
network_mode,
lookup,
friends_handler.clone(),
net_mode,
&friends_handler,
publish,
&ui_tx,
)
.await?;
net = stack;
net_mode = live;
} else {
net_rebuild_pending = true;
}
@@ -3121,7 +3282,11 @@ async fn run_core_loop(
.await;
}
CoreCommand::StartScreenShare { audio_app } => {
CoreCommand::StartScreenShare {
audio_app,
settings,
quality,
} => {
let Some(session) = &mut active_session else {
let _ = ui_tx
.send(UiEvent::Error(
@@ -3171,7 +3336,15 @@ async fn run_core_loop(
});
tx
});
match crate::screenshare::spawn_host(&bin, audio_app.as_deref(), notices).await {
match crate::screenshare::spawn_host(
&bin,
audio_app.as_deref(),
&settings,
quality,
notices,
)
.await
{
Ok((child, ticket)) => {
crate::log_msg("Screen share host started");
session.screenshare_host = Some(child);
@@ -3209,7 +3382,7 @@ async fn run_core_loop(
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
}
CoreCommand::ViewShare(ticket) => {
CoreCommand::ViewShare { ticket, settings } => {
let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
Some(b) => b,
None => {
@@ -3221,11 +3394,27 @@ async fn run_core_loop(
continue;
}
};
match crate::screenshare::spawn_viewer(&bin, &ticket).await {
if let Some(session) = &mut active_session {
// Drop viewers whose player window has already closed so the
// list only tracks live players.
session
.screenshare_viewers
.retain_mut(|(_, child)| !matches!(child.try_wait(), Ok(Some(_))));
// One player per share: a second Watch click on a share we're
// already viewing is a retry (usually because the first window
// froze), so replace the existing player rather than stacking a
// second mpv — two players would double the shared audio.
if let Some(pos) = replace_viewer_index(&session.screenshare_viewers, &ticket) {
let (_, mut old) = session.screenshare_viewers.remove(pos);
let _ = old.kill().await;
crate::log_msg("Screen share viewer replaced (re-watch)");
}
}
match crate::screenshare::spawn_viewer(&bin, &ticket, &settings).await {
Ok(child) => {
crate::log_msg("Screen share viewer started");
if let Some(session) = &mut active_session {
session.screenshare_viewers.push(child);
session.screenshare_viewers.push((ticket, child));
}
}
Err(e) => {
@@ -3241,14 +3430,23 @@ async fn run_core_loop(
Ok(())
}
/// Index of an existing viewer for `ticket` in the live-viewers list, if any.
/// A re-watch of the same share replaces that player instead of stacking a
/// second one — two players decoding the same stream would double the shared
/// audio. Generic over the child value so the dedup rule is unit-testable
/// without spawning real player processes.
fn replace_viewer_index<T>(viewers: &[(String, T)], ticket: &str) -> Option<usize> {
viewers.iter().position(|(t, _)| t == ticket)
}
#[cfg(test)]
mod tests {
use super::{
KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter,
PLAYBACK_HANDOFF_QUEUE_FRAMES, PeerSpeakTicket, admit_retained, apply_peer_volume,
apply_volume, audio_datagram_len_ok, coalesce_insert, coalesce_pop, frame_level,
mix_frames, mix_stereo_frames, next_game_change, send_playback_frame, should_auto_fetch,
stereo_to_mono,
NetworkMode, PLAYBACK_HANDOFF_QUEUE_FRAMES, PeerSpeakTicket, admit_retained,
apply_peer_volume, apply_volume, audio_datagram_len_ok, coalesce_insert, coalesce_pop,
frame_level, mix_frames, mix_stereo_frames, next_game_change, rebuild_with_fallback,
replace_viewer_index, send_playback_frame, should_auto_fetch, stereo_to_mono,
};
use crate::core::messages::{CoalesceKey, CoreCommand, coalesce_key};
use std::collections::{HashMap, HashSet};
@@ -3259,6 +3457,114 @@ mod tests {
iroh::SecretKey::generate().public()
}
#[test]
fn re_watch_replaces_existing_viewer_for_same_ticket() {
// The value type stands in for a viewer Child; only the ticket matters.
let viewers = vec![("ticket-A".to_string(), 0u8), ("ticket-B".to_string(), 1u8)];
// Re-watching an already-open share finds the existing player to replace.
assert_eq!(replace_viewer_index(&viewers, "ticket-A"), Some(0));
assert_eq!(replace_viewer_index(&viewers, "ticket-B"), Some(1));
// A different (new) share has nothing to replace — it opens fresh.
assert_eq!(replace_viewer_index(&viewers, "ticket-C"), None);
// Empty list: first watch of anything opens fresh.
assert_eq!(replace_viewer_index::<u8>(&[], "ticket-A"), None);
}
// --- rebuild_with_fallback: the retry policy behind replace_net_stack ---
// The builder is injected, so these cover the policy without sockets. The
// closure does its bookkeeping synchronously and returns a ready future.
#[tokio::test]
async fn rebuild_keeps_requested_posture_on_first_success() {
let calls = std::cell::RefCell::new(Vec::new());
let out = rebuild_with_fallback(
|mode| {
calls.borrow_mut().push(mode);
std::future::ready(Ok::<u8, String>(7))
},
NetworkMode::DirectOnly,
NetworkMode::N0Full,
)
.await;
assert_eq!(out, Ok((7, NetworkMode::DirectOnly, None)));
// No second build: the live posture is only a fallback.
assert_eq!(*calls.borrow(), vec![NetworkMode::DirectOnly]);
}
#[tokio::test]
async fn rebuild_falls_back_to_the_live_posture_when_the_requested_one_fails() {
let calls = std::cell::RefCell::new(Vec::new());
let out = rebuild_with_fallback(
|mode| {
calls.borrow_mut().push(mode);
std::future::ready(if mode == NetworkMode::DirectOnly {
Err("bind failed".to_string())
} else {
Ok(7u8)
})
},
NetworkMode::DirectOnly,
NetworkMode::N0Full,
)
.await;
// A stack is up on the OLD posture and the caller learns both that it
// fell back (mode) and why (the primary error) — no silent zombie.
assert_eq!(
out,
Ok((7, NetworkMode::N0Full, Some("bind failed".to_string())))
);
assert_eq!(
*calls.borrow(),
vec![NetworkMode::DirectOnly, NetworkMode::N0Full]
);
}
#[tokio::test]
async fn rebuild_reports_both_errors_when_networking_is_gone() {
let out = rebuild_with_fallback(
|_| std::future::ready(Err::<u8, String>("bind failed".to_string())),
NetworkMode::DirectOnly,
NetworkMode::N0Full,
)
.await;
assert_eq!(
out,
Err(("bind failed".to_string(), "bind failed".to_string()))
);
}
#[tokio::test]
async fn rebuild_with_equal_postures_is_a_plain_retry() {
// RegenerateIdentity rebuilds under the same mode: the fallback is a
// second attempt with identical parameters, not a posture change.
let calls = std::cell::Cell::new(0u8);
let out = rebuild_with_fallback(
|mode| {
calls.set(calls.get() + 1);
assert_eq!(mode, NetworkMode::RelayNoDiscovery);
std::future::ready(if calls.get() == 1 {
Err("transient".to_string())
} else {
Ok(7u8)
})
},
NetworkMode::RelayNoDiscovery,
NetworkMode::RelayNoDiscovery,
)
.await;
// Succeeded on the requested posture, so the caller treats the change
// as applied (the Some(err) is logged, not surfaced as a UI error).
assert_eq!(
out,
Ok((
7,
NetworkMode::RelayNoDiscovery,
Some("transient".to_string())
))
);
assert_eq!(calls.get(), 2);
}
#[test]
fn admit_retained_rejects_only_new_ids_at_the_cap() {
// Below the cap, a brand-new identity is retained.
+1 -3
View File
@@ -97,14 +97,12 @@ fn windows_toolhelp_executables() -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn enumerates_at_least_this_process() {
// The test runner itself is a process, so /proc enumeration must be
// non-empty and include something that normalizes to our own exe basename.
let exes = running_executables();
let exes = super::running_executables();
assert!(
!exes.is_empty(),
"expected to see running processes via /proc"
+51
View File
@@ -689,6 +689,57 @@ impl IrohTransport {
Ok(bytes)
}
/// Snapshot the selected QUIC path of every live audio connection, for the
/// UI's per-peer connection badge (direct/relay, RTT, loss, bitrate).
/// Cheap and lock-light: the `live_conns` guard is released before touching
/// any connection, and `Connection::paths()` reads shared state without I/O.
pub fn connection_stats(&self) -> Vec<(EndpointId, crate::network::PathSnapshot)> {
// Clone the connections out so the map lock isn't held while we inspect
// paths (a supervisor inserts/removes entries as links come and go).
let conns: Vec<(EndpointId, Connection)> = self
.shared
.live_conns
.lock()
.unwrap()
.iter()
.map(|(id, conn)| (*id, conn.clone()))
.collect();
conns
.into_iter()
.filter_map(|(id, conn)| {
let paths = conn.paths();
// The selected path is the one carrying application data. In the
// brief window where none is flagged (e.g. mid-migration), fall
// back to the first open path rather than dropping the badge.
let path = paths
.iter()
.find(|p| p.is_selected())
.or_else(|| paths.iter().next())?;
let stats = path.stats();
// Per-variant display: `TransportAddr`'s own `Display` prefixes
// a scheme ("ip:1.2.3.4:5") that's noise next to the badge's
// Direct/Relay label.
let remote_addr = match path.remote_addr() {
iroh::TransportAddr::Ip(sock) => sock.to_string(),
iroh::TransportAddr::Relay(url) => url.to_string(),
other => other.to_string(),
};
Some((
id,
crate::network::PathSnapshot {
is_relay: path.remote_addr().is_relay(),
remote_addr,
rtt: stats.rtt,
tx_bytes: stats.udp_tx.bytes,
rx_bytes: stats.udp_rx.bytes,
tx_datagrams: stats.udp_tx.datagrams,
lost_packets: stats.lost_packets,
},
))
})
.collect()
}
/// Fetch a chat attachment's bytes from its sender over the file plane.
pub async fn fetch_attachment(
&self,
+26
View File
@@ -184,6 +184,32 @@ pub enum ConnEvent {
Left(EndpointId),
}
/// Owned snapshot of a peer's *selected* QUIC path (the one currently carrying
/// application data), taken from the live audio connection for the UI's
/// connection-transparency badge. Counters are cumulative for the path's
/// lifetime; rate/loss derivation over a poll window happens in
/// `core::connstats` (which also detects path switches via `remote_addr`).
#[derive(Debug, Clone, PartialEq)]
pub struct PathSnapshot {
/// True when the path runs through a relay server, false for a direct
/// (holepunched or local) IP path.
pub is_relay: bool,
/// The path's remote transport address: `ip:port` for a direct path, the
/// relay URL for a relayed one.
pub remote_addr: String,
/// Current QUIC round-trip-time estimate for the path.
pub rtt: std::time::Duration,
/// Cumulative bytes sent in UDP datagrams on the path.
pub tx_bytes: u64,
/// Cumulative bytes received in UDP datagrams on the path.
pub rx_bytes: u64,
/// Cumulative UDP datagrams sent on the path (the loss denominator: for our
/// small voice frames these map ~1:1 to QUIC packets).
pub tx_datagrams: u64,
/// Cumulative packets detected lost on the path.
pub lost_packets: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PeerSpeakTicket {
pub host_addr: iroh::EndpointAddr,
+269 -30
View File
@@ -21,6 +21,8 @@ use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
/// The binary we shell out to. Looked up on `$PATH` unless a config override
/// points elsewhere.
const PIXELPASS_BIN: &str = "pixelpass";
@@ -139,7 +141,11 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
/// otherwise rejects hyphen-leading option values). The name is locally chosen
/// (our own enumeration / the user's pick), not peer-supplied, but is still
/// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O.
pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
pub fn host_args(
audio_app: Option<&str>,
settings: &ScreenShareSettings,
quality: ShareQuality,
) -> Vec<String> {
let mut args = vec![
"--host".to_string(),
"--output".to_string(),
@@ -149,9 +155,44 @@ pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
args.push(format!("--app={name}"));
args.push("--strict-audio".to_string());
}
if quality != ShareQuality::Auto {
args.push(format!("--quality={}", pixelpass_quality(quality)));
}
if let Some(height) = settings.max_height {
args.push(format!("--max-height={height}"));
}
if let Some(mbps) = settings.bitrate_mbps {
args.push(format!("--bitrate={}", mbps.saturating_mul(1000)));
}
if let Some(fps) = settings.framerate {
args.push(format!("--framerate={fps}"));
}
if settings.force_software_encode {
args.push("--no-hwencode".to_string());
}
if let Some(max) = settings.max_viewers {
args.push(format!("--max-viewers={max}"));
}
args.extend(split_extra_args(&settings.extra_host_args));
args
}
fn pixelpass_quality(quality: ShareQuality) -> &'static str {
match quality {
ShareQuality::Auto => "auto",
ShareQuality::Low => "low",
ShareQuality::Medium => "medium",
ShareQuality::High => "high",
ShareQuality::Source => "source",
}
}
/// Split user-supplied advanced argv text into separate tokens. Peerspeak does
/// not depend on a shell lexer, so quoted values are not interpreted here.
fn split_extra_args(raw: &str) -> impl Iterator<Item = String> + '_ {
raw.split_whitespace().map(str::to_string)
}
/// Validate a locally-chosen audio app name before it becomes a `--app` value:
/// trim, reject empty / overlong, and reject names carrying control characters
/// (newlines etc.) that have no place in a real `application.name`. `None` means
@@ -320,15 +361,26 @@ pub fn is_available(config_override: Option<&str>) -> bool {
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
/// running (streaming to viewers) until killed or dropped; remaining stdout is
/// drained in a background task so a full pipe can't stall the host. We do
/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap,
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`.
/// not pass encode/viewer overrides unless the local settings explicitly ask for
/// them, so pixelpass keeps its own defaults in the common case.
pub async fn spawn_host(
bin: &Path,
audio_app: Option<&str>,
settings: &ScreenShareSettings,
quality: ShareQuality,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
) -> std::io::Result<(Child, String)> {
let args = host_args(audio_app, settings, quality);
// Log the exact argv we hand pixelpass so a field log can confirm which
// encode/quality flags (e.g. --bitrate) actually reached the host — these
// are local flags with no ticket/secret, so logging them verbatim is safe.
crate::log_msg(&format!(
"pixelpass host spawn: {} {}",
bin.display(),
args.join(" ")
));
let mut child = Command::new(bin)
.args(host_args(audio_app))
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
// Capture stderr (not null): pixelpass prints its startup precondition
@@ -428,10 +480,14 @@ pub fn pixelpass_failure_detail(stderr: &str) -> String {
}
/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
/// stream in a local player (mpv, falling back to vlc). Returns the live viewer
/// child so the caller can kill it on room-leave; it also self-exits when the
/// player window closes (its tunnel ends).
pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
/// stream in a local player (mpv/VLC in the configured order, then fallback).
/// Returns the live viewer child so the caller can kill it on room-leave; it also
/// self-exits when the player window closes (its tunnel ends).
pub async fn spawn_viewer(
bin: &Path,
ticket: &str,
settings: &ScreenShareSettings,
) -> std::io::Result<Child> {
let mut child = Command::new(bin)
.args(viewer_args(ticket))
.stdin(Stdio::null())
@@ -465,7 +521,7 @@ pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
}
};
if let Err(e) = launch_player(&url) {
if let Err(e) = launch_player(&url, settings) {
let _ = child.kill().await;
return Err(e);
}
@@ -547,23 +603,33 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
}
}
/// Open the viewer stream URL in a media player. Mirrors pixelpass's own
/// low-latency mpv invocation; falls back to vlc. The player is reaped in a
/// background task so it doesn't linger as a zombie when its window closes.
fn launch_player(url: &str) -> std::io::Result<()> {
const MPV_ARGS: &[&str] = &[
"--profile=low-latency",
"--untimed",
"--hwdec=auto",
"--audio-buffer=0.2",
"--demuxer-max-bytes=2M",
"--demuxer-readahead-secs=0.5",
];
const VLC_ARGS: &[&str] = &["--network-caching=200", "--live-caching=200"];
/// Open the viewer stream URL in a media player, then fall back to vlc. The
/// player is reaped in a background task so it doesn't linger as a zombie when
/// its window closes.
///
/// The flags keep latency low while preserving A/V sync. We deliberately do
/// NOT pass mpv's `--untimed`: that displays each video frame the instant it
/// decodes, ignoring audio timestamps, which makes a shared *video* drift
/// progressively out of sync with its audio. Pacing to the audio clock costs a
/// little latency (negligible for pointing at a desktop) and keeps a shared
/// video in sync. We also leave hwdec at the `low-latency` default (software
/// decode): forcing `--hwdec=auto` froze some viewers on frame 1 while audio
/// kept playing.
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
let mpv_args = mpv_args(settings);
let vlc_args = vlc_args(settings);
let first = match settings.player {
SharePlayer::Mpv => ("mpv", &mpv_args),
SharePlayer::Vlc => ("vlc", &vlc_args),
};
let second = match settings.player {
SharePlayer::Mpv => ("vlc", &vlc_args),
SharePlayer::Vlc => ("mpv", &mpv_args),
};
let child = match spawn_player("mpv", MPV_ARGS, url) {
let child = match spawn_player(first.0, first.1, url) {
Ok(c) => c,
Err(_) => spawn_player("vlc", VLC_ARGS, url).map_err(|_| {
Err(_) => spawn_player(second.0, second.1, url).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"no media player found — install mpv or vlc to watch screen shares",
@@ -577,7 +643,64 @@ fn launch_player(url: &str) -> std::io::Result<()> {
Ok(())
}
fn spawn_player(bin: &str, args: &[&str], url: &str) -> std::io::Result<Child> {
pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
let mut args = Vec::new();
match settings.buffering {
ShareBuffering::LowLatency => {
args.push("--profile=low-latency".to_string());
args.push("--audio-buffer=0.2".to_string());
args.push("--demuxer-readahead-secs=0.5".to_string());
}
ShareBuffering::Smooth => {
args.push("--cache=yes".to_string());
args.push("--demuxer-readahead-secs=2".to_string());
}
}
args.push(format!("--demuxer-max-bytes={}M", settings.cache_mb));
if settings.hardware_decode {
args.push("--hwdec=auto".to_string());
}
args.extend(split_extra_args(&settings.extra_mpv_args));
args
}
/// Build the argv for a VLC viewer. VLC honors the subset of viewer settings
/// that map cleanly onto its option set: the buffering posture (network/live
/// caching, in ms) and hardware decoding. The rest of the viewer knobs are
/// mpv-specific — `cache_mb` is an mpv demuxer *byte* cache (VLC's caching is
/// time-based, already covered by `buffering`) and `extra_mpv_args` is literally
/// mpv flags — so they are deliberately not mapped here; the Settings UI labels
/// them as mpv-only. Pure: no I/O.
///
/// The hardware-decode mapping is the load-bearing one: VLC hardware-decodes by
/// default, so without an explicit `--avcodec-hw=none` a VLC viewer would ignore
/// the (default-off) hardware-decode toggle and could hit the frame-1 freeze
/// that default exists to avoid — the same A-bug that made us drop mpv's forced
/// `--hwdec=auto`.
fn vlc_args(settings: &ScreenShareSettings) -> Vec<String> {
let caching_ms = match settings.buffering {
ShareBuffering::LowLatency => 200,
ShareBuffering::Smooth => 1500,
};
let hw = if settings.hardware_decode {
"--avcodec-hw=any"
} else {
"--avcodec-hw=none"
};
vec![
format!("--network-caching={caching_ms}"),
format!("--live-caching={caching_ms}"),
hw.to_string(),
]
}
fn spawn_player(bin: &str, args: &[String], url: &str) -> std::io::Result<Child> {
// Log the player + its flags (mpv/vlc, incl. hardware-decode: --hwdec /
// --avcodec-hw) so a field log can confirm the viewer settings reached the
// player. The `url` is omitted deliberately — it is the local stream address
// and is not needed to verify the flags. Logged on each attempt, so a
// fallback from the preferred player to the other one is visible too.
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
Command::new(bin)
.args(args)
.arg(url)
@@ -621,7 +744,11 @@ mod tests {
fn host_args_without_app_shares_whole_desktop() {
// No app selected → no --app flag → pixelpass keeps its default
// (whole-desktop) audio capture.
assert_eq!(host_args(None), vec!["--host", "--output", "json"]);
let settings = ScreenShareSettings::default();
assert_eq!(
host_args(None, &settings, ShareQuality::Auto),
vec!["--host", "--output", "json"]
);
}
#[test]
@@ -629,8 +756,9 @@ mod tests {
// The chosen app rides in the `--app=<name>` single-token form so a
// name beginning with `-` can never be reparsed as a flag (A23), plus
// `--strict-audio` so pixelpass never falls back to whole-desktop audio.
let settings = ScreenShareSettings::default();
assert_eq!(
host_args(Some("Firefox")),
host_args(Some("Firefox"), &settings, ShareQuality::Auto),
vec![
"--host",
"--output",
@@ -641,7 +769,7 @@ mod tests {
);
// The hyphen-leading name is still bound to --app as a single token;
// --strict-audio is the trailing flag.
let args = host_args(Some("-rm -rf"));
let args = host_args(Some("-rm -rf"), &settings, ShareQuality::Auto);
assert_eq!(args[3], "--app=-rm -rf");
assert_eq!(args[4], "--strict-audio");
}
@@ -650,11 +778,122 @@ mod tests {
fn host_args_blank_or_control_app_is_dropped() {
// An empty / whitespace / control-laden selection is sanitized away,
// falling back to whole-desktop capture rather than a broken flag.
assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]);
let settings = ScreenShareSettings::default();
assert_eq!(
host_args(Some("bad\nname")),
host_args(Some(" "), &settings, ShareQuality::Auto),
vec!["--host", "--output", "json"]
);
assert_eq!(
host_args(Some("bad\nname"), &settings, ShareQuality::Auto),
vec!["--host", "--output", "json"]
);
}
#[test]
fn host_args_apply_screen_share_settings_and_extra_args_last() {
let settings = ScreenShareSettings {
bitrate_mbps: Some(5),
framerate: Some(60),
max_height: Some(1080),
max_viewers: Some(4),
force_software_encode: true,
extra_host_args: "--relay https://relay.example --verbose".to_string(),
..ScreenShareSettings::default()
};
assert_eq!(
host_args(Some("Firefox"), &settings, ShareQuality::High),
vec![
"--host",
"--output",
"json",
"--app=Firefox",
"--strict-audio",
"--quality=high",
"--max-height=1080",
"--bitrate=5000",
"--framerate=60",
"--no-hwencode",
"--max-viewers=4",
"--relay",
"https://relay.example",
"--verbose",
]
);
}
#[test]
fn mpv_args_default_matches_low_latency_software_decode() {
assert_eq!(
mpv_args(&ScreenShareSettings::default()),
vec![
"--profile=low-latency",
"--audio-buffer=0.2",
"--demuxer-readahead-secs=0.5",
"--demuxer-max-bytes=2M",
]
);
}
#[test]
fn mpv_args_smooth_hwdecode_and_extra_args_last() {
let settings = ScreenShareSettings {
hardware_decode: true,
buffering: ShareBuffering::Smooth,
cache_mb: 16,
extra_mpv_args: "--no-osc --vd-lavc-threads=2".to_string(),
..ScreenShareSettings::default()
};
assert_eq!(
mpv_args(&settings),
vec![
"--cache=yes",
"--demuxer-readahead-secs=2",
"--demuxer-max-bytes=16M",
"--hwdec=auto",
"--no-osc",
"--vd-lavc-threads=2",
]
);
}
#[test]
fn vlc_args_default_disables_hardware_decode() {
// The A-bug fix default (hardware_decode = false) must reach VLC too:
// VLC hardware-decodes by default, so without an explicit
// `--avcodec-hw=none` a VLC viewer would ignore the toggle and could hit
// the frame-1 freeze. Low-latency buffering keeps the 200 ms caches.
assert_eq!(
vlc_args(&ScreenShareSettings::default()),
vec![
"--network-caching=200",
"--live-caching=200",
"--avcodec-hw=none",
]
);
}
#[test]
fn vlc_args_smooth_buffering_and_hwdecode() {
// Enabling hardware decode flips VLC to `--avcodec-hw=any`; Smooth
// buffering raises the network/live caches. cache_mb / extra_mpv_args are
// mpv-only and must NOT leak into the VLC argv.
let settings = ScreenShareSettings {
hardware_decode: true,
buffering: ShareBuffering::Smooth,
cache_mb: 16,
extra_mpv_args: "--no-osc".to_string(),
..ScreenShareSettings::default()
};
assert_eq!(
vlc_args(&settings),
vec![
"--network-caching=1500",
"--live-caching=1500",
"--avcodec-hw=any",
]
);
}
#[test]
+64 -6
View File
@@ -70,6 +70,13 @@ pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit {
}
}
/// Strip control characters (e.g. a trailing newline on an X11 PRIMARY
/// selection) from clipboard text before it is pasted. Shared by the
/// right-click menu Paste and the middle-click PRIMARY paste.
pub fn sanitize_clip(raw: &str) -> String {
raw.chars().filter(|c| !c.is_control()).collect()
}
pub fn select_all_range(value: &str) -> (usize, usize) {
let value = text_input::Value::new(value);
@@ -362,6 +369,48 @@ where
return;
}
// Middle-click pastes the X11 PRIMARY selection at the cursor. iced's
// base text_input only wires Ctrl+V to the Standard (CLIPBOARD)
// selection, so without this the common "select text, middle-click to
// paste" workflow does nothing on X11.
let middle_click_on_input = matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle))
) && cursor.is_over(layout.bounds());
if middle_click_on_input && !self.locked {
let clip = sanitize_clip(&clipboard.read(clipboard::Kind::Primary).unwrap_or_default());
if !clip.is_empty() {
let value = text_input::Value::new(&self.value);
let input_state = tree.children[0]
.state
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
let (start, end) = match input_state.cursor().state(&value) {
text_input::cursor::State::Index(index) => {
let index = index.min(value.len());
(index, index)
}
text_input::cursor::State::Selection { start, end } => {
normalized_range(&value, start, end)
}
};
let edit = paste(&self.value, start, end, &clip);
input_state.move_cursor_to(edit.cursor);
if let Some(on_paste) = &self.on_paste {
shell.publish(on_paste.as_ref()(edit.value));
} else if let Some(on_input) = &self.on_input {
shell.publish(on_input.as_ref()(edit.value));
}
}
shell.capture_event();
shell.request_redraw();
return;
}
Widget::update(
&mut self.input,
&mut tree.children[0],
@@ -717,12 +766,11 @@ where
}
}
MenuAction::Paste => {
let clip = clipboard
.read(clipboard::Kind::Standard)
.unwrap_or_default()
.chars()
.filter(|c| !c.is_control())
.collect::<String>();
let clip = sanitize_clip(
&clipboard
.read(clipboard::Kind::Standard)
.unwrap_or_default(),
);
let edit = paste(self.value, start, end, &clip);
self.publish_paste(edit, shell);
@@ -842,6 +890,16 @@ mod tests {
assert_eq!(clip, None);
}
#[test]
fn sanitize_clip_strips_control_chars_keeps_text() {
// An X11 PRIMARY selection commonly carries a trailing newline.
assert_eq!(sanitize_clip("pixelpassF1:abc\n"), "pixelpassF1:abc");
assert_eq!(sanitize_clip("a\tb\r\nc"), "abc");
// Non-control unicode is preserved.
assert_eq!(sanitize_clip("héllo🦀"), "héllo🦀");
assert_eq!(sanitize_clip(""), "");
}
#[test]
fn paste_replaces_selection_or_inserts_at_cursor() {
assert_eq!(
+67
View File
@@ -244,6 +244,73 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
);
}
/// Connection transparency: over a real loopback link, `connection_stats()`
/// must report the peer's selected path as direct (relay disabled here), with
/// an IP remote address and counters that advance while audio flows — and the
/// `connstats::derive` seam must turn two such snapshots into badge info with
/// live rates.
#[tokio::test]
async fn connection_stats_report_a_direct_path_with_live_counters() {
let a = spawn_node().await;
let b = spawn_node().await;
a.lookup.add_endpoint_info(b.endpoint.addr());
b.lookup.add_endpoint_info(a.endpoint.addr());
let a_id = a.endpoint.id();
let b_id = b.endpoint.id();
a.transport.admit_audio_sender(b_id);
b.transport.admit_audio_sender(a_id);
// Keep B's receive path subscribed like production (drained implicitly).
let _b_rx = b.transport.receive_datagrams().await.expect("subscribe B");
a.transport.connect_peer(b.endpoint.addr()).await;
b.transport.connect_peer(a.endpoint.addr()).await;
tokio::time::sleep(Duration::from_millis(500)).await;
let snap = |stats: Vec<(iroh::EndpointId, peerspeak::network::PathSnapshot)>| {
stats
.into_iter()
.find(|(id, _)| *id == b_id)
.map(|(_, s)| s)
.expect("peer B should appear in A's connection stats")
};
let s1 = snap(a.transport.connection_stats());
assert!(!s1.is_relay, "loopback with relay disabled must be direct");
assert!(
s1.remote_addr.parse::<std::net::SocketAddr>().is_ok(),
"direct path address should be ip:port, got {}",
s1.remote_addr
);
// Stream real audio so the path counters move.
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
for seq in 0..25u32 {
a.transport.broadcast(packet(&mut enc, seq));
tokio::time::sleep(Duration::from_millis(5)).await;
}
let s2 = snap(a.transport.connection_stats());
assert!(s2.tx_bytes > s1.tx_bytes, "sent bytes should advance");
assert!(
s2.tx_datagrams > s1.tx_datagrams,
"sent datagrams should advance"
);
// The derivation seam turns the two snapshots into live badge info.
let info = peerspeak::core::connstats::derive(Some(&s1), &s2, Duration::from_millis(200));
assert!(!info.relay);
assert_eq!(info.remote_addr, s2.remote_addr);
assert!(info.rtt_ms < 1000, "localhost RTT should be sane");
assert!(
info.up_kbps
.expect("same path + positive window has a rate")
> 0.0,
"audio was flowing, so the upstream rate must be non-zero"
);
}
/// Read datagrams off a raw connection until `target` arrive or the deadline
/// passes, asserting each carries the 4-byte sequence header.
async fn count_audio(conn: &Connection, target: u32, deadline: tokio::time::Instant) -> u32 {