Compare commits

...
Author SHA1 Message Date
mollusk 601ec92181 Show custom background in room view 2026-06-20 17:57:16 -04:00
mollusk eab9357f23 Add custom background settings controls 2026-06-20 17:54:27 -04:00
molluskandClaude Opus 4.8 70a0e6798f W16 custom backgrounds: core + render layer (Settings UI pending)
Pure src/background.rs (process_background downscale→PNG, scrim_color; 5 tests),
AppConfig.background/background_dim + background_path(), cached AppState.background_image,
PickBackgroundFile/BackgroundFilePicked/RemoveBackground/SetBackgroundDim handlers,
view_with_background stack(image Cover→scrim→ui) + transparent screen roots.
Lib builds clean. Remaining: Settings UI controls + full build/clippy/test pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:29:09 -04:00
molluskandClaude Opus 4.8 a30d9d5dbf Add plain-English Windows install/join guide for end users
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
A non-technical walkthrough to send alongside the installer: covers the
SmartScreen "unknown publisher" warning, the firewall/desktop-shortcut
checkboxes, and joining/creating a call via room tickets. Uses the actual UI
labels (Join Room / Create New Room / Copy Ticket / Leave Room).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:46:07 -04:00
molluskandClaude Opus 4.8 2a6e6401ad Add Windows installer (Inno Setup) + GUI-subsystem release builds
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Package PeerSpeak for Windows as a single self-contained binary. The GUI
icon, notification chimes, and avatar presets are already embedded via
include_bytes!, and the cross-compiled .exe is statically linked (no extra
DLLs), so the installer payload is just peerspeak.exe plus an .ico.

- src/main.rs: set windows_subsystem = "windows" for release builds so the
  GUI launches without a stray console window (debug keeps the console for
  stderr/panics).
- packaging/windows/: Inno Setup script (peerspeak.iss), multi-resolution
  app icon (peerspeak.ico), and a build README. The installer drops a
  Start-menu/desktop shortcut, optionally adds a Windows Firewall allow-rule
  (iroh UDP hole-punching), and provides an uninstaller.
- win-cross-build.sh: promote the cross-build helper from a throwaway to the
  documented installer build step; .gitignore the staged exe + compiled
  setup.exe build artifacts.

Built with Inno Setup 6.7.1 under Wine; binary is unsigned (SmartScreen will
warn until code-signed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:27:03 -04:00
molluskandClaude Opus 4.8 a0a5922389 Merge reconnect-resilience: post-grace peer recovery coordinator
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Fixes the field-observed dead-end where a network outage longer than the
45s anti-flap grace evicted the peer with no path back (required manual
Leave + re-ticket). On grace expiry the peer is now torn down cleanly and a
bounded per-session recovery coordinator re-bootstraps the gossip overlay
via GossipSender::join_peers on retained authenticated addresses, with
immediate-then-1/2/4/8/15/30/60s capped backoff. Readmission still requires
a fresh authenticated signed Announce, preserving the S8/S11 membership
boundary; a transport link alone cannot readmit a grace-expired peer.

Field-verified 2026-06-20 on a 2-machine Linux-host <-> Windows-VM call
through a 93s link outage on the libvirt NAT path: both UIs auto-recovered
to "2 in room" with no manual Leave/Join, exactly one Reconnected chime,
host showed "reconnecting" during the outage, and the VM log captured the
full sequence (grace expiry -> rebootstrap 1->2->4 backoff -> NeighborUp ->
authenticated Announce -> readmit -> audio link up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:01:47 -04:00
mollusk 2c93c1c24f Add post-grace peer recovery coordinator 2026-06-20 15:37:33 -04:00
mollusk 5564af02f9 Spike targeted gossip rebootstrap 2026-06-20 04:28:53 -04:00
molluskandClaude Opus 4.8 ae29d1fea2 Merge windows-port-phase2: native Windows cpal/WASAPI audio port (b0fdd4e)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Brings the native Windows audio backend to main after a live host<->VM smoke
test: cpal/WASAPI capture+playback, device remap/resampling (W4/B5), cpal
RT-audit closed (B1-B5 + P3), Windows notification chimes, and Wine startup fix.

Verified on real Win11 (libvirt VM) this session: 2-way audio (host<->VM both
directions), audible join/leave/reconnect chimes, GUI renders, echo-cancel
correctly gated off. Linux unchanged (all changes cfg(windows); cargo test --lib
326/0, clippy clean). Windows build is GNU cross-compiled (b0fdd4e tester zip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 02:55:10 -04:00
molluskandClaude Opus 4.8 7ff7766ede packaging: add test-pack split PKGBUILD (peerspeak + pixelpass)
One `makepkg -si` from packaging/test-pack/ builds and installs both
peerspeak and pixelpass from the public gitbutter repos over https, so a
tester can clone the repo and get a working voice+screenshare pair in one
command. pixelpass installs to /usr/bin so peerspeak's screen-share button
finds it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 22:56:51 -04:00
molluskandClaude Opus 4.8 b0fdd4e058 audio(win): filter choose_config to drivable formats (Codex B3/B5 re-review P3)
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
Codex's xhigh re-review of 306bc29 confirmed B3 sound and bounded_rate
correct (no P1/P2), and caught one real P3: choose_config ranked supported
config ranges by sample rate + channel count only, but the stream builders
accept just F32/I16/U16 — cpal can also expose U8/I8/I32/U32/I64/U64/F64.
An unsupported-format range (or a zero-channel range) could therefore out-
rank a usable one, win selection, and then hard-fail in setup()'s
`other => Err(unsupported sample format)` arm without trying another
candidate. This was latent in the exact-48 kHz path too, not only B5's
bounded case 3.

Fix: a pure `format_supported` predicate + `usable_range` (nonzero channels
AND a drivable format), applied as a filter in BOTH the exact-48 kHz `pick`
and the bounded `pick_bounded`, so an undrivable range is never ranked. A
zero-channel range can no longer be logged as "using bounded …" and then
rejected by resolve. +1 unit test enumerating every cpal SampleFormat.

Verified: windows-gnu cargo check --release --lib --tests --bins clean, no
warnings; Linux paths untouched (cfg(windows)).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:54:26 -04:00
molluskandClaude Opus 4.8 306bc295b1 audio(win): land the deferred cpal start-resilience items (B3 + B5)
Closes the two Windows-only follow-ups Codex deferred in the RT-audit
re-review (review-2026-06-19-cpal-rt-audit.md). Both are cfg(windows),
so they carry zero risk to the shared Linux audio path.

B3 — orphan-thread tombstone on a wedged start. On the FINISH_START_TIMEOUT
path the owner thread is detached (not joined) so start_*/stop can't hang;
previously the slot was left empty, so a retry against a permanently wedged
device spawned ANOTHER orphan worker holding its own COM/device handle, and
so on without bound. The slot is now a SlotState { Idle | Live | Wedged }:

- Each worker carries an `exited: Arc<AtomicBool>` flipped true by an
  ExitGuard at the top of the thread body — fires on normal return, panic
  unwind, or whenever the wedged driver call finally releases the thread.
- A timed-out start detaches its thread and leaves a `Wedged { exited }`
  tombstone instead of an empty slot.
- `ensure_idle` (pure, unit-tested) rejects new starts while the orphan is
  still alive, but clears the tombstone once `exited` flips, so the slot
  becomes reusable after the device recovers. `stop` restores a still-live
  tombstone rather than silently clearing it.

B5 — choose_config picks a bounded supported rate before the device default.
A device whose default rate is outside the drivable 8k–384k window but which
also exposes a usable in-window config was previously rejected by resolve().
New case 3 scans the supported config ranges for one overlapping the window
and drives it at a `bounded_rate` (48 kHz when reachable, else the nearest
in-window bound), preferring the native layout; the device default is now a
last resort. `bounded_rate` is pure and unit-tested.

6 new unit tests (bounded_rate x4, ensure_idle x2) — they're in the
cfg(windows) module, so they compile/run under the windows-gnu target, not
the Linux lib suite.

Verified: Linux cargo test --lib 326/0 + clippy --lib --tests clean (shared
paths untouched); windows-gnu cargo check --release --lib --tests --bins
clean, no warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:48:07 -04:00
molluskandClaude Opus 4.8 8e0b4c16ec audio(win): tighten the cpal start-handshake (Codex re-review B1/B2/B4)
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
Codex's xhigh re-review of the prior cpal RT fixes confirmed W2/W3/W7/W4-diag
addressed (and validated the reserve-first ring-publish ordering), but found the
W1/W6 start-handshake fixes were partial. This closes the holes:

- B1 (P1): wait_for_stream_start checked the liveness flag before the error code,
  so a callback that ran then failed in the same WASAPI cycle could still report
  Ok on a dead stream. Readiness now (a) treats the error as terminal — checked
  first each loop AND re-checked before returning Ok — and (b) requires
  MIN_START_CALLBACKS (2) completed callbacks, not one, so a fire-once-then-die
  stream is caught by the error/timeout path. The liveness signal is now a
  callback counter (AtomicUsize) instead of a one-shot bool.
- B2 (P2): on the inner STREAM_START_TIMEOUT the owner sent Err and THEN dropped
  the stream; since cpal Stream::drop joins its (wedged) WASAPI worker and
  finish_start joins the owner on that Err, start_*/stop could still hang past the
  backstop. The owner now drops the stream BEFORE reporting Err, so a wedged drop
  withholds the Err and lets finish_start's timeout branch detach.
- B4 (P3): the two timeouts didn't compose — a slow-but-valid setup plus a slow
  first callback could exceed the 6s backstop and be falsely failed. Raised
  FINISH_START_TIMEOUT to 10s (setup budget + callback wait + cleanup slack) and
  corrected the comment.

Deferred (logged in review-2026-06-19-cpal-rt-audit.md): B3 (orphan-thread
tombstone accounting on a permanent >10s driver wedge — rare, non-crashing, needs
a slot-state redesign) and B5 (choose_config picking a bounded supported rate for
an oddball sub-8k/over-384k default-rate device — rare; the safety validation
already prevents the panic/spin).

Verified: Linux cargo test --lib 326/0, clippy --all-targets clean; windows-gnu
cargo check --lib --tests --bins clean; windows-gnu release peerspeak.exe links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:27:35 -04:00
molluskandClaude Opus 4.8 f52b5ea64e audio(win): fix RT-safety + start-handshake bugs in the cpal backend
Addresses Codex's xhigh RT-audio audit of the new Windows cpal path (review
2026-06-19; all Windows-only, no Linux-path change):

- W1 (P1): start_capture/start_playback reported Ok as soon as cpal's play()
  returned, but cpal's WASAPI play() only QUEUES IAudioClient::Start(); a later
  Start failure left the UI joined-but-silent. Readiness is now driven by the
  stream actually proving itself: the first RT data callback sets a started
  flag (or the error callback sets an error code), and the owner thread waits
  (bounded by STREAM_START_TIMEOUT) before reporting Ok.
- W2: both RT error callbacks ran format!+log_msg on the time-critical stream
  thread. They now store a category in an AtomicU8 only; the owner / health
  logger translate + log off the RT path.
- W3: the playback ring was published one interleaved sample at a time, letting
  the RT consumer read a half-written L/R pair and letting a raced fetch_sub
  wrap ring_fill to usize::MAX (wedging mixer pacing). Now reserves occupancy
  before publishing and writes the whole frame with a single push_slice.
- W6: finish_start did an unbounded recv() while holding the slot mutex, so a
  wedged driver hung start_* and any concurrent stop. Now recv_timeout with a
  FINISH_START_TIMEOUT backstop; on timeout it signals + detaches (never joins).
- W7: OS-reported device geometry is validated in resolve() (channels>0, rate in
  8k-384k) so 0 channels can't panic chunks_exact(0) and a 0/absurd rate can't
  make an infinite/huge resample ratio. resample.rs constructors also clamp
  rates >=1 (release-safe; +2 tests) instead of a debug-only assert.
- W4 (diagnostic half): the playout-health logger compared raw device samples
  against the internal-stereo prefill target. The callback now records demand in
  internal 48 kHz-stereo units (internal_demand) so the comparison is correct
  for remapped/non-48k devices. The dynamic-target restructure stays deferred.

Deferred (logged in review-2026-06-19-cpal-rt-audit.md): W5 (bounded mixer->
worker channel) touches the shared Linux audio path and wants its own design +
regression pass; the W2 dynamic-target sizing needs a real WASAPI callback.

Verified: Linux cargo test --lib 326/0, clippy --all-targets clean; windows-gnu
cargo check --lib --tests --bins clean; windows-gnu release peerspeak.exe builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:15:49 -04:00
molluskandClaude Opus 4.8 4d07e03395 core: skip network-stack rebuild when SetNetworkMode is a no-op
The GUI re-sends the saved network mode as part of its startup config-sync.
The SetNetworkMode handler unconditionally tore down + rebuilt the iroh
endpoint whenever idle, so every launch rebuilt the freshly-built stack for
an identical posture — a needless ~1s teardown+rebuild bounce visible in the
logs on both Linux and Windows/Wine (the 'start core loop -> shut down network
stack ~1s later' pattern from the Wine spike). Guard the rebuild on an actual
mode change; a real change still rebuilds exactly as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:57:35 -04:00
mollusk 20bfcffe6d Complete Windows audio remap path
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
2026-06-19 04:56:01 -04:00
molluskandClaude Opus 4.8 185d47aa8d W4 (WIP): dep-free resampler + capture/config wiring (playback pending)
- src/audio/resample.rs: pure linear PushResampler (capture) +
  StereoPullResampler (playback pull), 6 unit tests green on Linux.
- choose_config: prefer native 48kHz, else fall back to device default
  config and convert at the boundary instead of hard-erroring.
- run_capture: resample device-rate mono -> 48kHz on the drain thread.
- i16<->f32 helpers. Playback build_output remap still TODO (Codex).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 04:42:51 -04:00
25 changed files with 2785 additions and 199 deletions
+5
View File
@@ -6,3 +6,8 @@
/packaging/peerspeak/ /packaging/peerspeak/
/packaging/*.pkg.tar.* /packaging/*.pkg.tar.*
/packaging/*.log /packaging/*.log
# Windows installer build artifacts (the staged exe + compiled setup.exe);
# the .iss script and .ico are the tracked sources.
/packaging/windows/peerspeak.exe
/packaging/windows/output/
+2 -2
View File
@@ -68,9 +68,9 @@ connections are expected and valid.
| Echo cancellation | Linux-only PipeWire feature. The Windows UI shows it disabled as unavailable. | | 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. | | 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. | | Chimes | Now routed through Windows `SoundPlayer`; needs a real Windows host to audibly verify. |
| Resampling/device format | Open. Devices must support 48 kHz, and output must support stereo; a 44.1 kHz-only/default device currently errors instead of playing. | | 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. | | Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
| Playback pacing | Open. The fixed playback target under WASAPI shared mode still needs real-hardware verification. | | Playback pacing | Cross-compiled. The fixed playback target under WASAPI shared mode still needs real-hardware verification with `audio_probe`. |
Before calling Windows support done, verify a real Windows machine can create/join a room, 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 capture mic audio, hear remote audio, select devices, restart with selections preserved, and
+112
View File
@@ -0,0 +1,112 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
#
# Test-pack split package: ONE `makepkg -si` builds + installs BOTH peerspeak
# (voice chat) and pixelpass (screen sharing) from the public gitbutter repos
# over https. pixelpass lands on /usr/bin so peerspeak's screen-share button
# finds it. Shared version string is derived from peerspeak's git.
#
# Clone this repo and build from here:
# git clone https://gitbutter.xyz/mollusk/peerspeak.git
# cd peerspeak/packaging/test-pack
# makepkg -si
pkgbase=peerspeak-git
pkgname=('peerspeak-git' 'pixelpass')
pkgver=0.1.0
pkgrel=1
arch=('x86_64')
url="https://gitbutter.xyz/mollusk/peerspeak"
license=('custom' 'MIT' 'Apache-2.0' 'OFL-1.1')
makedepends=('git' 'cargo' 'pkgconf')
options=('!lto' '!debug')
source=("peerspeak::git+https://gitbutter.xyz/mollusk/peerspeak.git"
"pixelpass::git+https://gitbutter.xyz/mollusk/pixelpass.git#branch=main")
sha256sums=('SKIP'
'SKIP')
pkgver() {
cd "$srcdir/peerspeak"
# Shared across both split packages. 0.1.0.r<commits>.g<short-sha>.
printf '%s.r%s.g%s' \
"$(awk -F'\"' '/^version =/{print $2; exit}' Cargo.toml)" \
"$(git rev-list --count HEAD)" \
"$(git rev-parse --short HEAD)"
}
prepare() {
# Vendor deps up front so build() can run --frozen (no surprise network).
export CARGO_HOME="$srcdir/cargo-home"
local host; host="$(rustc -vV | sed -n 's/host: //p')"
cd "$srcdir/peerspeak"; cargo fetch --locked --target "$host"
cd "$srcdir/pixelpass"; cargo fetch --locked --target "$host"
}
build() {
export CARGO_HOME="$srcdir/cargo-home"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cd "$srcdir/peerspeak"
cargo build --frozen --release --bin peerspeak
cd "$srcdir/pixelpass"
# --features gui so the .desktop launcher (pixelpass --gui) works.
cargo build --frozen --release --features gui
}
check() {
export CARGO_HOME="$srcdir/cargo-home"
export RUSTUP_TOOLCHAIN=stable
# peerspeak library unit tests only — its integration suites bind real
# iroh/QUIC endpoints and fail in a sandboxed/offline build environment.
cd "$srcdir/peerspeak"
cargo test --frozen --release --lib
}
package_peerspeak-git() {
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
depends=('pipewire' 'opus')
optdepends=('pixelpass: screen sharing inside a room'
'mpv: screen-share viewer (vlc is used as a fallback)')
provides=('peerspeak')
conflicts=('peerspeak')
license=('custom')
cd "$srcdir/peerspeak"
install -Dm755 "target/release/peerspeak" "$pkgdir/usr/bin/peerspeak"
install -Dm644 "packaging/peerspeak.desktop" \
"$pkgdir/usr/share/applications/peerspeak.desktop"
# Hicolor icon theme (scalable SVG + the rendered raster sizes).
install -Dm644 "assets/icons/peerspeak.svg" \
"$pkgdir/usr/share/icons/hicolor/scalable/apps/peerspeak.svg"
local s
for s in 16 24 32 48 64 128 256 512; do
install -Dm644 "assets/icons/peerspeak-$s.png" \
"$pkgdir/usr/share/icons/hicolor/${s}x${s}/apps/peerspeak.png"
done
}
package_pixelpass() {
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
depends=('gstreamer' 'gst-plugins-base' 'gst-plugins-good' 'gst-plugins-bad'
'gst-libav' 'gst-plugin-va' 'libpulse' 'hicolor-icon-theme'
'libglvnd' 'libxkbcommon' 'wayland')
optdepends=('mpv: recommended stream viewer (the GUI launches mpv)'
'vlc: alternative stream viewer'
'gst-plugins-ugly: software x264 encoding for `pixelpass --no-hwencode`'
'gst-plugin-pipewire: screen capture on Wayland sessions'
'xorg-xwininfo: share a single window on X11 (`pixelpass --window`)')
license=('MIT' 'Apache-2.0' 'OFL-1.1')
cd "$srcdir/pixelpass"
install -Dm0755 "target/release/pixelpass" "$pkgdir/usr/bin/pixelpass"
install -Dm0644 assets/pixelpass.desktop \
"$pkgdir/usr/share/applications/pixelpass.desktop"
install -Dm0644 assets/pixelpass.svg \
"$pkgdir/usr/share/icons/hicolor/scalable/apps/pixelpass.svg"
install -Dm0644 README.md "$pkgdir/usr/share/doc/pixelpass/README.md"
install -Dm0644 LICENSE-MIT "$pkgdir/usr/share/licenses/pixelpass/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "$pkgdir/usr/share/licenses/pixelpass/LICENSE-APACHE"
install -Dm0644 assets/NotoSans-OFL.txt \
"$pkgdir/usr/share/licenses/pixelpass/NotoSans-OFL.txt"
}
+52
View File
@@ -0,0 +1,52 @@
# PeerSpeak + PixelPass — CachyOS/Arch test pack
A single **split PKGBUILD** that builds the latest code from the public gitbutter
repos and installs **both** programs at once:
- `peerspeak` — decentralized P2P voice chat
- `pixelpass` — P2P screen sharing (peerspeak launches it for the screen-share button)
## Build & install (one command)
```sh
git clone https://gitbutter.xyz/mollusk/peerspeak.git
cd peerspeak/packaging/test-pack
makepkg -si
```
`makepkg -si` auto-installs every dependency via pacman before building —
including the Rust toolchain itself (the `cargo` makedepend is provided by the
`rust` package), `git`, `pkgconf`, pipewire + opus for peerspeak, and the
gstreamer/VA-API stack for pixelpass. The only prerequisite is the `base-devel`
group (which provides `makepkg`). If you already use `rustup`, that satisfies the
`cargo` makedepend and the `rust` package won't be pulled in — no conflict.
When it finishes you'll have `peerspeak` and `pixelpass` on your PATH at
`/usr/bin`. To rebuild later with fresh upstream code, re-run `makepkg -si`; the
git sources re-pull `main` and the version bumps automatically.
> Skip the test step with `makepkg -si --nocheck` for a faster build.
## Running the cross-internet test
1. Launch `peerspeak` on both machines.
2. One person **creates** a room and shares the room code/ticket with the other.
3. The other **joins** with that code.
4. iroh does NAT hole-punching automatically; if a direct path can't be made it
falls back to a public n0 relay — **no port forwarding required**.
5. Allow the app through any local firewall if prompted (outbound UDP / QUIC;
nothing needs to be opened inbound for relay mode).
### What we're smoke-testing
- Two real humans, two networks, over the internet.
- Mic capture + remote playback both directions, no crackle/dropouts.
- Mute / deafen, push-to-talk.
- Text chat in-room.
- Avatars (presets + custom upload) show up on the other side.
- Screen share: click the screen-share control → it launches `pixelpass`; the
viewer opens in `mpv` on the receiving side.
- Notification chimes (join/leave/etc.).
- Leave / rejoin cleanly.
If anything misbehaves, grab the log path peerspeak prints on startup and the
exact repro steps.
+82
View File
@@ -0,0 +1,82 @@
# PeerSpeak — how to install and join a call (Windows)
PeerSpeak is a little voice-chat app — like a private phone call over the
internet, with no account, no signup, and no company in the middle. You install
it once, then you and I connect directly to each other.
---
## 1. Install it
1. Double-click **`peerspeak-0.2.0-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
paid certificate. It is **not** a virus warning.
- Click **More info**
- Then click **Run anyway**
3. Windows will ask *"Do you want to allow this app to make changes?"* — click
**Yes**.
4. The setup window opens. Just keep clicking **Next**. Two checkboxes you'll
see along the way:
- **"Allow PeerSpeak through Windows Firewall"** — leave this **checked**
(it lets the call connect without interruptions).
- **"Create a desktop shortcut"** — check it if you'd like an icon on your
desktop.
5. Click **Install**, then **Finish**. PeerSpeak opens.
That's it — it's installed. You can find it again any time from the **Start
menu** (search "PeerSpeak").
---
## 2. Get on a call with me
PeerSpeak connects two people using a **room ticket** — a long code that acts
like a one-time phone number for a specific call.
**The simple way (I host):**
1. I'll create a room and send you a **ticket** (a long jumble of letters and
numbers).
2. Copy the whole ticket I sent you.
3. In PeerSpeak, paste it into the **"Join Room"** box near the bottom and press
**Join**.
4. You're in — you should see both our names listed, and we can talk.
**If you want to host instead:**
1. Type a room name and click **Create New Room**.
2. PeerSpeak gives you a **ticket** — click **Copy Ticket** and send it to me.
3. I paste it on my end and join you.
Either way works the same; it just depends on who makes the room.
---
## 3. While you're on a call
- **Your microphone** is on by default. There's a **mute** button if you need
it.
- The first time, Windows might ask for permission to use your **microphone**
click **Yes / Allow**.
- If you can't hear me or I can't hear you, open **Settings** (top right) and
check that the right **microphone** and **speakers/headphones** are selected.
- To hang up, click **Leave Room**.
---
## Troubleshooting
- **"I don't hear anything."** Open Settings and pick the correct microphone and
output device. Headphones are best — they prevent echo.
- **"It won't connect."** Make sure you pasted the *entire* ticket (they're
long and easy to cut off). If it still won't connect, we may just need a fresh
ticket — they're meant to be used right away.
- **The blue warning again.** Same as install: **More info → Run anyway**. It's
the unsigned-app warning, not malware.
Any trouble, just message me and we'll sort it out.
+72
View File
@@ -0,0 +1,72 @@
# PeerSpeak — Windows installer
This directory builds a Windows setup installer for PeerSpeak using
[Inno Setup](https://jrsoftware.org/isinfo.php).
PeerSpeak ships as a **single self-contained `peerspeak.exe`** — the GUI icon,
notification chimes, and avatar presets are all embedded in the binary
(`include_bytes!`), and the executable is statically linked against the GNU
runtime, so there are no extra DLLs to bundle. The installer payload is just the
`.exe` plus an `.ico` for the Start-menu / desktop shortcuts.
## Files
| File | Tracked | Purpose |
|------|---------|---------|
| `peerspeak.iss` | yes | Inno Setup script |
| `peerspeak.ico` | yes | multi-resolution app icon (from `assets/icons/*.png`) |
| `README.md` | yes | this file |
| `peerspeak.exe` | no (gitignored) | staged build artifact, copied from `target/x86_64-pc-windows-gnu/release/` |
| `output/peerspeak-<ver>-setup.exe` | no (gitignored) | the compiled installer |
## Build steps
1. **Cross-compile the Windows binary** (from the repo root, inside the
`peerspeak-win` archlinux distrobox):
```sh
RUSTC_BOOTSTRAP=1 ./win-cross-build.sh -Z build-std=std,panic_abort
```
This needs the `rust-src` component and the `x86_64-pc-windows-gnu` target
installed in that toolchain. The result is a statically-linked,
GUI-subsystem `.exe` (no stray console window).
2. **Stage the binary** next to the script:
```sh
cp target/x86_64-pc-windows-gnu/release/peerspeak.exe packaging/windows/
```
3. **Regenerate the icon** if the source PNGs changed:
```sh
magick assets/icons/peerspeak-16.png assets/icons/peerspeak-24.png \
assets/icons/peerspeak-32.png assets/icons/peerspeak-48.png \
assets/icons/peerspeak-64.png assets/icons/peerspeak-128.png \
assets/icons/peerspeak-256.png packaging/windows/peerspeak.ico
```
4. **Compile the installer** with Inno Setup. On Linux this runs under Wine:
```sh
cd packaging/windows
wine ~/.wine/drive_c/InnoSetup6/ISCC.exe peerspeak.iss
```
The installer lands at `output/peerspeak-<version>-setup.exe`.
## What the installer does
- Installs `peerspeak.exe` to `Program Files\PeerSpeak` (requires admin / one
UAC prompt).
- Creates a Start-menu shortcut, with an optional desktop shortcut.
- Optionally adds a Windows Firewall allow-rule for PeerSpeak (recommended —
iroh uses UDP hole-punching, so this avoids a mid-call firewall prompt). The
rule is removed on uninstall.
- Provides a standard uninstaller.
> **Note:** the installer and the binary are **not code-signed**, so Windows
> SmartScreen will show an "unknown publisher" warning on first run. The user
> clicks *More info → Run anyway*. Removing this warning requires a paid
> code-signing certificate.
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

+63
View File
@@ -0,0 +1,63 @@
; Inno Setup script for PeerSpeak (Windows installer).
;
; PeerSpeak is a single self-contained binary: the GUI icon, notification
; chimes, and avatar presets are all embedded in the .exe (include_bytes!),
; so the only payload here is peerspeak.exe plus an .ico for the shortcuts.
;
; Build (under Wine on Linux, or native Windows):
; wine "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" peerspeak.iss
; Output lands in .\output\peerspeak-<version>-setup.exe
;
; The peerspeak.exe is cross-compiled with win-cross-build.sh
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak"
#define MyAppVersion "0.2.0"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
[Setup]
; A stable AppId keeps upgrades/uninstall tracking consistent across versions.
AppId={{2754D6C1-C8A4-4B13-9824-2D303439739D}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
UninstallDisplayIcon={app}\{#MyAppExeName}
SetupIconFile=peerspeak.ico
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
OutputDir=output
OutputBaseFilename=peerspeak-{#MyAppVersion}-setup
; Program Files install + firewall rule both need elevation.
PrivilegesRequired=admin
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
Name: "firewall"; Description: "Allow PeerSpeak through Windows Firewall (recommended for voice calls)"; GroupDescription: "Network:"
[Files]
Source: "peerspeak.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "peerspeak.ico"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\peerspeak.ico"
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\peerspeak.ico"; Tasks: desktopicon
[Run]
; iroh uses UDP hole-punching; pre-authorizing avoids a mid-call firewall prompt.
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""PeerSpeak"" dir=in action=allow program=""{app}\{#MyAppExeName}"" enable=yes profile=any"; Flags: runhidden; Tasks: firewall
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent
[UninstallRun]
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""PeerSpeak"""; Flags: runhidden; RunOnceId: "DelPeerSpeakFirewall"
+175 -4
View File
@@ -290,6 +290,15 @@ pub enum AppMessage {
/// Result of the avatar file picker: the chosen file's raw bytes, or `None` /// Result of the avatar file picker: the chosen file's raw bytes, or `None`
/// if the user cancelled. /// if the user cancelled.
AvatarFilePicked(Option<Vec<u8>>), AvatarFilePicked(Option<Vec<u8>>),
/// Open the native file picker to choose a custom UI background image (W16).
PickBackgroundFile,
/// Result of the background file picker: the chosen file's raw bytes, or
/// `None` if the user cancelled.
BackgroundFilePicked(Option<Vec<u8>>),
/// Clear the custom background, reverting to the theme background (W16).
RemoveBackground,
/// Set the background legibility scrim strength (0.0..=1.0) (W16).
SetBackgroundDim(f32),
/// Toggle the Chat drawer open/closed (drawer layout). /// Toggle the Chat drawer open/closed (drawer layout).
ToggleDrawerChat, ToggleDrawerChat,
/// Start/stop sharing our own screen (spawns/kills a pixelpass host). /// Start/stop sharing our own screen (spawns/kills a pixelpass host).
@@ -335,6 +344,10 @@ pub struct AppState {
selected_input: Option<AudioDevice>, selected_input: Option<AudioDevice>,
selected_output: Option<AudioDevice>, selected_output: Option<AudioDevice>,
config: AppConfig, config: AppConfig,
/// Decoded bytes of the custom background image (W16), cached so `view()`
/// doesn't read the file from disk on every redraw. Loaded on startup and
/// refreshed when the background is changed/removed. `None` = no custom bg.
background_image: Option<bytes::Bytes>,
peers: HashMap<EndpointId, PeerState>, peers: HashMap<EndpointId, PeerState>,
peer_volumes: HashMap<EndpointId, f32>, peer_volumes: HashMap<EndpointId, f32>,
audio_levels: HashMap<EndpointId, f32>, audio_levels: HashMap<EndpointId, f32>,
@@ -470,6 +483,7 @@ impl Default for AppState {
let selected_input = input_devices.iter().find(|d| d.name == config.input_device).cloned(); let selected_input = input_devices.iter().find(|d| d.name == config.input_device).cloned();
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned(); let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
let background_image = load_background_bytes(&config);
Self { Self {
// Pre-fill the nickname with the last one used (or "Peer" by default). // Pre-fill the nickname with the last one used (or "Peer" by default).
@@ -489,6 +503,7 @@ impl Default for AppState {
selected_input, selected_input,
selected_output, selected_output,
config, config,
background_image,
peers: HashMap::new(), peers: HashMap::new(),
peer_volumes: HashMap::new(), peer_volumes: HashMap::new(),
audio_levels: HashMap::new(), audio_levels: HashMap::new(),
@@ -533,6 +548,15 @@ fn theme(state: &AppState) -> Theme {
state.config.theme.base_theme() state.config.theme.base_theme()
} }
/// Read the custom background PNG (W16) from disk into memory, if one is set and
/// readable. Called once on startup and whenever the background changes, so the
/// per-frame `view()` never touches the filesystem. A missing/unreadable file
/// silently yields `None` (the UI falls back to the theme background).
fn load_background_bytes(config: &AppConfig) -> Option<bytes::Bytes> {
let path = config.background.as_deref()?;
std::fs::read(path).ok().map(bytes::Bytes::from)
}
pub fn run_gui() -> iced::Result { pub fn run_gui() -> iced::Result {
// Restore the last window size (saved on close). Position is restored too, // Restore the last window size (saved on close). Position is restored too,
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own // but only on X11 — Wayland's xdg-shell gives clients no way to set their own
@@ -540,7 +564,7 @@ pub fn run_gui() -> iced::Result {
let saved = AppConfig::load(); let saved = AppConfig::load();
let init_size = iced::Size::new(saved.window_width, saved.window_height); let init_size = iced::Size::new(saved.window_width, saved.window_height);
let init_position = initial_window_position(saved.window_x, saved.window_y, is_wayland()); let init_position = initial_window_position(saved.window_x, saved.window_y, is_wayland());
iced::application(AppState::default, update, view) iced::application(AppState::default, update, view_with_background)
.title("PeerSpeak P2P Voice Chat") .title("PeerSpeak P2P Voice Chat")
.theme(theme) .theme(theme)
.subscription(subscription) .subscription(subscription)
@@ -888,6 +912,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.ever_connected.remove(&id); state.ever_connected.remove(&id);
notify::play(Sound::PeerLeave, state.config.custom_sound_peer_leave.as_deref()); notify::play(Sound::PeerLeave, state.config.custom_sound_peer_leave.as_deref());
} }
// Core-only recovery phase: presentation for this state lands in
// the separate UI follow-up. In particular, do not play the
// terminal ReconnectFailed chime here.
UiEvent::PeerRecoveryStarted { .. } => {}
UiEvent::PeerConnectionFailed { id } => { UiEvent::PeerConnectionFailed { id } => {
state.peers.remove(&id); state.peers.remove(&id);
state.audio_levels.remove(&id); state.audio_levels.remove(&id);
@@ -1343,6 +1371,73 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
} }
} }
} }
AppMessage::PickBackgroundFile => {
// Native picker off the UI thread; result returns as BackgroundFilePicked.
return Task::perform(
async {
let handle = rfd::AsyncFileDialog::new()
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"])
.set_title("Choose a background image")
.pick_file()
.await;
match handle {
Some(h) => Some(h.read().await),
None => None,
}
},
AppMessage::BackgroundFilePicked,
);
}
AppMessage::BackgroundFilePicked(picked) => {
if let Some(bytes) = picked {
match crate::background::process_background(&bytes) {
Ok(png) => match AppConfig::background_path() {
Some(path) => {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match std::fs::write(&path, &png) {
Ok(()) => {
state.config.background =
Some(path.to_string_lossy().into_owned());
state.config.save();
// Refresh the in-memory cache from the bytes we
// just wrote (avoids re-reading from disk).
state.background_image = Some(bytes::Bytes::from(png));
state.status_message = "Background updated.".to_string();
}
Err(e) => {
state.status_message =
format!("Couldn't save background: {e}");
}
}
}
None => {
state.status_message =
"Couldn't find a config directory to save the background."
.to_string();
}
},
Err(e) => {
state.status_message = e;
}
}
}
}
AppMessage::RemoveBackground => {
// Best-effort delete of our stored copy; clear the config + cache.
if let Some(path) = AppConfig::background_path() {
let _ = std::fs::remove_file(path);
}
state.config.background = None;
state.config.save();
state.background_image = None;
state.status_message = "Background removed.".to_string();
}
AppMessage::SetBackgroundDim(dim) => {
state.config.background_dim = dim.clamp(0.0, 1.0);
state.config.save();
}
AppMessage::ToggleDrawerChat => { AppMessage::ToggleDrawerChat => {
state.drawer_chat_open = !state.drawer_chat_open; state.drawer_chat_open = !state.drawer_chat_open;
} }
@@ -2014,6 +2109,40 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
.into() .into()
} }
/// Wrap the main [`view`] with the custom background layer (W16). When a
/// background image is set, render `stack![ image(Cover), scrim, ui ]` so the
/// photo sits behind the whole UI with a legibility scrim (the theme base colour
/// at `background_dim` alpha) between them; otherwise return the UI untouched. The
/// three screen roots go transparent (`root_bg` in `view`) so the image shows
/// through the gaps between panels. This is the registered top-level view.
fn view_with_background(state: &AppState) -> Element<'_, AppMessage> {
let content = view(state);
let Some(bytes) = state.background_image.clone() else {
return content;
};
let pal = state.config.theme.palette();
let dim = state.config.background_dim;
let image_layer = iced::widget::image(cached_image_handle(bytes))
.content_fit(iced::ContentFit::Cover)
.width(iced::Length::Fill)
.height(iced::Length::Fill);
let scrim = container(
iced::widget::Space::new()
.width(iced::Length::Fill)
.height(iced::Length::Fill),
)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(crate::background::scrim_color(pal.base, dim))),
..Default::default()
});
iced::widget::stack![image_layer, scrim, content]
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.into()
}
fn view(state: &AppState) -> Element<'_, AppMessage> { fn view(state: &AppState) -> Element<'_, AppMessage> {
// Theme colours — sourced from the active palette (see `src/theme.rs`), so // Theme colours — sourced from the active palette (see `src/theme.rs`), so
// all styling below re-themes when the user picks a different theme. // all styling below re-themes when the user picks a different theme.
@@ -2032,6 +2161,16 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
let color_green = pal.green; let color_green = pal.green;
let color_yellow = pal.yellow; let color_yellow = pal.yellow;
// The window backdrop fill for the three screen roots. When a custom
// background image is set (W16), the root goes transparent so the image +
// scrim layered behind by `view_with_background` shows through the gaps
// between panels; otherwise it's the usual opaque `crust`.
let root_bg = if state.background_image.is_some() {
Color::TRANSPARENT
} else {
color_crust
};
// Style Helpers // Style Helpers
let c_style = move |bg: Color, b_color: Color, radius: f32| { let c_style = move |bg: Color, b_color: Color, radius: f32| {
move |_theme: &Theme| container::Style { move |_theme: &Theme| container::Style {
@@ -2293,6 +2432,35 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.spacing(10) .spacing(10)
.width(iced::Length::Fill); .width(iced::Length::Fill);
let remove_background: Element<'_, AppMessage> = if state.config.background.is_some() {
button(text("Remove background").size(13))
.on_press(AppMessage::RemoveBackground)
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(8)
.into()
} else {
iced::widget::Space::new().width(0.0).height(0.0).into()
};
let background_section = column![
row![
button(text("Choose image…").size(13))
.on_press(AppMessage::PickBackgroundFile)
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(8),
remove_background,
].spacing(8),
text(format!("Background dimming: {:.0}%", state.config.background_dim * 100.0))
.size(11)
.color(color_subtext),
slider(0.0..=1.0, state.config.background_dim, AppMessage::SetBackgroundDim)
.step(0.05),
text("Set a picture from your computer as the app background. Auto-resized; a dimming overlay keeps text readable. Applies live.")
.size(11)
.color(color_subtext),
]
.spacing(10)
.width(iced::Length::Fill);
// Inline avatar chooser (W4): the monogram fallback plus the bundled // Inline avatar chooser (W4): the monogram fallback plus the bundled
// presets, each a clickable tile. Same SelectAvatar message, applied live // presets, each a clickable tile. Same SelectAvatar message, applied live
// + persisted (and re-announced to the room). // + persisted (and re-announced to the room).
@@ -2641,6 +2809,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
vertical_space(section_gap), vertical_space(section_gap),
section_header("Theme"), section_header("Theme"),
theme_section, theme_section,
vertical_space(section_gap),
section_header("Background"),
background_section,
] ]
.spacing(10) .spacing(10)
.width(iced::Length::Fill) .width(iced::Length::Fill)
@@ -2826,7 +2997,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.height(iced::Length::Fill) .height(iced::Length::Fill)
.padding(24) .padding(24)
.center_x(iced::Length::Fill) .center_x(iced::Length::Fill)
.style(c_style(color_crust, Color::TRANSPARENT, 0.0)); .style(c_style(root_bg, Color::TRANSPARENT, 0.0));
return with_regenerate_confirm(settings_screen.into(), state); return with_regenerate_confirm(settings_screen.into(), state);
} }
@@ -2885,7 +3056,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
) )
.width(iced::Length::Fill) .width(iced::Length::Fill)
.height(iced::Length::Fill) .height(iced::Length::Fill)
.style(c_style(color_crust, Color::TRANSPARENT, 0.0)); .style(c_style(root_bg, Color::TRANSPARENT, 0.0));
with_hotkey_info(with_layout_picker(home.into(), state), state) with_hotkey_info(with_layout_picker(home.into(), state), state)
} else { } else {
@@ -3586,7 +3757,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.padding(15) .padding(15)
.width(iced::Length::Fill) .width(iced::Length::Fill)
.height(iced::Length::Fill) .height(iced::Length::Fill)
.style(c_style(color_crust, Color::TRANSPARENT, 0.0)); .style(c_style(root_bg, Color::TRANSPARENT, 0.0));
with_hotkey_info( with_hotkey_info(
with_pixelpass_help(with_layout_picker(room.into(), state), state), with_pixelpass_help(with_layout_picker(room.into(), state), state),
+782 -141
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -61,6 +61,10 @@ pub mod gate;
pub mod limiter; pub mod limiter;
pub mod multitrack; pub mod multitrack;
pub mod pan; pub mod pan;
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
// pure, so it builds (and its tests run) everywhere even though only the cpal
// backend wires it in.
pub mod resample;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod echo_cancel; pub mod echo_cancel;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
+307
View File
@@ -0,0 +1,307 @@
//! Dep-free linear-interpolation resamplers for the Windows/cpal backend (W4).
//!
//! The pipeline runs internally at 48 kHz (Opus + the 20 ms frame), but a WASAPI
//! endpoint may run at a different rate (commonly 44.1 kHz) and/or a non-stereo
//! channel layout. These convert at the device boundary so such a device plays and
//! captures instead of hard-erroring (the W4 limitation in the Windows port).
//!
//! ## Where each is used
//! - [`PushResampler`] (single channel) converts **capture** from the device rate
//! to 48 kHz on the capture drain thread — off the RT callback.
//! - [`StereoPullResampler`] converts **playback** from the internal 48 kHz stereo
//! bus to the device rate inside the output RT callback, pulling internal frames
//! from the ring on demand. It allocates nothing in `next`, so it is RT-safe.
//!
//! ## Quality
//! This is plain linear interpolation with no anti-aliasing filter: correct,
//! allocation-free, and adequate for speech, but it adds some aliasing when
//! downsampling. The seam is intentionally tiny so a higher-quality polyphase/FIR
//! resampler (e.g. the `rubato` crate, pending a supply-chain decision) can later
//! replace the internals without touching the cpal backend. The matching-rate /
//! matching-layout path in the backend bypasses these entirely and stays bit-exact.
/// Linear interpolation between `a` and `b` at fractional position `frac` in `[0, 1)`.
#[inline]
fn lerp(a: f32, b: f32, frac: f32) -> f32 {
a + (b - a) * frac
}
/// Stateful single-channel **push** resampler: feed input samples at `in_rate`,
/// receive output samples at `out_rate` through an `emit` callback. It carries the
/// fractional read position and the previous input sample across calls, so feeding
/// the stream block-by-block joins seamlessly. Neither [`push`](Self::push) nor
/// [`process`](Self::process) allocates.
pub struct PushResampler {
/// Input samples consumed per output sample (`in_rate / out_rate`).
step: f64,
/// Position of the next output sample, in input-sample units, measured from the
/// index of `prev` (the most recent input). Always advanced to stay `< 1.0`
/// after each input is consumed.
next: f64,
/// The previous input sample (left edge of the current interpolation segment).
prev: f32,
/// Whether any input has been seen yet (anchors the first output at input[0]).
started: bool,
}
impl PushResampler {
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
/// clamped to `>= 1` so `step` is always finite and non-zero: a zero `step`
/// would make [`push`](Self::push)'s `while self.next < 1.0` loop forever. The
/// cpal backend's `resolve()` also rejects such rates up front, so this is
/// belt-and-suspenders against a future caller (review W7).
pub fn new(in_rate: u32, out_rate: u32) -> Self {
Self {
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
next: 0.0,
prev: 0.0,
started: false,
}
}
/// Feed one input sample; `emit` is called for each output sample produced
/// (zero or more, depending on the rate ratio).
pub fn push(&mut self, cur: f32, mut emit: impl FnMut(f32)) {
if !self.started {
// First sample: just establish the left edge. Linear interpolation
// needs the next input as the right edge, so the first output is
// produced on the next push. This gives exact alignment
// (`output[k] == input[k]` at equal rates) with one input-sample of
// latency — negligible (~20 µs at 48 kHz).
self.started = true;
self.prev = cur;
self.next = 0.0;
return;
}
// `prev` sits at position 0 of this segment and `cur` at position 1; emit
// every output whose position falls in [0, 1).
while self.next < 1.0 {
emit(lerp(self.prev, cur, self.next as f32));
self.next += self.step;
}
self.next -= 1.0;
self.prev = cur;
}
/// Convenience for tests / batch callers: push a whole slice.
pub fn process(&mut self, input: &[f32], mut emit: impl FnMut(f32)) {
for &s in input {
self.push(s, &mut emit);
}
}
}
/// Stateful stereo **pull** resampler: produce output frames at `out_rate` by
/// pulling input frames at `in_rate` from a closure on demand. Call
/// [`next`](Self::next) once per output frame; it pulls as many input frames as the
/// ratio requires and returns the interpolated `(left, right)`, or `None` when the
/// puller runs dry (an underrun). Allocates nothing, so it is safe in an RT output
/// callback.
pub struct StereoPullResampler {
/// Input frames consumed per output frame (`in_rate / out_rate`).
step: f64,
/// Position of the next output frame within `[prev, cur)`, in `[0, 1)`.
frac: f64,
/// Left edge of the current interpolation segment.
prev: (f32, f32),
/// Right edge of the current interpolation segment.
cur: (f32, f32),
/// Whether `prev`/`cur` have been primed from the puller yet.
primed: bool,
}
impl StereoPullResampler {
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
/// clamped to `>= 1` so `step` is finite and non-zero — otherwise
/// [`next`](Self::next)'s `while self.frac >= 1.0` could spin (review W7).
pub fn new(in_rate: u32, out_rate: u32) -> Self {
Self {
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
frac: 0.0,
prev: (0.0, 0.0),
cur: (0.0, 0.0),
primed: false,
}
}
/// Produce the next output frame, pulling input frames via `pull` as needed.
/// Returns `None` if `pull` returns `None` before the frame can be formed
/// (underrun); the caller should substitute silence for that frame.
pub fn next(&mut self, mut pull: impl FnMut() -> Option<(f32, f32)>) -> Option<(f32, f32)> {
if !self.primed {
// Prime both edges from two pulls so the first output frame aligns
// exactly with the first input frame (`out[0] == in[0]` at equal
// rates). Needs two frames available to start, which the prefilled
// playback ring always has.
self.prev = pull()?;
self.cur = pull()?;
self.primed = true;
self.frac = 0.0;
}
// Advance the segment until the read position lands inside [prev, cur).
while self.frac >= 1.0 {
self.prev = self.cur;
self.cur = pull()?;
self.frac -= 1.0;
}
let f = self.frac as f32;
let out = (lerp(self.prev.0, self.cur.0, f), lerp(self.prev.1, self.cur.1, f));
self.frac += self.step;
Some(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Equal rates align exactly: `output[k] == input[k]`. The final input lands on
/// the next push (one-sample streaming latency), so we get `n - 1` outputs.
#[test]
fn push_identity_when_rates_match() {
let mut r = PushResampler::new(48_000, 48_000);
let input = [0.0, 0.1, 0.2, 0.3, 0.4];
let mut out = Vec::new();
r.process(&input, |s| out.push(s));
assert_eq!(out.len(), input.len() - 1);
for (a, b) in out.iter().zip(input.iter()) {
assert!((a - b).abs() < 1e-6, "{a} vs {b}");
}
}
/// Upsampling 2x roughly doubles the output count and the midpoints interpolate.
#[test]
fn push_upsample_2x_interpolates_midpoints() {
let mut r = PushResampler::new(24_000, 48_000); // step = 0.5
let input = [0.0, 1.0, 2.0, 3.0];
let mut out = Vec::new();
r.process(&input, |s| out.push(s));
// (n - 1) segments at 2 outputs each = 6.
assert_eq!(out.len(), 6, "out {out:?}");
// A half-step between 1.0 and 2.0 must appear near 1.5.
assert!(
out.iter().any(|&s| (s - 1.5).abs() < 1e-3),
"expected a ~1.5 midpoint in {out:?}"
);
}
/// Downsampling drops the rate: fewer outputs than inputs, monotonic ramp preserved.
#[test]
fn push_downsample_reduces_count() {
let mut r = PushResampler::new(48_000, 44_100); // step ~1.088
let input: Vec<f32> = (0..441).map(|i| i as f32).collect();
let mut out = Vec::new();
r.process(&input, |s| out.push(s));
// 441 in @ 48k -> ~405 out @ 44.1k.
assert!(
(390..=410).contains(&out.len()),
"expected ~405 outputs, got {}",
out.len()
);
// Output stays within the input's value range and is non-decreasing.
for w in out.windows(2) {
assert!(w[1] >= w[0] - 1e-3, "ramp should not reverse: {w:?}");
}
assert!(*out.last().unwrap() <= 440.0 + 1e-3);
}
/// Pull resampler at equal rates returns each input frame in order, aligned.
/// Two-pull priming uses one frame of lookahead, so `n` inputs yield `n - 1`
/// outputs (the last frame emits once a successor arrives).
#[test]
fn pull_identity_when_rates_match() {
let mut r = StereoPullResampler::new(48_000, 48_000);
let frames = [(0.0, 9.0), (1.0, 8.0), (2.0, 7.0), (3.0, 6.0)];
let mut idx = 0;
let mut out = Vec::new();
while let Some(f) = r.next(|| {
let v = frames.get(idx).copied();
idx += 1;
v
}) {
out.push(f);
}
assert_eq!(out.len(), frames.len() - 1, "out {out:?}");
for (got, want) in out.iter().zip(frames.iter()) {
assert!((got.0 - want.0).abs() < 1e-6 && (got.1 - want.1).abs() < 1e-6);
}
}
/// Pull resampler reports underrun (`None`) once the source is exhausted.
#[test]
fn pull_returns_none_on_underrun() {
let mut r = StereoPullResampler::new(48_000, 44_100); // step ~1.088 -> pulls >1 per out
let frames = [(0.0, 0.0), (1.0, -1.0)];
let mut idx = 0;
let mut pull = || {
let v = frames.get(idx).copied();
idx += 1;
v
};
// First frame primes + emits; subsequent calls eventually exhaust the source.
let mut produced = 0;
let mut hit_none = false;
for _ in 0..10 {
if r.next(&mut pull).is_some() {
produced += 1;
} else {
hit_none = true;
break;
}
}
assert!(produced >= 1, "should produce at least the primed frame");
assert!(hit_none, "should report underrun once the puller is dry");
}
/// Downsampling via pull consumes more input frames than it emits output frames.
#[test]
fn pull_downsample_consumes_more_than_it_emits() {
let mut r = StereoPullResampler::new(48_000, 24_000); // step = 2.0
let input: Vec<(f32, f32)> = (0..100).map(|i| (i as f32, -(i as f32))).collect();
let mut idx = 0;
let mut emitted = 0;
for _ in 0..40 {
let f = r.next(|| {
let v = input.get(idx).copied();
idx += 1;
v
});
if f.is_some() {
emitted += 1;
} else {
break;
}
}
// At step 2.0 we consume ~2 input frames per output frame.
assert!(idx > emitted, "consumed {idx} input, emitted {emitted} output");
}
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner
/// `while self.next < 1.0` forever). Clamping makes the call terminate (W7).
#[test]
fn push_zero_rate_does_not_spin() {
let mut r = PushResampler::new(0, 48_000);
let mut count = 0usize;
// Feed two samples; with a clamped non-zero step this returns promptly.
r.push(0.0, |_| count += 1);
r.push(1.0, |_| count += 1);
// Reaching here at all is the assertion (no hang); some output is produced.
assert!(count >= 1);
}
/// A zero output rate must not make the pull resampler's segment-advance loop
/// spin. Clamping keeps `step` finite so `next` terminates (W7).
#[test]
fn pull_zero_out_rate_does_not_spin() {
let mut r = StereoPullResampler::new(48_000, 0);
let frames = [(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)];
let mut idx = 0;
let got = r.next(|| {
let v = frames.get(idx).copied();
idx += 1;
v
});
// Terminates and yields the primed frame instead of hanging.
assert!(got.is_some());
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Custom UI background (W16): turn a user-picked image into a capped PNG to
//! render behind the whole UI, plus the legibility scrim drawn over it.
//!
//! This is the pure, unit-testable seam — `process_background` decodes/downscales
//! arbitrary input defensively (same caution as avatar uploads) and `scrim_color`
//! computes the overlay tint. The file I/O, the `rfd` picker, and the iced
//! `stack!` that layers image → scrim → UI all live at the app edge in
//! `src/app/mod.rs`. The background is **local-only** — never sent to peers — so
//! there's no gossip-frame budget here (hence a much larger size cap than avatars).
use iced::Color;
/// Longest side a custom background is downscaled to on ingest (aspect preserved,
/// never upscaled). Big enough to look crisp filling the window, small enough to
/// decode and cache cheaply. Local-only, so this is generous vs. the avatar cap.
pub const BACKGROUND_MAX_PX: u32 = 1920;
/// Default scrim strength. `0.0` = the image shows at full strength, `1.0` = it's
/// fully hidden behind the theme's base colour. Half keeps a photo clearly visible
/// while text and cards stay readable over it.
pub const DEFAULT_DIM: f32 = 0.5;
/// Decode an arbitrary user image (png/jpeg/…), downscale so its longest side is
/// at most [`BACKGROUND_MAX_PX`] (aspect preserved; smaller images are left as-is,
/// never upscaled), and re-encode as PNG bytes ready to write to disk. Decoding is
/// bounded by the `image` crate's defaults so a malformed/huge file is rejected
/// rather than exhausting memory. Errors come back as a message for the UI.
pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
let img = image::load_from_memory(raw).map_err(|e| format!("Couldn't read image: {e}"))?;
// Only ever shrink. `resize` preserves aspect, fitting within the box; a
// higher-quality filter than `thumbnail` since a background fills the window.
let scaled = if img.width() > BACKGROUND_MAX_PX || img.height() > BACKGROUND_MAX_PX {
img.resize(
BACKGROUND_MAX_PX,
BACKGROUND_MAX_PX,
image::imageops::FilterType::Lanczos3,
)
} else {
img
};
let mut png = std::io::Cursor::new(Vec::new());
scaled
.write_to(&mut png, image::ImageFormat::Png)
.map_err(|e| format!("Couldn't encode image: {e}"))?;
Ok(png.into_inner())
}
/// The legibility scrim drawn between the background image and the UI: the active
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
/// recedes the image so body text and panel chrome stay readable, and it re-tints
/// per theme since `base` comes from the active palette.
pub fn scrim_color(base: Color, dim: f32) -> Color {
Color { a: dim.clamp(0.0, 1.0), ..base }
}
#[cfg(test)]
mod tests {
use super::*;
/// A valid PNG of the given size, as raw bytes (test helper).
fn make_png(w: u32, h: u32) -> Vec<u8> {
let img = image::DynamicImage::new_rgb8(w, h);
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
buf.into_inner()
}
#[test]
fn process_background_downscales_oversized() {
// A 4000x2000 image is shrunk so the longest side is BACKGROUND_MAX_PX,
// aspect preserved, and the result re-decodes as a PNG within bounds.
let raw = make_png(4000, 2000);
let png = process_background(&raw).expect("should process");
let decoded = image::load_from_memory(&png).unwrap();
assert_eq!(decoded.width().max(decoded.height()), BACKGROUND_MAX_PX);
assert_eq!(decoded.width(), BACKGROUND_MAX_PX);
assert_eq!(decoded.height(), BACKGROUND_MAX_PX / 2); // 2:1 aspect kept
}
#[test]
fn process_background_leaves_small_images_unscaled() {
let raw = make_png(640, 480);
let png = process_background(&raw).expect("should process");
let decoded = image::load_from_memory(&png).unwrap();
assert_eq!((decoded.width(), decoded.height()), (640, 480));
}
#[test]
fn process_background_rejects_non_image() {
assert!(process_background(b"definitely not an image").is_err());
}
#[test]
fn scrim_color_sets_alpha_and_keeps_rgb() {
let base = Color::from_rgb(0.1, 0.2, 0.3);
let s = scrim_color(base, 0.5);
assert_eq!((s.r, s.g, s.b), (0.1, 0.2, 0.3));
assert!((s.a - 0.5).abs() < f32::EPSILON);
}
#[test]
fn scrim_color_clamps_dim() {
let base = Color::BLACK;
assert!((scrim_color(base, -1.0).a - 0.0).abs() < f32::EPSILON);
assert!((scrim_color(base, 2.0).a - 1.0).abs() < f32::EPSILON);
}
}
+124 -10
View File
@@ -1,11 +1,11 @@
//! Audio playout diagnostic probe. //! Audio playout diagnostic probe.
//! //!
//! Drives a phase-continuous sine tone through the *real* PipeWire playback path //! Drives a phase-continuous sine tone through the *real* playback path
//! (`PipeWireBackend::start_playback`), using the *same* fill-paced production //! (PipeWire on Linux, cpal/WASAPI on Windows), using the *same* fill-paced
//! the production mixer uses (`core/mod.rs`): generate a frame only while the //! production the production mixer uses (`core/mod.rs`): generate a frame only while the
//! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the //! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
//! PipeWire hardware clock. No network, no microphone — this isolates the local //! hardware clock. No network, no microphone — this isolates the local output
//! output path so we can confirm the clock-paced playout is glitch-free. //! path so we can confirm the clock-paced playout is glitch-free.
//! //!
//! Use your ears on the tone (any click/pop is a glitch) together with the //! Use your ears on the tone (any click/pop is a glitch) together with the
//! `playout-health:` lines tailed to stdout: //! `playout-health:` lines tailed to stdout:
@@ -18,17 +18,24 @@
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node] //! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
//! e.g. cargo run --release --bin audio_probe -- 440 30 //! e.g. cargo run --release --bin audio_probe -- 440 30
//! //!
//! This probe exercises the PipeWire backend directly, so it is a Linux-only tool. //! This probe exercises the platform playback backend directly: PipeWire on Linux
//! On non-Linux targets `main` is a stub that explains the limitation. //! and cpal/WASAPI on Windows. Other targets use a stub that explains the limitation.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn main() { fn main() {
unix_probe::run(); unix_probe::run();
} }
#[cfg(not(target_os = "linux"))] #[cfg(windows)]
fn main() { fn main() {
eprintln!("audio_probe is only supported on Linux builds (it drives the PipeWire backend directly)."); win_probe::run();
}
#[cfg(not(any(target_os = "linux", windows)))]
fn main() {
eprintln!(
"audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly)."
);
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -88,7 +95,114 @@ mod unix_probe {
for _ in 0..FRAME_SAMPLES { for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE; let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh. // 0.25 amplitude: clearly audible but not harsh.
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16; let sample =
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
// Stereo playback bus: duplicate the probe tone to L/R.
frame.push(sample);
frame.push(sample);
n += 1;
}
if tx.send(frame).is_err() {
eprintln!("playback channel closed early");
break;
}
}
// Let the ring drain, then stop.
tokio::time::sleep(Duration::from_millis(300)).await;
let _ = backend.stop();
println!("\naudio_probe: done.");
}
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
/// reports) to stdout once they appear.
fn spawn_log_tailer() {
let path = peerspeak::log_file_path();
std::thread::spawn(move || {
// Wait for the file to exist (first log_msg creates it).
let file = loop {
if let Ok(f) = std::fs::File::open(&path) {
break f;
}
std::thread::sleep(Duration::from_millis(100));
};
let mut reader = BufReader::new(file);
let _ = reader.seek(SeekFrom::End(0));
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
Ok(_) => {
if line.contains("playout-health:") {
print!("{line}");
}
}
Err(_) => std::thread::sleep(Duration::from_millis(150)),
}
}
});
}
}
#[cfg(windows)]
mod win_probe {
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
use std::time::Duration;
use peerspeak::audio::AudioBackend;
use peerspeak::audio::cpal_impl::CpalBackend;
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
const SAMPLE_RATE: f32 = 48_000.0;
#[tokio::main]
pub async fn run() {
let mut args = std::env::args().skip(1);
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
let target_node: Option<String> = args.next();
// The playout-health logger is quiet in normal operation (it only logs
// glitches); ask it for the full once-per-second heartbeat so the probe can
// show the steady-state numbers.
// SAFETY: set before any playback thread starts, so no concurrent env read.
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
// Tail the app log (where playout-health lines land) to stdout in the
// background so it's all in one terminal.
spawn_log_tailer();
let backend = CpalBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
eprintln!("failed to start playback: {e}");
return;
}
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
// exactly like the production mixer: only produce while the ring is below
// target, so production tracks the cpal/WASAPI hardware clock.
use std::sync::atomic::Ordering;
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
while tokio::time::Instant::now() < deadline {
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
tokio::time::sleep(Duration::from_millis(2)).await;
continue;
}
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh.
let sample =
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
// Stereo playback bus: duplicate the probe tone to L/R. // Stereo playback bus: duplicate the probe tone to L/R.
frame.push(sample); frame.push(sample);
frame.push(sample); frame.push(sample);
+27
View File
@@ -106,6 +106,10 @@ fn default_true() -> bool {
true true
} }
fn default_background_dim() -> f32 {
crate::background::DEFAULT_DIM
}
fn default_volume() -> f32 { fn default_volume() -> f32 {
1.0 1.0
} }
@@ -185,6 +189,16 @@ pub struct AppConfig {
/// Our chosen avatar (W4): monogram fallback or a bundled preset. /// Our chosen avatar (W4): monogram fallback or a bundled preset.
#[serde(default)] #[serde(default)]
pub avatar: crate::avatar::Avatar, pub avatar: crate::avatar::Avatar,
/// Custom UI background image (W16): path to the downscaled PNG we wrote into
/// the config dir (see `background_path`). `None` = use the theme background.
/// Local-only; never sent to peers.
#[serde(default)]
pub background: Option<String>,
/// Scrim strength drawn over the custom background for legibility (0.0 = image
/// at full strength, 1.0 = fully hidden behind the theme base). See
/// `crate::background::scrim_color`.
#[serde(default = "default_background_dim")]
pub background_dim: f32,
/// What a call recording captures (mixed / per-peer stems / both). /// What a call recording captures (mixed / per-peer stems / both).
#[serde(default)] #[serde(default)]
pub recording_mode: RecordingMode, pub recording_mode: RecordingMode,
@@ -280,6 +294,8 @@ impl Default for AppConfig {
room_layout: RoomLayout::default(), room_layout: RoomLayout::default(),
theme: AppTheme::default(), theme: AppTheme::default(),
avatar: crate::avatar::Avatar::default(), avatar: crate::avatar::Avatar::default(),
background: None,
background_dim: default_background_dim(),
recording_mode: RecordingMode::default(), recording_mode: RecordingMode::default(),
custom_sound_self_join: None, custom_sound_self_join: None,
custom_sound_peer_join: None, custom_sound_peer_join: None,
@@ -348,6 +364,17 @@ impl AppConfig {
}) })
} }
/// Path the processed custom-background PNG (W16) is written to, alongside
/// `config.json` in the app config dir. We store our own downscaled copy here
/// (rather than base64 in the config) so the JSON stays small.
pub fn background_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut p| {
p.push("peerspeak");
p.push("background.png");
p
})
}
pub fn load() -> Self { pub fn load() -> Self {
if let Some(path) = Self::config_path() if let Some(path) = Self::config_path()
&& let Ok(contents) = fs::read_to_string(&path) && let Ok(contents) = fs::read_to_string(&path)
+3
View File
@@ -85,6 +85,9 @@ pub enum UiEvent {
RoomLeft, RoomLeft,
PeerJoined { id: EndpointId, state: PeerState }, PeerJoined { id: EndpointId, state: PeerState },
PeerLeft { id: EndpointId }, PeerLeft { id: EndpointId },
/// The fixed reconnect grace expired and bounded background gossip recovery
/// has started. This is non-terminal and must not play the failure chime.
PeerRecoveryStarted { id: EndpointId },
PeerConnectionFailed { id: EndpointId }, PeerConnectionFailed { id: EndpointId },
PeerUpdated { id: EndpointId, state: PeerState }, PeerUpdated { id: EndpointId, state: PeerState },
/// Audio link to a peer is being (re)established — show a connecting state. /// Audio link to a peer is being (re)established — show a connecting state.
+164 -37
View File
@@ -1,5 +1,6 @@
pub mod messages; pub mod messages;
pub mod jitter; pub mod jitter;
mod recovery;
use crate::audio::{AudioBackend, PlatformAudioBackend}; use crate::audio::{AudioBackend, PlatformAudioBackend};
use crate::audio::eq::{Eq, EqSettings}; use crate::audio::eq::{Eq, EqSettings};
@@ -11,6 +12,7 @@ use crate::network::{
gossip::IrohGossipState, gossip::IrohGossipState,
}; };
use crate::core::messages::{CoreCommand, UiEvent}; use crate::core::messages::{CoreCommand, UiEvent};
use crate::core::recovery::RecoveryCoordinator;
use crate::config::{NetworkMode, RecordingMode}; use crate::config::{NetworkMode, RecordingMode};
use crate::presence::PresenceMode; use crate::presence::PresenceMode;
@@ -102,6 +104,39 @@ type GraceTimers = Arc<std::sync::Mutex<HashMap<EndpointId, tokio::task::JoinHan
/// Scrubbed whenever a peer is evicted or leaves so a later rejoin starts clean. /// Scrubbed whenever a peer is evicted or leaves so a later rejoin starts clean.
type SeenConnected = Arc<std::sync::Mutex<HashSet<EndpointId>>>; type SeenConnected = Arc<std::sync::Mutex<HashSet<EndpointId>>>;
type KnownPeers =
Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>>;
#[derive(Clone)]
struct RecoveryContext {
coordinator: RecoveryCoordinator,
room_state: Arc<IrohGossipState>,
known_peers: KnownPeers,
ticket: String,
}
impl RecoveryContext {
fn retained_addr(&self, peer_id: &EndpointId) -> Option<EndpointAddr> {
self.known_peers
.lock()
.unwrap()
.get(&self.ticket)
.and_then(|peers| peers.get(peer_id))
.cloned()
}
fn cancel(&self, peer_id: EndpointId) {
self.coordinator.cancel(peer_id);
}
fn forget(&self, peer_id: EndpointId) {
if let Some(peers) = self.known_peers.lock().unwrap().get_mut(&self.ticket) {
peers.remove(&peer_id);
}
self.coordinator.cancel(peer_id);
}
}
/// Cancel and forget a peer's pending grace timer, if any. No-op if none is armed. /// Cancel and forget a peer's pending grace timer, if any. No-op if none is armed.
fn cancel_grace_timer(timers: &GraceTimers, peer_id: &EndpointId) { fn cancel_grace_timer(timers: &GraceTimers, peer_id: &EndpointId) {
if let Some(handle) = timers.lock().unwrap().remove(peer_id) { if let Some(handle) = timers.lock().unwrap().remove(peer_id) {
@@ -116,12 +151,17 @@ fn cancel_grace_timer(timers: &GraceTimers, peer_id: &EndpointId) {
/// link repeatedly resetting the clock and dodging eviction forever. On firing it /// link repeatedly resetting the clock and dodging eviction forever. On firing it
/// also scrubs the peer from `seen_connected` so a later rejoin isn't treated as a /// also scrubs the peer from `seen_connected` so a later rejoin isn't treated as a
/// reconnect on its initial dial. /// reconnect on its initial dial.
struct GraceExpiry<'a> {
transport: &'a Arc<IrohTransport>,
jitter: &'a Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>,
ui_tx: &'a mpsc::Sender<UiEvent>,
recovery: Option<&'a RecoveryContext>,
}
fn arm_grace_timer( fn arm_grace_timer(
timers: &GraceTimers, timers: &GraceTimers,
seen_connected: &SeenConnected, seen_connected: &SeenConnected,
transport: &Arc<IrohTransport>, expiry: GraceExpiry<'_>,
jitter: &Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>,
ui_tx: &mpsc::Sender<UiEvent>,
grace: Duration, grace: Duration,
peer_id: EndpointId, peer_id: EndpointId,
) { ) {
@@ -129,15 +169,29 @@ fn arm_grace_timer(
if timers_guard.contains_key(&peer_id) { if timers_guard.contains_key(&peer_id) {
return; return;
} }
let transport_evict = transport.clone(); let transport_evict = expiry.transport.clone();
let jitter_evict = jitter.clone(); let jitter_evict = expiry.jitter.clone();
let ui_evict = ui_tx.clone(); let ui_evict = expiry.ui_tx.clone();
let timers_evict = timers.clone(); let timers_evict = timers.clone();
let seen_evict = seen_connected.clone(); let seen_evict = seen_connected.clone();
let recovery_evict = expiry.recovery.cloned();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
tokio::time::sleep(grace).await; tokio::time::sleep(grace).await;
crate::log_msg(&format!("Reconnect grace expired; evicting peer {:?}", peer_id)); crate::log_msg(&format!("Reconnect grace expired for peer {:?}", peer_id));
if let Some(recovery) = &recovery_evict
&& !recovery.coordinator.begin(peer_id)
{
return;
}
transport_evict.remove_audio_sender(peer_id); transport_evict.remove_audio_sender(peer_id);
if let Some(recovery) = &recovery_evict {
// Revoke roster authority before the first await in teardown. A
// verified Announce racing after this point is then a PeerJoined and
// cancels recovery instead of being erased after it was accepted.
recovery.room_state.mark_peer_disconnected(peer_id);
}
transport_evict.disconnect_peer(peer_id).await; transport_evict.disconnect_peer(peer_id).await;
jitter_evict.lock().await.remove(&peer_id); jitter_evict.lock().await.remove(&peer_id);
// Scrub our internal state *before* announcing the eviction, so anything // Scrub our internal state *before* announcing the eviction, so anything
@@ -146,7 +200,38 @@ fn arm_grace_timer(
// reconnect. // reconnect.
timers_evict.lock().unwrap().remove(&peer_id); timers_evict.lock().unwrap().remove(&peer_id);
seen_evict.lock().unwrap().remove(&peer_id); seen_evict.lock().unwrap().remove(&peer_id);
let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await;
let Some(recovery) = recovery_evict else {
let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await;
return;
};
if !recovery.coordinator.is_active(&peer_id) {
return;
}
let Some(addr) = recovery.retained_addr(&peer_id) else {
crate::log_msg(&format!(
"Cannot recover peer {:?}: no retained authenticated address",
peer_id
));
recovery.cancel(peer_id);
let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await;
return;
};
match recovery.coordinator.activate(peer_id, addr) {
Ok(true) => {
let _ = ui_evict.send(UiEvent::PeerRecoveryStarted { id: peer_id }).await;
}
Ok(false) => {}
Err(()) => {
crate::log_msg(&format!(
"Cannot recover peer {:?}: recovery coordinator unavailable",
peer_id
));
let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await;
}
}
}); });
timers_guard.insert(peer_id, handle); timers_guard.insert(peer_id, handle);
} }
@@ -309,6 +394,7 @@ pub struct ConnEventHandler {
seen_connected: SeenConnected, seen_connected: SeenConnected,
transport: Arc<IrohTransport>, transport: Arc<IrohTransport>,
jitter: Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>, jitter: Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>,
recovery: Option<RecoveryContext>,
grace: Duration, grace: Duration,
} }
@@ -326,6 +412,7 @@ impl ConnEventHandler {
seen_connected, seen_connected,
transport, transport,
jitter, jitter,
recovery: None,
grace: RECONNECT_GRACE, grace: RECONNECT_GRACE,
} }
} }
@@ -336,6 +423,11 @@ impl ConnEventHandler {
self self
} }
fn with_recovery(mut self, recovery: RecoveryContext) -> Self {
self.recovery = Some(recovery);
self
}
pub async fn handle(&self, event: ConnEvent) { pub async fn handle(&self, event: ConnEvent) {
match event { match event {
ConnEvent::Connecting(id) => { ConnEvent::Connecting(id) => {
@@ -349,9 +441,12 @@ impl ConnEventHandler {
arm_grace_timer( arm_grace_timer(
&self.grace_timers, &self.grace_timers,
&self.seen_connected, &self.seen_connected,
&self.transport, GraceExpiry {
&self.jitter, transport: &self.transport,
&self.ui_tx, jitter: &self.jitter,
ui_tx: &self.ui_tx,
recovery: self.recovery.as_ref(),
},
self.grace, self.grace,
id, id,
); );
@@ -359,6 +454,15 @@ impl ConnEventHandler {
let _ = self.ui_tx.send(UiEvent::PeerConnecting { id }).await; let _ = self.ui_tx.send(UiEvent::PeerConnecting { id }).await;
} }
ConnEvent::Connected(id) => { ConnEvent::Connected(id) => {
// A transport event cannot readmit a grace-expired peer. Ignore a
// stale/racing link until authenticated gossip emits PeerJoined.
if self
.recovery
.as_ref()
.is_some_and(|recovery| recovery.coordinator.is_active(&id))
{
return;
}
// The audio link came back — the peer recovered within the grace // The audio link came back — the peer recovered within the grace
// window, so cancel its eviction. // window, so cancel its eviction.
cancel_grace_timer(&self.grace_timers, &id); cancel_grace_timer(&self.grace_timers, &id);
@@ -371,6 +475,9 @@ impl ConnEventHandler {
// until the grace timer or the slow gossip Leave. // until the grace timer or the slow gossip Leave.
cancel_grace_timer(&self.grace_timers, &id); cancel_grace_timer(&self.grace_timers, &id);
self.seen_connected.lock().unwrap().remove(&id); self.seen_connected.lock().unwrap().remove(&id);
if let Some(recovery) = &self.recovery {
recovery.forget(id);
}
self.transport.remove_audio_sender(id); self.transport.remove_audio_sender(id);
self.transport.disconnect_peer(id).await; self.transport.disconnect_peer(id).await;
self.jitter.lock().await.remove(&id); self.jitter.lock().await.remove(&id);
@@ -387,6 +494,7 @@ struct ActiveSession {
mixer_task: tokio::task::JoinHandle<()>, mixer_task: tokio::task::JoinHandle<()>,
event_task: tokio::task::JoinHandle<()>, event_task: tokio::task::JoinHandle<()>,
conn_event_task: tokio::task::JoinHandle<()>, conn_event_task: tokio::task::JoinHandle<()>,
recovery_task: tokio::task::JoinHandle<()>,
grace_timers: GraceTimers, grace_timers: GraceTimers,
transport: Arc<IrohTransport>, transport: Arc<IrohTransport>,
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop. /// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
@@ -421,6 +529,7 @@ impl ActiveSession {
for (_, handle) in self.grace_timers.lock().unwrap().drain() { for (_, handle) in self.grace_timers.lock().unwrap().drain() {
handle.abort(); handle.abort();
} }
self.recovery_task.abort();
crate::log_msg("Aborted tasks"); crate::log_msg("Aborted tasks");
let audio_backend_clone = audio_backend.clone(); let audio_backend_clone = audio_backend.clone();
@@ -730,8 +839,7 @@ async fn run_core_loop(
// first room's peers — the old single-set version cleared them on any ticket // first room's peers — the old single-set version cleared them on any ticket
// change, so an A→B→A bounce stranded the rejoiner with an empty bootstrap. // change, so an A→B→A bounce stranded the rejoiner with an empty bootstrap.
// Inner map keyed by peer id so updates refresh the address. // Inner map keyed by peer id so updates refresh the address.
let known_peers: Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>> = let known_peers: KnownPeers = Arc::new(std::sync::Mutex::new(HashMap::new()));
Arc::new(std::sync::Mutex::new(HashMap::new()));
let audio_backend = Arc::new(PlatformAudioBackend::new()); let audio_backend = Arc::new(PlatformAudioBackend::new());
@@ -1474,6 +1582,15 @@ async fn run_core_loop(
// The ticket of the room this event loop serves, so peer add/remove // The ticket of the room this event loop serves, so peer add/remove
// updates the right per-ticket bucket in `known_peers` (A8 archive). // updates the right per-ticket bucket in `known_peers` (A8 archive).
let ticket_events = ticket_str.clone(); let ticket_events = ticket_str.clone();
let (recovery_coordinator, recovery_task) =
RecoveryCoordinator::spawn(room_state.clone());
let recovery_context = RecoveryContext {
coordinator: recovery_coordinator,
room_state: room_state.clone(),
known_peers: known_peers.clone(),
ticket: ticket_str.clone(),
};
let recovery_events = recovery_context.clone();
// Friends store + ui sender, so a connected peer who is a friend has // Friends store + ui sender, so a connected peer who is a friend has
// their saved address auto-healed (W7) — populates `last_addr` so the // their saved address auto-healed (W7) — populates `last_addr` so the
// presence scheduler can reach them later. // presence scheduler can reach them later.
@@ -1486,6 +1603,7 @@ async fn run_core_loop(
// A (re)join means the peer is back — cancel any // A (re)join means the peer is back — cancel any
// pending reconnect grace timer before re-adding it. // pending reconnect grace timer before re-adding it.
cancel_grace_timer(&grace_timers_events, &peer_id); cancel_grace_timer(&grace_timers_events, &peer_id);
recovery_events.cancel(peer_id);
transport_events.admit_audio_sender(peer_id); transport_events.admit_audio_sender(peer_id);
// Establish the audio connection as soon as the peer // Establish the audio connection as soon as the peer
// is known (the transport dedupes the full-mesh race). // is known (the transport dedupes the full-mesh race).
@@ -1529,15 +1647,9 @@ async fn run_core_loop(
// Graceful leave — evict immediately. // Graceful leave — evict immediately.
cancel_grace_timer(&grace_timers_events, &peer_id); cancel_grace_timer(&grace_timers_events, &peer_id);
seen_connected_events.lock().unwrap().remove(&peer_id); seen_connected_events.lock().unwrap().remove(&peer_id);
// Graceful leave: drop them as a rejoin dial target // A signed Leave cancels background recovery and
// for this room (a transient PeerConnectionLost // drops the retained target. Transient loss keeps it.
// deliberately does NOT, so we can still re-dial a recovery_events.forget(peer_id);
// peer who's still up).
if let Some(peers) =
known_peers_events.lock().unwrap().get_mut(&ticket_events)
{
peers.remove(&peer_id);
}
transport_events.remove_audio_sender(peer_id); transport_events.remove_audio_sender(peer_id);
transport_events.disconnect_peer(peer_id).await; transport_events.disconnect_peer(peer_id).await;
jitter_events.lock().await.remove(&peer_id); jitter_events.lock().await.remove(&peer_id);
@@ -1551,6 +1663,7 @@ async fn run_core_loop(
// it. Idempotent: an ordinary mute/unmute update just // it. Idempotent: an ordinary mute/unmute update just
// re-records the same address. // re-records the same address.
cancel_grace_timer(&grace_timers_events, &peer_id); cancel_grace_timer(&grace_timers_events, &peer_id);
recovery_events.cancel(peer_id);
transport_events.admit_audio_sender(peer_id); transport_events.admit_audio_sender(peer_id);
transport_events.connect_peer(state.addr.clone()).await; transport_events.connect_peer(state.addr.clone()).await;
// Auto-heal a friend's saved address (W7) on the // Auto-heal a friend's saved address (W7) on the
@@ -1598,9 +1711,12 @@ async fn run_core_loop(
arm_grace_timer( arm_grace_timer(
&grace_timers_events, &grace_timers_events,
&seen_connected_events, &seen_connected_events,
&transport_events, GraceExpiry {
&jitter_events, transport: &transport_events,
&ui_tx_events, jitter: &jitter_events,
ui_tx: &ui_tx_events,
recovery: Some(&recovery_events),
},
RECONNECT_GRACE, RECONNECT_GRACE,
peer_id, peer_id,
); );
@@ -1624,7 +1740,8 @@ async fn run_core_loop(
seen_connected.clone(), seen_connected.clone(),
transport.clone(), transport.clone(),
jitter.clone(), jitter.clone(),
); )
.with_recovery(recovery_context);
let conn_event_task = tokio::spawn(async move { let conn_event_task = tokio::spawn(async move {
while let Some(event) = conn_events.recv().await { while let Some(event) = conn_events.recv().await {
conn_handler.handle(event).await; conn_handler.handle(event).await;
@@ -1638,6 +1755,7 @@ async fn run_core_loop(
mixer_task, mixer_task,
event_task, event_task,
conn_event_task, conn_event_task,
recovery_task,
grace_timers, grace_timers,
transport: transport.clone(), transport: transport.clone(),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -1816,17 +1934,26 @@ async fn run_core_loop(
} }
CoreCommand::SetNetworkMode(mode) => { CoreCommand::SetNetworkMode(mode) => {
network_mode = mode; // Skip when the posture is unchanged. The GUI re-sends the saved
// Rebuild the persistent stack to the new posture immediately if // network mode as part of its startup config-sync, and that mode
// idle; if a call is active, defer to the next Leave/Join so the // usually already matches the freshly-built stack — rebuilding the
// live call isn't disrupted (preserves "applies on next join"). // iroh endpoint for an identical posture just churns the network
if active_session.is_none() { // and adds a needless ~1s teardown+rebuild bounce at every launch
let lookup = net.memory_lookup.clone(); // (seen on both Linux and Windows/Wine). A real change still
net.shutdown().await; // rebuilds exactly as before.
let publish = presence_mode.lock().unwrap().publishes_to_discovery(); if mode != network_mode {
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; network_mode = mode;
} else { // Rebuild the persistent stack to the new posture immediately if
net_rebuild_pending = true; // 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(), network_mode, lookup, friends_handler.clone(), publish).await?;
} else {
net_rebuild_pending = true;
}
} }
} }
+264
View File
@@ -0,0 +1,264 @@
use crate::network::{RoomState, gossip::IrohGossipState};
use iroh::{EndpointAddr, EndpointId};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::time::Instant;
const RECOVERY_COMMAND_CAPACITY: usize = 64;
const RECOVERY_DELAYS: [Duration; 7] = [
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
Duration::from_secs(8),
Duration::from_secs(15),
Duration::from_secs(30),
Duration::from_secs(60),
];
fn recovery_delay(attempt: usize) -> Duration {
RECOVERY_DELAYS[attempt.min(RECOVERY_DELAYS.len() - 1)]
}
enum RecoveryCommand {
Start {
peer_id: EndpointId,
addr: EndpointAddr,
},
Cancel(EndpointId),
}
struct RecoveryEntry {
addr: EndpointAddr,
attempt: usize,
next_attempt: Instant,
}
#[async_trait::async_trait]
trait RecoveryRoom: Send + Sync {
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String>;
}
#[async_trait::async_trait]
impl RecoveryRoom for IrohGossipState {
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String> {
RoomState::rebootstrap_peers(self, peers)
.await
.map_err(|error| error.to_string())
}
}
/// Cloneable command side of the single per-session recovery coordinator.
/// `active` is shared with transport/event handlers so cancellation is visible
/// immediately even while the coordinator is awaiting an in-flight gossip call.
#[derive(Clone)]
pub(super) struct RecoveryCoordinator {
tx: mpsc::Sender<RecoveryCommand>,
active: Arc<Mutex<HashSet<EndpointId>>>,
}
impl RecoveryCoordinator {
pub(super) fn spawn(room_state: Arc<IrohGossipState>) -> (Self, JoinHandle<()>) {
Self::spawn_inner(room_state)
}
fn spawn_inner(room_state: Arc<dyn RecoveryRoom>) -> (Self, JoinHandle<()>) {
let (tx, rx) = mpsc::channel(RECOVERY_COMMAND_CAPACITY);
let active = Arc::new(Mutex::new(HashSet::new()));
let handle = Self {
tx,
active: active.clone(),
};
let task = tokio::spawn(run_coordinator(room_state, active, rx));
(handle, task)
}
/// Reserve one recovery slot before grace-expiry teardown begins. Returns
/// false when the peer is already recovering, preventing duplicate work.
pub(super) fn begin(&self, peer_id: EndpointId) -> bool {
self.active.lock().unwrap().insert(peer_id)
}
/// Activate the reserved slot with its retained authenticated address.
/// Uses a bounded non-blocking send while holding the active-set lock so a
/// concurrent cancellation is ordered before or after this command.
pub(super) fn activate(&self, peer_id: EndpointId, addr: EndpointAddr) -> Result<bool, ()> {
let mut active = self.active.lock().unwrap();
if !active.contains(&peer_id) {
return Ok(false);
}
if self
.tx
.try_send(RecoveryCommand::Start { peer_id, addr })
.is_err()
{
active.remove(&peer_id);
return Err(());
}
Ok(true)
}
pub(super) fn cancel(&self, peer_id: EndpointId) {
self.active.lock().unwrap().remove(&peer_id);
// Cancellation is governed by the shared active set, so it remains
// immediate even if the bounded command queue is temporarily full.
let _ = self.tx.try_send(RecoveryCommand::Cancel(peer_id));
}
pub(super) fn is_active(&self, peer_id: &EndpointId) -> bool {
self.active.lock().unwrap().contains(peer_id)
}
}
async fn run_coordinator(
room_state: Arc<dyn RecoveryRoom>,
active: Arc<Mutex<HashSet<EndpointId>>>,
mut rx: mpsc::Receiver<RecoveryCommand>,
) {
let mut entries: HashMap<EndpointId, RecoveryEntry> = HashMap::new();
loop {
// The shared active set is the authoritative cancellation gate. Prune
// here as well as on Cancel commands so a saturated command queue cannot
// leave an inactive, past-due entry spinning the timer loop.
let active_snapshot = active.lock().unwrap().clone();
entries.retain(|peer_id, _| active_snapshot.contains(peer_id));
let next_deadline = entries.values().map(|entry| entry.next_attempt).min();
let command = match next_deadline {
Some(deadline) => {
tokio::select! {
command = rx.recv() => command,
_ = tokio::time::sleep_until(deadline) => {
let now = Instant::now();
let active_snapshot = active.lock().unwrap().clone();
let due: Vec<(EndpointId, EndpointAddr)> = entries
.iter()
.filter(|(id, entry)| {
entry.next_attempt <= now && active_snapshot.contains(*id)
})
.map(|(id, entry)| (*id, entry.addr.clone()))
.collect();
if !due.is_empty() {
let addrs = due.iter().map(|(_, addr)| addr.clone()).collect();
if let Err(error) = room_state.rebootstrap_peers(addrs).await {
crate::log_msg(&format!(
"Background peer recovery attempt failed: {error}"
));
}
let scheduled_at = Instant::now();
for (peer_id, _) in due {
if !active.lock().unwrap().contains(&peer_id) {
entries.remove(&peer_id);
continue;
}
if let Some(entry) = entries.get_mut(&peer_id) {
entry.next_attempt = scheduled_at + recovery_delay(entry.attempt);
entry.attempt = entry.attempt.saturating_add(1);
}
}
}
continue;
}
}
}
None => rx.recv().await,
};
match command {
Some(RecoveryCommand::Start { peer_id, addr }) => {
if active.lock().unwrap().contains(&peer_id) {
entries.entry(peer_id).or_insert(RecoveryEntry {
addr,
attempt: 0,
next_attempt: Instant::now(),
});
}
}
Some(RecoveryCommand::Cancel(peer_id)) => {
entries.remove(&peer_id);
}
None => break,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use iroh::SecretKey;
struct RecordingRoom {
attempts: mpsc::UnboundedSender<Vec<EndpointAddr>>,
}
#[async_trait::async_trait]
impl RecoveryRoom for RecordingRoom {
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String> {
self.attempts.send(peers).map_err(|error| error.to_string())
}
}
#[test]
fn retry_backoff_reaches_and_stays_at_sixty_seconds() {
let actual: Vec<u64> = (0..10)
.map(|attempt| recovery_delay(attempt).as_secs())
.collect();
assert_eq!(actual, vec![1, 2, 4, 8, 15, 30, 60, 60, 60, 60]);
}
#[test]
fn recovery_slots_are_deduplicated_and_cancel_immediately() {
let (tx, mut rx) = mpsc::channel(4);
let coordinator = RecoveryCoordinator {
tx,
active: Arc::new(Mutex::new(HashSet::new())),
};
let peer_id = SecretKey::generate().public();
assert!(coordinator.begin(peer_id));
assert!(
!coordinator.begin(peer_id),
"a peer gets only one recovery slot"
);
assert_eq!(
coordinator.activate(peer_id, EndpointAddr::from(peer_id)),
Ok(true)
);
assert!(matches!(
rx.try_recv(),
Ok(RecoveryCommand::Start { peer_id: id, .. }) if id == peer_id
));
coordinator.cancel(peer_id);
assert!(!coordinator.is_active(&peer_id));
assert!(matches!(
rx.try_recv(),
Ok(RecoveryCommand::Cancel(id)) if id == peer_id
));
}
#[tokio::test]
async fn coordinator_attempts_rebootstrap_immediately() {
let (attempts_tx, mut attempts_rx) = mpsc::unbounded_channel();
let (coordinator, task) = RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
attempts: attempts_tx,
}));
let peer_id = SecretKey::generate().public();
let addr = EndpointAddr::from(peer_id);
assert!(coordinator.begin(peer_id));
assert_eq!(coordinator.activate(peer_id, addr.clone()), Ok(true));
let attempted = tokio::time::timeout(Duration::from_secs(1), attempts_rx.recv())
.await
.expect("first recovery attempt should be immediate")
.expect("recording room remains subscribed");
assert_eq!(attempted, vec![addr]);
coordinator.cancel(peer_id);
task.abort();
}
}
+1
View File
@@ -15,6 +15,7 @@ pub mod notify;
pub mod screenshare; pub mod screenshare;
pub mod sanitize; pub mod sanitize;
pub mod avatar; pub mod avatar;
pub mod background;
pub mod recents; pub mod recents;
pub mod discovery; pub mod discovery;
pub mod hotkeys; pub mod hotkeys;
+4
View File
@@ -1,3 +1,7 @@
// On Windows, suppress the extra console window for release GUI builds while
// keeping it in debug builds so stderr/panics stay visible during development.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { fn main() {
if let Err(e) = peerspeak::app::run_gui() { if let Err(e) = peerspeak::app::run_gui() {
eprintln!("Error running GUI: {:?}", e); eprintln!("Error running GUI: {:?}", e);
+52 -2
View File
@@ -5,7 +5,7 @@ use iroh_gossip::proto::TopicId;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use async_trait::async_trait; use async_trait::async_trait;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
@@ -198,6 +198,10 @@ pub struct IrohGossipState {
secret_key: SecretKey, secret_key: SecretKey,
self_state: Arc<Mutex<Option<PeerState>>>, self_state: Arc<Mutex<Option<PeerState>>>,
peers: Arc<Mutex<HashMap<EndpointId, PeerState>>>, peers: Arc<Mutex<HashMap<EndpointId, PeerState>>>,
/// Previously verified peers whose live roster entry was removed by a
/// transient disconnect. Retained only so a later authenticated `Leave`
/// still reaches core and cancels background recovery.
disconnected_peers: Arc<Mutex<HashSet<EndpointId>>>,
event_tx: mpsc::Sender<RoomEvent>, event_tx: mpsc::Sender<RoomEvent>,
event_rx: Mutex<Option<mpsc::Receiver<RoomEvent>>>, event_rx: Mutex<Option<mpsc::Receiver<RoomEvent>>>,
active_topic: Mutex<Option<tokio::task::JoinHandle<()>>>, active_topic: Mutex<Option<tokio::task::JoinHandle<()>>>,
@@ -223,6 +227,7 @@ impl IrohGossipState {
secret_key, secret_key,
self_state: Arc::new(Mutex::new(None)), self_state: Arc::new(Mutex::new(None)),
peers: Arc::new(Mutex::new(HashMap::new())), peers: Arc::new(Mutex::new(HashMap::new())),
disconnected_peers: Arc::new(Mutex::new(HashSet::new())),
event_tx, event_tx,
event_rx: Mutex::new(Some(event_rx)), event_rx: Mutex::new(Some(event_rx)),
active_topic: Mutex::new(None), active_topic: Mutex::new(None),
@@ -295,6 +300,7 @@ impl RoomState for IrohGossipState {
let event_tx = self.event_tx.clone(); let event_tx = self.event_tx.clone();
let peers = self.peers.clone(); let peers = self.peers.clone();
let disconnected_peers = self.disconnected_peers.clone();
let address_lookup = self.address_lookup.clone(); let address_lookup = self.address_lookup.clone();
let self_state_clone = self.self_state.clone(); let self_state_clone = self.self_state.clone();
let gossip_sender_clone = gossip_sender.clone(); let gossip_sender_clone = gossip_sender.clone();
@@ -393,6 +399,7 @@ impl RoomState for IrohGossipState {
// peer-supplied: cap/validate once at ingest // peer-supplied: cap/validate once at ingest
// so invalid offers never render a Watch button. // so invalid offers never render a Watch button.
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket); state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
disconnected_peers.lock().unwrap().remove(&payload.author);
let (is_new, state_changed) = { let (is_new, state_changed) = {
let mut peer_map = peers.lock().unwrap(); let mut peer_map = peers.lock().unwrap();
let is_new = !peer_map.contains_key(&payload.author); let is_new = !peer_map.contains_key(&payload.author);
@@ -423,7 +430,11 @@ impl RoomState for IrohGossipState {
GossipMessage::Leave => { GossipMessage::Leave => {
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author)); crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author));
let removed = peers.lock().unwrap().remove(&payload.author).is_some(); let removed = peers.lock().unwrap().remove(&payload.author).is_some();
if removed { let was_disconnected = disconnected_peers
.lock()
.unwrap()
.remove(&payload.author);
if removed || was_disconnected {
let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await; let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await;
} }
} }
@@ -472,6 +483,7 @@ impl RoomState for IrohGossipState {
// cached presence entry; a rejoin re-announces as new. // cached presence entry; a rejoin re-announces as new.
let removed = peers.lock().unwrap().remove(&peer_id).is_some(); let removed = peers.lock().unwrap().remove(&peer_id).is_some();
if removed { if removed {
disconnected_peers.lock().unwrap().insert(peer_id);
crate::log_msg(&format!("Peer connection lost (NeighborDown): {:?}", peer_id)); crate::log_msg(&format!("Peer connection lost (NeighborDown): {:?}", peer_id));
let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await; let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await;
} }
@@ -516,6 +528,43 @@ impl RoomState for IrohGossipState {
Ok(()) Ok(())
} }
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), NetError> {
let self_id = self._endpoint.id();
let mut peer_ids = Vec::new();
for addr in peers {
if addr.id == self_id || peer_ids.contains(&addr.id) {
continue;
}
self.address_lookup.add_endpoint_info(addr.clone());
peer_ids.push(addr.id);
}
if peer_ids.is_empty() {
return Ok(());
}
// Clone the sender before awaiting: active_sender is a standard mutex and
// must never be held across an async gossip operation.
let sender = self
.active_sender
.lock()
.unwrap()
.clone()
.ok_or_else(|| NetError::Other("Not in a room".to_string()))?;
crate::log_msg(&format!("Rebootstrapping gossip peers: {:?}", peer_ids));
sender
.join_peers(peer_ids)
.await
.map_err(|e| NetError::Gossip(e.to_string()))
}
fn mark_peer_disconnected(&self, peer_id: EndpointId) {
if self.peers.lock().unwrap().remove(&peer_id).is_some() {
self.disconnected_peers.lock().unwrap().insert(peer_id);
}
}
async fn send_chat(&self, text: String) -> Result<(), NetError> { async fn send_chat(&self, text: String) -> Result<(), NetError> {
let name = { let name = {
let guard = self.self_state.lock().unwrap(); let guard = self.self_state.lock().unwrap();
@@ -571,6 +620,7 @@ impl RoomState for IrohGossipState {
} }
self.peers.lock().unwrap().clear(); self.peers.lock().unwrap().clear();
self.disconnected_peers.lock().unwrap().clear();
Ok(()) Ok(())
} }
+11 -1
View File
@@ -194,6 +194,17 @@ pub trait RoomState: Send + Sync {
/// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it. /// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it.
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>; async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>;
/// Ask the active gossip topic to connect to retained peer addresses without
/// leaving or replacing the subscription. This is a recovery primitive only:
/// it does not add peers to the authenticated room roster. A peer becomes
/// active only after its normal signed `Announce` is received and verified.
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), NetError>;
/// Remove a peer from the authenticated live roster before background
/// recovery. This only revokes membership; a fresh verified `Announce` is
/// required to add the peer again.
fn mark_peer_disconnected(&self, peer_id: EndpointId);
/// Broadcasts a room text-chat message authored by us (our display name is /// Broadcasts a room text-chat message authored by us (our display name is
/// taken from the current self-state). /// taken from the current self-state).
async fn send_chat(&self, text: String) -> Result<(), NetError>; async fn send_chat(&self, text: String) -> Result<(), NetError>;
@@ -342,4 +353,3 @@ mod tests {
assert_eq!(original, deserialized); assert_eq!(original, deserialized);
} }
} }
+354
View File
@@ -0,0 +1,354 @@
//! Phase-0 spike for post-grace gossip recovery.
//!
//! These tests prove that `GossipSender::join_peers` can restore an existing
//! topic subscription after the other peer drops and rejoins without its own
//! bootstrap target. The second case disables relays, clears the surviving
//! node's lookup, and moves the peer to a fresh endpoint address so only the
//! retained full address passed to `rebootstrap_peers` can drive recovery.
use std::sync::Arc;
use std::time::Duration;
use iroh::address_lookup::memory::MemoryLookup;
use iroh::endpoint::presets;
use iroh::protocol::Router;
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey};
use iroh_gossip::net::Gossip;
use tokio::sync::mpsc;
use peerspeak::network::gossip::IrohGossipState;
use peerspeak::network::{PeerSpeakTicket, PeerState, RoomEvent, RoomState};
const EVENT_TIMEOUT: Duration = Duration::from_secs(10);
struct GossipNode {
endpoint: Endpoint,
lookup: MemoryLookup,
room: Arc<IrohGossipState>,
_router: Router,
}
async fn spawn_node(secret: SecretKey) -> GossipNode {
let lookup = MemoryLookup::new();
let endpoint = Endpoint::builder(presets::Minimal)
.secret_key(secret.clone())
.relay_mode(RelayMode::Disabled)
.address_lookup(lookup.clone())
.bind()
.await
.expect("bind gossip endpoint");
let gossip = Gossip::builder().spawn(endpoint.clone());
let router = Router::builder(endpoint.clone())
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip.clone())
.spawn();
let room = Arc::new(IrohGossipState::new(
endpoint.clone(),
gossip,
lookup.clone(),
secret,
));
GossipNode {
endpoint,
lookup,
room,
_router: router,
}
}
fn state(name: &str, addr: EndpointAddr) -> PeerState {
PeerState {
name: name.to_string(),
is_muted: false,
addr,
sharing: None,
avatar: Default::default(),
}
}
async fn await_joined(rx: &mut mpsc::Receiver<RoomEvent>, peer_id: EndpointId) -> PeerState {
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
loop {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(RoomEvent::PeerJoined(id, peer_state))) if id == peer_id => return peer_state,
Ok(Some(_)) => continue,
Ok(None) => panic!("room event channel closed while waiting for PeerJoined"),
Err(_) => panic!("timed out waiting for PeerJoined({peer_id:?})"),
}
}
}
async fn await_joined_all(rx: &mut mpsc::Receiver<RoomEvent>, peer_ids: &[EndpointId]) {
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
let mut remaining = peer_ids.to_vec();
while !remaining.is_empty() {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(RoomEvent::PeerJoined(id, _))) => remaining.retain(|wanted| *wanted != id),
Ok(Some(_)) => {}
Ok(None) => panic!("room event channel closed while waiting for PeerJoined set"),
Err(_) => panic!("timed out waiting for PeerJoined set: {remaining:?}"),
}
}
}
async fn await_left(rx: &mut mpsc::Receiver<RoomEvent>, peer_id: EndpointId) {
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
loop {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(RoomEvent::PeerLeft(id))) if id == peer_id => return,
Ok(Some(_)) => continue,
Ok(None) => panic!("room event channel closed while waiting for PeerLeft"),
Err(_) => panic!("timed out waiting for PeerLeft({peer_id:?})"),
}
}
}
async fn await_absent(room: &IrohGossipState, peer_id: EndpointId) {
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
loop {
if !room.active_peers().iter().any(|(id, _)| *id == peer_id) {
return;
}
assert!(
tokio::time::Instant::now() < deadline,
"timed out waiting for peer to leave the roster"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
fn ticket(host_addr: EndpointAddr) -> String {
PeerSpeakTicket {
host_addr,
topic_id: rand::random(),
name: "rebootstrap-spike".to_string(),
}
.to_string()
}
async fn establish_room(
a: &GossipNode,
b: &GossipNode,
ticket: &str,
events_a: &mut mpsc::Receiver<RoomEvent>,
) {
// B is the ticket host. Its own bootstrap set is empty; A is the only side
// that initially dials, which is also how the recovery setup is controlled.
b.room
.join(ticket, state("Bob", b.endpoint.addr()), vec![])
.await
.expect("host joins topic");
a.room
.join(ticket, state("Alice", a.endpoint.addr()), vec![])
.await
.expect("client joins topic");
let joined = await_joined(events_a, b.endpoint.id()).await;
assert_eq!(joined.name, "Bob");
}
#[tokio::test]
async fn rebootstrap_restores_roster_on_existing_subscription() {
let a = spawn_node(SecretKey::generate()).await;
let b = spawn_node(SecretKey::generate()).await;
let ticket = ticket(b.endpoint.addr());
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
establish_room(&a, &b, &ticket, &mut events_a).await;
// Drop only B's topic subscription. A stays subscribed. B then rejoins as
// the ticket host, so compute_bootstrap removes self and B has nobody to dial.
b.room.leave().await.expect("B leaves topic");
await_absent(&a.room, b.endpoint.id()).await;
b.room
.join(&ticket, state("Bob recovered", b.endpoint.addr()), vec![])
.await
.expect("B rejoins without bootstrap peers");
tokio::time::sleep(Duration::from_millis(250)).await;
assert!(
!a.room
.active_peers()
.iter()
.any(|(id, _)| *id == b.endpoint.id()),
"B must not recover before A explicitly re-bootstraps it"
);
a.room
.rebootstrap_peers(vec![b.endpoint.addr()])
.await
.expect("targeted gossip re-bootstrap");
let recovered = await_joined(&mut events_a, b.endpoint.id()).await;
assert_eq!(recovered.name, "Bob recovered");
}
#[tokio::test]
async fn rebootstrap_uses_retained_full_address_with_empty_lookup() {
let a = spawn_node(SecretKey::generate()).await;
let b_secret = SecretKey::generate();
let b = spawn_node(b_secret.clone()).await;
let b_id = b.endpoint.id();
let old_b_addr = b.endpoint.addr();
let ticket = ticket(old_b_addr.clone());
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
establish_room(&a, &b, &ticket, &mut events_a).await;
b.room.leave().await.expect("old B leaves topic");
await_absent(&a.room, b_id).await;
// Move the same authenticated identity to a newly-bound direct-only endpoint.
// The old cached path is now dead; the new full address is the only valid one.
b.endpoint.close().await;
drop(b);
tokio::time::sleep(Duration::from_millis(250)).await;
let b_rebound = spawn_node(b_secret).await;
let new_b_addr = b_rebound.endpoint.addr();
assert_eq!(new_b_addr.id, b_id, "identity must survive the rebind");
assert_ne!(
new_b_addr, old_b_addr,
"rebound peer must have a fresh address"
);
b_rebound
.room
.join(&ticket, state("Bob rebound", new_b_addr.clone()), vec![])
.await
.expect("rebound host joins without bootstrap peers");
// Remove the stale lookup entry. `rebootstrap_peers` must seed the retained
// new full address before asking gossip to join the peer by id.
a.lookup.remove_endpoint_info(b_id);
assert!(
a.lookup.get_endpoint_info(b_id).is_none(),
"A lookup starts empty for B"
);
tokio::time::sleep(Duration::from_millis(250)).await;
assert!(
!a.room.active_peers().iter().any(|(id, _)| *id == b_id),
"rebound B must not be rediscovered without the retained address"
);
a.room
.rebootstrap_peers(vec![new_b_addr])
.await
.expect("retained-address gossip re-bootstrap");
assert!(
a.lookup.get_endpoint_info(b_id).is_some(),
"re-bootstrap must restore B's address to the lookup"
);
let recovered = await_joined(&mut events_a, b_id).await;
assert_eq!(recovered.name, "Bob rebound");
}
#[tokio::test]
async fn demoted_peer_requires_a_fresh_signed_announce_to_rejoin() {
let a = spawn_node(SecretKey::generate()).await;
let b = spawn_node(SecretKey::generate()).await;
let ticket = ticket(b.endpoint.addr());
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
establish_room(&a, &b, &ticket, &mut events_a).await;
a.room.mark_peer_disconnected(b.endpoint.id());
assert!(
!a.room
.active_peers()
.iter()
.any(|(id, _)| *id == b.endpoint.id()),
"demotion must revoke live roster membership"
);
b.room
.update_self_state(state("Bob authenticated again", b.endpoint.addr()))
.await
.expect("broadcast fresh signed announce");
let recovered = await_joined(&mut events_a, b.endpoint.id()).await;
assert_eq!(recovered.name, "Bob authenticated again");
}
#[tokio::test]
async fn signed_leave_after_demotion_still_emits_peer_left() {
let a = spawn_node(SecretKey::generate()).await;
let b = spawn_node(SecretKey::generate()).await;
let ticket = ticket(b.endpoint.addr());
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
establish_room(&a, &b, &ticket, &mut events_a).await;
a.room.mark_peer_disconnected(b.endpoint.id());
b.room
.leave()
.await
.expect("broadcast signed Leave after demotion");
await_left(&mut events_a, b.endpoint.id()).await;
}
#[tokio::test]
async fn targeted_rebootstrap_preserves_healthy_peer_in_three_peer_room() {
let a = spawn_node(SecretKey::generate()).await;
let b = spawn_node(SecretKey::generate()).await;
let c_secret = SecretKey::generate();
let c = spawn_node(c_secret.clone()).await;
let c_id = c.endpoint.id();
let ticket = ticket(c.endpoint.addr());
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
let mut events_b = b.room.subscribe_events().await.expect("subscribe B events");
c.room
.join(&ticket, state("Carol", c.endpoint.addr()), vec![])
.await
.expect("C hosts topic");
b.room
.join(&ticket, state("Bob", b.endpoint.addr()), vec![])
.await
.expect("B joins C");
await_joined(&mut events_b, c_id).await;
a.room
.join(&ticket, state("Alice", a.endpoint.addr()), vec![])
.await
.expect("A joins C");
await_joined_all(&mut events_a, &[b.endpoint.id(), c_id]).await;
await_joined(&mut events_b, a.endpoint.id()).await;
// Remove only C. A and B keep their existing topic subscriptions and remain
// mutually present while C is rebound to a fresh address.
c.endpoint.close().await;
drop(c);
a.room.mark_peer_disconnected(c_id);
b.room.mark_peer_disconnected(c_id);
let c_rebound = spawn_node(c_secret).await;
let rebound_addr = c_rebound.endpoint.addr();
c_rebound
.room
.join(
&ticket,
state("Carol recovered", rebound_addr.clone()),
vec![],
)
.await
.expect("rebound C rejoins as host without bootstrap");
a.room
.rebootstrap_peers(vec![rebound_addr])
.await
.expect("A targets only C for recovery");
let recovered = await_joined(&mut events_a, c_id).await;
assert_eq!(recovered.name, "Carol recovered");
await_joined(&mut events_b, c_id).await;
assert!(
a.room
.active_peers()
.iter()
.any(|(id, _)| *id == b.endpoint.id()),
"healthy B must remain present at A throughout C recovery"
);
assert!(
b.room
.active_peers()
.iter()
.any(|(id, _)| *id == a.endpoint.id()),
"healthy A must remain present at B throughout C recovery"
);
}
+1 -2
View File
@@ -25,8 +25,7 @@ use peerspeak::codec::opus_impl::OpusEncoder;
use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer}; use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer};
use peerspeak::network::{ConnEvent, NetworkTransport}; use peerspeak::network::{ConnEvent, NetworkTransport};
use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport}; use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport};
use peerspeak::protocol::AUDIO_ALPN;
const AUDIO_ALPN: &[u8] = b"peerspeak-audio";
struct Node { struct Node {
endpoint: Endpoint, endpoint: Endpoint,
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# Cross-compile peerspeak to x86_64-pc-windows-gnu (run inside the peerspeak-win distrobox).
# Produces a statically-linked, self-contained .exe (no extra DLLs) for the
# Windows installer in packaging/windows/. Requires the rust-src component and
# the x86_64-pc-windows-gnu target; invoke with build-std for the static link:
# RUSTC_BOOTSTRAP=1 ./win-cross-build.sh -Z build-std=std,panic_abort
set -euo pipefail
cd "$(dirname "$0")"
export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
export CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar
# Bundled libopus declares cmake_minimum_required < 3.5; cmake 4.x refuses it.
export CMAKE_POLICY_VERSION_MINIMUM=3.5
cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak "$@"